Menu

支付流程与自定义开发

为了让模板购买者更容易上手并实现自己的支付逻辑,本章将介绍 Nexty.dev 内置支付流程的代码实现,并讲解如何开启你的自定义逻辑开发。

支付流程

这一小节,我们重点介绍用户可感知的支付流程,Webhook 部分的流程在下一小节单独讲解。

显示定价计划

默认显示定价计划的组件是 components/home/Pricing.tsx 及其子组件。

通过 getPublicPricingPlans 方法获取管理员设置的激活的定价计划,会分组展示月付、年付、一次性支付的定价。

pricing

但并不是所有产品都需要同时提供这3组定价计划,可能你的产品只有1-3个定价计划,你更希望把3个卡片不分组一起列出来,那么可以使用 components/home/PricingAll.tsx,修改方式如下:

components/home/index.tsx
export default async function HomeComponent() {
  const messages = await getMessages();
 
  return (
    <div className="w-full">
      {messages.Landing.Hero && <Hero />}
 
      // 把调用 <Pricing /> 改成调用 <PricingAll>
      // {messages.Landing.Pricing && <Pricing />}
      {messages.Landing.Pricing && <PricingAll />}
 
    </div>
  );
}
pricingAll

用户付款

用户点击购买按钮,会向 /api/stripe/checkout-session 接口发送请求。

在该接口里,会查询当前用户在 Stripe 账户里的唯一标识(customer_id),然后根据用户想要购买的计划,向 Stripe 请求付款页面(checkout 页面)。

在请求 Stripe 的参数里还包含 success_urlcancel_url,分别用于指定付款成功和取消付款后重定向的页面。

前端获取到 checkout 地址后,会进入 Stripe 的付款页面。付款成功会重定向到 success_url 指定的页面。

