PAYATHON 2026

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 天,不是一个月。夏令时、时区转换和月底日期也会让简单的秒数计算更容易出错。

可以采用以下规则:

  1. 保存客户的计费锚点,例如注册日为每月 15 日。
  2. 使用支持“增加一个日历月”的日期库计算下次扣款时间。
  3. 为在 29、30、31 日注册的客户制定月底处理策略。
  4. 在固定时区或 UTC 中保存时间,并明确扣款日期以哪个业务时区为准。
  5. 仅在扣款成功后推进账期。失败后的重试仍属于同一个账单周期。
  6. 使用支付平台提供的 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”的定价,最稳妥的做法是:客户首次注册时收费,之后在同一计费日按日历月续费,并为月底日期制定明确的处理规则。

备注:内容仅供参考。