PAYATHON 2026

如何计算当前 billing period 的结束日期?

数据阿明

结论

可以把 contractRenewal 当作一个 billing period 的边界,再按 monthsInBillingPeriod 指定的整月数向前回推。找到不早于计算日期、且距离它最近的周期边界后,减去一天即可得到当前 billing period 的结束日期。

不必分别计算月份和年份,也不需要为季度、半年和年度周期编写不同的逻辑。DateTime.AddMonths() 会处理跨年问题。

public static DateTime GetCurrentBillingPeriodEnd(
    DateTime contractRenewal,
    int monthsInBillingPeriod,
    DateTime asOfDate)
{
    if (monthsInBillingPeriod <= 0)
    {
        throw new ArgumentOutOfRangeException(
            nameof(monthsInBillingPeriod),
            "Billing period must contain at least one month.");
    }

    DateTime boundary = contractRenewal.Date;
    DateTime target = asOfDate.Date;

    // 如果 renewal date 已经过期,就向后查找下一个周期边界。
    while (boundary < target)
    {
        boundary = boundary.AddMonths(monthsInBillingPeriod);
    }

    // 从未来的 renewal date 向前查找距离 target 最近的周期边界。
    while (boundary.AddMonths(-monthsInBillingPeriod) >= target)
    {
        boundary = boundary.AddMonths(-monthsInBillingPeriod);
    }

    return boundary.AddDays(-1);
}

使用示例

要得到原问题中的预期结果,计算日期应为 2017-10-01:

DateTime contractRenewal = new DateTime(2018, 2, 1);
DateTime asOfDate = new DateTime(2017, 10, 1);

DateTime monthlyEnd = GetCurrentBillingPeriodEnd(
    contractRenewal, 1, asOfDate);

DateTime quarterlyEnd = GetCurrentBillingPeriodEnd(
    contractRenewal, 3, asOfDate);

DateTime biannualEnd = GetCurrentBillingPeriodEnd(
    contractRenewal, 6, asOfDate);

DateTime annualEnd = GetCurrentBillingPeriodEnd(
    contractRenewal, 12, asOfDate);

Console.WriteLine(monthlyEnd.ToString("dd/MM/yyyy"));   // 30/09/2017
Console.WriteLine(quarterlyEnd.ToString("dd/MM/yyyy")); // 31/10/2017
Console.WriteLine(biannualEnd.ToString("dd/MM/yyyy"));  // 31/01/2018
Console.WriteLine(annualEnd.ToString("dd/MM/yyyy"));    // 31/01/2018

各周期对应的边界和结束日期是:

  • Monthly:边界为 01/10/2017,结束日期为前一天 30/09/2017
  • Quarterly:边界为 01/11/2017,结束日期为 31/10/2017
  • Biannual:边界为 01/02/2018,结束日期为 31/01/2018
  • Annual:边界为 01/02/2018,结束日期为 31/01/2018

原来的计算为什么容易出错

原代码只使用了 DateTime.Now.Month 和 contractRenewal.Month:

DateTime.Now.Month - contractRenewal.Month

月份数字不包含年份信息。例如,2017-10 到 2018-02 相差 4 个月,直接用月份数字相减却会得到 10 - 2 = 8。负数和跨年情况再遇上整数除法,也很容易出现意外结果。

下面这段代码还有另一个问题:

DateTime.Now.AddMonths(month).Year

这里的 month 是目标月份编号,却被当作要增加的月数传给了 AddMonths(),所以由此得到的年份并不可靠。

直接对完整的 DateTime 调用 AddMonths(),可以一并处理月份、年份和跨年。

边界日期的含义

上面的实现遵循这条规则:

如果 asOfDate 正好落在一个 billing period 边界上,则返回该边界的前一天。

因此,以 2017-10-01 计算 monthly period 时,结果是 2017-09-30。

如果业务上把 2017-10-01 视为新月份的开始,并希望返回 2017-10-31,可以把向前回推时的比较条件从 >= 改为 >:

while (boundary.AddMonths(-monthsInBillingPeriod) > target)
{
    boundary = boundary.AddMonths(-monthsInBillingPeriod);
}

具体采用哪条规则,要看 renewal date 表示旧周期结束后的续费日,还是新周期的开始日。

注意事项

示例中的 renewal day 是每月 1 日,所以 AddDays(-1) 会得到上个月的最后一天。如果 renewal day 可能是 29、30 或 31 日,就需要先确定月底如何处理。DateTime.AddMonths() 会把目标月份中不存在的日期调整为该月最后一天。例如,从 1 月 31 日增加一个月,结果可能是 2 月 28 日或 29 日。计费规则是否接受这种处理方式,需要由业务定义决定。

实际使用时,最好显式传入 asOfDate,不要在方法内部直接读取 DateTime.Now。这样可以减少时间和时区差异带来的影响,也便于为周期边界编写稳定的测试。

备注:内容仅供参考。