计费是一条状态机:五个状态、九条迁移与一次多收 420 美元

把订阅写成 is_active 和 is_trial 两个布尔字段,是计费系统最常见的错。这一篇用五个状态、九条合法迁移与 25 格状态空间,拆开一次多收 420 美元的事故,并给出迁移守卫、幂等键与 28 个测试的修法。

状态不是标签,是合同;标签可以随手改,合同改一次就要有人付钱。

一、一次本不该发生的第二次扣款

他把订阅表设计成三个布尔字段:is_active、is_trialing、is_past_due。逻辑很直觉——能登录就是 active,试用中就是 trialing,扣款失败就打个标记。上线十四个月,这张表没有一次被人打开逐列看过。

以下情境为教学假设,人物、引语和数字不代表经核实的真实案例。

十月的一个周二,七个团队客户同时来信:这个月被扣了两次 30 美元。他先怀疑支付通道重复推送,把 webhook 日志翻了一遍,事件只有四条,一条不多。问题在自己的代码里。

  • 第 0 天:续费扣款失败,is_past_due 置 true,is_active 保持 true
  • 第 4 天:重试成功,is_active 被再次置 true
  • 第 4 天:同一晚,「激活」分支按当前周期开了第二张账单
  • 第 34 天:两张账单各自续费,客户看到两笔 30 美元

四条事件是对的,所以问题不在通道。错在本地把「重试成功」当成了「新订阅激活」——同一件事在通道侧叫 payment_intent.succeeded,在他代码里却走了和首次付款完全相同的分支。

两个布尔字段能表达四种组合,而订阅至少有五种状态。 四种装五种,必然有一格被两个状态共用;共用那一格就是重复扣款的入口。本例里被共用的是「active 且 past_due」,它同时表示「正常服务」和「欠款宽限」。

状态机的第一价值是穷举。 五个状态写出 25 格,你被迫逐格回答「这一格允许吗」;布尔字段永远给不出这个问题,因为它连格子都没有。

二、五个状态:每个只回答一个问题

状态的价值不在名字,而在于它唯一地回答两件事:现在能不能用,现在能不能收钱。

状态含义能否使用能否收款本组数量
trialing试用中,未产生第一笔付款是到期才收18
active已付款且在服务期内是周期到点收113
past_due扣款失败,仍在宽限期是重试时收7
paused客户主动暂停,服务挂起否否2
canceled合同结束,不再续费否否0

把本组数量代进去:active 113 加 past_due 7 等于 120 个付费订阅,再加 trialing 18 与 paused 2,有效订阅 140。canceled 记 0,是因为取消后整行归档到历史表,不留在活跃表里。

120 与 140 要分开用。120 是付费口径,算收入、算流失率、算 ARPU 都用它;140 是有效口径,算容量、算支持工时、算邮件发送量用它。把 trialing 混进付费口径,ARPU 会被稀释到 1,710 ÷ 140 ≈ 12.2,看起来像定价出了问题。

past_due 是唯一一个「能用但钱没到」的状态。 它存在的全部意义,是把失败扣款从异常变成一条正常路径。每月 84 笔月付续费里约有 5.0 笔会失败,其中 2.75 笔会在四次重试后回来;如果失败就立刻停服务,这 2.75 笔连解释的机会都没有。

trialing 与 active 的边界要卡在「第一笔钱到账」。 试用期扣款成功的那一刻才迁移,不是试用期结束的那一刻;两者差几个小时,就会有一批人免费用完最后一天再走。

paused 必须有上限。 本组 2 个暂停订阅都设了 90 天上限;没有上限的暂停,等于一个永久免费档,还会让「有效订阅 140」这个数字永久虚高。

canceled 不是删除。 记录要留,账本要留,发票要留;删掉之后退款、拒付与审计全都无从追溯,而这三件事恰恰是钱出问题时才需要查的。

持续时间要单独记,不要从状态推。 past_due 停了几天、paused 挂了几周,都应存成两个时间戳相减;只存状态,三个月后就没人说得清宽限期到底给了几天。

三、九条合法迁移,其余十六格必须报错

五个状态两两组合是 25 格。真正允许的只有九条,剩下 16 格必须在代码里显式抛错,而不是「什么都不做」。

从到触发副作用拒绝条件
trialingactive试用期扣款成功生成首张账单、写账本已有未结账单
trialingcanceled试用到期未付款释放占位、发挽留信已转 active
activepast_due续费扣款失败进宽限期、排重试已是 canceled
past_dueactive重试成功清欠款、恢复周期已有本期成功付款
past_duecanceled宽限期结束停服务、结清欠款有未处理退款
activepaused客户主动暂停挂起服务、记暂停起点有未结发票
pausedactive客户恢复顺延周期、重算下次扣款暂停超过 90 天
activecanceled客户取消按生效方式停服有争议中的拒付
pausedcanceled暂停期内取消结束合同、退未用部分退款额超过已收

九格除以 25 格等于 36.0%,另外 64.0% 是必须被拒绝的组合。这个比例看起来夸张,但它是真实结构:大部分状态对在业务上没有意义。

九条不是从 25 格里挑出来的,是从客户能做的动作倒推的:开始试用、付费、扣款失败、恢复、暂停、取消。每个动作只对应一到两条迁移,多出来的都是想象出来的。

