支付流程与自定义开发
为了让模板购买者更容易上手并实现自己的支付逻辑,本章将介绍 Nexty.dev 内置支付流程的代码实现,并讲解如何开启你的自定义逻辑开发。
支付流程
这一小节,我们重点介绍用户可感知的支付流程,Webhook 部分的流程在下一小节单独讲解。
显示定价计划
显示定价卡片的组件是 components/home/Pricing.tsx
及其子组件。
通过 getPublicPricingPlans
方法获取管理员设置的激活的定价卡片。

用户付款
用户点击购买按钮,会向 /api/stripe/checkout-session
接口发送请求。
在 /api/stripe/checkout-session
接口里,会查询当前用户在 Stripe 账户里的唯一标识(customer_id),然后根据用户想要购买的计划,向 Stripe 请求付款页面(checkout 页面)地址。
在请求 Stripe 的参数里还包含 success_url
和 cancel_url
,分别用于指定付款成功和取消付款后重定向的页面。
前端获取到 checkout 地址后,会进入 Stripe 的付款流程。付款成功会重定向到 success_url
指定的页面。
提示
success_url
不需要修改,但需要修改app/[locale]payment/success/page.tsx
页面的内容,以适应你的业务cancel_url
通常也不需要修改,它指向首页的定价卡片区域
提示
测试支付流程前,必须完成Stripe 集成中创建 Webhook 和开启 Forward Port 等关键步骤。
使用 Stripe 提供的测试信息测试支付流程:
- 信用卡:4242 4242 4242 4242
- 使用有效的未来日期,例如 12/34
- 使用任意三位数 CVC(American Express 卡为四位)
- 其他表单字段使用任意值。
付款成功
你需要修改 app/[locale]payment/success/page.tsx
页面,因为 Nexty.dev 模板默认的付款成功页面是显示用户购买的定价计划。你应该按照自己的业务修改展示内容,让用户体验更好。

用户权益展示
我们先尝试购买内置的初始化定价计划,分别购买一次性付款和周期订阅,完成后可以在 /dashboard/subscription
和页面右上角下拉框看到当前的权益。

打开 Supabase 的 Table Editor,可以分别在 orders
、subscriptions
和 usage
看到对应的数据。
获取用户权益的核心方法在 lib/stripe/actions.ts
的 getUserBenefits
,返回的权益信息定义如下:
export interface UserBenefits {
activePlanId: string | null;
subscriptionStatus: string | null; // e.g., 'active', 'trialing', 'past_due', 'canceled', null
totalAvailableCredits: number;
subscriptionCreditsBalance: number;
oneTimeCreditsBalance: number;
// Add other plan-specific benefits if needed, fetched via planId
}
当你需要自定义权益时,应该注意:
activePlanId
和subscriptionStatus
从subscriptions
获取,反映了用户购买的计划和当前订阅状态,不应该修改totalAvailableCredits
、subscriptionCreditsBalance
和oneTimeCreditsBalance
从usage
表获取,反映了内置的一次性支付与周期订阅积分(credits)余额,如果用不到,仍然建议保留,但不使用,即在调用getUserBenefits
方法的地方不取这三个参数的返回值即可- 你自定义的权益,应该在现有的
UserBenefits
进行扩展,同时要修改getUserBenefits
里usage
表查询方法和返回对象(即return
的对象)
当你完成 getUserBenefits
的修改后,需要检查 components/layout/CurrentUserBenefitsDisplay.tsx
,这个组件会影响 /dashboard/subscription
页面和右上角展示的用户权益。
提示
如果你坚持修改
UserBenefits
原有的定义,那么还需要检查以下文件的对应字段是否需要变更:
app/[locale]/(protected)/dashboard/(user)/subscription/page.tsx
actions/usage/deduct.ts
模拟用户使用积分
从代码仓库 v1.1.4 开始,Nexty.dev 提供了模拟用户使用积分的示例。
你可以在本地启动项目,进入 /dashboard/credit-usage-example
页面测试内置的积分扣除流程。该页面仅在开发环境可以打开,无需担心生产环境会被滥用。
与 /dashboard/credit-usage-example
配套的积分扣除方法在 actions/usage/deduct.ts
里,其中调用了 Supabase 的 RPC(Remote Procedure Call),对应的 RPC 定义在 data/5、usage(deduct_rpc_demo).sql
文件里。
提示
模拟用户使用积分前,你需要确认:
- 已在 Supabase 的 SQL Editor 执行过
data/5、usage(deduct_rpc_demo).sql
- 在本地已更新 Supabase 数据库定义,参考Supabase 集成中的步骤

