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(),这样才能准确表示“到期前一天扣费”。 - 如果使用的是具体时间,而非单纯的日期,需要明确时区和扣费时刻。
- 扣费失败后即使发生延期或重试,也不应自动改变原始账期锚点,除非业务规则明确要求重置账期。
- 月末订阅的定义必须保持一致。上述方案使用的规则是:目标月份不存在原始日号时,以该月最后一天作为到期日。
备注:内容仅供参考。
