订阅变更规格:分摊计费与续费边界条件
我上线过的每一个订阅 bug,都藏在"用户想换套餐"和"账单系统认账"这两件事中间的那道缝里。代码从来不是难点,规格才是。如果你没法用文字告诉我,一个 30 美元的套餐在周期第 10 天升级到 100 美元会发生什么,那你一定会带着 bug 上线。下面是我写这份规格的方法。
内容整理说明
复查日期:2026-05-06。本文已重新纳入公开索引路径,作为 Spec-First 开发 Hub 的延伸阅读。我们补齐了专题路径、站内链接和可索引元数据,便于搜索引擎和读者理解它与核心主题的关系。
先列出四种变更方向
用户对订阅能做的事情只有四种,每一种都有不同的账单语义。我会把它们先单独列出来,因为我见过太多团队把"暂停"和"取消"混为一谈,然后在 postmortem 上吵起来。
- Upgrade:升级到更大的套餐,MRR 上升。立即生效。当场按比例收取差额,续费日期不变。
- Downgrade:降级到更小的套餐。周期末生效。当天不动钱。新价格在续费时开始应用。
- Cancel:访问权限到周期末才结束,不是点击那一刻。订阅保持
active,并带上cancel_at_period_end=true,直到边界。 - Pause:订阅冻结。不扣费。恢复时从暂停那一刻继续往下走,而不是从零开始。
把这四种行为写在规格的第一节。如果 PM 对其中任何一条有异议,那你已经挖出了一次值得在写代码之前吵清楚的对话。
选定一套 proration 规则并坚持执行
proration 常见的有三种口味,"选哪种"这个决策是承重的。选一个、写下来、永远不要让它退化成口口相传的民间传说。
- 按秒计算:最精确,和 Stripe 的默认行为一致。未使用时间精确到秒。
- 按天计算:对客服同学来说心智模型更简单。30 天的周期被切成 30 等份。
- Flat:不做 proration。升级全额收费,降级不退款。对于低 ACV 的 to C 产品可以接受,对 B2B 就是耍流氓。
我个人偏好按秒计算求精度,按天计算求客服侧的理智。无论选哪一种,规格里都必须写清楚舍入规则(banker's rounding、向下取整到分等)以及货币精度。你的服务和支付服务商之间哪怕只差 $0.005,都会让对账折腾你一整年。
续费边界是一切崩盘的地方
经典 bug 场景:一个用户在续费日的 23:59:58 UTC 点了升级。续费任务在 00:00:00 触发,按旧套餐收了新一个周期的钱。升级处理器在 00:00:01 触发,按新套餐又收了一个周期的钱。双重扣费、边界漂移、webhook 乱序,一套全来。
规格必须写清楚:边界归谁管。我的规则是:如果变更请求落在续费前后各 5 分钟的缓冲窗口内,这次变更会被延后到续费 post 之后,再作为一次全新的变更应用。规格必须定义这个窗口的长度、由谁来检查,以及 UI 在变更被排队时显示什么。
Downgrade 与 credit 归属问题
用户降级时,你欠他一笔。规格必须写清楚这笔到底是什么。三种站得住脚的选择:
- 用户 credit。应用到下一张发票上。这是我的默认选择,把现金留在账上,还能降低 chargeback 风险。
- 下一张发票上的行项目。结果一样,会计口径不同。税务影响有差异——找财务确认。
- 退回原支付方式。信任感最高,但单位经济最差。留给年付套餐或受监管市场用。
无论选哪一种,规格必须说清楚:如果用户在下一张发票之前就取消了,credit 余额怎么处理?退款、作废,还是继续保留?在这个问题上保持沉默,永远都是错误答案。
Trial 值得单独写一章
trial 边界是我见过最多即兴发挥的地方。一个 14 天 trial 的用户在第 5 天升级。新的 trial 吗?trial 立刻结束?付费时钟从第 5 天还是第 14 天开始走?把每一种组合都写下来:
- trial 期间 upgrade:trial 继续,新套餐在 trial 结束后生效。今天不扣费。
- downgrade 到一个没有 trial 的套餐:原 trial 到期日保留,到期后按新套餐开始付费。trial 中途不要扣费。
- trial 延期:只能通过客服工具操作,单独记录。永远不要作为套餐变更的副作用出现。
- trial 期间 cancel:立即结束还是 trial 到期才结束?选一个,并在 UI 上讲清楚。
Dunning、税务以及没人画的状态机
一个 past_due 的用户来升级,会发生什么?在我的规格里:升级会被阻塞,直到失败的发票被结清。否则你就是在给一个已经进催收流程的人发延期信用。规格应该把每一个订阅状态都列出来,并说明每个状态下哪些迁移是合法的:active、past_due、paused、canceled、incomplete。
税务是另一个安静的地雷。VAT 和 GST 的税率会根据套餐金额而变,有时还会根据用户所在地而变。规格必须说清楚:什么时候重新计算税——每次套餐变更时、只在续费时,还是两者都要?我选择每次变更都重算。代价很小,但能避免"为什么我的 VAT 和发票上不一样"这种客服工单。
Webhook 顺序也是契约的一部分
当套餐发生变更,下游服务需要按特定顺序知道这件事。如果 invoice.created 比 plan.changed 先触发,一个监听 plan.changed 的 CRM 就会去读一张还不存在的发票。我给 upgrade 定的标准顺序是:
customer.updated——把新的套餐层级同步到客户记录上。invoice.created——proration 发票已经生成。invoice.payment_succeeded——钱已经到账。subscription.updated(plan changed)——最终的状态变更。
规格必须声明这个顺序、是保证顺序还是尽力而为,以及消费方应该如何处理乱序投递。每个 webhook 上带幂等键不是可选项。
带真实算术的验收标准
这是我每份订阅规格里都会放的一个测试用例。就是那个"$30/月的用户在 30 天周期的第 10 天升级到 $100/月"的场景,全部写清楚。
Given an active subscription on the $30/month plan
And the current cycle started 10 days ago (20 days remaining)
And proration is calculated by seconds
And the customer is not in a trial, dunning, or paused state
When the customer upgrades to the $100/month plan
Then an immediate invoice is created for the prorated delta:
unused_old = $30 * (20 / 30) = $20.00 credit
used_new = $100 * (20 / 30) = $66.67 charge
net due now = $66.67 - $20.00 = $46.67
And the renewal date does NOT move
And the next invoice on the renewal date is $100.00
And webhooks fire in order: customer.updated, invoice.created,
invoice.payment_succeeded, subscription.updated
And tax is recomputed on the $46.67 line, not the old $30 line
一份规格里只要有一个像这样被算到底的例子,评审时能抓出的 bug 比四页散文还多。把数字写出来。把算术过程摆出来。
最后的结论
订阅变更之所以复杂,不是因为数学难,而是因为每一个决策背后都藏着一个政策选择——这个选择对做决定的人来说显而易见,对其他所有人来说却完全不可见。规格的工作就是把这些选择拖到阳光下:用哪种 proration 规则、哪个生效日期、怎样处理 credit、webhook 顺序是什么、边界归谁。把它们写下来一次、吵清楚一次,从此不再每季度重复同一个账单 bug。
订阅变更要先画出账单时间线
分摊计费最容易在“今天”“周期末”“试用结束”这些词上出错。规格里要写绝对时间、当前计划、目标计划、立即生效还是下周期生效,以及 invoice 如何生成。
Proration example: - current plan: Pro monthly, renews 2026-05-15T00:00Z - requested change: Pro -> Team on 2026-04-28T12:00Z - effective_at: immediate - credit: unused Pro time from 2026-04-28T12:00Z to 2026-05-15 - charge: Team price for same interval - invoice: one proration invoice, no renewal date change - rollback: downgrade request can be voided before invoice is paid
边界:不要把免费试用、优惠券、税率变化和账单周期变更塞进同一个首版 spec。每加一个变量,测试矩阵都会翻倍。
测试矩阵至少覆盖四个时间点
订阅变更的测试不要只测今天升级。至少覆盖周期第一天、周期最后一天、试用结束前、续费扣款失败后。每个用例都要断言 invoice 行、credit 行、subscription 状态、next_renewal_at 和用户看到的金额。
规格最后要把 invoice 表、subscription 状态、credit 行、tax 字段、payment intent 和回滚条件放进同一组测试。只断言 UI 金额不够,计费系统的真正证据在账单数据和支付状态里。
还要把 preview API 和 commit API 分开测试。preview 返回金额、税费、credit、next_renewal_at 和状态,但不能写表;commit 才创建 invoice、payment_intent 和 subscription_event。回滚时也要说明哪些字段能撤销,哪些财务记录只能追加冲正。
我还会把订阅状态写成字段清单:current_plan_id、target_plan_id、effective_at、billing_anchor、proration_mode、invoice_status、payment_status、rollback_owner。测试代码逐项断言这些字段,避免只看 UI 上的一个总金额。
专题阅读路径
先读主题 Hub,再用下面的相邻文章和模板把这篇内容放进完整工作流。
继续阅读
编辑说明与免责声明
本文用于软件工程教学与实践参考,不构成法律、税务或投资建议。示例场景用于解释规格方法,不对应真实客户数据。
- 作者信息:Spec Coding 编辑部
- 编辑政策:编辑与事实核查政策
- 联系方式:联系页面