使用 PHP Carbon 正确设置下个月付款日期
结论
不要用上一次实际到期日的“日”作为下一期的基准。应该单独保存约定付款日,例如 31。计算时先进入目标月份,再按下面的方式设置日期:
min($billingDay, $targetMonth->daysInMonth)
这样,从 1 月 31 日开始,后续日期会依次为 2 月 28 日、3 月 31 日和 4 月 30 日,不会因为 2 月被截断,之后就一直沿用每月 28 日。
推荐写法
use Carbon\Carbon;
function nextPaymentDate(Carbon $currentDueDate, int $billingDay): Carbon
{
$targetMonth = $currentDueDate
->copy()
->startOfMonth()
->addMonth();
return $targetMonth->day(
min($billingDay, $targetMonth->daysInMonth)
);
}
$billingDay = 31;
$januaryDue = Carbon::create(2018, 1, 31, 0, 0, 0);
$februaryDue = nextPaymentDate($januaryDue, $billingDay);
$marchDue = nextPaymentDate($februaryDue, $billingDay);
echo $februaryDue->toDateString(); // 2018-02-28
echo $marchDue->toDateString(); // 2018-03-31
这里的 $billingDay 始终保留原始约定日 31。2018-02-28 只是因为当月没有 31 日而得到的实际付款日期,并不表示付款规则已经改成每月 28 日。
为什么直接调用 day(31) 会溢出
下面这段代码先得到 2018-02-28,随后又尝试把 2 月的日期设为 31:
Carbon::create(2018, 1, 31)
->addMonthsNoOverflow(1)
->day(31);
2018 年 2 月没有 31 日,底层日期运算会将超出的 3 天滚动到 3 月,最终得到 2018-03-03。
addMonthsNoOverflow() 只保证“增加月份”这一步不会溢出,之后调用 day(31) 时仍然不会自动截断日期。
使用 Carbon 的无溢出单位设置
部分 Carbon 版本提供了通用的 setUnitNoOverflow(),可以将日期限制在指定月份内:
use Carbon\Carbon;
$billingDay = 31;
$currentDueDate = Carbon::create(2018, 1, 31, 0, 0, 0);
$nextDueDate = $currentDueDate
->copy()
->startOfMonth()
->addMonth()
->setUnitNoOverflow('day', $billingDay, 'month');
echo $nextDueDate->toDateString(); // 2018-02-28
计算下一期时,继续使用同一个 $billingDay:
$followingDueDate = $nextDueDate
->copy()
->startOfMonth()
->addMonth()
->setUnitNoOverflow('day', $billingDay, 'month');
echo $followingDueDate->toDateString(); // 2018-03-31
能否使用 setUnitNoOverflow(),取决于项目安装的 Carbon 版本。如果当前版本没有这个 API,使用 min($billingDay, $targetMonth->daysInMonth) 更直观,也不依赖 setDayNoOverflow(31) 之类的专用方法。
注意事项
Carbon 对象默认通常是可变的,所以示例中调用了 copy(),以免意外修改原日期。也可以改用 CarbonImmutable:
use Carbon\CarbonImmutable;
$currentDueDate = CarbonImmutable::create(2018, 1, 31);
业务数据中还应分别保存“约定付款日”和“本期实际到期日”。如果只保留截断后的 2018-02-28,就无法判断原来的规则究竟是每月 28 日,还是每月月底。
备注:内容仅供参考。
