当我构建最后一个SaaS时,我犯了一个错误:我启动了,没有集成付款。
我认为稍后总是可以添加。当时,这似乎不是最大的未知数。
,但这意味着我必须手动发送发票和电子邮件提醒,并打来电话,以了解逾期付款。这不是那么有趣。
在事后看来,我了解到付款是SaaS正在工作的验证。将其推开只会延迟重要的信号。
好消息是:很容易避免我犯的错误。条纹订阅可以在下午添加到您的Sveltekit网站上。
注意:一种替代方法是条纹结帐。 Need help choosing?
付款流
要使客户成功支付订阅费用,需要以下步骤:
- 创建一个客户在条纹中记录。
-
创建订阅条纹记录。它将具有
incomplete
的status
,直到付款完成为止。 -
使用Stripe的
<PaymentElement/>
组件显示付款表格 - 处理付款表提交并使用Stripe的API完成付款。
- 处理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应用程序的最终验证。这是对您的想法的需求。
没有它,您必须求助于手动过程,并希望人们按时付款。最好尽早获得该验证。
即使人们不买,也最好知道,而不是希望稍后会发生。至少您可以开始旋转。
快乐黑客!