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
donation_match(£50.00, 100%, —, £0.00, —, £0.00)→ matched £50.00, uncapped £50.00, capped by none 1:1 on £50 with no caps is £50donation_match(£50.00, 200%, —, £0.00, —, £0.00)→ matched £100.00, uncapped £100.00, capped by none 2:1 on £50 is £100donation_match(£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.
def donation_match(donation: Money, ratio_basis_points: int, donor_cap: Optional[Money], donor_matched_so_far: Money, programme_cap: Optional[Money], programme_matched_so_far: Money) -> DonationMatch
| donation | Money | the employee's donation |
| ratio_basis_points | int | 10000 = 1:1 (£1 for £1), 20000 = 2:1, 5000 = 50p per £1 |
| donor_cap | Money? | most this donor can be matched in the period (usually a year); null for no cap |
| donor_matched_so_far | Money | already matched for this donor in the period |
| programme_cap | Money? | the employer's budget for the period across all donors; null for no cap |
| programme_matched_so_far | Money | already matched across the programme in the period |
| returns | DonationMatch |
The types it declares, generated into your project
MatchLimit = Literal["none", "donor", "programme"]
@dataclass(frozen=True)
class DonationMatch:
"""The match, what it would have been without caps, and which cap bit."""
#: the employer's contribution, rounded down to the minor unit
matched: Money
#: donation x ratio, rounded down, before any cap
uncapped: Money
#: none, or the cap that reduced the match; donor when both leave the same room
capped_by: MatchLimit
Your code names it in one line, in the file that uses it
from fune.charity.donation_matching import donation_match # 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.
from typing import Optional
from .charity_donation_matching_types import DonationMatch, MatchLimit
from .money_amount import Money, money ← from money.amount ^1.0.0 · built alongside by fune
from .money_apply_rate import apply_rate ← from money.apply-rate ^1.0.0 · built alongside by fune
def _check(name: str, currency: str, amount: Money) -> None:
if amount.currency != currency:
raise ValueError("currency mismatch: %s and %s" % (amount.currency, currency))
if amount.minor < 0:
raise ValueError("%s must not be negative, received %d" % (name, amount.minor))
def donation_match(
donation: Money,
ratio_basis_points: int,
donor_cap: Optional[Money],
donor_matched_so_far: Money,
programme_cap: Optional[Money],
programme_matched_so_far: Money,
) -> DonationMatch:
"""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.
"""
currency = donation.currency
_check("donation", currency, donation)
if isinstance(ratio_basis_points, bool) or not isinstance(ratio_basis_points, int) or ratio_basis_points < 0:
raise ValueError("ratioBasisPoints must be a non-negative integer, received %s" % (ratio_basis_points,))
if donor_cap is not None:
_check("donorCap", currency, donor_cap)
_check("donorMatchedSoFar", currency, donor_matched_so_far)
if programme_cap is not None:
_check("programmeCap", currency, programme_cap)
_check("programmeMatchedSoFar", currency, programme_matched_so_far)
uncapped = apply_rate(donation, ratio_basis_points, "down").minor
matched = uncapped
capped_by: MatchLimit = "none"
if donor_cap is not None:
room = max(0, donor_cap.minor - donor_matched_so_far.minor)
if room < matched:
matched = room
capped_by = "donor"
if programme_cap is not None:
room = max(0, programme_cap.minor - programme_matched_so_far.minor)
if room < matched:
matched = room
capped_by = "programme"
return DonationMatch(matched=money(matched, currency), uncapped=money(uncapped, currency), capped_by=capped_by)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.donation-matching
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./charity.donation-matching-1.0.0-python.fune, or fetch it from a terminal with fune pull charity.donation-matching@1.0.0:python.
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 |