Functional Weave
Code in Rust

charity.higher-rate-relief

A Gift Aid donor's extra tax relief at higher, intermediate, additional and top rates, by extending the tax bands.

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

Pinned by 16 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 tax adviser review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.

What it does

What a donor's Gift Aid donations save the donor in their own tax, on top of the basic rate tax the charity reclaims. Give it the donor's taxable income for the year, the total Gift Aid donations they made in it, and where they pay income tax.

ITA 2007 s414(2)(b) treats the basic rate limit and the higher rate limit as raised by the grossed up donations. For a Scottish taxpayer the upper limit of the Scottish basic rate and the limits of every Scottish rate above it are raised too, but **not the starter rate limit**. Income that the wider bands move from a higher rate into a lower one is the relief:

For example

  • higher_rate_relief(£60,000.00, £800.00, rest-of-uk, 2026-06-01) → gross donations £1,000.00, basic rate relief £200.00, tax before £16,460.00, tax after £16,260.00, extra relief £200.00, net cost £600.00 higher rate taxpayer, £800 given: £1,000 gross, the donor saves another £200
  • higher_rate_relief(£38,000.00, £800.00, rest-of-uk, 2026-06-01) → gross donations £1,000.00, basic rate relief £200.00, tax before £7,660.00, tax after £7,600.00, extra relief £60.00, net cost £740.00 income just £300 into the higher band: only £300 moves, relief £60 not £200
  • higher_rate_relief(£150,000.00, £800.00, rest-of-uk, 2026-06-01) → gross donations £1,000.00, basic rate relief £200.00, tax before £53,703.00, tax after £53,453.00, extra relief £250.00, net cost £550.00 additional rate taxpayer: the higher rate limit moves too, relief 25% of gross

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 higher_rate_relief(taxable_income: &Money, donations: &Money, region: &str, on_date: &str) -> HigherRateRelief
taxable_incomeMoneyincome after allowances and reliefs for the year, in GBP, before any Gift Aid adjustment
donationsMoneytotal Gift Aid donations the donor made in the tax year: the amounts actually paid
regionTaxRegionwhere the donor pays income tax
on_datedateany date in the tax year of the gifts
returnsHigherRateRelief

The types it declares, generated into your project

// TaxRegion is a string in Rust, one of: "rest-of-uk", "scotland", "wales".
// Parameters take it as &str and results hold it as String.

/// What the donor's Gift Aid is worth to the donor, beyond the charity's claim.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HigherRateRelief {
    /// donations grossed up at the UK basic rate: the band extension
    pub gross_donations: Money,
    /// the tax the charity reclaims: grossDonations less donations
    pub basic_rate_relief: Money,
    /// tax on taxableIncome with the ordinary bands, rounded down to the penny
    pub tax_before: Money,
    /// taxBefore less extraRelief
    pub tax_after: Money,
    /// the donor's own saving from the extended bands, rounded down to the penny
    pub extra_relief: Money,
    /// donations less extraRelief: what the gifts cost the donor
    pub net_cost: Money,
}

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

fune!(charity.higher-rate-relief@^1);  // then call higher_rate_relief(…)
impl/rust.rs · 125 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::money_amount::{money, money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune
use super::payroll_income_tax_data::{IncomeTaxBand, INCOME_TAX_BANDS, INCOME_TAX_BANDS_HISTORY};  ← payroll.income-tax’s rule data (^1.0.0) · built alongside by fune

const REGIONS: [&str; 3] = ["rest-of-uk", "scotland", "wales"];

fn bands_for(region: &str, on_date: &str) -> Vec<&'static IncomeTaxBand> {
    let mut rows: Vec<&'static IncomeTaxBand> = INCOME_TAX_BANDS
        .iter()
        .filter(|b| b.region == region && b.valid_from <= on_date && b.valid_to.map_or(true, |to| on_date <= to))
        .collect();
    if rows.is_empty() {
        // A history=current build only carries this year's bands; an older year
        // would otherwise be answered with the wrong bands.
        if INCOME_TAX_BANDS_HISTORY != "full" {
            if let Some(earliest) = INCOME_TAX_BANDS.iter().filter(|b| b.region == region).map(|b| b.valid_from).min() {
                if on_date < earliest {
                    panic!(
                        "no income tax bands for {} on {}: this build was installed with history={}, so it only carries rules from {}. Reinstall with history=full for earlier tax years.",
                        region, on_date, INCOME_TAX_BANDS_HISTORY, earliest
                    );
                }
            }
        }
        panic!("no income tax bands for {} on {}", region, on_date);
    }
    rows.sort_by_key(|b| b.band);
    rows
}

fn basic_band_index(bands: &[&IncomeTaxBand], region: &str, on_date: &str) -> usize {
    match bands.iter().position(|b| b.basic_rate) {
        Some(i) => i,
        None => panic!("no basic rate band for {} on {}", region, on_date),
    }
}

