PAYATHON 2026

如何准确计算 direct debit 账单的分期付款日期

支付阿杰

结论

根据示例的预期结果,15、30、45、60、75、90 并非严格的自然日天数,而是按照”每月固定 30 天”的商业日历计算:

  • 2016-04-15 + 45 → 2016-05-30
  • 2016-04-15 + 75 → 2016-06-30
  • 2016-04-30 + 45 → 2016-06-15

因此,不能同时使用 mktime() 的自然月进位规则和手工计算的 30 天周期。可以按以下方式处理:

  1. 先在”每月 30 天”的虚拟日历中算出目标年月日。
  2. 将结果转换为实际的公历日期。
  3. 如果目标日期在公历中不存在,例如 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 天商业月 + 不存在日期取月末”这一规则。银行工作日、节假日和扣款失败后的重试日期,应在基础付款日期计算完成后,再按照各自的业务规则处理。

备注:内容仅供参考。