Functional Weave
Code in TypeScript

subscriptions.dunning-schedule

Retry dates for a failed subscription payment from a retry policy, in calendar or business days.

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

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

What it does

When a renewal payment fails, the retry policy says when to try again: "retry 3, 5 and 7 days later" is the usual shape. This turns a policy and a failure date into the actual dates.

Every offset is counted from the date of the first failure, not from the previous retry: [3, 5, 7] means three, five and seven days after the failure. Some billing dashboards state a policy as gaps between attempts instead ("2 days after the previous attempt"); add the gaps up to get offsets. The offsets must be whole days of 1 or more and strictly increasing, so the schedule is always ascending and never retries twice on one day.

For example

  • dunningSchedule(2026-09-23, 1, 3, 5, 7, false, ) → 2026-09-24, 2026-09-26, 2026-09-28, 2026-09-30 calendar days from a Wednesday failure
  • dunningSchedule(2026-09-23, 1, 3, 5, 7, true, ) → 2026-09-24, 2026-09-28, 2026-09-30, 2026-10-02 the same policy in business days steps over the weekend
  • dunningSchedule(2026-12-23, 1, 3, 5, true, 2026-12-25, 2026-12-28, 2027-01-01) → 2026-12-24, 2026-12-30, 2027-01-04 business days over Christmas skip the bank holidays too

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 dunningSchedule(failedOn: string, retryOffsetsDays: readonly number[], businessDays: boolean, holidays: readonly string[]): readonly string[]
failedOndatethe date the payment first failed
retryOffsetsDaysint[]days after failedOn for each retry, 1 or more and strictly increasing: [3, 5, 7]
businessDaysbooltrue to count the offsets in working days, skipping weekends and holidays
holidaysdate[]non-working dates, used only when businessDays is true
returnsdate[]one retry date per offset, ascending

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

import { dunningSchedule } from "#fune/subscriptions.dunning-schedule@^1";
impl/typescript.ts · 23 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 } from "./dates_add_days.ts";  ← from dates.add-days ^1.0.0 · built alongside by fune
import { addBusinessDays } from "./dates_add_business_days.ts";  ← from dates.add-business-days ^1.0.0 · built alongside by fune

/**
 * The retry dates for a payment that failed on `failedOn`. Each offset counts
 * from the failure date, not from the previous retry, in calendar days or in
 * working days.
 */
export function dunningSchedule(failedOn: string, retryOffsetsDays: readonly number[], businessDays: boolean, holidays: readonly string[]): readonly string[] {
  let previous = 0;
  for (const offset of retryOffsetsDays) {
    if (!Number.isInteger(offset) || offset < 1) {
      throw new RangeError(`retry offsets must be whole days of 1 or more, received ${offset}`);
    }
    if (offset <= previous) {
      throw new RangeError(`retry offsets must be strictly increasing, received ${offset} after ${previous}`);
    }
    previous = offset;
  }
  // Validates the failure date even for an empty policy.
  addDays(failedOn, 0);
  return retryOffsetsDays.map((offset) => (businessDays ? addBusinessDays(failedOn, offset, holidays) : addDays(failedOn, offset)));
}

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.dunning-schedule
Download for TypeScript subscriptions.dunning-schedule-1.0.0-typescript.fune · 6,945 bytes sha256 e9a79d33c013481c15bd325b5fff0d45f3aef6ca77df486fccc06b3c42c9ef0c

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

The whole function, every language, is one file too: subscriptions.dunning-schedule-1.0.0.fune, 9,946 bytes, sha256 29af783bacb57f016d8ee8462b19297e142602118f308f1cf84ac8beb18c75d1. 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.dunning-schedule

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

// fune: after subscriptions.dunning-schedule

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-business-days in subscriptions.dunning-schedule
// fune: replace dates.add-days in subscriptions.dunning-schedule

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.dunning-schedule --steps.

// fune: step subscriptions.dunning-schedule 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
calendar days from a Wednesday failure 2026-09-23, 1, 3, 5, 7, false, → 2026-09-24, 2026-09-26, 2026-09-28, 2026-09-30
the same policy in business days steps over the weekend 2026-09-23, 1, 3, 5, 7, true, → 2026-09-24, 2026-09-28, 2026-09-30, 2026-10-02
business days over Christmas skip the bank holidays too 2026-12-23, 1, 3, 5, true, 2026-12-25, 2026-12-28, 2027-01-01 → 2026-12-24, 2026-12-30, 2027-01-04
in calendar mode holidays are ignored 2026-12-23, 1, 3, 5, false, 2026-12-25, 2026-12-28, 2027-01-01 → 2026-12-24, 2026-12-26, 2026-12-28
calendar retries across a leap day 2024-02-27, 1, 2, 3, false, → 2024-02-28, 2024-02-29, 2024-03-01
a Saturday failure retried one business day later is retried on Monday 2026-09-26, 1, true, → 2026-09-28
offsets count from the failure, not from the previous retry 2026-01-01, 3, 5, 7, false, → 2026-01-04, 2026-01-06, 2026-01-08
a long policy crosses the year end 2026-12-15, 7, 14, 21, false, → 2026-12-22, 2026-12-29, 2027-01-05
no retries configured is an empty schedule 2026-09-23, , true, →
an offset of zero is an error 2026-09-23, 0, 3, false, → error: retry offsets must be whole days of 1 or more
Show the other 4 tests
CaseArgumentsExpected
repeated offsets are an error 2026-09-23, 3, 3, false, → error: retry offsets must be strictly increasing
decreasing offsets are an error 2026-09-23, 5, 3, false, → error: retry offsets must be strictly increasing
an impossible failure date is an error even with no retries 2026-02-30, , false, → error: is not a real calendar date
a malformed holiday is an error in business-day mode 2026-09-23, 1, true, 25/12/2026 → error: is not an ISO date

More from the author

With `businessDays` true, the offsets are counted in working days using dates.add-business-days: weekends and the listed holidays are skipped, and a failure on a Saturday retried 1 business day later is retried on Monday. Pass bank holidays (dates.bank-holidays) when retries should land on days the banks process card and Direct Debit payments. With `businessDays` false the offsets are plain calendar days and `holidays` is ignored.

What happens after the last retry (cancel, mark unpaid, pause) is a policy decision for the caller; this only computes when the retries fall. An empty policy gives an empty schedule.

Errors: an offset below 1, offsets that do not increase, an impossible date, and (in business-day mode) a malformed holiday.

Files

PathBytes
README.md1,399
impl/python.py1,153
impl/rust.rs1,735
impl/typescript.ts1,062
vectors.json2,296