Functional Weave
Code in Rust

charity.reclaim-batch

Gift Aid claim totals for a period from donation records: donor lines, aggregated small gifts, tax due.

1.0.1 · published 2026-10-03 by charlie · Anterra

Pinned by 12 tests, run in TypeScript, Python and Rust.

Not professional advice. This capability calculates tax figures from published rules. It is a software component for developers, not tax advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a charity tax specialist review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.

What it does

Turn a period's donation records into the figures of a Gift Aid repayment claim: the schedule lines, the total of eligible donations and the tax to reclaim, plus every donation left out and why.

For each donation, in input order:

For example

  • gift_aid_claim_batch(donations ×9, 2026-04-06, 2027-04-05, true) → lines ×3, eligible count 5, eligible total £150.01, claimable £37.50, excluded ×4 a mixed batch: donor lines, £20-or-less gifts aggregated, every exclusion named
  • gift_aid_claim_batch(donations ×9, 2026-04-06, 2027-04-05, false) → lines ×3, eligible count 5, eligible total £150.01, claimable £37.50, excluded ×4 the same batch without aggregation: every eligible gift on its donor's line
  • gift_aid_claim_batch(donations ×51, 2026-04-06, 2027-04-05, true) → lines ×2, eligible count 51, eligible total £1,020.00, claimable £255.00, excluded an aggregated line stops at £1,000: 51 gifts of £20 make a full line of 50 and a line of 1

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 gift_aid_claim_batch(donations: &[GiftAidDonation], period_start: &str, period_end: &str, aggregate_small: bool) -> GiftAidClaimBatch
donationsGiftAidDonation[]donation records in any order; ids must be unique
period_startdatefirst day of the claim period, inclusive
period_enddatelast day of the claim period, inclusive
aggregate_smallboolput donations at or under the aggregation limit on aggregated lines instead of donor lines
returnsGiftAidClaimBatch

The types it declares, generated into your project

/// One donation as the charity recorded it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GiftAidDonation {
    pub id: String,
    /// null for an anonymous gift
    pub donor_id: Option<String>,
    pub date: String,
    /// GBP
    pub amount: Money,
    /// a Gift Aid declaration from the donor covers it
    pub has_declaration: bool,
}

// ClaimLineKind is a string in Rust, one of: "donor", "aggregated".
// Parameters take it as &str and results hold it as String.

/// One entry on the claim schedule.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClaimLine {
    pub kind: String,
    /// null on an aggregated line
    pub donor_id: Option<String>,
    /// date of the last donation on the line, as the schedule asks
    pub last_date: String,
    pub count: i64,
    pub total: Money,
}

/// A donation left out of the claim, and why.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExcludedDonation {
    pub id: String,
    /// outside-period, not-positive, no-declaration or no-donor
    pub reason: String,
}

/// The claim: its lines, totals and what was left out.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GiftAidClaimBatch {
    /// donor lines by donorId, then aggregated lines by date
    pub lines: Vec<ClaimLine>,
    pub eligible_count: i64,
    pub eligible_total: Money,
    /// Gift Aid on eligibleTotal, worked out per tax year and rounded down once per year
    pub claimable: Money,
    /// in input order
    pub excluded: Vec<ExcludedDonation>,
}

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

fune!(charity.reclaim-batch@^1);  // then call gift_aid_claim_batch(…)
impl/rust.rs · 183 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::charity_gift_aid::gift_aid;  ← from charity.gift-aid ^1.0.0 · built alongside by fune
use super::charity_reclaim_batch_data::AGGREGATION;  ← this capability’s own data, compiled from data/aggregation.json into the same file by fune build
use super::dates_uk_tax_year::uk_tax_year;  ← from dates.uk-tax-year ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value};  ← from money.amount ^1.0.0 · built alongside by fune

