Functional Weave
Code in TypeScript

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

  • nextBillingDate(2026-01-15, month, 1, 2026-09-23) → 2026-10-15 monthly on the 15th, asked mid-September
  • nextBillingDate(2026-01-31, month, 1, 2026-02-01) → 2026-02-28 anchored on the 31st, the February renewal is the 28th
  • nextBillingDate(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.

export function nextBillingDate(anchorDate: string, interval: BillingInterval, intervalCount: number, afterDate: string): string
anchorDatedatethe billing cycle anchor: the first billing date, which every later one is counted from
intervalBillingIntervalday, week, month, quarter or year
intervalCountinthow many intervals between renewals, 1 or more: month with 6 is every six months
afterDatedateusually 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

export type BillingInterval = "day" | "week" | "month" | "quarter" | "year";

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

import { nextBillingDate } from "#fune/subscriptions.next-billing-date@^1";
impl/typescript.ts · 46 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.

import { addDays, epochDayFromIso, parseIsoDate } from "./dates_add_days.ts";  ← from dates.add-days ^1.0.0 · built alongside by fune
import { addMonths } from "./dates_add_months.ts";  ← from dates.add-months ^1.0.0 · built alongside by fune
import { type BillingInterval } from "./subscriptions_next_billing_date_types.ts";

const MONTHS = new Map<string, number>([["month", 1], ["quarter", 3], ["year", 12]]);
const DAYS = new Map<string, number>([["day", 1], ["week", 7]]);

/**
 * The first billing date strictly after `afterDate`: 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.
 */
export function nextBillingDate(anchorDate: string, interval: BillingInterval, intervalCount: number, afterDate: string): string {
  if (!Number.isInteger(intervalCount) || intervalCount < 1) {
    throw new RangeError(`interval count must be 1 or more, received ${intervalCount}`);
  }
  if (!MONTHS.has(interval) && !DAYS.has(interval)) {
    throw new RangeError(`unknown billing interval "${interval}": expected day, week, month, quarter or year`);
  }
  const anchor = epochDayFromIso(anchorDate);
  const after = epochDayFromIso(afterDate);
  if (after < anchor) return anchorDate;

  const days = DAYS.get(interval);
  if (days !== undefined) {
    const step = days * intervalCount;
    const k = Math.floor((after - anchor) / step) + 1;
    return addDays(anchorDate, k * step);
  }

  // Jump straight to the period containing afterDate's month; the clamp can
  // only put that candidate on or before afterDate, so at most one or two
  // steps forward are needed from there.
  const step = (MONTHS.get(interval) as number) * intervalCount;
  const a = parseIsoDate(anchorDate);
  const b = parseIsoDate(afterDate);
  const monthsApart = (b.year - a.year) * 12 + (b.month - a.month);
  let k = Math.floor(monthsApart / step);
  let candidate = addMonths(anchorDate, k * step);
  while (epochDayFromIso(candidate) <= after) {
    k += 1;
    candidate = addMonths(anchorDate, k * step);
  }
  return candidate;
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the TypeScript 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 TypeScript subscriptions.next-billing-date-1.0.0-typescript.fune · 9,382 bytes sha256 889755b71e4b7553cb529c68cdb6823ad15d25378e5537ec8d84b3450c6e1c7b

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

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