from .dates_add_days import add_days, epoch_day_from_iso, parse_iso_date from .dates_add_months import add_months from .subscriptions_next_billing_date_types import BillingInterval MONTHS = {"month": 1, "quarter": 3, "year": 12} DAYS = {"day": 1, "week": 7} def next_billing_date(anchor_date: str, interval: BillingInterval, interval_count: int, after_date: str) -> str: """The first billing date strictly after ``after_date``: anchor + k intervals for the smallest k >= 0 that lands after it. Each candidate is computed from the anchor, never from the previous billing date, so the 31st stays on month ends instead of decaying to the 28th after February. """ if isinstance(interval_count, bool) or not isinstance(interval_count, int) or interval_count < 1: raise ValueError("interval count must be 1 or more, received %r" % (interval_count,)) if interval not in MONTHS and interval not in DAYS: raise ValueError( 'unknown billing interval "%s": expected day, week, month, quarter or year' % (interval,) ) anchor = epoch_day_from_iso(anchor_date) after = epoch_day_from_iso(after_date) if after < anchor: return anchor_date if interval in DAYS: step = DAYS[interval] * interval_count k = (after - anchor) // step + 1 return add_days(anchor_date, k * step) # Jump straight to the period containing after_date's month; the clamp can # only put that candidate on or before after_date, so at most one or two # steps forward are needed from there. step = MONTHS[interval] * interval_count a = parse_iso_date(anchor_date) b = parse_iso_date(after_date) months_apart = (b.year - a.year) * 12 + (b.month - a.month) k = months_apart // step candidate = add_months(anchor_date, k * step) while epoch_day_from_iso(candidate) <= after: k += 1 candidate = add_months(anchor_date, k * step) return candidate