计费与订阅
基于 Stripe 的订阅(月付/年付)与一次性终身买断,带幂等 webhook、套餐门控(requirePlan)、计费事件钩子。本页讲权益模型、webhook 处理、以及**扣款失败(dunning)**怎么分工。
权益模型(entitlement.ts)
getEntitlement() 返回一个 Entitlement:{ plan, status, isActive, currentPeriodEnd, lifetime, paymentFailed }。
plan:free|pro。requirePlan('pro')据此做路由门控;role === 'admin'直接放行(角色高于付费墙)。功能门控用hasProAccess(role, ent),计费 UI(套餐徽章/升级按钮)保持用ent.plan反映真实订阅。lifetime:终身买断优先于一切 status(即使canceled仍是 Pro)。paymentFailed:续费扣款失败的站内信号(见下文)。
Webhook 处理(开源版范围)
translateStripeEvent 把 Stripe 事件翻成领域事件,落库时幂等(processed_webhook_events 去重):
| Stripe 事件 | 处理 |
|---|---|
customer.subscription.created/updated | upsert 订阅状态;active/trialing → Pro 门控开,past_due 等 → 关 |
customer.subscription.deleted | 降级 free |
checkout.session.completed(mode=payment) | 终身买断激活(仅 payment_status=paid;延迟到账时不预授) |
checkout.session.async_payment_succeeded | 延迟到账支付(ACH/SEPA 等)入账 → 终身买断激活 |
charge.refunded(全额) | 终身买断退款 → 降级 |
invoice.payment_failed | 置 paymentFailed flag(站内 banner),触发 onPaymentFailed 钩子 |
其余(invoice.paid / dispute 等) | 忽略(商业版扩展点) |
扣款失败(Dunning):Stripe 负责重试,app 负责站内提示
重试与催缴邮件交给 Stripe——它做得比手搓的好,而且零代码:
- Stripe Dashboard → Settings → Billing → Subscriptions and emails。
- 开启 Smart Retries(智能重试失败扣款)。
- 开启 failed payment 的客户邮件(自带"更新支付方式"的托管链接)。
- 可选:配置重试耗尽后是
cancel还是mark unpaid订阅。
模板只补 Stripe 给不了的那一件事——站内信号:
- 收到
invoice.payment_failed→ 在订阅行置payment_failed_at(billing.server.ts)。 resolveEntitlement据此暴露entitlement.paymentFailed。- 登录后的 app 顶部显示
PaymentFailedBanner:"上次扣款失败,请更新支付方式" → 跳 Stripe Customer Portal 换卡。 - 扣款恢复(订阅回到
active/trialing)时,flag 自动清除。
为什么不在 app 里发阶梯催缴邮件?那会和 Stripe 的能力重复、且节奏脆弱。分工后,dunning 不需要 Cron——这也是 Cron 在开源版被定性为"清理任务参考实现"而非计费基建的原因(见 cf-gotchas)。
微信支付(赞助页的一次性付款)
微信支付走 Stripe 的 wechat_pay,不需要独立的微信商户号——对本系统而言它只是"一笔非 USD 计价的普通 Stripe 收款",webhook / 退款 / 幂等全部复用现有链路。
只在赞助页(/sponsor)的一次性赞助上提供,原因是 Stripe 的 wechat_pay 不支持 subscription / setup mode(recurring 目前仍是 private preview)。所以:
- 月度赞助、Pro 月付/年付订阅 → 没有微信入口(前端不渲染按钮,后端一并拒绝)。
- Pro 终身买断虽然是一次性付款,但当前用预设 Price ID(USD)结账,未接微信——需要时按
sponsor.stripe.ts的写法照搬即可。
收款币种与固定汇率
wechat_pay 按 Stripe 商户国限制计价币种:cny 所有国家可用、hkd 仅 HK 账户、usd 仅 US 账户,其余各限对应国家。HK 账户不能用 USD 收微信款——所以配置里给了一个固定汇率:
// src/features/sponsor/sponsor.config.ts
wechat: {
currency: 'hkd', // 必须匹配你的 Stripe 商户国
usdRate: 7.8, // 1 USD 折多少 currency
}定价基准仍是 USD(档位/自定义金额都是美分);usdRate 只在创建 Checkout Session 那一刻换算一次,结果随即冻结进订单:
| 列 | 含义 |
|---|---|
amount + currency | 实收额(微信为 HKD 分)。对账 Stripe 流水的口径,永不换算 |
amount_usd | 冻结的 USD 分等值(写入 session metadata 再由 webhook 回读) |
赞助墙的累计分层只用 amount_usd——它把同一 GitHub 的多行金额相加再比阈值,混币种直接相加会把 HK$78 算成 $78 顶进金牌档。冻结而非读取时换算,意味着日后调 usdRate 只影响新订单,历史订单展示不漂移。换算逻辑与单测在 src/features/sponsor/currency.ts。
顾客在微信收银台看到的始终是换算后的人民币金额,与 currency 填什么无关。
上线前提与开关
- 必须先在 Stripe Dashboard → Payment methods 为该账户开通 WeChat Pay。这一步无法从代码探测,未开通时用户点击会由 Stripe 报错。
- 入口默认开:
STRIPE_SECRET_KEY+STRIPE_WEBHOOK_SECRET配好即显示。 - kill switch:设
STRIPE_WECHAT_PAY_ENABLED=false即隐藏按钮并拒绝下单。
退款说明
微信支付没有顾客发起的拒付/争议(Stripe 原文:"there is no dispute process that can result in a chargeback"),退款只能由商户在 Dashboard 手动发起,窗口 180 天、异步到账。全额退款照常触发 charge.refunded → 赞助行置 refunded 并下墙(与卡支付同一条路径)。
计费事件钩子(hooks.ts)
BillingHooks 让你在状态跃迁后挂副作用(best-effort,失败不影响 webhook 返回 200):
onProActivated(ctx, via)—— 例:发"Pro 已开通"邮件(默认已示范)。onProDeactivated(ctx, reason)—— 例:回收已开通资源。onPaymentFailed(ctx)—— 例:发 Slack 提醒。重试邮件别在这发(Stripe 已发)。
编辑 src/features/billing/hooks.ts 接你自己的逻辑。
本地开发
留空 STRIPE_* key 时,计费入口优雅降级(无结账按钮)。测试 webhook 用 Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger invoice.payment_failed # 验证站内 banner 出现