Functional Weave
Code in Rust

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 failure
  • dunning_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 weekend
  • dunning_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_ondatethe date the payment first failed
retry_offsets_daysint[]days after failedOn for each retry, 1 or more and strictly increasing: [3, 5, 7]
business_daysbooltrue 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

fune!(subscriptions.dunning-schedule@^1);  // then call dunning_schedule(…)
impl/rust.rs · 49 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.

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
Download for Rust subscriptions.dunning-schedule-1.0.0-rust.fune · 7,632 bytes sha256 e4f8dd94cab3c0574a983ffdbbcfba2905f5b7c47601e9b4784b7136c6db61e4

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.

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