/// The totals of a Gift Aid repayment claim for a period. Every donation is
/// either on the claim or listed as excluded with its reason, lines come out
/// in a fixed order (donor lines by donorId, then aggregated lines by date),
/// and the tax is worked out on each tax year's total.
///
/// # Panics
/// Panics on a malformed date, a period that ends before it starts, a
/// duplicate donation id, a non-GBP donation, or a date with no basic rate.
pub fn gift_aid_claim_batch(
    donations: &[GiftAidDonation],
    period_start: &str,
    period_end: &str,
    aggregate_small: bool,
) -> GiftAidClaimBatch {
    uk_tax_year(period_start);
    uk_tax_year(period_end);
    if period_end < period_start {
        panic!("periodEnd {} is before periodStart {}", period_end, period_start);
    }
    let rule = &AGGREGATION[0];
    let mut ids: Vec<&str> = Vec::new();
    let mut excluded: Vec<ExcludedDonation> = Vec::new();
    let mut eligible: Vec<&GiftAidDonation> = Vec::new();
    for d in donations {
        if ids.contains(&d.id.as_str()) {
            panic!("duplicate donation id \"{}\"", d.id);
        }
        ids.push(&d.id);
        if d.amount.currency != "GBP" {
            panic!("donation \"{}\" must be in GBP, received {}", d.id, d.amount.currency);
        }
        uk_tax_year(&d.date);
        let reason = if d.date.as_str() < period_start || d.date.as_str() > period_end {
            Some("outside-period")
        } else if d.amount.minor <= 0 {
            Some("not-positive")
        } else if !d.has_declaration {
            Some("no-declaration")
        } else if d.donor_id.is_none() {
            Some("no-donor")
        } else {
            None
        };
        match reason {
            None => eligible.push(d),
            Some(r) => excluded.push(ExcludedDonation { id: d.id.clone(), reason: r.to_string() }),
        }
    }

    let (small, named): (Vec<&GiftAidDonation>, Vec<&GiftAidDonation>) = if aggregate_small {
        eligible.iter().partition(|d| d.amount.minor <= rule.max_donation)
    } else {
        (Vec::new(), eligible.clone())
    };

    let mut donor_ids: Vec<&str> = Vec::new();
    for d in &named {
        let key = d.donor_id.as_deref().unwrap();
        if !donor_ids.contains(&key) {
            donor_ids.push(key);
        }
    }
    donor_ids.sort();
    let mut lines: Vec<ClaimLine> = Vec::new();
    for donor_id in donor_ids {
        let group: Vec<&&GiftAidDonation> = named.iter().filter(|d| d.donor_id.as_deref() == Some(donor_id)).collect();
        lines.push(ClaimLine {
            kind: "donor".to_string(),
            donor_id: Some(donor_id.to_string()),
            last_date: group.iter().map(|d| d.date.clone()).max().unwrap(),
            count: group.len() as i64,
            total: money(group.iter().map(|d| d.amount.minor).sum(), "GBP"),
        });
    }

    // Aggregated lines fill in date order (ties by id) up to the line limit.
    let mut sorted_small = small.clone();
    sorted_small.sort_by(|a, b| a.date.cmp(&b.date).then_with(|| a.id.cmp(&b.id)));
    let mut current: Vec<&GiftAidDonation> = Vec::new();
    let mut current_total = 0i64;
    for d in sorted_small {
        if current_total + d.amount.minor > rule.max_line_total && !current.is_empty() {
            lines.push(ClaimLine {
                kind: "aggregated".to_string(),
                donor_id: None,
                last_date: current[current.len() - 1].date.clone(),
                count: current.len() as i64,
                total: money(current_total, "GBP"),
            });
            current.clear();
            current_total = 0;
        }
        current.push(d);
        current_total += d.amount.minor;
    }
    if !current.is_empty() {
        lines.push(ClaimLine {
            kind: "aggregated".to_string(),
            donor_id: None,
            last_date: current[current.len() - 1].date.clone(),
            count: current.len() as i64,
            total: money(current_total, "GBP"),
        });
    }

    // One rounding per tax year, since each year has its own basic rate.
    let mut by_year: Vec<(String, i64)> = Vec::new();
    for d in &eligible {
        let start = uk_tax_year(&d.date).start;
        match by_year.iter_mut().find(|(s, _)| *s == start) {
            Some(entry) => entry.1 += d.amount.minor,
            None => by_year.push((start, d.amount.minor)),
        }
    }
    by_year.sort();
    let claimable: i64 = by_year
        .iter()
        .map(|(start, total)| gift_aid(&money(*total, "GBP"), start).tax_reclaimable.minor)
        .sum();

    GiftAidClaimBatch {
        lines,
        eligible_count: eligible.len() as i64,
        eligible_total: money(eligible.iter().map(|d| d.amount.minor).sum(), "GBP"),
        claimable: money(claimable, "GBP"),
        excluded,
    }
}

