状态不是标签,是合同;标签可以随手改,合同改一次就要有人付钱。
一、一次本不该发生的第二次扣款
他把订阅表设计成三个布尔字段: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 格必须在代码里显式抛错,而不是「什么都不做」。
| 从 | 到 | 触发 | 副作用 | 拒绝条件 |
|---|---|---|---|---|
| trialing | active | 试用期扣款成功 | 生成首张账单、写账本 | 已有未结账单 |
| trialing | canceled | 试用到期未付款 | 释放占位、发挽留信 | 已转 active |
| active | past_due | 续费扣款失败 | 进宽限期、排重试 | 已是 canceled |
| past_due | active | 重试成功 | 清欠款、恢复周期 | 已有本期成功付款 |
| past_due | canceled | 宽限期结束 | 停服务、结清欠款 | 有未处理退款 |
| active | paused | 客户主动暂停 | 挂起服务、记暂停起点 | 有未结发票 |
| paused | active | 客户恢复 | 顺延周期、重算下次扣款 | 暂停超过 90 天 |
| active | canceled | 客户取消 | 按生效方式停服 | 有争议中的拒付 |
| paused | canceled | 暂停期内取消 | 结束合同、退未用部分 | 退款额超过已收 |
九格除以 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 个测试守住的是已知路径;每月用真实账单重放一次,才能发现表里没有的迁移。
关键参考与核验范围
- Stripe 订阅计费概览 :用于核对订阅状态、账单周期与续费的基本概念。
- 升级与降级 :用于核对套餐变更时的生效方式与按比例处理。
- 订阅 Webhook 事件 :用于核对状态变更事件有哪些、顺序是否被保证。
- 幂等请求 :用于核对写操作幂等键的取值与重放语义。
- 状态图规范 :用于核对状态、迁移与守卫这套建模语言的一般用法。
以上链接于 2026-10-26 实测可公开访问;未核验版本、价格与配额。核验日期:2026-10-26。
核验的是「状态—迁移—守卫」这套结构能否落地,不是某家通道的字段名。420、270、120 与 3.38% 全部由本组基线推出,与上述文档无对应关系。
系列导航
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。