提示

  • success_url 通常不需要修改,但需要修改 app/[locale]/payment/success/page.tsx 页面的内容,以适应你的业务
  • cancel_url 通常不需要修改,它指向环境变量设置的定价页面(默认是首页的 /#pricing

提示

  • 测试支付流程前,必须完成Stripe 集成中创建 Webhook 和开启 Forward Port 等关键步骤。

  • 使用 Stripe 提供的测试信息测试支付流程:

    • 信用卡:4242 4242 4242 4242
    • 使用有效的未来日期,例如 12/34
    • 使用任意三位数 CVC(American Express 卡为四位)
    • 其他表单字段使用任意值。

付款成功

需要修改 app/[locale]/payment/success/page.tsx 页面,因为 Nexty 默认的付款成功页面是显示用户购买的定价计划。你应该按照自己的业务修改展示内容,让用户体验更好。

payment success

用户权益展示

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

benefits

还可以在 /dashboard/credit-history 看到积分历史记录

credit_logs

打开 Supabase 的 Table Editor,可以分别在 orderssubscriptionsusagecredit_logs 看到对应的数据。

获取用户权益的核心方法在 actions/usage/benefits.tsgetUserBenefits,返回的权益信息定义如下:

export interface UserBenefits {
  activePlanId: string | null;
  subscriptionStatus: string | null; // e.g., 'active', 'trialing', 'past_due', 'canceled', null
  currentPeriodEnd: string | null;
  nextCreditDate: string | null;
  totalAvailableCredits: number;
  subscriptionCreditsBalance: number;
  oneTimeCreditsBalance: number;
  // Add other plan-specific benefits if needed, fetched via planId
}

当你需要自定义权益时,应该注意:

  • activePlanIdsubscriptionStatuscurrentPeriodEndsubscriptions 表获取,反映了用户购买的计划和当前订阅状态,不应该修改
  • nextCreditDate 是年付订阅按月重置积分使用的,让用户知道下一次重置积分是什么时候;如果用不到,可以保留但不使用,也可以全局搜索 “nextCreditDate”,然后全部删掉
  • totalAvailableCreditssubscriptionCreditsBalanceoneTimeCreditsBalanceusage 表获取,反映了内置的一次性支付与周期订阅积分余额,如果用不到,仍然建议保留但不使用,即在调用 getUserBenefits 方法的地方不取这三个参数的返回值即可
  • 你自定义的权益,应该在现有的 UserBenefits 进行扩展,同时要修改 getUserBenefitsusage 表查询方法和返回对象

当你完成 getUserBenefits 的修改后,需要检查 components/layout/CurrentUserBenefitsDisplay.tsx,这个组件会影响 /dashboard/subscription页面和右上角展示的用户权益。

模拟用户使用积分

从代码仓库 v1.1.4 开始,Nexty 提供了模拟用户使用积分的示例。

你可以在本地启动项目,进入 /dashboard/credit-usage-example 页面测试内置的积分扣除流程。该页面仅在开发环境可以打开,无需担心生产环境会被滥用。

/dashboard/credit-usage-example 配套的积分扣除方法在 actions/usage/deduct.ts 里,其中调用了 Supabase 的 RPC deduct_credits_and_log,该 RPC 定义在 supabase/migrations/20250725053916_initial_credit_logs.sql 文件里。

credit-usage-example

Webhook

Stripe 集成章节里,我们已经在 Stripe 控制台勾选了以下7种 Webhook 事件:

  • charge.refunded
  • checkout.session.completed
  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.paid
  • invoice.payment_failed

接收 Webhook 的接口在 app/api/stripe/webhook/route.ts,主要处理逻辑放在 processWebhookEvent 方法里。

现在,你可以开始对照代码和接下来的文档,深入理解 Nexty 提供的支付体系。

一次性付款

一次性付款只要接收到 checkout.session.completed 事件就说明用户已经完成付款,可以开始处理一次性付款的权益了。

包括 Stripe 在内的支付平台,Webhook 事件都存在重复推送的可能,所以在往 orders 表写入订单数据之前,我们需要先检查数据库是否已存在相同订单数据。这就是"幂等性检查",在 handleCheckoutSessionCompleted 方法里执行。

const { data: existingOrder, error: queryError } = await supabaseAdmin
  .from('orders')
  .select('id')
  .eq('provider', 'stripe')
  .eq('provider_order_id', paymentIntentId)
  .maybeSingle();

通过幂等性检查后,我们会把 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 定义的权益,然后为用户升级付费权益。Nexty 默认使用了内置的 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('grant_one_time_credits_and_log', {
    p_user_id: userId,
    p_credits_to_add: creditsToGrant,
    p_related_order_id: orderId,
  });
}

如果你创建了新的定价计划,并且自定义 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();

通过幂等性检查后,我们通过 Stripe SDK 获取最新订阅信息,把订阅信息写入 orders 表,同时通过 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 定义的权益,然后为用户升级付费权益。Nexty 默认使用了内置的 monthly_credits 为用户增加积分。

const { data: planData, error: planError } = await supabaseAdmin
  .from('pricing_plans')
  .select('recurring_interval, benefits_jsonb')
  .eq('id', planId)
  .single();
 
const benefits = planData.benefits_jsonb as any;
const recurringInterval = planData.recurring_interval;
 
const creditsToGrant = benefits?.monthly_credits || 0;
 
if (recurringInterval === 'month' && creditsToGrant) {
  const { error: usageError } = await supabaseAdmin.rpc('grant_subscription_credits_and_log', {
    p_user_id: userId,
    p_credits_to_set: creditsToGrant,
    p_related_order_id: orderId,
  });
}

如果你创建了新的定价计划,并且自定义 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 提供了 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();

通过幂等性检查后,会在 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 定义的权益,然后将权益回收:

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_and_log', {
    p_user_id: originalOrder.user_id,
    p_revoke_one_time: oneTimeToRevoke,
    p_revoke_subscription: 0,
    p_log_type: 'refund_revoke',
    p_notes: `Full refund for order ${originalOrder.id}.`,
    p_related_order_id: refundOrderId,
    p_clear_yearly_details: false,
    p_clear_monthly_details: false
  });
}

