Functional Weave
Code in Python

subscriptions.next-billing-date

The next renewal date after a given day, from a billing anchor and interval; month-end safe.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 21 tests, run in TypeScript, Python and Rust.

What it does

Every billing date is the anchor plus k whole intervals, counted from the anchor each time, and the answer is the first of them strictly after `afterDate`. That one rule is what keeps month-end subscriptions on month ends. A plan anchored on 31 January bills on 28 February, 31 March, 30 April, 31 May: each date is dates.add-months from the anchor, clamped to the length of its own month. The common bug is to add one month to the previous billing date instead, which turns 28 February into 28 March and keeps the customer on the 28th for ever. Stripe's billing cycle anchor behaves the same way: a subscription anchored on the 31st is billed on the last day of shorter months.

"Strictly after" is deliberate. On a billing date itself the renewal is today's invoice, and the next one is a whole interval away. When `afterDate` is before the anchor (a subscription with a future start, or one still in its trial) the next billing date is the anchor.

For example

  • next_billing_date(2026-01-15, month, 1, 2026-09-23) → 2026-10-15 monthly on the 15th, asked mid-September
  • next_billing_date(2026-01-31, month, 1, 2026-02-01) → 2026-02-28 anchored on the 31st, the February renewal is the 28th
  • next_billing_date(2026-01-31, month, 1, 2026-02-28) → 2026-03-31 after the 28 February renewal the next is 31 March, not 28 March

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def next_billing_date(anchor_date: str, interval: BillingInterval, interval_count: int, after_date: str) -> str
anchor_datedatethe billing cycle anchor: the first billing date, which every later one is counted from
intervalBillingIntervalday, week, month, quarter or year
interval_countinthow many intervals between renewals, 1 or more: month with 6 is every six months
after_datedateusually today; the result is the first billing date strictly after it
returnsdateanchorDate itself when afterDate is before it

The type it declares, generated into your project

BillingInterval = Literal["day", "week", "month", "quarter", "year"]

Your code names it in one line, in the file that uses it

from fune.subscriptions.next_billing_date import next_billing_date  # subscriptions.next-billing-date@^1
impl/python.py · 43 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from .dates_add_days import add_days, epoch_day_from_iso, parse_iso_date  ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_add_months import add_months  ← from dates.add-months ^1.0.0 · built alongside by fune
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

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add subscriptions.next-billing-date
Download for Python subscriptions.next-billing-date-1.0.0-python.fune · 9,257 bytes sha256 ec67d9ae57ce21fcb523414586db8b7d098fad56ab55c1362a794ecc7217a4ec

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./subscriptions.next-billing-date-1.0.0-python.fune, or fetch it from a terminal with fune pull subscriptions.next-billing-date@1.0.0:python.

The whole function, every language, is one file too: subscriptions.next-billing-date-1.0.0.fune, 13,831 bytes, sha256 192959e1946903593052c3226a188eb9198309e0da4edf0b391ed04485bb4400. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before subscriptions.next-billing-date

after — your function gets the result and the arguments, and returns the final result.

# fune: after subscriptions.next-billing-date

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

# fune: replace dates.add-days in subscriptions.next-billing-date
# fune: replace dates.add-months in subscriptions.next-billing-date

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show subscriptions.next-billing-date --steps.

# fune: step subscriptions.next-billing-date after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
monthly on the 15th, asked mid-September 2026-01-15, month, 1, 2026-09-23 → 2026-10-15
anchored on the 31st, the February renewal is the 28th 2026-01-31, month, 1, 2026-02-01 → 2026-02-28
after the 28 February renewal the next is 31 March, not 28 March 2026-01-31, month, 1, 2026-02-28 → 2026-03-31
anchored on the 31st, April's renewal is its last day, the 30th 2026-01-31, month, 1, 2026-04-15 → 2026-04-30
anchored on the 31st, February of a leap year renews on the 29th 2024-01-31, month, 1, 2024-02-10 → 2024-02-29
on a billing date itself, the next renewal is a whole interval away 2026-01-15, month, 1, 2026-03-15 → 2026-04-15
on the anchor date itself, the next renewal is one interval on 2026-09-23, month, 1, 2026-09-23 → 2026-10-23
before the anchor (a future start), the next billing date is the anchor 2026-10-01, month, 1, 2026-09-23 → 2026-10-01
quarterly from 30 November: the clamped 28 February is a billing date, so the next is 30 May 2025-11-30, quarter, 1, 2026-02-28 → 2026-05-30
quarterly from 30 November, asked the day before the clamped date 2025-11-30, quarter, 1, 2026-02-27 → 2026-02-28
Show the other 11 tests
CaseArgumentsExpected
yearly from 29 February renews on 28 February in an ordinary year 2024-02-29, year, 1, 2025-01-01 → 2025-02-28
yearly from 29 February is back on the 29th in the next leap year 2024-02-29, year, 1, 2027-06-01 → 2028-02-29
yearly on New Year's Eve, asked on the renewal day 2025-12-31, year, 1, 2026-12-31 → 2027-12-31
every 14 days, asked on a billing day 2026-01-01, day, 14, 2026-01-29 → 2026-02-12
every 14 days, asked the day before a billing day 2026-01-01, day, 14, 2026-01-28 → 2026-01-29
every two weeks from a Tuesday 2026-09-01, week, 2, 2026-09-23 → 2026-09-29
every two months from 31 January skips February entirely 2026-01-31, month, 2, 2026-03-01 → 2026-03-31
every six months from 31 August lands on the last day of February 2025-08-31, month, 6, 2025-12-01 → 2026-02-28
an interval count of zero is an error 2026-01-15, month, 0, 2026-09-23 → error: interval count must be 1 or more
an unknown interval is an error 2026-01-15, fortnight, 1, 2026-09-23 → error: unknown billing interval
an impossible anchor date is an error 2026-02-30, month, 1, 2026-09-23 → error: is not a real calendar date

More from the author

Intervals: `day` and `week` step in exact days (a week is 7), `month`, `quarter` (3 months) and `year` (12 months) step in calendar months with the month-end clamp, so a yearly plan anchored on 29 February renews on 28 February in ordinary years and on 29 February again in leap years. `intervalCount` multiplies the interval, as Stripe's `interval_count` does: `month` with 6 is half-yearly, `day` with 14 is fortnightly.

An interval count below 1, an unknown interval, and an impossible date are errors. Dates past 9999-12-31 are outside the supported range.

Files

PathBytes
README.md1,549
impl/python.py1,971
impl/rust.rs2,312
impl/typescript.ts2,087
vectors.json3,169