pub fn gift_aid_donation_from_value(v: &Value) -> GiftAidDonation {
    GiftAidDonation {
        id: v.get("id").as_str().to_string(),
        donor_id: if v.get("donorId").is_null() { None } else { Some(v.get("donorId").as_str().to_string()) },
        date: v.get("date").as_str().to_string(),
        amount: money_from_value(v.get("amount")),
        has_declaration: v.get("hasDeclaration").as_bool(),
    }
}

pub fn gift_aid_claim_batch_to_value(b: &GiftAidClaimBatch) -> Value {
    Value::obj(vec![
        (
            "lines",
            Value::Arr(
                b.lines
                    .iter()
                    .map(|l| {
                        Value::obj(vec![
                            ("kind", Value::str(&l.kind)),
                            ("donorId", match &l.donor_id { Some(id) => Value::str(id), None => Value::Null }),
                            ("lastDate", Value::str(&l.last_date)),
                            ("count", Value::Int(l.count)),
                            ("total", money_to_value(&l.total)),
                        ])
                    })
                    .collect(),
            ),
        ),
        ("eligibleCount", Value::Int(b.eligible_count)),
        ("eligibleTotal", money_to_value(&b.eligible_total)),
        ("claimable", money_to_value(&b.claimable)),
        (
            "excluded",
            Value::Arr(
                b.excluded
                    .iter()
                    .map(|e| Value::obj(vec![("id", Value::str(&e.id)), ("reason", Value::str(&e.reason))]))
                    .collect(),
            ),
        ),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let donations: Vec<GiftAidDonation> = args[0].as_arr().iter().map(gift_aid_donation_from_value).collect();
    gift_aid_claim_batch_to_value(&gift_aid_claim_batch(&donations, args[1].as_str(), args[2].as_str(), args[3].as_bool()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 3 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.reclaim-batch
Download for Rust charity.reclaim-batch-1.0.1-rust.fune · 33,388 bytes sha256 72146c064e63819f6c3c01386707385b228bc671385a7aa65d877129138ba10c

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

The whole function, every language, is one file too: charity.reclaim-batch-1.0.1.fune, 41,775 bytes, sha256 61c4ca7d06e99d0fef814257877c589ba302a746d61de27aaf668ef9615116ee. 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.reclaim-batch

after — your function gets the result and the arguments, and returns the final result.

// fune: after charity.reclaim-batch

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 charity.gift-aid in charity.reclaim-batch
// fune: replace dates.uk-tax-year in charity.reclaim-batch
// fune: replace money.amount in charity.reclaim-batch

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.reclaim-batch --steps.

// fune: step charity.reclaim-batch 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
a mixed batch: donor lines, £20-or-less gifts aggregated, every exclusion named donations ×9, 2026-04-06, 2027-04-05, true → lines ×3, eligible count 5, eligible total £150.01, claimable £37.50, excluded ×4
the same batch without aggregation: every eligible gift on its donor's line donations ×9, 2026-04-06, 2027-04-05, false → lines ×3, eligible count 5, eligible total £150.01, claimable £37.50, excluded ×4
an aggregated line stops at £1,000: 51 gifts of £20 make a full line of 50 and a line of 1 donations ×51, 2026-04-06, 2027-04-05, true → lines ×2, eligible count 51, eligible total £1,020.00, claimable £255.00, excluded
a claim across 5 April 2008 uses 22% before it and 20% after donations ×2, 2008-01-01, 2008-12-31, false → lines ×1, eligible count 2, eligible total £178.00, claimable £47.00, excluded
the tax is worked out on the year's total: £10.01 and £10.03 give 501p, not 250p + 250p donations ×2, 2026-04-06, 2027-04-05, false → lines ×2, eligible count 2, eligible total £20.04, claimable £5.01, excluded
donor lines are ordered by donor id, not by input order donations ×2, 2026-04-06, 2027-04-05, true → lines ×2, eligible count 2, eligible total £100.00, claimable £25.00, excluded
the period bounds are inclusive donations ×3, 2026-04-06, 2027-04-05, false → lines ×1, eligible count 2, eligible total £100.00, claimable £25.00, excluded ×1
an empty batch claims nothing , 2026-04-06, 2027-04-05, true → lines , eligible count 0, eligible total £0.00, claimable £0.00, excluded
a duplicate donation id is an error donations ×2, 2026-04-06, 2027-04-05, true → error: duplicate donation id "a"
a period that ends before it starts is an error , 2027-04-05, 2026-04-06, true → error: periodEnd 2026-04-06 is before periodStart 2027-04-05
Show the other 2 tests
CaseArgumentsExpected
a euro donation is an error donations ×1, 2026-04-06, 2027-04-05, true → error: donation "a" must be in GBP, received EUR
a malformed donation date is an error donations ×1, 2026-04-06, 2027-04-05, true → error: is not an ISO date

More from the author

- outside `periodStart`..`periodEnd` (inclusive): excluded, `outside-period`; - zero or negative (a refund, a reversal): excluded, `not-positive`; - no Gift Aid declaration: excluded, `no-declaration`; - no donor (anonymous): excluded, `no-donor`; - otherwise it is on the claim.

Eligible donations become **donor lines**, one per donor with the donor's total, count and last donation date (the schedule asks for the date of the last donation in a series), ordered by donor id. With `aggregateSmall`, donations of **£20 or less** go instead onto **aggregated lines** of up to **£1,000** each, filled in date order (ties by donation id), as Charities Online allows; each line's date is its last donation's. A donor's larger gifts stay on their donor line.

The tax is Gift Aid (charity.gift-aid) on the eligible total of **each tax year** in the period, rounded down once per year: a claim that crosses 5 April 2008 uses 22% before and 20% after, and pennies are not lost by rounding every gift. Ordering is fixed throughout, so the same records always give the same claim.

## Decisions and limits

- The aggregation limits are one row of data (`maxDonation` 2000p, `maxLineTotal` 100000p) with **no effective dates**: they are HMRC's administrative rule for Charities Online rather than law, and HMRC has not published the date they took effect, so there is nothing honest to put in a `validFrom`. It is kept as data so a change is a data release. Note that GOV.UK's schedule spreadsheet page also describes aggregated donations in passing as "multiple donations under £30", while its own detailed section and the Charities Online page both say "£20 or less"; this follows the detailed rule. - Donor ids sort by plain string order (ordinal, not locale): use ASCII ids. - It does not check benefits (charity.gift-aid-eligibility), the sponsored-event rule (sponsorship under £500 per sponsor listed under the participant), the four-year time limit, or visitor-attraction admissions, which may not be aggregated.

## Sources

- GOV.UK, "Claim tax back on donations using Charities Online" ("add together small donations of £20 or less, up to a total of £1,000 per entry"), https://www.gov.uk/guidance/claim-tax-back-on-donations-using-charities-online - GOV.UK, "Schedule spreadsheet to claim back tax on Gift Aid donations" ("donations of £20 or less from different donors ... cannot be higher than £1,000"; date of the last donation), https://www.gov.uk/guidance/schedule-spreadsheet-to-claim-back-tax-on-gift-aid-donations - Basic rates: see charity.gift-aid.

## Notices

Contains public sector information licensed under the Open Government Licence v3.0 (https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/).

1.0.1 adds its attribution notices (NOTICE). The code and the tests are unchanged.

Files

PathBytes
NOTICE184
README.md3,117
data/aggregation.json214
impl/python.py3,944
impl/rust.rs6,976
impl/typescript.ts4,122
vectors.json15,098