如何统一管理多个不同价格订阅的 billing periods?
结论
比较实用的做法是:以客户账户为单位设置统一的 billing anchor,把多个订阅作为同一张账单中的不同 line items,并在新增、升级或取消订阅时计算 proration。
正常情况下,每个账期只需收款一次:
- Channel A 和 Channel B 可以有不同的价格与生效时间。
- 所有项目统一在每月 1 日结算。
- 月中新增 Channel B 时,只生成一条按比例计费的明细。
- 到结算日,再把所有明细合并成一张 invoice,并执行一次 payment。
- 除非客户的信用风险较高,否则不必在每次新增订阅时立即扣款。
需要注意的是,不要用二进制浮点数计算收费金额。金额应使用最小货币单位,例如以美分整数保存,或者使用可靠的十进制定点类型。
将订阅周期和收款周期分开
“订阅何时生效”和”何时向用户收款”是两个问题,不必绑定在一起。
系统可以分为三层:
-
Entitlement
记录用户从什么时候开始有权使用某个 Channel。例如,Channel B 从 1 月 17 日开始生效。
-
Billing period
记录某项服务覆盖的时间范围,例如
[2026-01-17, 2026-02-01)。 -
Invoice
汇总本次结算需要收取的所有费用。一张 invoice 可以包含多个订阅、折扣、税费和调整项。
这样一来,Channel B 可以在 1 月 17 日立即开通,其按比例费用先记入未出账明细,到 2 月 1 日再与 Channel A 一起收取。
与”每个 subscription 各自生成一笔 recurring payment”相比,这种模型更容易控制收款次数。
先确定是预付还是后付
题目中的 Channel A 于 1 月 1 日开通,2 月 1 日支付 $10,属于后付模式。采用这种模式时,2 月 1 日的账单可以包含:
- Channel A:1 月 1 日至 2 月 1 日,$10
- Channel B:1 月 17 日至 2 月 1 日,按比例收费
3 月 1 日再收取 2 月份的完整费用:
Channel A = $10
Channel B = $15
Total = $25
如果采用预付模式,完整周期的费用通常在周期开始时收取。此时,2 月 1 日会收取 2 月份的 $25。Channel B 在 1 月 17 日至 2 月 1 日之间产生的费用,可以选择:
- 在 1 月 17 日立即收取;
- 先记账,到 2 月 1 日合并收取。
第二种方式可以减少扣款次数,但平台需要承担用户在 2 月 1 日付款失败的风险。
Proration 不能固定按 30 天计算
只有合同明确规定”每月固定 30 天”时,下面的公式才成立:
remaining_days * monthly_price / 30
自然月有 28、29、30 或 31 天,因此系统需要先确定统一的 proration policy。常见做法如下。
按自然月实际天数
prorated_amount =
monthly_amount
× billable_time_in_partial_period
÷ total_time_in_full_period
如果计费区间采用半开区间 [start, end),Channel B 的区间为:
[January 17, February 1)
这个区间包含 15 个自然日。1 月有 31 天,所以:
$15 × 15 / 31 = $7.258064...
按美分四舍五入后为:
$7.26
因此,2 月 1 日的后付账单为:
Channel A: $10.00
Channel B: $7.26
Total: $17.26
固定 30 天制
有些合同会明确使用 30/360 或每月固定 30 天的规则。这种方式同样可行,但必须对所有客户采用一致的标准,并在价格说明或合同中写明。
按秒计算
如果订阅可以精确到小时或分钟生效,可以根据时间戳计算:
prorated_amount =
monthly_amount
× active_seconds
÷ period_seconds
这种方式的计时更精确,但系统必须明确时区、夏令时和账期边界。对于按自然日提供的 SaaS 服务,按 UTC 时间或账户的固定时区计算自然日,通常更容易向用户解释。
推荐的账单处理流程
1. 为账户设置统一的 billing anchor
例如:
billing_anchor = 每月 1 日 00:00:00
billing_timezone = Asia/Shanghai
billing anchor 应设置在客户账户或 billing group 上,而不是分别设置在每个 Channel 上。
2. 新增订阅时切分计费区间
用户在 1 月 17 日购买 Channel B 时,系统创建一条计费明细:
subscription_item: Channel B
service_period: [January 17, February 1)
amount: $7.26
status: pending
Channel B 从 1 月 17 日开始提供服务,但此时不一定需要扣款。
3. 在统一结算日生成一张 invoice
2 月 1 日生成 invoice,并合并所有 pending 明细:
Invoice
├── Channel A, Jan 1 - Feb 1 $10.00
└── Channel B, Jan 17 - Feb 1 $7.26
------
Total $17.26
系统只为这张 invoice 发起一次 payment。
4. 后续按完整周期计费
从 2 月 1 日开始,两个 Channel 的周期都与账户的 billing anchor 对齐:
[February 1, March 1)
对应总价为:
$10 + $15 = $25
5. 取消和降级也要生成调整明细
对于月中取消,需要预先确定处理规则:
- 立即停止服务,并按比例退款;
- 服务持续到当前周期结束,不退款;
- 立即停止服务,但把余额作为 credit 留在账户中。
除了新增订阅产生的正向 proration,系统还需要处理取消、升级、折扣和退款带来的负向调整。
金额计算示例
下面的示例使用美分整数,并采用”自然月实际天数”规则:
type ProrationInput = {
monthlyAmountCents: bigint;
activeDays: bigint;
periodDays: bigint;
};
function prorate({
monthlyAmountCents,
activeDays,
periodDays,
}: ProrationInput): bigint {
if (periodDays <= 0n) {
throw new Error("periodDays must be greater than zero");
}
if (activeDays < 0n || activeDays > periodDays) {
throw new Error("activeDays is outside the billing period");
}
// Round half up to the nearest cent.
return (
monthlyAmountCents * activeDays +
periodDays / 2n
) / periodDays;
}
const channelBProration = prorate({
monthlyAmountCents: 1500n,
activeDays: 15n,
periodDays: 31n,
});
console.log(channelBProration); // 726n, meaning $7.26
账单金额应由服务端根据价格快照和服务区间计算,不能直接采用客户端提交的最终金额。
数据库中也建议使用整数保存金额:
currency = USD
unit_amount_minor = 1500
currency_exponent = 2
并非所有货币都有两位小数。金额模型应保留 currency,再根据货币元数据确定展示方式和舍入规则。
如何进一步减少收款次数
统一结算日可以减少正常周期内的收款次数,还可以配合以下策略:
- Pending invoice items:周期内产生的费用先累计,到 billing anchor 时统一结算。
- Minimum charge threshold:金额过小时不立即扣款,累计到下一期。
- Account balance / credit balance:退款和负向调整先形成账户余额,用于抵扣后续账单。
- Consolidated invoicing:在付款主体、币种和税务条件相同的情况下,合并订阅并统一开票。
- One payment per invoice:不要为每个 invoice line item 分别创建支付交易。
以下情况通常不适合强行合并:
- 币种不同;
- 付款方式或付款主体不同;
- 税务实体不同;
- 法规或合同要求分别开票;
- 某项服务要求即时预付;
- 新增金额过大,平台不愿承担延期收款风险。
系统可以设置风险阈值。小额变更留到结算日统一处理,大额购买则立即生成 invoice 并收款。
容易遗漏的实现细节
舍入规则必须固定
系统需要明确采用哪种规则:
- round half up;
- round half even;
- 向下取整;
- 支付服务商规定的其他规则。
同一笔 proration 不应在 subscription、invoice 和 payment 三个阶段重复舍入。通常可以先用较高精度计算每条明细,再按既定规则生成以最小货币单位表示的最终金额。
保存价格快照
账单明细应保存计费时使用的价格,不能等到出账时再读取 Channel 的当前价格。否则,管理员修改价格后,历史服务区间可能会被错误地按新价格收费。
使用半开时间区间
统一采用:
[start_at, end_at)
这样,前一个周期的 end_at 可以直接作为下一个周期的 start_at,避免同一天被重复收费。
保证幂等
生成 invoice、创建 payment 和处理支付回调时,都应使用稳定的幂等键。例如:
invoice:{account_id}:{period_start}:{period_end}
任务重试时,系统应返回已经创建的 invoice,而不是再次扣款。
不要修改已经确定的账单
已经 finalized 或已经支付的 invoice 不应直接重算。如果之后发现差异,应通过 credit note、debit adjustment 或下一期调整项进行修正,以保留完整的审计记录。
可参考的资料
Stripe 文档对 billing cycle anchor、proration 和账单明细合并有较完整的说明。不同支付平台的具体 API 会有差异,但建模思路相通:
具体实现应遵循所用支付服务商的金额精度、舍入方式、税费处理和 recurring payment 规则。支付平台可以负责扣款,但服务区间、价格快照和 proration policy 仍需由业务系统明确建模。
备注:内容仅供参考。
