PAYATHON 2026

Carbon 如何按订阅起始日计算每月账单日期

支付小周

结论

Carbon 不需要,也不适合保存“2 月 30 日”这类虚拟日期。应把订阅起始日保留为固定锚点,每次都从该日期计算第 n 个账期,不要以上一次扣费日期为基础连续调用 addMonthNoOverflow()。

计算规则如下:

扣费日期 = 订阅起始日 + n 个月(禁止溢出)- 1 天

按这种方式计算,较短月份对日期的调整不会延续到后面的月份。

为什么连续增加月份会导致日期永久提前

假设订阅从 1 月 31 日开始,并且每次都以上一次调整后的日期继续计算:

1 月 31 日
→ 加 1 个月:2 月 28 日
→ 再加 1 个月:3 月 28 日

第二次计算以 2 月 28 日为基准,Carbon 无法再得知原始账单锚点是 31 日。

因此,问题不在 addMonthNoOverflow() 本身,而在于计算过程中丢失了原始订阅日期。

从订阅起始日计算账单日期

可以使用 CarbonImmutable,以免计算过程意外修改原始日期:

<?php

use Carbon\CarbonImmutable;

$subscriptionStartedAt = CarbonImmutable::create(2024, 1, 31);

for ($cycle = 1; $cycle <= 4; $cycle++) {
    $expiresAt = $subscriptionStartedAt
        ->addMonthsNoOverflow($cycle);

    $chargeAt = $expiresAt->subDay();

    echo sprintf(
        "第 %d 个账期:到期日 %s,扣费日 %s\n",
        $cycle,
        $expiresAt->toDateString(),
        $chargeAt->toDateString()
    );
}

以闰年 2024 年为例,结果是:

第 1 个账期:到期日 2024-02-29,扣费日 2024-02-28
第 2 个账期:到期日 2024-03-31,扣费日 2024-03-30
第 3 个账期:到期日 2024-04-30,扣费日 2024-04-29
第 4 个账期:到期日 2024-05-31,扣费日 2024-05-30

到了 3 月,扣费日会恢复为 30 日,因为该日期是从最初的 1 月 31 日算出的,并非从 2 月 28 日继续递增。

使用可变 Carbon 对象时

Laravel 项目中常见的 Carbon 对象默认可变,因此每次计算前都应调用 copy():

<?php

use Carbon\Carbon;

$subscriptionStartedAt = Carbon::create(2024, 1, 31);

$cycle = 2;

$chargeAt = $subscriptionStartedAt
    ->copy()
    ->addMonthsNoOverflow($cycle)
    ->subDay();

echo $chargeAt->toDateString(); // 2024-03-30

如果不调用 copy(),addMonthsNoOverflow() 可能直接修改 $subscriptionStartedAt,后续计算也就失去了原始锚点。

计算下一个待扣费日期

需要根据当前时间查找下一个账期时,可以逐个计算从起始日期开始的账期:

<?php

use Carbon\CarbonImmutable;

function nextChargeDate(
    CarbonImmutable $startedAt,
    CarbonImmutable $now
): CarbonImmutable {
    $cycle = 1;

    do {
        $chargeAt = $startedAt
            ->addMonthsNoOverflow($cycle)
            ->subDay();

        $cycle++;
    } while ($chargeAt->lt($now));

    return $chargeAt;
}

$startedAt = CarbonImmutable::create(2024, 1, 31);
$now = CarbonImmutable::create(2024, 3, 1);

echo nextChargeDate($startedAt, $now)->toDateString();
// 2024-03-30

如果账期跨度较长,可以先根据月份差估算 $cycle,再检查相邻账期,这样不必从第一个月开始逐一遍历。

注意事项

不要创建“2 月 30 日”,因为它不是有效的公历日期。日期库可能会将其自动归一化到 3 月,也可能根据所用 API 抛出异常。这两种行为都无法可靠表达账单规则。

还需注意:

  • 永久保存订阅起始时间或原始账单日,不要只保存上一次扣费日期。
  • 应先计算账期到期日,再调用 subDay(),这样才能准确表示“到期前一天扣费”。
  • 如果使用的是具体时间,而非单纯的日期,需要明确时区和扣费时刻。
  • 扣费失败后即使发生延期或重试,也不应自动改变原始账期锚点,除非业务规则明确要求重置账期。
  • 月末订阅的定义必须保持一致。上述方案使用的规则是:目标月份不存在原始日号时,以该月最后一天作为到期日。

备注:内容仅供参考。