如何计算当前 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。这样可以减少时间和时区差异带来的影响,也便于为周期边界编写稳定的测试。
备注:内容仅供参考。
