Functional Weave
Code in Python

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.

def dunning_schedule(failed_on: str, retry_offsets_days: Sequence[int], business_days: bool, holidays: Sequence[str]) -> List[str]
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

from fune.subscriptions.dunning_schedule import dunning_schedule  # subscriptions.dunning-schedule@^1
impl/python.py · 28 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.

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
Download for Python subscriptions.dunning-schedule-1.0.0-python.fune · 7,039 bytes sha256 b1d0c9db1d456243d9444c1c1a5316dd3a7850fd48c02196bcfe3daa11ab5b64

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.

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