PAYATHON 2026

寻找用于确定 fiscal months 的现有 Python module

数据小许

结论

推荐使用第三方库 python-dateutil,主要用到的 API 是 dateutil.relativedelta.relativedelta。

它可以正确处理每个月天数不同的情况,也符合题目中的截断规则:如果目标月份没有 29、30 或 31 日,日期就取该月最后一天。这个库负责日历运算,但不直接提供“判断订阅账期”的单一函数。账期编号和日期归属还需要用少量业务代码计算。

fiscalyear 这类库主要用于企业统一的财年和财季,不适合处理每位用户从各自订阅日期开始计算的滚动 billing month。

安装

python -m pip install python-dateutil

完整用法

每个账期边界都要从原始订阅日期计算,不要在上一个边界的基础上反复增加一个月。否则会出现 January 31 → February 28 → March 28 这样的日期漂移。

from datetime import date
from dateutil.relativedelta import relativedelta


def billing_boundary(subscription_date: date, month_index: int) -> date:
    """返回第 month_index 个账期边界;0 表示订阅开始日期。"""
    if month_index < 0:
        raise ValueError("month_index must be non-negative")

    return subscription_date + relativedelta(
        months=month_index,
        day=subscription_date.day,
    )


def billing_period(subscription_date: date, session_date: date):
    """
    返回 (账期编号, 开始日期, 结束日期)。

    账期编号从 1 开始,日期区间采用 [start, end),
    即包含 start,但不包含 end。
    """
    if session_date < subscription_date:
        raise ValueError("session_date is before the subscription date")

    month_index = (
        (session_date.year - subscription_date.year) * 12
        + session_date.month
        - subscription_date.month
    )

    if session_date < billing_boundary(subscription_date, month_index):
        month_index -= 1

    start = billing_boundary(subscription_date, month_index)
    end = billing_boundary(subscription_date, month_index + 1)

    return month_index + 1, start, end

例如:

subscription_date = date(2011, 1, 13)

print(billing_period(subscription_date, date(2011, 1, 20)))
# (1, datetime.date(2011, 1, 13), datetime.date(2011, 2, 13))

print(billing_period(subscription_date, date(2011, 2, 10)))
# (1, datetime.date(2011, 1, 13), datetime.date(2011, 2, 13))

print(billing_period(subscription_date, date(2011, 2, 15)))
# (2, datetime.date(2011, 2, 13), datetime.date(2011, 3, 13))

月末订阅日期也能正确处理:

subscription_date = date(2011, 1, 31)

print(billing_boundary(subscription_date, 1))
# 2011-02-28

print(billing_boundary(subscription_date, 2))
# 2011-03-31

print(billing_boundary(subscription_date, 3))
# 2011-04-30

这些结果符合 min(subscription_day, days_in_target_month) 规则,三月的边界仍然是 March 31,不会漂移到 March 28。

注意事项

账期适合定义为半开区间 [start, end)。假设当前账期的结束日期是 2011-02-13,发生在这个边界上的 session 应归入从 2011-02-13 开始的下一个账期。这样,相邻账期既不会重叠,也不会出现空隙。

如果业务精确到时间,而不只是日期,还需要明确:

  • 账期采用哪个时区;
  • cutoff 是当天 00:00,还是订阅发生的具体时刻;
  • 夏令时切换时,如何处理不存在或重复的本地时间。

python-dateutil 可以处理月份运算,时区规则则需要由业务定义。通常可以先根据本地日历日期确定账期边界,再把边界转换为带有明确时区的 datetime。数据库中的 session 时间统一保存为 UTC。

备注:内容仅供参考。