最危险的三格是 canceled → active、paused → past_due、past_due → paused。 第一格会让已退订的客户被重新计费;第二格会在暂停期里生成账单;第三格会让一个欠款客户借暂停逃过宽限期,把 7 天拖成 90 天。

「什么都不做」不是安全的默认值。 非法迁移若静默通过,状态会漂到一个没人设计的格子里,之后再怎么查都对不上账——而账对不上时,人第一反应总是去查支付通道,不是查状态。

拒绝条件比副作用更重要。 表里最后一列才是守卫:已有本期成功付款就不能再迁一次,暂停超过 90 天就不能直接恢复,有争议中的拒付就不能取消。副作用决定扣多少钱,拒绝条件决定会不会扣第二次。

四、把守卫写进代码,而不是写进文档

修法有三层,缺一层都还会再犯;只写文档、不改入口函数的,等于没修。

第一层是迁移守卫。任何状态变更只走一个函数,函数内部查上面那张表;不在表里就抛异常,并写一条审计日志,记下旧状态、新状态、触发源与时间。

审计日志要和账本一一对应。每次迁移写一条记录,字段至少包含订阅号、旧状态、新状态、生效时间与操作来源;本组 7 张钱相关表里,这张是唯一能回答「这笔扣款是谁、在什么状态下发起的」的表。

第二层是幂等键。所有写操作带 subscription_id + period_start + event_type,数据库加唯一索引;重复事件在插入时就被挡住,而不是靠代码里的 if 判断——if 会漏分支,唯一索引不会。

第三层是测试。九条合法迁移各两条用例(成功一次、重复一次,共 18 条),加上十格最常见的非法迁移各一条,共 28 个。

测试要跑在测试时钟上,不能靠等。重试间隔是第 1、3、5、7 天,真等一轮是七天;用可推进的测试时钟,28 个用例几十秒就能跑完,也才跑得进每次提交。

十四个月的账要一起算。多收 420 来自 7 个团队各 2 个月;少收 270 来自 9 笔降级没有补差额;重复退款 120 来自 1 笔个人年付被退了两次。

复算:

状态空间 5 × 5 = 25 格
合法迁移 9 = 36.0%;非法迁移 16 = 64.0%
差错:多收 420(7×30×2)+ 少收 270(9×30)+ 重复退款 120 = 810
14 个月确认收入 1,710 × 14 = 23,940
差错率 810 ÷ 23,940 = 3.38%

3.38% 不是小数。 按每月 1,710 的确认收入,一年漏掉的钱接近 700 美元;而这些钱本来只需要一张迁移表和三个字段就能守住。

修复上线后他做了一次重放:把过去半年的 webhook 按新守卫重新跑一遍,有 11 条被拒绝。11 条里 7 条正是这次的多收,另外 4 条是历史上没被发现的重复退款——重放比测试更能说明守卫是否真的在工作。

五、行动清单与复算验收

顺序很重要:先穷举状态,再画迁移表,最后才写代码。

如果只能先做一件,做守卫。幂等键解决的是重复,守卫解决的是不该发生;重复还能退款,不该发生的那次扣款已经在客户账单上了。

  • 把订阅表里所有布尔状态字段列出来,标出哪些组合会同时为 true。
  • 写下五个状态的定义,每个状态只回答「能否使用」与「能否收款」。
  • 把九条合法迁移画成表,补上触发条件、副作用与拒绝条件。
  • 在状态变更入口加守卫,非法迁移抛错并写审计日志。
  • 给所有写操作加 subscription_id + period_start + event_type 唯一索引。
  • 写 28 个测试:九条合法迁移各两条,十格非法迁移各一条。
  • 用测试时钟重放一次「失败→重试成功」,确认不会生成第二张账单。
  • 把 420、270、120 三笔历史差错记进账本,标注根因是状态还是幂等。

验收标准:在沙箱里手动把一条 past_due 订阅重试成功,账单表只新增 1 行;若新增 2 行,说明守卫或幂等键仍有一个没生效。

使用边界与实操验收

边界一:状态机不替代支付通道的状态。 通道自己也有订阅状态,两边必须定期对齐;这里管的是你库里那一份。

边界二:五个状态是最小集,不是标准。 有按量计费或多产品线时状态会更多;扩状态时先扩迁移表,不要先加字段。

边界三:九条迁移按本组产品形态划定。 没有免费试用、没有暂停功能的产品,合法迁移会更少;照抄条目而不看业务,会造出永远走不到的状态。

边界四:幂等键依赖 period_start 的稳定性。 如果周期起点会因升级而漂移,键就会失效;改周期时要么保留原起点,要么换一套键。

边界五:测试覆盖不等于生产正确。 28 个测试守住的是已知路径;每月用真实账单重放一次,才能发现表里没有的迁移。

关键参考与核验范围

以上链接于 2026-10-26 实测可公开访问;未核验版本、价格与配额。核验日期:2026-10-26。

核验的是「状态—迁移—守卫」这套结构能否落地,不是某家通道的字段名。420、270、120 与 3.38% 全部由本组基线推出,与上述文档无对应关系。

系列导航

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Indie」更多文章

  1. 开源项目验收:十项能力、四维评分与三种不该开源的项目
  2. 治理、fork 与退出:17 位贡献者、82% 的提交与一次改协议的代价
  3. 双许可与企业版:九家询价、两家成交与 4,800 美元