月度 billing cycle 的最佳实现模式是什么?
结论
不要把“今天的日号是否等于 cycle day”作为收费条件。更可靠的做法是:
- 将账单周期保存为明确的
cycleDay,例如31。 - 每月计算一次实际到期日:
min(cycleDay, 当月最后一天)。 - 当
today >= dueDate,且该账户尚未成功支付当前账期时,允许发起收费。 - 使用“账户 ID + 账期”作为唯一键和幂等键,避免任务重复或并发执行导致重复扣款。
- 交易失败只算一次尝试,之后按独立的重试策略执行。不要把失败交易标记为已收费。
按这种方式处理:
cycleDay = 31时,4 月的到期日是 30 日,2 月的到期日是 28 日或 29 日。cycleDay = 29时,非闰年 2 月的到期日是 28 日。- 到期当天交易失败后,后续日期仍满足
today >= dueDate,因此可以继续重试。 - 同一账期成功收费后,后续任务不会重复扣款。
为什么原判断不可靠
原代码只在日期完全相等时触发:
DateTime.UtcNow.Day == cycleDate.Day
如果当月没有这个日号,或者收费任务当天没有运行,整个账期都会被跳过。下面这个判断也有问题:
!DateTime.UtcNow.Day.Equals(planLastBilled)
左侧是 int,右侧是 DateTime,类型不同,无法正确判断“今天是否已经收费”。
即使改为比较 planLastBilled.Day,问题仍然存在。例如,上个月和本月都在 15 日收费,只比较 Day 无法区分月份。是否已经收费应按完整账期判断,不能只看日号。
计算本月实际到期日
如果业务规则规定短月份在当月最后一天到期,可以这样计算:
static DateOnly GetDueDate(int year, int month, int cycleDay)
{
if (cycleDay is < 1 or > 31)
throw new ArgumentOutOfRangeException(nameof(cycleDay));
int lastDay = DateTime.DaysInMonth(year, month);
int effectiveDay = Math.Min(cycleDay, lastDay);
return new DateOnly(year, month, effectiveDay);
}
示例结果:
GetDueDate(2024, 2, 31); // 2024-02-29
GetDueDate(2025, 2, 31); // 2025-02-28
GetDueDate(2025, 4, 31); // 2025-04-30
GetDueDate(2025, 5, 31); // 2025-05-31
这里使用 DateOnly,因为月度账单的到期日通常只是一个业务日期,不需要时分秒。如果项目使用的 .NET 版本不支持 DateOnly,也可以使用 UTC 零点的 DateTime,但必须统一 DateTimeKind 和时区规则。
推荐的判断逻辑
不要只依赖 planLastBilled。应为每个账期保存一条账单记录,再通过账期状态判断是否已经成功收费:
public enum BillingStatus
{
Pending,
Processing,
Paid,
RetryableFailure,
PermanentFailure
}
public sealed record BillingPeriod(int Year, int Month);
static bool NeedToBill(
DateOnly today,
int cycleDay,
BillingStatus? currentPeriodStatus,
DateTimeOffset? nextRetryAt,
DateTimeOffset now)
{
DateOnly dueDate = GetDueDate(today.Year, today.Month, cycleDay);
if (today < dueDate)
return false;
if (currentPeriodStatus is BillingStatus.Paid
or BillingStatus.Processing
or BillingStatus.PermanentFailure)
{
return false;
}
if (nextRetryAt.HasValue && now < nextRetryAt.Value)
return false;
return true;
}
调用前,先按当前账期查询状态:
DateTimeOffset now = DateTimeOffset.UtcNow;
DateOnly today = GetBusinessDate(now, customerTimeZone);
var period = new BillingPeriod(today.Year, today.Month);
BillingRecord? record = repository.Find(customerId, period);
bool shouldBill = NeedToBill(
today,
customer.CycleDay,
record?.Status,
record?.NextRetryAt,
now);
业务日期应根据客户或账户约定的时区计算:
static DateOnly GetBusinessDate(
DateTimeOffset now,
TimeZoneInfo timeZone)
{
DateTimeOffset localNow = TimeZoneInfo.ConvertTime(now, timeZone);
return DateOnly.FromDateTime(localNow.DateTime);
}
如果系统明确规定所有客户都按 UTC 结算,也可以直接使用 UTC 日期。重要的是固定这套规则,不能在不同服务中混用服务器本地时间、UTC 和客户当地时间。
交易失败后的重试
“已经尝试收费”和“已经成功收费”必须是两种不同的状态。典型流程如下:
- 到达当前账期的实际到期日。
- 原子地创建或锁定当前账期记录,将状态改为
Processing。 - 使用稳定的幂等键调用支付渠道。
- 收费成功后,将状态标记为
Paid。 - 遇到临时失败时,将状态标记为
RetryableFailure,并设置NextRetryAt。 - 遇到明确不可重试的失败时,将状态标记为
PermanentFailure,转入催收或人工处理流程。
幂等键可以由账户和账期组成:
static string CreateIdempotencyKey(
Guid accountId,
BillingPeriod period)
{
return $"billing:{accountId:N}:{period.Year:D4}-{period.Month:D2}";
}
数据库还应设置唯一约束,例如:
UNIQUE(account_id, period_year, period_month)
即使多个工作节点同时扫描到同一账户,这个约束也能保证系统只创建一条账期记录。如果支付渠道支持 idempotency key,还应传入上述稳定键。数据库唯一约束和支付渠道幂等需要同时使用,不能只依赖进程内判断。
重试时间不能只由 today >= dueDate 控制,否则定时任务每次运行时都可能立即重试。可以采用固定间隔或指数退避,例如几小时或一天后再次尝试。具体间隔、最大重试次数和停止条件,应根据支付渠道的限制及业务催收规则确定。
需要提前确定的业务规则
短月份如何处理没有唯一的技术答案,需要先定义业务语义。常见规则是:
effectiveDay = min(cycleDay, 当月天数)
还需要决定进入长月份后是否恢复原来的日号。通常应该恢复:
1 月 31 日 → 2 月 28/29 日 → 3 月 31 日 → 4 月 30 日
因此,系统应永久保存原始的 cycleDay = 31。不要在 2 月收费后将它改成 28,否则后续月份可能永久漂移到 28 日。
以下事项也需要提前明确:
- 账期按客户时区、商户时区还是 UTC 计算。
- 首次订阅是否立即收费,以及第一个不完整月份如何计费。
- 修改
cycleDay后,从当前账期还是下一个账期开始生效。 - 连续失败后是否继续进入下一账期,以及是否允许累积欠款。
- 退款、撤销和拒付是否会改变账期的
Paid状态。 - 月末提前到期是否符合合同约定和页面展示的结算规则。
处理收费时,应先计算当前账期的确定到期日,再根据账期状态判断是否需要收费。日期计算、账期去重和失败重试应分别建模,不要全部塞进一个比较 DateTime.Day 的条件里。
备注:内容仅供参考。
