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
dunning_schedule(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 failuredunning_schedule(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 weekenddunning_schedule(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.
def dunning_schedule(failed_on: str, retry_offsets_days: Sequence[int], business_days: bool, holidays: Sequence[str]) -> List[str]
| failed_on | date | the date the payment first failed |
| retry_offsets_days | int[] | days after failedOn for each retry, 1 or more and strictly increasing: [3, 5, 7] |
| business_days | bool | true to count the offsets in working days, skipping weekends and holidays |
| holidays | date[] | non-working dates, used only when businessDays is true |
| returns | date[] | one retry date per offset, ascending |
Your code names it in one line, in the file that uses it
from fune.subscriptions.dunning_schedule import dunning_schedule # subscriptions.dunning-schedule@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import List, Sequence
from .dates_add_business_days import add_business_days ← from dates.add-business-days ^1.0.0 · built alongside by fune
from .dates_add_days import add_days ← from dates.add-days ^1.0.0 · built alongside by fune
def dunning_schedule(
failed_on: str, retry_offsets_days: Sequence[int], business_days: bool, holidays: Sequence[str]
) -> List[str]:
"""The retry dates for a payment that failed on ``failed_on``. Each offset
counts from the failure date, not from the previous retry, in calendar days
or in working days.
"""
previous = 0
for offset in retry_offsets_days:
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 1:
raise ValueError("retry offsets must be whole days of 1 or more, received %r" % (offset,))
if offset <= previous:
raise ValueError(
"retry offsets must be strictly increasing, received %d after %d" % (offset, previous)
)
previous = offset
# Validates the failure date even for an empty policy.
add_days(failed_on, 0)
return [
add_business_days(failed_on, offset, holidays) if business_days else add_days(failed_on, offset)
for offset in retry_offsets_days
]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.dunning-schedule
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./subscriptions.dunning-schedule-1.0.0-python.fune, or fetch it from a terminal with fune pull subscriptions.dunning-schedule@1.0.0:python.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,399 |
| impl/python.py | 1,153 |
| impl/rust.rs | 1,735 |
| impl/typescript.ts | 1,062 |
| vectors.json | 2,296 |