如果你创建了新的定价计划,并且自定义 benefits_jsonb,那么你需要根据实际需求自定义 revokeOneTimeCredits 方法。

周期订阅取消订阅和退款

对于周期订阅,仅退款一般无法解决用户争议,正确的做法是取消用户的订阅并退款。因此,针对周期订阅的场景,我们应该监听 customer.subscription.deleted 事件,这一般是在 Stripe 控制台手动操作触发的。

customer.subscription.deleted 事件的处理逻辑在 handleSubscriptionUpdate,和首次订阅与续订是同一个方法,会更新订阅状态。取消订阅时,状态会变成 canceled,且 canceled_atended_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 逻辑中,我们根据 usage 表的 balance_jsonb 字段记录的权益定义进行权益回收:

if (subscriptionToRevoke) {
  const { data: revokeResult, error: revokeError } = await supabaseAdmin.rpc('revoke_credits_and_log', {
    p_user_id: userId,
    p_revoke_one_time: 0,
    p_revoke_subscription: subscriptionToRevoke,
    p_log_type: 'subscription_cancel_revoke',
    p_notes: `Subscription ${subscriptionId} cancelled/ended.`,
    p_related_order_id: null,
    p_clear_yearly_details: clearYearly,
    p_clear_monthly_details: clearMonthly
  });
}

如果你创建了新的定价计划,并且自定义 benefits_jsonb,那么你需要根据实际需求自定义 revokeSubscriptionCredits 方法。

提示

无论是根据定价计划的 benefits_jsonb 还是根据 usagebalance_jsonb 进行权益回退,都不是最好的处理方式,下一个版本会升级成与 order 数据关联。

退款如何操作?

遇到用户争议,退款需要从 Stripe 控制台页面发起,一次性付款和订阅的处理方式不同。

一次性支付退款

一次性支付的退款,直接在 Transactions 页面退款即可。

refund-onetime-1

完成退款后,会触发 Webhook 的 charge.refunded 事件,进入我们上面分析的流程。

周期订阅取消并退款

周期订阅的退款通常需要同步取消订阅,所以需要在 Subscriptions 页面进行取消订阅并退款。

refund-sub-1
refund-sub-2

完成取消并退款后,会触发 Webhook 的 customer.subscription.deleted 事件,进入我们上面分析的流程。

自定义开发 Checklist

  • 修改 app/[locale]/payment/success/page.tsx 页面,结合自己的业务设计付款成功的展示信息
  • 自定义用户权益
    • 需要修改获取权益的方法,即 actions/usage/benefits.tsgetUserBenefits
    • 需要修改用户权益展示,即 components/layout/CurrentUserBenefitsDisplay.tsx 组件
    • 对于一次性付款
      • 需要重新实现权益升级方法,即 lib/stripe/webhook-handlers.tsupgradeOneTimeCredits
      • 需要重新实现退款权益回收方法,即 lib/stripe/webhook-handlers.tsrevokeOneTimeCredits
    • 对于周期订阅付款
      • 需要重新实现权益升级方法,即 lib/stripe/webhook-handlers.tsupgradeSubscriptionCredits
      • 需要重新实现退款权益回收方法,即 lib/stripe/webhook-handlers.tsrevokeSubscriptionCredits
    • 如果你的产品用户权益使用积分体系
      • 使用内置的 usage 表以及 one_time_credits_balancesubscription_credits_balance 字段,可直接使用 actions/usage/deduct.ts 提供的方法进行积分扣除
      • 重新设计了积分体系,则可以参考 actions/usage/deduct.ts 实现自定义扣减积分方法
  • 修改周期订阅续订失败的邮件通知(非必需,默认模板可通用),即 emails/invoice-payment-failed.tsx