actions/usage/deduct.ts
提供了四种积分扣除策略:
// 1. 仅扣除一次性积分
await deductOneTimeCredits(amount, locale);
// 2. 仅扣除订阅积分
await deductSubscriptionCredits(amount, locale);
// 3. 优先扣除订阅积分
await deductCreditsPrioritizingSubscription(amount, locale);
// 4. 优先扣除一次性积分
await deductCreditsPrioritizingOneTime(amount, locale);
这四个方法可以直接使用,但需要注意,实际开发中,这些方法不应该在前端调用,应该在服务端用户使用完功能的位置调用。
如果你需要自定义积分体系和扣减方法,可以参考这些方法实现新的自定义方法。
Webhook
在Stripe 集成章节里,我们已经在 Stripe 控制台勾选了以下7种 Webhook 事件:
- charge.refunded
- checkout.session.completed
- customer.subscription.created
- customer.subscription.updated
- customer.subscription.deleted
- invoice.paid
- invoice.payment_failed
在 Nexty.dev 代码中,接收 Webhook 的入口在 app/api/stripe/webhook/route.ts
,主要处理逻辑放在 processWebhookEvent
方法里。
现在,你可以开始对照代码和接下来的文档,深入理解 Nexty.dev 提供的支付体系。
一次性付款
一次性付款只要接收到 checkout.session.completed
事件就说明用户已经完成付款,在 Stripe 侧已经产生了订单,所以我们可以以此为准处理一次性付款的权益了。
包括 Stripe 在内的支付平台,Webhook 事件都存在重复推送的可能,所以在往 orders
表写入订单数据之前,我们需要先检查数据库是否已存在相同订单数据。这就是"幂等性检查",在 handleCheckoutSessionCompleted
方法里执行。
const { data: existingOrder, error: queryError } = await supabaseAdmin
.from('orders')
.select('id')
.eq('provider', 'stripe')
.eq('provider_order_id', paymentIntentId)
.maybeSingle();
if (existingOrder) {
return;
}
通过幂等性检查的事件,我们会把 Stripe 推送的必要信息写入数据库,同时通过 status
标记订单已完成、通过 order_type
标记这是一次性付款的订单。
const orderData = {
user_id: userId,
status: 'succeeded',
order_type: 'one_time_purchase',
// ...others...
};
完成订单数据写入 orders
表之后,就可以开始为一次性付款用户升级权益了,即 upgradeOneTimeCredits
方法。
// --- [custom] Upgrade the user's benefits ---
upgradeOneTimeCredits(userId, planId);
// --- End: [custom] Upgrade the user's benefits ---
在 upgradeOneTimeCredits
逻辑中,我们根据用户购买的付费计划的 plan_id 字段查询数据库,获取管理员在 benefits_jsonb
(即创建/编辑定价计划页面的 Benefits 项)设置的权益。默认使用了内置的 one_time_credits
为用户增加积分。
const { data: planData, error: planError } = await supabaseAdmin
.from('pricing_plans')
.select('benefits_jsonb')
.eq('id', planId)
.single();
const creditsToGrant = (planData.benefits_jsonb as any)?.one_time_credits || 0;
if (creditsToGrant && creditsToGrant > 0) {
const { error: usageError } = await supabaseAdmin.rpc('upsert_and_increment_one_time_credits', {
p_user_id: userId,
p_credits_to_add: creditsToGrant
});
}
如果你创建的定价计划,自定义了 benefits_jsonb
,那么你需要重新实现符合需求的 upgradeOneTimeCredits
方法。
周期订阅和续订
对于周期订阅,只有首次付费会触发 checkout.session.completed
事件,之后的续订不会触发,所以周期订阅的场景,我们不处理该事件。
为了追踪首次订阅和续订的付款情况,应该监听 invoice.paid
,这个事件表示用户付款已到账。除此之外,为了获取最新的订阅信息,还需要在首次订阅监听 customer.subscription.created
,在订阅续订监听 customer.subscription.updated
。
提示
Stripe 的 Subscription 对象里,订阅状态字段 status 可能值有:
'active' | 'canceled' | 'incomplete' | 'incomplete_expired' | 'past_due' | 'paused' | 'trialing' | 'unpaid';
invoice.paid
invoice.paid
的核心处理逻辑在 handleInvoicePaid
方法中。同样需要进行幂等性检查:
const { data: existingOrder, error: queryError } = await supabaseAdmin
.from('orders')
.select('id')
.eq('provider', 'stripe')
.eq('provider_order_id', invoiceId)
.maybeSingle();
if (existingOrder) {
return;
}
通过幂等性检查后,我们会把 Stripe 推送的必要信息写入数据库,同时通过 status
标记订单已完成、通过 order_type
标记这是订阅付款的订单。
const orderType = invoice.billing_reason === 'subscription_create' ? 'subscription_initial' : 'subscription_renewal';
const orderData = {
user_id: userId,
status: 'succeeded',
order_type: orderType,
// ...others...
};
完成订单数据写入 orders
表之后,就可以开始为周期订阅用户升级或者更新权益了,即 upgradeSubscriptionCredits
方法。
// --- [custom] Upgrade the user's benefits ---
upgradeSubscriptionCredits(userId, planId, invoiceId);
// --- End: [custom] Upgrade the user's benefits ---
在 upgradeSubscriptionCredits
逻辑中,我们根据用户购买的付费计划的 plan_id
字段查询数据库,获取管理员在 benefits_jsonb
(即创建/编辑定价计划页面的 Benefits 项)设置的权益。默认使用了内置的 monthly_credits
为用户增加积分。
const { data: planData, error: planError } = await supabaseAdmin
.from('pricing_plans')
.select('benefits_jsonb')
.eq('id', planId)
.single();
const creditsToGrant = (planData.benefits_jsonb as any)?.monthly_credits || 0;
if (creditsToGrant && creditsToGrant > 0) {
const { error: usageError } = await supabaseAdmin.rpc('upsert_and_set_subscription_credits', {
p_user_id: userId,
p_credits_to_set: creditsToGrant
});
}
如果你创建的定价计划,自定义了 benefits_jsonb
,那么你需要重新实现符合需求的 upgradeSubscriptionCredits
方法。
在 handleInvoicePaid
方法末尾,我们还调用了 syncSubscriptionData
方法,从 Stripe 拉取用户的订阅信息并保存到数据库。设计这一步的原因是,实际运营中可能因为网络等原因,Stripe 推送的事件顺序和我们的 Webhook 实际接收的顺序不同,如果仅凭 Webhook 携带的信息更新,可能出现数据错误覆盖。syncSubscriptionData
是遵循最佳实践的思路,主动从 Stripe 获取最新数据,这样就不用关心 Stripe 实际推送的事件顺序。
try {
await syncSubscriptionData(subscriptionId, customerId);
} catch (syncError) {
console.error(`Error during post-invoice sync for sub ${subscriptionId}:`, syncError);
}
customer.subscription.created
customer.subscription.created
在首次订阅时触发,监听的原因也是为了获取最新的用户订阅信息,所以也是调用 syncSubscriptionData
,理由同上。
customer.subscription.updated
customer.subscription.updated
在订阅续订时触发,监听的原因也是为了获取最新的用户订阅信息,所以也是调用 syncSubscriptionData
,理由同上。
周期订阅续订失败
我们不需要处理一次性支付失败的场景,因为一次性支付失败会在 Checkout 页面看到提示。
对于周期订阅,可能出现用户在订阅续订时因为余额不足、卡片失效等原因造成付款失败,所以我们必须合理处理续订失败的场景,也就是 invoice.payment_failed
事件。
invoice.payment_failed
事件核心处理逻辑在 handleInvoicePaymentFailed
方法里。
这个方法里最重要的是调用 syncSubscriptionData
以更新订阅状态,续订失败后的状态值可能变更为:
- past_due:逾期未付款
- unpaid:未付款
除此之外,还调用了 sendInvoicePaymentFailedEmail
方法向用户发送付费失败通知,尝试召回用户。邮件模板在 emails/invoice-payment-failed.tsx
,你可以根据需求进行自定义。
一次性付款订单退款
订单退款通常是我们在 Stripe 控制台手动操作,但退款后的权益回收需要代码自动执行,所以针对一次性付款的退款,Nexty.dev 提供了 charge.refunded
事件的监听,核心处理逻辑在 handleRefund
方法里。
监听 charge.refunded
仍然需要幂等性检查:
const { data: existingRefundOrder, error: queryError } = await supabaseAdmin
.from('orders')
.select('id')
.eq('provider', 'stripe')
.eq('provider_order_id', refundId)
.eq('order_type', 'refund')
.maybeSingle();
if (existingRefundOrder) {
return;
}
通过幂等性检查后,会在 orders
表里找出和退款订单 charge.payment_intent
对应的数据,将这条数据的 order_type
赋值为 refund
。
然后进行权益回收处理:
// --- [custom] Revoke the user's benefits (only for one time purchase) ---
if (originalOrder) {
revokeOneTimeCredits(charge, originalOrder, refundId);
}
// --- End: [custom] Revoke the user's benefits ---
在 revokeOneTimeCredits
逻辑中,我们根据用户购买的付费计划的 plan_id 字段查询数据库,获取管理员在 benefits_jsonb(即创建/编辑定价计划页面的 Benefits 项)设置的权益,然后将权益回收:
let oneTimeToRevoke = 0;
const benefits = planData.benefits_jsonb as any;
if (benefits?.one_time_credits > 0) {
oneTimeToRevoke = benefits.one_time_credits;
}
if (oneTimeToRevoke > 0) {
const { data: revokeResult, error: revokeError } = await supabaseAdmin.rpc('revoke_credits', {
p_user_id: originalOrder.user_id,
p_revoke_one_time: oneTimeToRevoke,
p_revoke_subscription: 0
});
}
如果你创建的定价计划,自定义了 benefits_jsonb
,那么你需要重新实现符合需求的 revokeOneTimeCredits
方法。
周期订阅取消订阅和退款
对于周期订阅,仅退款一般无法解决用户争议,正确的做法是取消用户的订阅并退款。因此,针对周期订阅的场景,我们应该监听 customer.subscription.deleted
事件,这一般是在 Stripe 控制台手动操作触发的。
customer.subscription.deleted
事件的处理逻辑在 handleSubscriptionUpdate
,和首次订阅与续订是同一个方法,会更新订阅状态。取消订阅时,状态会变成 canceled
,且 canceled_at
和 ended_at
会赋值当前时间。
customer.subscription.deleted
事件里还会传递一个 isDeleted
标识给 handleSubscriptionUpdate
,用于触发权益回收方法:
if (isDeleted && userId && planId) {
// --- [custom] Revoke the user's benefits (only for one time purchase) ---
revokeSubscriptionCredits(userId, planId, subscription.id);
// --- End: [custom] Revoke the user's benefits ---
}
在 revokeSubscriptionCredits
逻辑中,我们根据用户购买的付费计划的 plan_id
字段查询数据库,获取管理员在 benefits_jsonb
(即创建/编辑定价计划页面的 Benefits 项)设置的权益,然后将权益回收:
let subscriptionToRevoke = 0;
const benefits = planData.benefits_jsonb as any;
if (benefits?.monthly_credits > 0) {
subscriptionToRevoke = benefits.monthly_credits;
}
if (subscriptionToRevoke >= 0) {
const { data: revokeResult, error: revokeError } = await supabaseAdmin.rpc('revoke_credits', {
p_user_id: userId,
p_revoke_one_time: 0,
p_revoke_subscription: subscriptionToRevoke
});
}
如果你创建的定价计划,自定义了 benefits_jsonb
,那么你需要重新实现符合需求的 revokeSubscriptionCredits
方法。
退款如何操作?
遇到用户争议,退款需要从 Stripe 控制台页面发起,一次性付款和订阅的处理方式不同。
一次性支付退款
一次性支付的退款,直接在 Transactions 页面退款即可。

