Menu

支付系统概述

提示

本章描述 v4.0.0 及之后的支付系统。v4 是一次重写:定价从数据库搬进了代码,三家 provider 的履约逻辑收敛成了一层。从 3.x 升级请先读 版本 4.x 更新记录。

NEXTY.DEV 的支付系统同时支持 Stripe、Creem、PayPal 三家渠道,覆盖订阅(月付/年付)与一次性购买(积分包),内置积分账本、团队共享池、退款、对账和欺诈处理。

一条链路看懂全貌

config/pricing.ts                    定价唯一事实来源(套餐 = 对象)
        │ planId
        ▼
actions/billing/checkout.ts          统一结账入口,按 plan.provider 分发
        │
        ▼
provider 托管收银台                    Stripe Checkout / Creem Checkout / PayPal
        │ 用户付款
        ▼
app/api/{stripe,creem,paypal}/webhook    路由只做签名校验 + 分发,不含业务逻辑
        │
        ▼
lib/billing/{stripe,creem,paypal}.ts     适配器:把 webhook 负载翻译成统一入参
        │
        ▼
lib/billing/fulfillment.ts           履约核心(provider 无关),一个事务写完
        │
        ├──▶ orders            订单行(幂等键 = provider + provider_order_id)
        ├──▶ subscriptions     订阅行(含年付定投计划)
        └──▶ lib/credits/      积分账本(余额 + 只追加流水)
 
app/api/cron/credits                 定时任务:补救丢失的 webhook、结算年付定投

五条设计原则

理解了这五条,后面几篇文档都会变得显然。

1. 定价是代码,不是数据。 没有 pricing_plans 表,没有后台 CRUD。config/pricing.ts 是唯一事实来源,改价走代码审查和部署。详见定价配置。

2. 金额一律整数分。 数据库里所有金额字段都是 integer,单位是 provider 原生的「分」。绝不使用 numeric 或浮点数。

3. 幂等是结构性的,不是防御式的。 orders 表上有 (provider, provider_order_id) 唯一索引,写入用 onConflictDoNothing,退款累计金额而非追加行。webhook 重放天然无害,不需要在业务代码里到处判重。

4. 积分只能通过账本变更。 余额表不允许被直接 UPDATE,所有变动经 lib/credits/ledger.ts,且在同一事务内往 credit_transactions 追加一行带快照的流水。

5. provider SDK 类型不出适配器。 履约核心只认 lib/billing/types.ts 里的统一入参,三家 provider 的原生负载在各自适配器里就翻译完了。新增一家 provider = 新增一个适配器,核心不动。

数据库表

表作用要点
orders每一笔钱的记录幂等键 (provider, provider_order_id);退款改写原行不新增行;用户删除后 user_id 置 NULL,订单不消失
subscriptions订阅状态统一状态字段(provider 状态由适配器归一化);年付定投计划以一等字段存在:monthly_credits / remaining_grants / next_grant_at
credit_balances个人积分余额两个桶:subscription_credits(每次发放重置)与 purchased_credits(永不过期)
organization_credit_balances团队共享积分池与个人表同形,主键换成 organization_id
credit_transactions积分流水只追加;每行带 amount(有符号)、分桶增量、变动后快照和单调递增的 seq

订单类型 order_type:

值含义
one_time积分包购买
subscription_initial订阅首期账单
subscription_renewal订阅续费账单
subscription_upgrade周期内变更套餐的按比例补差账单

订单状态 order_status:pending(仅 PayPal eCheck 等挂起捕获会用到,积分为 0,由定时任务推进)、completed、partially_refunded、refunded、failed。

代码地图

config/
  pricing.ts            套餐定义 + 查询辅助方法(唯一事实来源)
  credits.ts            非付费积分规则(注册赠送)
 
actions/
  billing/checkout.ts   统一结账入口
  billing/portal.ts     订阅自助管理入口(按 provider 分发)
  credits/              积分读取与消费(用户端)、后台调整与批量发放
  orders/               订单查询(用户端/管理端)、退款入口
  coupons/admin.ts      优惠券管理(Stripe 为唯一存储)
 
lib/billing/
  types.ts              统一类型契约
  fulfillment.ts        provider 无关的履约核心
  stripe.ts creem.ts paypal.ts    三个适配器
  cancel.ts             统一的 provider 侧取消
  duplicate.ts          重复订阅结算
  reconcile.ts          定时对账(补救丢失的 webhook)
  notify.ts             webhook 触发的通知(Redis 去重)
  customer.ts           stripeCustomerId 的唯一读写口
 
lib/credits/
  ledger.ts             写层:所有余额变动的唯一通道
  index.ts              读层 + 年付定投结算
 
app/api/
  stripe/webhook  creem/webhook  paypal/webhook    三个 webhook 路由
  paypal/create-order  paypal/capture-order        PayPal 一次性购买
  cron/credits                                     定时任务入口
 
components/pricing/     PricingSection / PricingCard / CheckoutButton

计费主体:个人与团队

同一套代码服务两种计费主体:

  • 个人:organization_id 为 NULL,积分进 credit_balances
  • 团队:organization_id 有值,积分进 organization_credit_balances 共享池,user_id 降级为「付款人 / 操作人」

两者共享同一套桶结构和幂等机制,只是余额表不同。几条约定:

  • 团队套餐用 audience: 'team' 标记,seats 决定席位上限;无订阅时席位为 1(只容得下 owner)
  • 积分包是个人资产,团队工作区内禁止购买(结账会返回 TEAM_WORKSPACE_CREDIT_PACK,前端提示切回个人工作区)
  • 「有效订阅」的判定在个人与团队之间彼此隔离,各占各的坑位

三家 provider 的能力差异

能力StripeCreemPayPal
订阅✅ Checkout Session✅ Checkout✅ 需预先创建 Billing Plan
一次性购买✅ Checkout Session✅ Checkout✅ 按 price 动态创建订单
托管自助门户✅ Billing Portal✅ Customer Portal❌ 跳转 PayPal 自动付款页
自动应用促销码✅✅ discountCode❌
优惠券管理后台✅❌❌
退款 API✅✅✅
挂起付款(eCheck)——✅ 落 pending 订单,定时任务推进
欺诈预警✅ Radar✅ dispute✅ reversal

每个套餐由 provider 字段单独声明走哪家,三家可以同时启用。

接着读