Menu

支付流程详解

本文讲清楚一笔钱从点击到入账都经过了什么。

流程概览

① 定价页点击                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_REQUIRED

5. 单订阅守卫 —— 同一计费主体同时只能有一个有效订阅。

原因是订阅积分的发放是重置语义:两个并发订阅会互相覆盖对方的积分。变更套餐请走渠道自助门户,而不是再买一份。

这里的「有效」采用 ENTITLED 口径(active / trialing / past_due / unpaid)—— 催款期的订阅仍然占着坑位,因为渠道还在重试、随时可能恢复。催款中的用户应该去门户里换卡,而不是叠加第二份订阅。

个人与团队的坑位彼此独立:个人订阅和团队订阅互不冲突。

不通过时返回 SUBSCRIPTION_EXISTS。

按 provider 分发

provider订阅积分包
stripeCheckout Session(mode: 'subscription')Checkout Session(mode: 'payment')
creemCreem CheckoutCreem Checkout
paypalBilling 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参数
Stripesession_id
Creemcheckout_id
PayPalorder_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_BUTTONPayPal 积分包走错了通道提示用 PayPal 按钮

测试支付流程

  1. 三家渠道都用测试/沙盒模式的密钥,config/pricing.ts 的 test 列填测试环境的价格 ID
  2. 本地转发 webhook(Stripe 用 stripe listen --forward-to localhost:3000/api/stripe/webhook)
  3. Stripe 测试卡:4242 4242 4242 4242,任意未来日期,任意 CVC
  4. 付款后检查三处:/payment/success 的展示、/dashboard/billing 的余额与记录、管理后台 /dashboard/admin/orders 的订单行