billing module 中月度计费间隔应如何设置?
结论
“按月计费”应以日历月为准,而不是固定为 28、29 或 30 天。
客户首次注册并付款后,通常在下个月的同一日期再次扣款。例如:
- 1 月 10 日注册,之后每月 10 日计费;
- 4 月 30 日注册,下一次在 5 月 30 日计费;
- 1 月 31 日注册,而下个月没有 31 日,则按照预先说明的规则处理。常见做法是在当月最后一天计费。
如果固定每 30 天扣款,一年通常会产生约 365 / 30 ≈ 12.17 个计费周期。长期运行后,扣款日期会逐渐提前,某些连续 12 个月内甚至可能扣款 13 次。这与用户对“每月 $10”的通常理解并不一致。
为什么不能把一个月定义为固定天数
日历月可能有 28、29、30 或 31 天。无论选择哪一个固定天数,都无法长期与自然月份对应。
例如,客户在 1 月 1 日首次付款,之后每 30 天计费:
2026-01-01
2026-01-31
2026-03-02
2026-04-01
...
扣款日期会不断变化。按日历月递增则更符合“每月收费”的含义:
2026-01-01
2026-02-01
2026-03-01
2026-04-01
...
因此,需要区分两类产品:
- 月度订阅:按日历月计费,可以宣传为“每月 $10”。
- 固定周期订阅:每 28 天或每 30 天计费,应明确写成“每 28 天 $10”或“每 30 天 $10”。
有些服务确实采用 28 天、4 周或 30 天的固定周期,但不能在没有说明的情况下将其称为“每月”。
推荐的实现方式
保存客户的首次计费时间,并维护一个明确的 next_billing_at。每次扣款成功后,再按日历月计算下一次计费时间。
不要使用下面这种固定秒数:
30 * 24 * 60 * 60
这段计算表示 30 天,不是一个月。夏令时、时区转换和月底日期也会让简单的秒数计算更容易出错。
可以采用以下规则:
- 保存客户的计费锚点,例如注册日为每月 15 日。
- 使用支持“增加一个日历月”的日期库计算下次扣款时间。
- 为在 29、30、31 日注册的客户制定月底处理策略。
- 在固定时区或 UTC 中保存时间,并明确扣款日期以哪个业务时区为准。
- 仅在扣款成功后推进账期。失败后的重试仍属于同一个账单周期。
- 使用支付平台提供的 recurring billing 功能时,优先选择其
month周期,不要自行换算成天数。
代码示例
下面的 JavaScript 示例采用这样的规则:以原始注册日为锚点;如果目标月份没有对应日期,则使用当月最后一天。
function daysInUTCMonth(year, monthIndex) {
return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
}
function billingDateForMonth(year, monthIndex, anchorDay, hour = 0) {
const lastDay = daysInUTCMonth(year, monthIndex);
const day = Math.min(anchorDay, lastDay);
return new Date(Date.UTC(year, monthIndex, day, hour, 0, 0));
}
function nextMonthlyBillingDate(currentBillingAt, anchorDay) {
const year = currentBillingAt.getUTCFullYear();
const nextMonthIndex = currentBillingAt.getUTCMonth() + 1;
return billingDateForMonth(
year,
nextMonthIndex,
anchorDay,
currentBillingAt.getUTCHours()
);
}
const firstBillingAt = new Date("2026-01-31T00:00:00Z");
const anchorDay = firstBillingAt.getUTCDate();
const februaryBillingAt = nextMonthlyBillingDate(
firstBillingAt,
anchorDay
);
const marchBillingAt = nextMonthlyBillingDate(
februaryBillingAt,
anchorDay
);
console.log(februaryBillingAt.toISOString());
// 2026-02-28T00:00:00.000Z
console.log(marchBillingAt.toISOString());
// 2026-03-31T00:00:00.000Z
这里需要单独保存 anchorDay。如果只在 2 月 28 日的基础上继续“加一个月”,某些日期库可能会将后续账期一直固定在每月 28 日,无法在 3 月恢复到 31 日。
数据结构可以很简单:
subscription
id
customer_id
amount
currency
billing_interval = "month"
billing_anchor_day = 31
next_billing_at
status
实际执行扣款时,还应结合订阅 ID 和账期生成幂等键,防止任务重试导致重复收费:
const idempotencyKey =
`subscription:${subscription.id}:period:${subscription.next_billing_at.toISOString()}`;
月底策略需要提前确定
对于在每月 29、30 或 31 日注册的客户,常见的处理方式有两种。
在当月最后一天计费
客户在 1 月 31 日注册后,计费日期可能是:
1 月 31 日
2 月 28 日或 29 日
3 月 31 日
4 月 30 日
这种方式保留了“月底注册的客户仍在月底计费”的含义,但实现时必须保存原始锚点。
统一限制计费日
也可以将计费锚点限制在每月 1 至 28 日。例如,将在 29 至 31 日注册的客户统一安排到每月 28 日续费。这种方案实现起来比较简单,但产品规则需要明确首次账期是否按比例计费。
两种策略都可行。选定后应保持一致,并在结账页面、服务条款和账单通知中写清楚。
其他注意事项
- 不要将“28 至 30 天都算月度”作为模糊的内部规则。计费规则必须能确定唯一的下一次扣款时间。
- 宣传“每月 $10”时,不应实际每 28 天收费,因为每 28 天收费一年通常有 13 个周期。
- 如果产品实际提供的是“购买后可使用 30 天”,应将其描述为 30 天有效期,而不是自然月订阅。
- 明确取消时间、宽限期、失败重试、退款和按比例计费规则。
- 不要只根据定时任务的触发时间判断是否应该收费,应以持久化的
next_billing_at和账期状态为准。 - 涉及税务、消费者权益或自动续费披露时,应根据实际经营地区咨询专业人士,因为不同司法辖区的要求可能不同。
对于“每月 $10”的定价,最稳妥的做法是:客户首次注册时收费,之后在同一计费日按日历月续费,并为月底日期制定明确的处理规则。
备注:内容仅供参考。
