支付系统概述
提示
本章描述 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 的能力差异
| 能力 | Stripe | Creem | PayPal |
|---|---|---|---|
| 订阅 | ✅ Checkout Session | ✅ Checkout | ✅ 需预先创建 Billing Plan |
| 一次性购买 | ✅ Checkout Session | ✅ Checkout | ✅ 按 price 动态创建订单 |
| 托管自助门户 | ✅ Billing Portal | ✅ Customer Portal | ❌ 跳转 PayPal 自动付款页 |
| 自动应用促销码 | ✅ | ✅ discountCode | ❌ |
| 优惠券管理后台 | ✅ | ❌ | ❌ |
| 退款 API | ✅ | ✅ | ✅ |
| 挂起付款(eCheck) | — | — | ✅ 落 pending 订单,定时任务推进 |
| 欺诈预警 | ✅ Radar | ✅ dispute | ✅ reversal |
每个套餐由 provider 字段单独声明走哪家,三家可以同时启用。