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.
pub fn regular_giving_schedule(frequency: &str, collection_day: i64, sign_up_date: &str, lead_days: i64, end_date: Option<&str>, count: i64) -> Vec<String>
| 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 is a string in Rust, one of: "monthly", "quarterly", "annually".
// Parameters take it as &str and results hold it as String.
Your code names it in one line, in the file that uses it
fune!(charity.regular-giving-schedule@^1); // then call regular_giving_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_days::{add_days, parse_iso_date}; ← from dates.add-days ^1.0.0 · built alongside by fune
use super::dates_recurrence::{recurrence, RecurrenceRule}; ← from dates.recurrence ^1.0.0 · built alongside by fune
/// 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.
///
/// # Panics
/// Panics on an unknown frequency, a day outside 1-31, negative lead days or
/// count, or a malformed date.
pub fn regular_giving_schedule(
frequency: &str,
collection_day: i64,
sign_up_date: &str,
lead_days: i64,
end_date: Option<&str>,
count: i64,
) -> Vec<String> {
let interval = match frequency {
"monthly" => 1,
"quarterly" => 3,
"annually" => 12,
_ => panic!("unknown giving frequency \"{}\": expected monthly, quarterly or annually", frequency),
};
if !(1..=31).contains(&collection_day) {
panic!("collectionDay must be 1-31, received {}", collection_day);
}
if lead_days < 0 {
panic!("leadDays must not be negative, received {}", lead_days);
}
if count < 0 {
panic!("count must be a non-negative integer, received {}", count);
}
if let Some(end) = end_date {
parse_iso_date(end);
}
let first = add_days(sign_up_date, lead_days);
if let Some(end) = end_date {
if end < first.as_str() {
return Vec::new();
}
}
let rule = RecurrenceRule {
kind: "monthly-on-day".to_string(),
interval,
day: Some(collection_day),
weekday: None,
};
recurrence(&rule, &first, count)
.into_iter()
.filter(|d| end_date.map_or(true, |end| d.as_str() <= end))
.collect()
}
fn int_arg(v: &Value, name: &str, message: &str) -> i64 {
if let Value::Float(f) = v {
if f.fract() != 0.0 {
panic!("{} {}, received {}", name, message, f);
}
}
v.as_i64()
}
pub fn fune_vector(args: &[Value]) -> Value {
let day = int_arg(&args[1], "collectionDay", "must be 1-31");
let lead = int_arg(&args[3], "leadDays", "must not be negative");
let count = int_arg(&args[5], "count", "must be a non-negative integer");
let end = if args[4].is_null() { None } else { Some(args[4].as_str()) };
let dates = regular_giving_schedule(args[0].as_str(), day, args[2].as_str(), lead, end, count);
Value::Arr(dates.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 charity.regular-giving-schedule
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./charity.regular-giving-schedule-1.0.0-rust.fune, or fetch it from a terminal with fune pull charity.regular-giving-schedule@1.0.0:rust.
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 |