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.
pub fn dunning_schedule(failed_on: &str, retry_offsets_days: &[i64], business_days: bool, holidays: &[String]) -> Vec<String>
| 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
fune!(subscriptions.dunning-schedule@^1); // then call dunning_schedule(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_business_days::add_business_days; ← from dates.add-business-days ^1.0.0 · built alongside by fune
use super::dates_add_days::add_days; ← from dates.add-days ^1.0.0 · built alongside by fune
/// 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.
///
/// # Panics
/// Panics if an offset is below 1 or not above the one before, or a date is
/// not a real ISO date.
pub fn dunning_schedule(failed_on: &str, retry_offsets_days: &[i64], business_days: bool, holidays: &[String]) -> Vec<String> {
let mut previous = 0;
for &offset in retry_offsets_days {
if offset < 1 {
panic!("retry offsets must be whole days of 1 or more, received {}", offset);
}
if offset <= previous {
panic!(
"retry offsets must be strictly increasing, received {} after {}",
offset, previous
);
}
previous = offset;
}
// Validates the failure date even for an empty policy.
add_days(failed_on, 0);
retry_offsets_days
.iter()
.map(|&offset| {
if business_days {
add_business_days(failed_on, offset, holidays)
} else {
add_days(failed_on, offset)
}
})
.collect()
}
pub fn fune_vector(args: &[Value]) -> Value {
let offsets: Vec<i64> = args[1].as_arr().iter().map(|v| v.as_i64()).collect();
let holidays: Vec<String> = args[3].as_arr().iter().map(|v| v.as_str().to_string()).collect();
Value::Arr(
dunning_schedule(args[0].as_str(), &offsets, args[2].as_bool(), &holidays)
.iter()
.map(|d| Value::str(d))
.collect(),
)
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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 Rust implementation. Install it without the registry with fune add ./subscriptions.dunning-schedule-1.0.0-rust.fune, or fetch it from a terminal with fune pull subscriptions.dunning-schedule@1.0.0:rust.
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 |