PAYATHON 2026

如何统一管理多个不同价格订阅的 billing periods?

支付阿杰

结论

比较实用的做法是:以客户账户为单位设置统一的 billing anchor,把多个订阅作为同一张账单中的不同 line items,并在新增、升级或取消订阅时计算 proration。

正常情况下,每个账期只需收款一次:

  • Channel A 和 Channel B 可以有不同的价格与生效时间。
  • 所有项目统一在每月 1 日结算。
  • 月中新增 Channel B 时,只生成一条按比例计费的明细。
  • 到结算日,再把所有明细合并成一张 invoice,并执行一次 payment。
  • 除非客户的信用风险较高,否则不必在每次新增订阅时立即扣款。

需要注意的是,不要用二进制浮点数计算收费金额。金额应使用最小货币单位,例如以美分整数保存,或者使用可靠的十进制定点类型。

将订阅周期和收款周期分开

“订阅何时生效”和”何时向用户收款”是两个问题,不必绑定在一起。

系统可以分为三层:

  1. Entitlement

    记录用户从什么时候开始有权使用某个 Channel。例如,Channel B 从 1 月 17 日开始生效。

  2. Billing period

    记录某项服务覆盖的时间范围,例如 [2026-01-17, 2026-02-01)。

  3. 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 仍需由业务系统明确建模。

备注:内容仅供参考。