Sveltekit +条纹的经常付款
#javascript #网络开发人员 #svelte

当我构建最后一个SaaS时,我犯了一个错误:我启动了,没有集成付款。

我认为稍后总是可以添加。当时,这似乎不是最大的未知数。

,但这意味着我必须手动发送发票和电子邮件提醒,并打来电话,以了解逾期付款。这不是那么有趣。

在事后看来,我了解到付款是SaaS正在工作的验证。将其推开只会延迟重要的信号。

好消息是:很容易避免我犯的错误。条纹订阅可以在下午添加到您的Sveltekit网站上。

注意:一种替代方法是条纹结帐。 Need help choosing?

付款流

要使客户成功支付订阅费用,需要以下步骤:

  1. 创建一个客户在条纹中记录。
  2. 创建订阅条纹记录。它将具有incompletestatus,直到付款完成为止。
  3. 使用Stripe的<PaymentElement/>组件显示付款表格
  4. 处理付款表提交并使用Stripe的API完成付款。
  5. 处理webhooks 提供订阅。

项目配置

首先,将这些依赖项添加到您的SvelteKit app:

# `stripe` is the official node.js package.
# `svelte-stripe` is a community maintained wrapper.
pnpm install -D stripe svelte-stripe

然后在.env中定义环境变量:

# Stripe secret key. Can be found at: https://dashboard.stripe.com/apikeys
SECRET_STRIPE_KEY=sk_...

# Stripe public key
# The PUBLIC_ prefix allows Svelte to include it in client bundle
PUBLIC_STRIPE_KEY=pk_...

# domain to use for redirections
DOMAIN=http://localhost:5173

您可能需要从应用程序中的多个位置调用条纹API,因此最好集中条纹客户端:

// in src/lib/server/stripe.js
import Stripe from 'stripe'
import { env } from '$env/dynamic/private'

// export the stripe instance
export const stripe = Stripe(env.SECRET_STRIPE_KEY, {
  // pin the api version
  apiVersion: '2022-11-15'
})

然后,任何时候您要访问条纹,导入$lib/server/stripe

// import singelton
import { stripe } from '$lib/server/stripe'

// Do stuff with Stripe client:
//
// stripe.resource.list(....)
// stripe.resource.create(....)
// stripe.resource.update(....)
// stripe.resource.retrieve(....)
// stripe.resource.del(....)

创建客户

如果您的应用程序具有身份验证,则您已经拥有客户的姓名和电子邮件地址。

如果没有,您可以显示一个表格以捕获它:

<!-- in src/routes/checkout/+page.svelte -->
<h1>Checkout</h1>

<!-- posts to default form action -->
<form method="post">
  <input name="name" required placeholder="Name" />
  <input name="email" type="email" required placeholder="E-mail" />

  <button>Continue</button>
</form>

然后在服务器端,创建条纹客户并将客户ID存储在数据库中。在我们的情况下,由于没有数据库,我们将其放在cookie中:

// in src/routes/checkout/+page.server.js
import { stripe } from '$lib/stripe'
import { redirect } from '@sveltejs/kit'

export const actions = {
  // default form action
  default: async ({ request, cookies }) => {
    // get the form
    const form = await request.formData()

    // create the customer
    const customer = await stripe.customers.create({
      email: form.get('email'),
      name: form.get('name')
    })

    // set a cookie
    cookies.set('customerId', customer.id)

    // redirect to collect payment
    throw redirect(303, '/checkout/payment')
  }
}

创建订阅

现在我们有了一个客户,可以代表他们创建订阅。

订阅被键入特定产品和价格。该产品将是“基本计划”或“企业计划”之类的东西。产品可以有多个价格,例如,基本计划的月价可能为20美元,但年价格为100美元。每个都是单独的价格ID。

最好将价格存储在数据库或配置文件中,但是出于演示目的,我们将其作为url中的参数传递:

// in src/routes/checkout/payment/+page.server.js
import { stripe } from '$lib/stripe'
import { env } from '$env/dynamic/private'

export async function load({ url, cookies }) {
  // pull customerId from cookie
  const customerId = cookies.get('customerId')
  // pull priceId from URL
  const priceId = url.searchParams.get('priceId')

  // create the subscription
  // status is `incomplete` until payment succeeds
  const subscription = await stripe.subscriptions.create({
    customer: customerId,
    items: [
      {
        price: priceId
      }
    ],
    payment_behavior: 'default_incomplete',
    payment_settings: { save_default_payment_method: 'on_subscription' },
    expand: ['latest_invoice.payment_intent']
  })

  return {
    clientSecret: subscription.latest_invoice.payment_intent.client_secret,
    returnUrl: new URL('/checkout/complete', env.DOMAIN).toString()
  }
}

端点返回两件事:

  • clientSecret:代表payment intent的令牌。条纹元素需要它。
  • returnURL:我们的感谢页面的URL。付款成功后,用户将在此处重定向。

付款表

在付款表中,<PaymentElement>组件将处理显示所有付款选项。这允许客户使用信用卡或该地区的任何supported payment methods付款。

<!-- in src/routes/checkout/payment/+page.svelte -->
<script>
  import { PUBLIC_STRIPE_KEY } from '$env/static/public'
  import { onMount } from 'svelte'
  import { loadStripe } from '@stripe/stripe-js'
  import { Elements, PaymentElement } from 'svelte-stripe'

  // data from server
  export let data

  // destructure server data
  $: ({ clientSecret, returnUrl } = data)

  // Stripe instance
  let stripe

  // Stripe Elements instance
  let elements

  // when component mounts
  onMount(async () => {
    // load the Stripe client
    stripe = await loadStripe(PUBLIC_STRIPE_KEY)
  })

  // handle form submission
  async function submit() {
    // ask Stripe to confirm the payment
    const { error } = await stripe.confirmPayment({
      // pass instance that was used to create the Payment Element
      elements,

      // specify where to send the user when payment succeeeds
      confirmParams: {
        return_url: returnUrl
      }
    })

    if (error) {
      // handle error
      console.error(error)
    }
  }