/// Tax on income in pence, times 10000 (pence x basis points), unrounded.
fn scaled_tax(income: i64, bands: &[&IncomeTaxBand], extend_from: usize, extension: i64) -> i64 {
    let mut total = 0i64;
    let mut lower = 0i64;
    for (i, band) in bands.iter().enumerate() {
        if income <= lower {
            break;
        }
        let upper = band.up_to.map(|u| u * 100 + if i >= extend_from { extension } else { 0 });
        let top = match upper {
            Some(u) if income >= u => u,
            _ => income,
        };
        if top > lower {
            total += (top - lower) * band.basis_points;
        }
        match upper {
            Some(u) => lower = u,
            None => break,
        }
    }
    total
}

/// The extra relief a Gift Aid donor gets through their own tax: ITA 2007
/// s414(2)(b) raises the basic rate limit and the higher rate limit (for a
/// Scottish taxpayer, the Scottish basic rate limit and every limit above it,
/// but not the starter rate limit) by the grossed up donations.
///
/// # Panics
/// Panics on non-GBP or negative amounts, an unknown region, a date with no
/// bands on file, or a devolved basic rate that differs from the UK one.
pub fn higher_rate_relief(taxable_income: &Money, donations: &Money, region: &str, on_date: &str) -> HigherRateRelief {
    if taxable_income.currency != "GBP" {
        panic!("taxableIncome must be in GBP, received {}", taxable_income.currency);
    }
    if taxable_income.minor < 0 {
        panic!("taxableIncome must not be negative, received {}", taxable_income.minor);
    }
    if !REGIONS.contains(&region) {
        panic!("unknown tax region \"{}\": expected rest-of-uk, scotland or wales", region);
    }
    let grossed = gift_aid(donations, on_date);
    let bands = bands_for(region, on_date);
    let basic = basic_band_index(&bands, region, on_date);
    let uk_bands = bands_for("rest-of-uk", on_date);
    let uk_basic = uk_bands[basic_band_index(&uk_bands, "rest-of-uk", on_date)];
    if bands[basic].basis_points != uk_basic.basis_points {
        // s414A then adjusts the donor's tax by the rate difference; not modelled.
        panic!(
            "the {} basic rate differs from the UK basic rate on {}; the section 414A adjustment is not supported",
            region, on_date
        );
    }
    let before = scaled_tax(taxable_income.minor, &bands, basic, 0);
    let after = scaled_tax(taxable_income.minor, &bands, basic, grossed.gross.minor);
    let tax_before = before.div_euclid(10000);
    let extra = (before - after).div_euclid(10000);
    HigherRateRelief {
        gross_donations: grossed.gross.clone(),
        basic_rate_relief: grossed.tax_reclaimable.clone(),
        tax_before: money(tax_before, "GBP"),
        tax_after: money(tax_before - extra, "GBP"),
        extra_relief: money(extra, "GBP"),
        net_cost: money(donations.minor - extra, "GBP"),
    }
}

