订阅变更
提示
v4.0.0 起,周期内套餐变更由模板内置处理,不再需要你自己实现。3.x 里那份「按注释自行开发」的指南已经作废。本文讲的是:怎么把入口配好,以及内置逻辑到底怎么算。
产品与定价的关系
开始之前有一个重要前提:Stripe 的订阅变更是基于不同产品之间的切换:
- 支持:Product A → Product B
- 不支持:Product A 的 Price 1 → Product A 的 Price 2
所以建议按功能层级划分产品,而不是把所有定价方案塞进同一个产品:
Product1:
├── Price: $10/month
└── Price: $100/year
Product2:
├── Price: $20/month
└── Price: $200/year
Product3:
├── Price: $50/month
└── Price: $500/yearconfig/pricing.ts 里对应的就是 starter-monthly / starter-yearly / pro-monthly / pro-yearly 这样的套餐条目,每个条目指向自己的 Price ID。
开启订阅变更的入口
进入 Stripe Customer Portal 设置:

在 Subscriptions 里开启变更入口,依次设置订阅变更的选项:



设置完成后,用户在 /dashboard/billing 点击管理订阅进入门户,就能看到变更套餐的功能:



用户变更计划时,Stripe 会自动计算差价并开出一张按比例的补差账单。
变更之后发生了什么
用户在 Billing Portal 变更套餐
│
▼
Stripe 开出按比例补差账单
│
▼
invoice.paid(billing_reason = subscription_update)
│
▼
lib/billing/stripe.ts 判定为周期内变更
│
▼
fulfillSubscriptionUpgrade() ← 增量履约,不是重置对比一下三种账单的路由:
billing_reason | 走哪条路 | 积分怎么处理 |
|---|---|---|
subscription_create | fulfillSubscriptionPayment | 完整重置为新套餐额度 |
subscription_cycle | fulfillSubscriptionPayment | 完整重置(新周期开始) |
subscription_update | fulfillSubscriptionUpgrade | 增量补差,不重置 |
积分差额怎么算
一句话公式:
delta = round( max(0, 新套餐月度额度 − 本窗口内已发放的累计额度) × 窗口剩余时间占比 )订阅桶只增不重置。两个因子各自承担一个职责:
因子一:窗口高水位(已发放累计额度)
从账本里累加本窗口内所有 subscription_grant 行的订阅桶增量得到,而不是读旧套餐的配置值。
这么做有两个好处:
- 天然免疫 webhook 乱序 —— 不依赖「变更前是哪个套餐」这种会被乱序打乱的状态
- 同时充当防刷上限 —— 一个发放窗口内的累计发放量,永远不会超过该窗口里达到过的最高套餐月度额度
因子二:剩余时间占比
让发放量与实际付的那笔按比例差价严格对应。
两个因子合起来防住了什么
来回横跳刷积分:先降级再升级时,降级产生的余额会抵掉升级账单(实付接近 0)。但高水位不会因为降级而下降,所以第二次升级算出来的 delta 恒为 0,刷不出积分。
周期末低价捞一个月:只按剩余时间比例发放,捞不到整月的差额。
没有金额门槛
一张被余额完全抵扣、实付 0 元的升级账单,照样按上面的公式补发 —— 高水位机制已经保证了安全性,不需要再加一道「付了多少钱才给发」的判断。
降级
既不发放,也不回收。
高水位不会下降,所以降级当期什么都不发;下一期的周期账单(subscription_cycle)会走完整重置路径,自然把额度降到新套餐的水平。
这个设计避免了「降级立刻扣走用户已经到手的积分」这种体验灾难。
跨周期变更(月 ↔ 年)
付款重新划定了计费周期,年付定投计划要跟着调整:
| 变更方向 | 定投怎么处理 |
|---|---|
| 月付 → 年付 | 重锚:本次订单即第一个月的差额,剩余 11 期写进调度 |
| 年付 → 月付 | 清空定投计划 |
| 年付 → 年付(同周期) | 不动定投。后续结算读订阅行上的 monthly_credits,而 syncSubscription 已经更新过它,所以自动按新额度发放 |
两个边界情况
已被判为重复订阅的订阅:本地状态已是终态,只保留钱的痕迹(一条 0 积分的订单行),不再发放任何积分。
金额为 0 且差额为 0 的补差账单(纯降级或平级切换):既没有钱也没有积分,不写订单行。
其他渠道
Creem 和 PayPal 目前不走这条周期内变更路径 —— 它们的订阅变更依赖各自门户的能力,付款事件会按常规的订阅付费处理。如果你的主力渠道是这两家,套餐变更请引导用户先取消再重新订阅。
测试
Stripe 的虚拟时钟(Test Clock)可以推进时间,模拟完整的订阅生命周期。
要覆盖的场景:
同周期变更
- 月付升级:
starter-monthly→pro-monthly - 月付降级:
pro-monthly→starter-monthly - 年付升级 / 降级:同理
跨周期变更
- 月转年:任意月付 → 对应年付
- 年转月:任意年付 → 对应月付
防刷验证
- 降级后立刻升级回去,确认第二次升级的 delta 为 0
- 在周期末尾升级,确认只发了剩余时间占比对应的量
每次变更后检查这四处:
| 位置 | 看什么 |
|---|---|
/dashboard/billing | 余额、当前套餐是否正确 |
/dashboard/my-orders | 是否有 subscription_upgrade 类型的订单 |
credit_transactions | 流水类型、增量、快照是否连贯 |
subscriptions 表 | 跨周期变更后,monthly_credits / remaining_grants / next_grant_at 是否符合预期 |
开发模式下还可以用 /dashboard/credit-usage-example 快速验证消费路径。