</script>

<h1>Payment</h1>

{#if stripe}
  <form on:submit|preventDefault={submit}>
    <!-- container for Stripe components -->
    <Elements {stripe} {clientSecret} bind:elements>

      <!-- display payment related fields -->
      <PaymentElement />
    </Elements>

    <button>Pay</button>
  </form>
{:else}
  Loading Stripe...
{/if}

完成付款

Stripe将将用户发送到我们的“谢谢”页面,并且付款意图以查询字符串传递。

URL看起来像这样:http://localhost:5173/payment/complete?payment_intent=pi_xyz123

使用付款意图ID,我们可以检查付款的状态并在帐户成功的情况下提供。

// in src/routes/checkout/complete/+page.server.js
import { stripe } from '$lib/stripe'
import { redirect } from '@sveltejs/kit'

export async function load({ url }) {
  // pull payment intent id from the URL query string
  const id = url.searchParams.get('payment_intent')

  // ask Stripe for latest info about this paymentIntent
  const paymentIntent = await stripe.paymentIntents.retrieve(id)

  /* Inspect the PaymentIntent `status` to indicate the status of the payment
   * to your customer.
   *
   * Some payment methods will [immediately succeed or fail][0] upon
   * confirmation, while others will first enter a `processing` state.
   *
   * [0] https://stripe.com/docs/payments/payment-methods#payment-notification
   */
  let message

  switch (paymentIntent.status) {
    case 'succeeded':
      message = 'Success! Payment received.'

      // TODO: provision account here

      break

    case 'processing':
      message = "Payment processing. We'll update you when payment is received."
      break

    case 'requires_payment_method':
      // Redirect user back to payment page to re-attempt payment
      throw redirect(303, '/checkout/payment')

    default:
      message = 'Something went wrong.'
      break
  }

  return { message }
}

和一个简单的“谢谢”页UI可能看起来像:

<-- in src/routes/checkout/complete/+page.svelte -->
<script>
  export let data
</script>

<h1>Checkout complete</h1>

<p>{data.message}</p>

处理网络钩

请注意,不能保证用户进入“谢谢”页面。

他们有可能关闭浏览器或互联网连接切断。这就是为什么也要处理Stripe的Webhooks也很重要的原因。

在我们的Sveltekit应用中,我们可以添加一个端点来处理Webhooks:

// in src/routes/stripe/webhooks/+server.js
import { stripe } from '$lib/stripe'
import { error, json } from '@sveltejs/kit'
import { env } from '$env/dynamic/private'

// endpoint to handle incoming webhooks
export async function POST({ request }) {
  // extract body
  const body = await request.text()

  // get the signature from the header
  const signature = request.headers.get('stripe-signature')

  // var to hold event data
  let event

  // verify the signature matches the body
  try {
    event = stripe.webhooks.constructEvent(body, signature, env.SECRET_STRIPE_WEBHOOK_KEY)
  } catch (err) {
    // warn when signature is invalid
    console.warn('⚠️  Webhook signature verification failed.', err.message)

    // return, because signature is invalid
    throw error(400, 'Invalid request')
  }

  /* Signature has been verified, so we can process events
   * 
   * Review important events for Billing webhooks:
   * https://stripe.com/docs/billing/webhooks
   */
  switch (event.type) {
    case 'customer.subscription.created':
      // Subscription was created
      // Note: status will be `incomplete`
      break
    case 'customer.subscription.updated':
      // Subscription has been changed
      break
    case 'invoice.paid':
      // Used to provision services after the trial has ended.
      // The status of the invoice will show up as paid. Store the status in your
      // database to reference when a user accesses your service to avoid hitting rate limits.
      break
    case 'invoice.payment_failed':
      // If the payment fails or the customer does not have a valid payment method,
      //  an invoice.payment_failed event is sent, the subscription becomes past_due.
      // Use this webhook to notify your user that their payment has
      // failed and to retrieve new card details.
      break
    case 'customer.subscription.deleted':
      if (event.request != null) {
        // handle a subscription canceled by your request
        // from above.
      } else {
        // handle subscription canceled automatically based
        // upon your subscription settings.
      }
      break
    default:
      // Unexpected event type
  }

  // return a 200 with an empty JSON response
  return json()
}

要在开发模式下测试Webhook,需要代理将事件隧道事件隧道隧道隧道挖掘到我们本地的机器。由于我们正在使用Localhost,而不是在公共互联网上。

幸运的是,Stripe's CLI可以做到这一点:

# login to your account
stripe login

# forward webhook events to your localhost
stripe listen --forward-to localhost:5173/stripe/webhooks

stripe listen将打印出“ Webhook Secret”(它以whsec_...开头)。确保将其添加到.env并重新启动开发服务器。

# in .env
SECRET_STRIPE_WEBHOOK_KEY=whsec_...

就是伙计们。您知道您的Sveltekit应用中有订阅!

有关完整的源代码,请参见:
https://github.com/joshnuss/sveltekit-stripe-subscriptions

结论

订阅是SaaS应用程序的最终验证。这是对您的想法的需求。

没有它,您必须求助于手动过程,并希望人们按时付款。最好尽早获得该验证。

即使人们不买,也最好知道,而不是希望稍后会发生。至少您可以开始旋转。

快乐黑客!