Functional Weave
Code in TypeScript

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 £50
  • donationMatch(£50.00, 200%, —, £0.00, —, £0.00) → matched £100.00, uncapped £100.00, capped by none 2:1 on £50 is £100
  • donationMatch(£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
donationMoneythe employee's donation
ratioBasisPointsint10000 = 1:1 (£1 for £1), 20000 = 2:1, 5000 = 50p per £1
donorCapMoney?most this donor can be matched in the period (usually a year); null for no cap
donorMatchedSoFarMoneyalready matched for this donor in the period
programmeCapMoney?the employer's budget for the period across all donors; null for no cap
programmeMatchedSoFarMoneyalready matched across the programme in the period
returnsDonationMatch

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";
impl/typescript.ts · 48 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.

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
Download for TypeScript charity.donation-matching-1.0.0-typescript.fune · 12,656 bytes sha256 bddebe83c1837dcdad3a6013f3191d5221bc20d308f13ce0b5de17fd988624d4

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md1,182
impl/python.py2,172
impl/rust.rs3,122
impl/typescript.ts2,214
vectors.json5,449