支付流程详解
本文讲清楚一笔钱从点击到入账都经过了什么。
流程概览
① 定价页点击 components/pricing/CheckoutButton.tsx
│ planId
▼
② 统一结账入口 actions/billing/checkout.ts
│ 守卫全部通过后,按 plan.provider 分发
▼
③ 渠道托管收银台 Stripe Checkout / Creem Checkout / PayPal 批准页
│ 用户付款
├────────────────────────────────┐
▼ ▼
④ 回跳成功页 ⑤ 渠道 webhook
/payment/success app/api/{provider}/webhook
│ │
└──────────┬─────────────────────┘
▼
lib/billing/fulfillment.ts 幂等履约,谁先到谁写
│
▼
orders + subscriptions + 积分账本④ 和 ⑤ 是双保险,不是二选一:两条路都会调用同一套幂等的履约函数,谁先到谁生效,另一条自然空转。webhook 丢了,用户刷新成功页也能到账;用户直接关掉浏览器,webhook 照样到账。
① 定价页点击
CheckoutButton 调用 createCheckoutSession({ planId, organizationId?, toltReferral? }),拿到 URL 后跳转。
PayPal 积分包是个例外:它不走这个 action,而是由 PayPalCheckoutButton 用 PayPal 官方按钮完成(见下文「PayPal 积分包的特殊路径」)。
② 统一结账入口
actions/billing/checkout.ts 里的守卫按顺序执行,provider 无关的检查全部在前,分发在最后。
守卫顺序
1. 登录校验 —— 未登录返回 UNAUTHORIZED。
2. 套餐存在且未下架 —— getPlanById 查不到,或 active === false,直接拒绝。
3. 积分包禁止在团队工作区购买 —— 积分包是个人资产,只进买家的个人购买桶。在团队工作区放行会造成「付了钱但当前工作区余额没动」,所以直接禁止,返回 TEAM_WORKSPACE_CREDIT_PACK,前端提示切回个人工作区。
4. 团队套餐的组织解析 —— 团队套餐的计费主体是组织,且只有 owner 能订阅。解析顺序:
显式传入的 organizationId
↓ 没有
当前工作区的组织(且调用者是 owner)
↓ 没有
该用户名下唯一拥有的组织
↓ 还是定不下来
返回 ORGANIZATION_REQUIRED5. 单订阅守卫 —— 同一计费主体同时只能有一个有效订阅。
原因是订阅积分的发放是重置语义:两个并发订阅会互相覆盖对方的积分。变更套餐请走渠道自助门户,而不是再买一份。
这里的「有效」采用 ENTITLED 口径(active / trialing / past_due / unpaid)—— 催款期的订阅仍然占着坑位,因为渠道还在重试、随时可能恢复。催款中的用户应该去门户里换卡,而不是叠加第二份订阅。
个人与团队的坑位彼此独立:个人订阅和团队订阅互不冲突。
不通过时返回 SUBSCRIPTION_EXISTS。
按 provider 分发
| provider | 订阅 | 积分包 |
|---|---|---|
stripe | Checkout Session(mode: 'subscription') | Checkout Session(mode: 'payment') |
creem | Creem Checkout | Creem Checkout |
paypal | Billing Subscription 批准链接 | ❌ 不走本 action,返回 PAYPAL_CREDIT_PACK_BUTTON |
Stripe 的重复订阅防御
创建 Stripe 订阅 session 之前,会先把该 customer 名下所有 status: 'open' 的订阅类 session 全部 expire 掉。
这是第一道防御。Stripe 的 checkout session 大约 24 小时内都可付款,用户开了 A 没付、又去买了 B 并付款,之后再回头付 A,就会产生两份订阅。在源头关掉这个窗口最省事。(第二道防御在履约侧:先到先得 + 自动退款,见 Webhook 处理。)
这一步失败不会阻断结账 —— 大不了多退一笔款,而不是让用户买不了。
metadata 里带什么
{
userId, // 买家
planId, // 套餐 slug
organizationId, // 仅团队订阅,履约据此把积分打进组织池
tolt_referral, // 仅有联盟推荐时
}团队订阅挂在购买者的个人 customer 上(没有组织级 customer),把积分路由到组织池的唯一依据就是 metadata.organizationId。Stripe 侧会把它同时钉在 session、subscription 和 payment intent 三处。
返回 URL 与语言
成功/取消回跳地址都带当前语言前缀。项目使用 as-needed 的前缀策略且关闭了 localeDetection,URL 前缀是语言的唯一载体 —— 丢了前缀,用户付完款回来就变成英文站了。
③ 渠道托管收银台
用户在渠道页面完成付款。配置了促销码的话,Stripe 与 Creem 会自动应用;没配置则展示渠道自带的手工输入框(详见定价配置)。
④ 回跳成功页
/payment/success 是三家渠道共用的 success_url,靠 query 参数区分:
| provider | 参数 |
|---|---|
| Stripe | session_id |
| Creem | checkout_id |
| PayPal | order_id(= capture id) |
打开这个页面本身就会触发一次履约,调用的是与 webhook 同一套幂等函数(fulfillCheckoutSessionById / fulfillCreemCheckoutById / fulfillPayPalCaptureById)。
页面有三种终态:
- 成功 —— 展示套餐名、到账积分。团队套餐时,第二个按钮是「邀请成员」,直达
/dashboard/team - 处理中 —— PayPal 挂起捕获(eCheck)会渲染这个状态,刷新页面会重新校验。这是 COMPLETED webhook 丢失时用户侧的自愈通道
- 失败 —— 终态失败(DECLINED / FAILED)渲染失败卡片,绝不显示误导性的「处理中」
未登录访客会带着语言前缀重定向到 /login。
⑤ Webhook 履约
这是主通道,详见 Webhook 处理机制。要点:
- 路由只做签名校验和分发,不含业务逻辑
- 适配器把渠道负载翻译成统一入参,履约核心一个事务写完订单 + 订阅 + 积分
- 可重试错误返回 5xx 让渠道重试;
PermanentFulfillmentError(比如买家已注销账号)先告警再 ack,不进重试循环
PayPal 积分包的特殊路径
PayPal 的一次性购买不经过 createCheckoutSession,而是走官方 JS 按钮:
PayPalCheckoutButton(客户端)
│
├─▶ POST /api/paypal/create-order 按 plan.price 动态创建订单
│
│ 用户在 PayPal 弹窗内批准
│
└─▶ POST /api/paypal/capture-order 捕获款项
│
├─ COMPLETED → 履约,跳成功页
├─ PENDING → 落 pending 订单行,跳成功页显示处理中
└─ DENIED → 标记失败pending 订单行是定时任务追踪的本地凭据:/api/cron/credits 会持续把它推进为 completed(发放积分)或 failed(零积分,仅留审计痕迹)。
拦截码速查
createCheckoutSession 失败时通过 customCode 返回,CheckoutButton 据此决定去处:
| customCode | 含义 | 前端行为 |
|---|---|---|
UNAUTHORIZED | 未登录 | 跳登录页 |
SUBSCRIPTION_EXISTS | 该主体已有有效订阅 | 跳 /dashboard/billing |
TEAM_WORKSPACE_CREDIT_PACK | 团队工作区内买积分包 | 提示切回个人工作区 |
ORGANIZATION_REQUIRED | 团队套餐但定不下组织 | 跳 /dashboard/team |
PAYPAL_CREDIT_PACK_BUTTON | PayPal 积分包走错了通道 | 提示用 PayPal 按钮 |
测试支付流程
- 三家渠道都用测试/沙盒模式的密钥,
config/pricing.ts的test列填测试环境的价格 ID - 本地转发 webhook(Stripe 用
stripe listen --forward-to localhost:3000/api/stripe/webhook) - Stripe 测试卡:
4242 4242 4242 4242,任意未来日期,任意 CVC - 付款后检查三处:
/payment/success的展示、/dashboard/billing的余额与记录、管理后台/dashboard/admin/orders的订单行