Functional Weave
Code in Python

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

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
impl/python.py · 51 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.

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
Download for Python charity.donation-matching-1.0.0-python.fune · 12,615 bytes sha256 3762fac8cac96471b7e5b56fafb78214a99f8ad2b227ce52e45e9559827217fa

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.

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