charity.donation-matching
Employer match on a donation: a ratio in basis points, limited by a per-donor cap and a programme budget.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 17 tests, run in TypeScript, Python and Rust.
What it does
How much an employer (or any match funder) adds to a donation under a matched giving scheme: a ratio, a cap per donor and a budget for the whole programme.
- The ratio is in basis points of the donation: 10000 is 1:1 (£1 for every £1), 20000 is 2:1, 5000 is 50p per £1. The uncapped match is rounded down to the minor unit, since a funder pays whole pennies and never more than promised. - The match is then limited to the room left under the donor's cap (`donorCap - donorMatchedSoFar`) and under the programme budget (`programmeCap - programmeMatchedSoFar`). Room is never negative: a donor already past the cap gets 0, not a clawback. Pass `null` for a cap that does not apply. - `cappedBy` says which limit reduced the match (`donor` when both leave the same room), or `none`. A match that exactly fills a cap is not capped.
For example
donationMatch(£50.00, 100%, —, £0.00, —, £0.00)→ matched £50.00, uncapped £50.00, capped by none 1:1 on £50 with no caps is £50donationMatch(£50.00, 200%, —, £0.00, —, £0.00)→ matched £100.00, uncapped £100.00, capped by none 2:1 on £50 is £100donationMatch(£3.33, 50%, —, £0.00, —, £0.00)→ matched £1.66, uncapped £1.66, capped by none 50p per £1 on £3.33 is 166.5p, rounded down to 166p
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.
export function donationMatch(donation: Money, ratioBasisPoints: number, donorCap: Money | null, donorMatchedSoFar: Money, programmeCap: Money | null, programmeMatchedSoFar: Money): DonationMatch
| donation | Money | the employee's donation |
| ratioBasisPoints | int | 10000 = 1:1 (£1 for £1), 20000 = 2:1, 5000 = 50p per £1 |
| donorCap | Money? | most this donor can be matched in the period (usually a year); null for no cap |
| donorMatchedSoFar | Money | already matched for this donor in the period |
| programmeCap | Money? | the employer's budget for the period across all donors; null for no cap |
| programmeMatchedSoFar | Money | already matched across the programme in the period |
| returns | DonationMatch |
The types it declares, generated into your project
export type MatchLimit = "none" | "donor" | "programme";
/** The match, what it would have been without caps, and which cap bit. */
export interface DonationMatch {
/** the employer's contribution, rounded down to the minor unit */
readonly matched: Money;
/** donation x ratio, rounded down, before any cap */
readonly uncapped: Money;
/** none, or the cap that reduced the match; donor when both leave the same room */
readonly cappedBy: MatchLimit;
}
Your code names it in one line, in the file that uses it
import { donationMatch } from "#fune/charity.donation-matching@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { applyRate } from "./money_apply_rate.ts"; ← from money.apply-rate ^1.0.0 · built alongside by fune
import { type Money, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { type DonationMatch, type MatchLimit } from "./charity_donation_matching_types.ts";
function check(name: string, currency: string, amount: Money): void {
if (amount.currency !== currency) throw new RangeError(`currency mismatch: ${amount.currency} and ${currency}`);
if (amount.minor < 0) throw new RangeError(`${name} must not be negative, received ${amount.minor}`);
}
/**
* An employer's matched-giving contribution. The ratio is applied first and
* rounded down; then the match is held to whatever room is left under the
* donor's cap and the programme's budget, never below zero, so a donor who
* has already used their cap gets nothing rather than a negative match.
*/
export function donationMatch(
donation: Money,
ratioBasisPoints: number,
donorCap: Money | null,
donorMatchedSoFar: Money,
programmeCap: Money | null,
programmeMatchedSoFar: Money
): DonationMatch {
const currency = donation.currency;
check("donation", currency, donation);
if (!Number.isInteger(ratioBasisPoints) || ratioBasisPoints < 0) {
throw new RangeError(`ratioBasisPoints must be a non-negative integer, received ${ratioBasisPoints}`);
}
if (donorCap !== null) check("donorCap", currency, donorCap);
check("donorMatchedSoFar", currency, donorMatchedSoFar);
if (programmeCap !== null) check("programmeCap", currency, programmeCap);
check("programmeMatchedSoFar", currency, programmeMatchedSoFar);
const uncapped = applyRate(donation, ratioBasisPoints, "down").minor;
let matched = uncapped;
let cappedBy: MatchLimit = "none";
const donorRoom = donorCap === null ? null : Math.max(0, donorCap.minor - donorMatchedSoFar.minor);
const programmeRoom = programmeCap === null ? null : Math.max(0, programmeCap.minor - programmeMatchedSoFar.minor);
if (donorRoom !== null && donorRoom < matched) {
matched = donorRoom;
cappedBy = "donor";
}
if (programmeRoom !== null && programmeRoom < matched) {
matched = programmeRoom;
cappedBy = "programme";
}
return { matched: money(matched, currency), uncapped: money(uncapped, currency), cappedBy };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the TypeScript 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.donation-matching
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./charity.donation-matching-1.0.0-typescript.fune, or fetch it from a terminal with fune pull charity.donation-matching@1.0.0:typescript.
The whole function, every language, is one file too: charity.donation-matching-1.0.0.fune, 18,175 bytes, sha256 56f6f175fee8d2980ff162ce3c1fb2cbd1539103a9132b6ecf141e0bed6c485a. 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.donation-matching
after — your function gets the result and the arguments, and returns the final result.
// fune: after charity.donation-matching
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 money.amount in charity.donation-matching
// fune: replace money.apply-rate in charity.donation-matching
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.donation-matching --steps.
// fune: step charity.donation-matching 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 | |
|---|---|---|---|
| 1:1 on £50 with no caps is £50 | £50.00, 100%, —, £0.00, —, £0.00 | → | matched £50.00, uncapped £50.00, capped by none |
| 2:1 on £50 is £100 | £50.00, 200%, —, £0.00, —, £0.00 | → | matched £100.00, uncapped £100.00, capped by none |
| 50p per £1 on £3.33 is 166.5p, rounded down to 166p | £3.33, 50%, —, £0.00, —, £0.00 | → | matched £1.66, uncapped £1.66, capped by none |
| the donor cap limits the match to the room left: £1,000 cap, £950 used | £100.00, 100%, £1,000.00, £950.00, —, £0.00 | → | matched £50.00, uncapped £100.00, capped by donor |
| a donor who has used the whole cap gets nothing | £100.00, 100%, £1,000.00, £1,000.00, —, £0.00 | → | matched £0.00, uncapped £100.00, capped by donor |
| a donor already over the cap gets nothing, not a negative match | £100.00, 100%, £1,000.00, £1,200.00, —, £0.00 | → | matched £0.00, uncapped £100.00, capped by donor |
| a match exactly filling the cap is not reported as capped | £50.00, 100%, £1,000.00, £950.00, —, £0.00 | → | matched £50.00, uncapped £50.00, capped by none |
| the programme budget binds when it is tighter than the donor cap | £100.00, 100%, £1,000.00, £0.00, £5,000.00, £4,970.00 | → | matched £30.00, uncapped £100.00, capped by programme |
| the donor cap binds when it is tighter than the programme budget | £100.00, 100%, £1,000.00, £980.00, £5,000.00, £4,000.00 | → | matched £20.00, uncapped £100.00, capped by donor |
| when both caps leave the same room, the donor cap is named | £100.00, 100%, £1,000.00, £980.00, £5,000.00, £4,980.00 | → | matched £20.00, uncapped £100.00, capped by donor |
Show the other 7 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a zero ratio matches nothing | £100.00, 0%, —, £0.00, —, £0.00 | → | matched £0.00, uncapped £0.00, capped by none |
| a zero donation matches nothing | £0.00, 100%, —, £0.00, —, £0.00 | → | matched £0.00, uncapped £0.00, capped by none |
| a negative ratio is an error | £100.00, -0.01%, —, £0.00, —, £0.00 | → | error: ratioBasisPoints must be a non-negative integer |
| a fractional ratio is an error | £100.00, 0.015%, —, £0.00, —, £0.00 | → | error: ratioBasisPoints must be a non-negative integer |
| a negative donation is an error | -£0.01, 100%, —, £0.00, —, £0.00 | → | error: donation must not be negative |
| a cap in another currency is an error | £100.00, 100%, €1,000.00, £0.00, —, £0.00 | → | error: currency mismatch: EUR and GBP |
| negative matched so far is an error | £100.00, 100%, —, -£0.05, —, £0.00 | → | error: donorMatchedSoFar must not be negative |
More from the author
The caller keeps the running totals and adds `matched` to both after paying, which keeps the function pure. Which donations qualify (minimum amounts, eligible charities, time limits) is scheme policy and stays with the caller. Matched funds are the employer's own gift: they are not Gift Aid donations.
Files
| Path | Bytes |
|---|---|
| README.md | 1,182 |
| impl/python.py | 2,172 |
| impl/rust.rs | 3,122 |
| impl/typescript.ts | 2,214 |
| vectors.json | 5,449 |