完成退款后,会触发 Webhook 的 charge.refunded
事件,进入我们上面分析的流程。
周期订阅取消并退款
周期订阅的退款通常需要同时取消订阅,所以需要在 Subscriptions 页面进行取消订阅并退款。


完成取消并退款后,会触发 Webhook 的 customer.subscription.deleted
事件,进入我们上面分析的流程。
自定义开发 Checklist
- 修改
app/[locale]payment/success/page.tsx
页面,结合自己的业务设计付款成功的展示信息 - 如果自定义用户权益
- 需要修改获取权益的方法,即
lib/stripe/actions.ts
的getUserBenefits
- 需要修改用户权益展示,即
components/layout/CurrentUserBenefitsDisplay.tsx
组件 - 对于一次性付款
- 需要重新实现权益升级方法,即
lib/stripe/webhook-handlers.ts
的upgradeOneTimeCredits
- 需要重新实现退款权益回收方法,即
lib/stripe/webhook-handlers.ts
的revokeOneTimeCredits
- 需要重新实现权益升级方法,即
- 对于周期订阅付款
- 需要重新实现权益升级方法,即
lib/stripe/webhook-handlers.ts
的upgradeSubscriptionCredits
- 需要重新实现退款权益回收方法,即
lib/stripe/webhook-handlers.ts
的revokeSubscriptionCredits
- 需要重新实现权益升级方法,即
- 如果你的产品用户权益使用积分体系
- 使用内置的
usage
表以及one_time_credits_balance
和subscription_credits_balance
字段,可直接使用actions/usage/deduct.ts
提供的方法进行积分扣除 - 重新设计了积分体系,则可以参考
actions/usage/deduct.ts
实现自定义扣减积分方法
- 使用内置的
- 需要修改获取权益的方法,即
- 修改周期订阅续订失败的邮件通知(非必需,默认模板可通用),即
emails/invoice-payment-failed.tsx