如何准确计算 direct debit 账单的分期付款日期
结论
根据示例的预期结果,15、30、45、60、75、90 并非严格的自然日天数,而是按照”每月固定 30 天”的商业日历计算:
2016-04-15 + 45→2016-05-302016-04-15 + 75→2016-06-302016-04-30 + 45→2016-06-15
因此,不能同时使用 mktime() 的自然月进位规则和手工计算的 30 天周期。可以按以下方式处理:
- 先在”每月 30 天”的虚拟日历中算出目标年月日。
- 将结果转换为实际的公历日期。
- 如果目标日期在公历中不存在,例如 2 月 30 日,就取该月的最后一天。
原来的计算为什么会出错
原代码一边把 30 天视为一个自然月,一边又把剩余天数作为自然日添加。自然月可能有 28、29、30 或 31 天,混用这两套规则,计算结果自然会出现偏差。
按照真实的自然日计算:
2016-04-15 + 45 days = 2016-05-30
2016-04-15 + 75 days = 2016-06-29
但问题中的预期结果是 2016-04-15 + 75 = 2016-06-30。这说明实际业务规则采用的是 30 天商业月,而非严格的自然日间隔。
PHP 实现
下面的函数按照每月固定 30 天的规则计算分期日期,同时处理 2 月等不足 30 天的月份:
<?php
/**
* 按“每月固定 30 天”的规则计算分期付款日期。
*
* @param string $invoiceDate 账单日期,格式为 Y-m-d
* @param array<int|string> $installments 分期偏移量,例如 [30, 45, 60]
* @return string[]
*/
function calculateInstallmentDates(
string $invoiceDate,
array $installments
): array {
$timezone = new DateTimeZone('UTC');
$baseDate = DateTimeImmutable::createFromFormat(
'!Y-m-d',
$invoiceDate,
$timezone
);
if (
$baseDate === false ||
$baseDate->format('Y-m-d') !== $invoiceDate
) {
throw new InvalidArgumentException(
'Invalid invoice date; expected Y-m-d.'
);
}
$baseDay = min((int) $baseDate->format('d'), 30);
$baseMonth = $baseDate->modify('first day of this month');
$dates = [];
foreach ($installments as $installment) {
$days = filter_var($installment, FILTER_VALIDATE_INT);
if ($days === false || $days < 0) {
throw new InvalidArgumentException(
'Installment values must be non-negative integers.'
);
}
// 转换成从当前商业月月初开始的零基天数。
$commercialDays = ($baseDay - 1) + $days;
$monthOffset = intdiv($commercialDays, 30);
$nominalDay = ($commercialDays % 30) + 1;
// 从每月 1 日进行月份移动,避免 29、30、31 日导致日期溢出。
$targetMonth = $baseMonth->modify(
sprintf('+%d months', $monthOffset)
);
$daysInTargetMonth = (int) $targetMonth->format('t');
// 例如名义日期为 2 月 30 日时,取 2 月最后一天。
$actualDay = min($nominalDay, $daysInTargetMonth);
$targetDate = $targetMonth->setDate(
(int) $targetMonth->format('Y'),
(int) $targetMonth->format('m'),
$actualDay
);
$dates[] = $targetDate->format('Y-m-d');
}
return $dates;
}
计算示例
print_r(calculateInstallmentDates(
'2016-04-15',
[15, 45, 75]
));
结果:
2016-04-30
2016-05-30
2016-06-30
print_r(calculateInstallmentDates(
'2016-04-15',
[30, 45, 60]
));
结果:
2016-05-15
2016-05-30
2016-06-15
print_r(calculateInstallmentDates(
'2016-04-30',
[30, 45, 60]
));
结果:
2016-05-30
2016-06-15
2016-06-30
print_r(calculateInstallmentDates(
'2016-04-30',
[15, 45, 75]
));
结果:
2016-05-15
2016-06-15
2016-07-15
如何处理月末日期
30 天商业日历可能会算出公历中不存在的日期,例如:
2025-01-30 + 30 → 名义上的 2025-02-30
这段代码会把不存在的日期截取到当月月末,因此结果是:
2025-02-28
如果遇到闰年,结果可能是 02-29。
每一期都要从原始账单日期开始独立计算,不能以上一期经过月末截取的日期为基础继续累加,否则 2 月的截取会影响后面所有付款日期。
例如,01-30 对应的分期日期可以是:
+30 → 02-28
+60 → 03-30
+90 → 04-30
其中,+60 必须从 01-30 在商业日历中的位置直接计算,不能从 02-28 再加一个月。
如果业务使用真实自然日
如果合同中的 45 days 确实是连续的 45 个自然日,就不应采用 30 天商业月。直接使用 DateTimeImmutable 添加天数即可:
function addCalendarDays(
string $invoiceDate,
array $installments
): array {
$baseDate = new DateTimeImmutable(
$invoiceDate,
new DateTimeZone('UTC')
);
return array_map(
static function ($installment) use ($baseDate): string {
$days = filter_var($installment, FILTER_VALIDATE_INT);
if ($days === false || $days < 0) {
throw new InvalidArgumentException(
'Installment values must be non-negative integers.'
);
}
return $baseDate
->modify(sprintf('+%d days', $days))
->format('Y-m-d');
},
$installments
);
}
按照这种规则,2016-04-15 + 75 days 的结果是 2016-06-29,而不是 2016-06-30。
注意事项
代码用于账单系统前,需要先明确以下业务规则:
- 分期数字代表自然日,还是每月固定 30 天的商业周期。
- 账单日期是每月 31 日时,是否统一按第 30 日处理。
- 公历中不存在的日期是否取当月最后一天。
- 到期日遇到周末或法定节假日时,是提前处理还是顺延。
- 顺延后的日期是否会影响后续分期。
上面的代码处理的是”30 天商业月 + 不存在日期取月末”这一规则。银行工作日、节假日和扣款失败后的重试日期,应在基础付款日期计算完成后,再按照各自的业务规则处理。
备注:内容仅供参考。
