PAYATHON 2026

月度 billing cycle 的最佳实现模式是什么?

支付阿杰

结论

不要把“今天的日号是否等于 cycle day”作为收费条件。更可靠的做法是:

  1. 将账单周期保存为明确的 cycleDay,例如 31。
  2. 每月计算一次实际到期日:min(cycleDay, 当月最后一天)。
  3. 当 today >= dueDate,且该账户尚未成功支付当前账期时,允许发起收费。
  4. 使用“账户 ID + 账期”作为唯一键和幂等键,避免任务重复或并发执行导致重复扣款。
  5. 交易失败只算一次尝试,之后按独立的重试策略执行。不要把失败交易标记为已收费。

按这种方式处理:

  • 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 和客户当地时间。

交易失败后的重试

“已经尝试收费”和“已经成功收费”必须是两种不同的状态。典型流程如下:

  1. 到达当前账期的实际到期日。
  2. 原子地创建或锁定当前账期记录,将状态改为 Processing。
  3. 使用稳定的幂等键调用支付渠道。
  4. 收费成功后,将状态标记为 Paid。
  5. 遇到临时失败时,将状态标记为 RetryableFailure,并设置 NextRetryAt。
  6. 遇到明确不可重试的失败时,将状态标记为 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 的条件里。

备注:内容仅供参考。