pub fn higher_rate_relief_to_value(r: &HigherRateRelief) -> Value {
    Value::obj(vec![
        ("grossDonations", money_to_value(&r.gross_donations)),
        ("basicRateRelief", money_to_value(&r.basic_rate_relief)),
        ("taxBefore", money_to_value(&r.tax_before)),
        ("taxAfter", money_to_value(&r.tax_after)),
        ("extraRelief", money_to_value(&r.extra_relief)),
        ("netCost", money_to_value(&r.net_cost)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    higher_rate_relief_to_value(&higher_rate_relief(
        &money_from_value(&args[0]),
        &money_from_value(&args[1]),
        args[2].as_str(),
        args[3].as_str(),
    ))
}

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.higher-rate-relief
Download for Rust charity.higher-rate-relief-1.0.0-rust.fune · 19,303 bytes sha256 647c2833723f1aa99dbb40b740c3c0959a4f723202a6453395b8741d03ca364e

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

The whole function, every language, is one file too: charity.higher-rate-relief-1.0.0.fune, 28,062 bytes, sha256 e2ac4b8686231fef1cf45c66ac8d43723d57152a6aeef90a157fc4660e0cc9cf. 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.higher-rate-relief

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

// fune: after charity.higher-rate-relief

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.higher-rate-relief
// fune: replace money.amount in charity.higher-rate-relief
// fune: replace payroll.income-tax in charity.higher-rate-relief

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.higher-rate-relief --steps.

// fune: step charity.higher-rate-relief 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
higher rate taxpayer, £800 given: £1,000 gross, the donor saves another £200 £60,000.00, £800.00, rest-of-uk, 2026-06-01 → gross donations £1,000.00, basic rate relief £200.00, tax before £16,460.00, tax after £16,260.00, extra relief £200.00, net cost £600.00
income just £300 into the higher band: only £300 moves, relief £60 not £200 £38,000.00, £800.00, rest-of-uk, 2026-06-01 → gross donations £1,000.00, basic rate relief £200.00, tax before £7,660.00, tax after £7,600.00, extra relief £60.00, net cost £740.00
additional rate taxpayer: the higher rate limit moves too, relief 25% of gross £150,000.00, £800.00, rest-of-uk, 2026-06-01 → gross donations £1,000.00, basic rate relief £200.00, tax before £53,703.00, tax after £53,453.00, extra relief £250.00, net cost £550.00
basic rate taxpayer: no extra relief £20,000.00, £800.00, rest-of-uk, 2026-06-01 → gross donations £1,000.00, basic rate relief £200.00, tax before £4,000.00, tax after £4,000.00, extra relief £0.00, net cost £800.00
no taxable income: no tax, no extra relief £0.00, £800.00, rest-of-uk, 2026-06-01 → gross donations £1,000.00, basic rate relief £200.00, tax before £0.00, tax after £0.00, extra relief £0.00, net cost £800.00
£10.01 given: 20% of £12.51 is 250.2p, rounded down to 250p £60,000.00, £10.01, rest-of-uk, 2026-06-01 → gross donations £12.51, basic rate relief £2.50, tax before £16,460.00, tax after £16,457.50, extra relief £2.50, net cost £7.51
Scottish higher rate taxpayer 2026-27: 42% less 20%, relief £220 on £1,000 gross £40,000.00, £800.00, scotland, 2026-06-01 → gross donations £1,000.00, basic rate relief £200.00, tax before £10,061.45, tax after £9,841.45, extra relief £220.00, net cost £580.00
Scottish intermediate rate taxpayer: relief 1%, the starter band is not extended £20,000.00, £800.00, scotland, 2026-06-01 → gross donations £1,000.00, basic rate relief £200.00, tax before £3,990.77, tax after £3,980.77, extra relief £10.00, net cost £790.00
Scottish top rate taxpayer 2023-24: the top rate threshold moves as well, 27% relief £130,000.00, £800.00, scotland, 2023-12-01 → gross donations £1,000.00, basic rate relief £200.00, tax before £48,160.88, tax after £47,890.88, extra relief £270.00, net cost £530.00
a Welsh taxpayer uses the UK limits £60,000.00, £800.00, wales, 2024-06-01 → gross donations £1,000.00, basic rate relief £200.00, tax before £16,460.00, tax after £16,260.00, extra relief £200.00, net cost £600.00
Show the other 6 tests
CaseArgumentsExpected
no donations: nothing changes £60,000.00, £0.00, rest-of-uk, 2026-06-01 → gross donations £0.00, basic rate relief £0.00, tax before £16,460.00, tax after £16,460.00, extra relief £0.00, net cost £0.00
a tax year before the bands on file is an error £60,000.00, £800.00, rest-of-uk, 2022-06-01 → error: no income tax bands for rest-of-uk on 2022-06-01
an unknown region is an error £60,000.00, £800.00, england, 2026-06-01 → error: unknown tax region "england"
negative taxable income is an error -£0.01, £800.00, rest-of-uk, 2026-06-01 → error: taxableIncome must not be negative
taxable income in euros is an error €60,000.00, £800.00, rest-of-uk, 2026-06-01 → error: taxableIncome must be in GBP
negative donations are an error £60,000.00, -£1.00, rest-of-uk, 2026-06-01 → error: donation must not be negative

More from the author

- a higher rate taxpayer giving £800 (£1,000 gross) saves another £200; - an additional rate taxpayer saves £250, because the additional rate threshold moves as well; - a Scottish intermediate rate taxpayer saves 1% of the gross, a Scottish higher rate taxpayer 22%; - someone only £300 into the higher band saves 20% of £300, not of £1,000.

The relief is worked out as the difference between the tax on the same income with the ordinary and the extended bands, in exact pence x basis points, and rounded down to the penny once. `taxAfter` is `taxBefore` less that relief.

## Where the bands come from

The bands are **payroll.income-tax's own dated band data** (region, rate, upper limit, which band is the basic rate), imported from its data module rather than copied, so a new tax year added there reaches this capability too. That data starts in 2023-24, so earlier tax years are an error. Its `upTo` figures are the statutory limits on taxable income (basic rate limit £37,700, higher rate limit £125,140; the Scottish limits by year). Grossing up uses the UK basic rate from charity.gift-aid, as s415 requires, for Scottish and Welsh taxpayers too.

## What it does not do

- **The personal allowance taper.** Gift Aid also reduces adjusted net income (ITA 2007 s58), which can restore personal allowance lost above £100,000 and is worth far more there. `taxableIncome` is taken as given, after allowances; work the allowance out first with the donations deducted from adjusted net income, then call this. - Savings and dividend income, which have their own rates. - s414A, the adjustment when a devolved basic rate differs from the UK basic rate. The Scottish and Welsh basic rates are 20% in every year on file, so it never applies; if the data ever says otherwise this is an error rather than a wrong answer. - Carry-back of gifts to the previous tax year (s426).

## Sources

- Income Tax Act 2007 s414 (band extension, including the Scottish limits) and s415 (grossed up at the basic rate), https://www.legislation.gov.uk/ukpga/2007/3/section/414 - ITA 2007 s58 (adjusted net income), https://www.legislation.gov.uk/ukpga/2007/3/section/58 - Band figures: see payroll.income-tax's README and data.

Files

PathBytes
README.md2,880
impl/python.py4,195
impl/rust.rs5,291
impl/typescript.ts4,270
vectors.json6,723