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-Septembernext_billing_date(2026-01-31, month, 1, 2026-02-01)→ 2026-02-28 anchored on the 31st, the February renewal is the 28thnext_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_date | date | the billing cycle anchor: the first billing date, which every later one is counted from |
| interval | BillingInterval | day, week, month, quarter or year |
| interval_count | int | how many intervals between renewals, 1 or more: month with 6 is every six months |
| after_date | date | usually today; the result is the first billing date strictly after it |
| returns | date | anchorDate 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
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 candidateInstall
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,549 |
| impl/python.py | 1,971 |
| impl/rust.rs | 2,312 |
| impl/typescript.ts | 2,087 |
| vectors.json | 3,169 |