charity.regular-giving-schedule
Collection dates for a regular gift: monthly, quarterly or annual on a day, clamped to short months, after a lead time.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 19 tests, run in TypeScript, Python and Rust.
What it does
The dates a regular gift (a Direct Debit, standing order or card subscription) will be collected: monthly, quarterly or annually on a chosen day of the month.
- **The first collection** is the first collection day on or after `signUpDate + leadDays`. `leadDays` is whatever notice your collection method needs (Direct Debit advance notice, a processing cut-off); 0 means the sign-up day itself can be the first collection. - **Short months clamp from the chosen day every time**: a gift on the 31st is collected on 30 April, 28 (or 29) February and 31 March again, never drifting down to the 28th. Quarterly and annual gifts count their months from the first collection. This reuses dates.recurrence (`monthly-on-day` every 1, 3 or 12 months) rather than re-implementing it. - **`endDate` is inclusive** and may cut the list short of `count`; a pledge with no end passes `null`.
For example
regular_giving_schedule(monthly, 1, 2026-09-23, 10, —, 3)→ 2026-11-01, 2026-12-01, 2027-01-01 monthly on the 1st with 10 days' notice: October's date is too soonregular_giving_schedule(monthly, 31, 2026-01-01, 0, —, 4)→ 2026-01-31, 2026-02-28, 2026-03-31, 2026-04-30 monthly on the 31st clamps each month from the 31st, not from February's 28thregular_giving_schedule(monthly, 30, 2028-01-31, 0, —, 3)→ 2028-02-29, 2028-03-30, 2028-04-30 monthly on the 30th in a leap year gives 29 February
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 regular_giving_schedule(frequency: GivingFrequency, collection_day: int, sign_up_date: str, lead_days: int, end_date: Optional[str], count: int) -> List[str]
| frequency | GivingFrequency | how often the gift is collected |
| collection_day | int | day of the month, 1 to 31; clamped to the last day of a shorter month |
| sign_up_date | date | the date the donor set up the gift |
| lead_days | int | calendar days needed before the first collection, e.g. for Direct Debit advance notice; 0 or more |
| end_date | date? | last date a collection may fall on, inclusive; null for an open-ended gift |
| count | int | most dates to return, 0 or more |
| returns | date[] | ascending: the first collection is the first collection day on or after signUpDate + leadDays |
The type it declares, generated into your project
GivingFrequency = Literal["monthly", "quarterly", "annually"]
Your code names it in one line, in the file that uses it
from fune.charity.regular_giving_schedule import regular_giving_schedule # charity.regular-giving-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, Optional
from .charity_regular_giving_schedule_types import GivingFrequency
from .dates_add_days import add_days, parse_iso_date ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_recurrence import recurrence ← from dates.recurrence ^1.0.0 · built alongside by fune
from .dates_recurrence_types import RecurrenceRule
INTERVAL = {"monthly": 1, "quarterly": 3, "annually": 12}
def _is_int(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def regular_giving_schedule(
frequency: GivingFrequency,
collection_day: int,
sign_up_date: str,
lead_days: int,
end_date: Optional[str],
count: int,
) -> List[str]:
"""Collection dates for a regular gift. Each date is clamped from the
chosen day, not from the previous date (dates.recurrence), so a gift on the
31st is taken on 28 February and on 31 March again.
"""
interval = INTERVAL.get(frequency) if isinstance(frequency, str) else None
if interval is None:
raise ValueError('unknown giving frequency "%s": expected monthly, quarterly or annually' % (frequency,))
if not _is_int(collection_day) or collection_day < 1 or collection_day > 31:
raise ValueError("collectionDay must be 1-31, received %s" % (collection_day,))
if not _is_int(lead_days) or lead_days < 0:
raise ValueError("leadDays must not be negative, received %s" % (lead_days,))
if not _is_int(count) or count < 0:
raise ValueError("count must be a non-negative integer, received %s" % (count,))
if end_date is not None:
parse_iso_date(end_date)
first = add_days(sign_up_date, lead_days)
rule = RecurrenceRule(kind="monthly-on-day", interval=interval, day=collection_day, weekday=None)
if end_date is not None and end_date < first:
return []
return [d for d in recurrence(rule, first, count) if end_date is None or d <= end_date]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 charity.regular-giving-schedule
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./charity.regular-giving-schedule-1.0.0-python.fune, or fetch it from a terminal with fune pull charity.regular-giving-schedule@1.0.0:python.
The whole function, every language, is one file too: charity.regular-giving-schedule-1.0.0.fune, 13,309 bytes, sha256 8c444f46d2bfebf41aa575d4afe0af1fbfa55ef813325a5987629057027978fa. 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 charity.regular-giving-schedule
after — your function gets the result and the arguments, and returns the final result.
# fune: after charity.regular-giving-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-days in charity.regular-giving-schedule
# fune: replace dates.recurrence in charity.regular-giving-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 charity.regular-giving-schedule --steps.
# fune: step charity.regular-giving-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 | |
|---|---|---|---|
| monthly on the 1st with 10 days' notice: October's date is too soon | monthly, 1, 2026-09-23, 10, —, 3 | → | 2026-11-01, 2026-12-01, 2027-01-01 |
| monthly on the 31st clamps each month from the 31st, not from February's 28th | monthly, 31, 2026-01-01, 0, —, 4 | → | 2026-01-31, 2026-02-28, 2026-03-31, 2026-04-30 |
| monthly on the 30th in a leap year gives 29 February | monthly, 30, 2028-01-31, 0, —, 3 | → | 2028-02-29, 2028-03-30, 2028-04-30 |
| the sign-up day itself is the first collection when there is no lead time | monthly, 23, 2026-09-23, 0, —, 2 | → | 2026-09-23, 2026-10-23 |
| quarterly on the 15th | quarterly, 15, 2026-09-23, 5, —, 4 | → | 2026-10-15, 2027-01-15, 2027-04-15, 2027-07-15 |
| annually on 6 April | annually, 6, 2026-04-01, 3, —, 3 | → | 2026-04-06, 2027-04-06, 2028-04-06 |
| annually on the 29th from February 2028: 28 February in ordinary years | annually, 29, 2028-02-01, 0, —, 3 | → | 2028-02-29, 2029-02-28, 2030-02-28 |
| the lead time crosses a year end | monthly, 5, 2026-12-25, 10, —, 2 | → | 2027-01-05, 2027-02-05 |
| an end date is inclusive and cuts the schedule short | monthly, 1, 2026-09-01, 0, 2026-12-01, 12 | → | 2026-09-01, 2026-10-01, 2026-11-01, 2026-12-01 |
| an end date before the first possible collection gives nothing | monthly, 1, 2026-09-23, 10, 2026-10-01, 12 | → |
Show the other 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a count of zero is an empty schedule | monthly, 1, 2026-09-23, 0, —, 0 | → | |
| an unknown frequency is an error | weekly, 1, 2026-09-23, 0, —, 3 | → | error: unknown giving frequency "weekly" |
| day 0 is an error | monthly, 0, 2026-09-23, 0, —, 3 | → | error: collectionDay must be 1-31 |
| day 32 is an error | monthly, 32, 2026-09-23, 0, —, 3 | → | error: collectionDay must be 1-31 |
| a fractional day is an error | monthly, 1.5, 2026-09-23, 0, —, 3 | → | error: collectionDay must be 1-31 |
| negative lead days are an error | monthly, 1, 2026-09-23, -1, —, 3 | → | error: leadDays must not be negative |
| a negative count is an error | monthly, 1, 2026-09-23, 0, —, -1 | → | error: count must be a non-negative integer |
| a malformed sign-up date is an error | monthly, 1, 23/09/2026, 0, —, 3 | → | error: is not an ISO date |
| an impossible end date is an error | monthly, 1, 2026-09-23, 0, 2026-02-30, 3 | → | error: is not a real calendar date |
More from the author
Dates are ISO strings and are not moved for weekends or bank holidays: banks move a collection that falls on a non-working day themselves, and a caller who wants the processing date can pass each date to banking.bacs-processing-date or dates.add-business-days.
Files
| Path | Bytes |
|---|---|
| README.md | 1,193 |
| impl/python.py | 1,840 |
| impl/rust.rs | 2,477 |
| impl/typescript.ts | 1,779 |
| vectors.json | 2,987 |