Functional Weave
Code in Rust

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.

pub fn donation_match(donation: &Money, ratio_basis_points: i64, donor_cap: Option<&Money>, donor_matched_so_far: &Money, programme_cap: Option<&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 is a string in Rust, one of: "none", "donor", "programme".
// Parameters take it as &str and results hold it as String.

/// The match, what it would have been without caps, and which cap bit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DonationMatch {
    /// the employer's contribution, rounded down to the minor unit
    pub matched: Money,
    /// donation x ratio, rounded down, before any cap
    pub uncapped: Money,
    /// none, or the cap that reduced the match; donor when both leave the same room
    pub capped_by: String,
}

Your code names it in one line, in the file that uses it

fune!(charity.donation-matching@^1);  // then call donation_match(…)
impl/rust.rs · 90 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.

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::money_amount::{money, money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune
use super::money_apply_rate::apply_rate;  ← from money.apply-rate ^1.0.0 · built alongside by fune

fn check(name: &str, currency: &str, amount: &Money) {
    if amount.currency != currency {
        panic!("currency mismatch: {} and {}", amount.currency, currency);
    }
    if amount.minor < 0 {
        panic!("{} must not be negative, received {}", name, 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.
///
/// # Panics
/// Panics on a negative amount or ratio, or mixed currencies.
pub fn donation_match(
    donation: &Money,
    ratio_basis_points: i64,
    donor_cap: Option<&Money>,
    donor_matched_so_far: &Money,
    programme_cap: Option<&Money>,
    programme_matched_so_far: &Money,
) -> DonationMatch {
    let currency = donation.currency.as_str();
    check("donation", currency, donation);
    if ratio_basis_points < 0 {
        panic!("ratioBasisPoints must be a non-negative integer, received {}", ratio_basis_points);
    }
    if let Some(cap) = donor_cap {
        check("donorCap", currency, cap);
    }
    check("donorMatchedSoFar", currency, donor_matched_so_far);
    if let Some(cap) = programme_cap {
        check("programmeCap", currency, cap);
    }
    check("programmeMatchedSoFar", currency, programme_matched_so_far);

    let uncapped = apply_rate(donation, ratio_basis_points, "down").minor;
    let mut matched = uncapped;
    let mut capped_by = "none";
    if let Some(cap) = donor_cap {
        let room = (cap.minor - donor_matched_so_far.minor).max(0);
        if room < matched {
            matched = room;
            capped_by = "donor";
        }
    }
    if let Some(cap) = programme_cap {
        let room = (cap.minor - programme_matched_so_far.minor).max(0);
        if room < matched {
            matched = room;
            capped_by = "programme";
        }
    }
    DonationMatch {
        matched: money(matched, currency),
        uncapped: money(uncapped, currency),
        capped_by: capped_by.to_string(),
    }
}

pub fn donation_match_to_value(m: &DonationMatch) -> Value {
    Value::obj(vec![
        ("matched", money_to_value(&m.matched)),
        ("uncapped", money_to_value(&m.uncapped)),
        ("cappedBy", Value::str(&m.capped_by)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    if let Value::Float(f) = args[1] {
        if f.fract() != 0.0 {
            panic!("ratioBasisPoints must be a non-negative integer, received {}", f);
        }
    }
    let donor_cap = if args[2].is_null() { None } else { Some(money_from_value(&args[2])) };
    let programme_cap = if args[4].is_null() { None } else { Some(money_from_value(&args[4])) };
    donation_match_to_value(&donation_match(
        &money_from_value(&args[0]),
        args[1].as_i64(),
        donor_cap.as_ref(),
        &money_from_value(&args[3]),
        programme_cap.as_ref(),
        &money_from_value(&args[5]),
    ))
}

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.donation-matching
Download for Rust charity.donation-matching-1.0.0-rust.fune · 13,602 bytes sha256 336bccee3735e88eb0a0496188cb927be8f603c17ccd9e5b89a548d785085e30

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./charity.donation-matching-1.0.0-rust.fune, or fetch it from a terminal with fune pull charity.donation-matching@1.0.0:rust.

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