Functional Weave
Code in Rust

payroll.pension-auto-enrolment

Workplace pension contributions for one pay period: qualifying earnings band, employee and employer shares, tax relief.

1.0.1 (not the latest) · published 2026-10-03 by charlie · Anterra

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

Not professional advice. This capability calculates payroll figures from published rules. It is a software component for developers, not tax or legal 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 payroll specialist review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.

What it does

One pay period's workplace pension contributions: the earnings they are worked out on, the employee's and employer's shares, and what the employee's share does to pay under the scheme's tax relief arrangement. Tax years 2023-24 to 2026-27 are carried as dated data.

## Decisions

For example

  • pension_contributions(£2,500.00, monthly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement relief-at-source, 2026-05-28) → pensionable earnings £1,980.00, employee gross £99.00, employee deduction £79.20, employer £59.40, deduct before tax false, earnings trigger met true statutory minimum, relief at source, monthly: 5% and 3% of pay between £520 and £4,189
  • pension_contributions(£2,500.00, monthly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement net-pay, 2026-05-28) → pensionable earnings £1,980.00, employee gross £99.00, employee deduction £99.00, employer £59.40, deduct before tax true, earnings trigger met true the same under a net pay arrangement: the whole 5% comes off before tax
  • pension_contributions(£5,000.00, monthly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement relief-at-source, 2026-05-28) → pensionable earnings £3,669.00, employee gross £183.45, employee deduction £146.76, employer £110.07, deduct before tax false, earnings trigger met true pay above the upper level: contributions stop at £4,189

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 pension_contributions(earnings: &Money, frequency: &str, scheme: &PensionScheme, pay_date: &str) -> PensionContributions
earningsMoneygross earnings for the pay reference period, before any pension deduction
frequencyPayFrequencythe pay reference period, which picks the band's weekly, fortnightly, four-weekly or monthly figures
schemePensionSchemethe contribution rates and how the scheme gives tax relief
pay_datedatethe date the earnings are paid, which picks the tax year's thresholds
returnsPensionContributions

The types it declares, generated into your project

/// How the employer's scheme works out contributions.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PensionScheme {
    /// null means the statutory minimum: 8% in total less the employer's share
    pub employee_basis_points: Option<i64>,
    /// null means the statutory minimum 3%
    pub employer_basis_points: Option<i64>,
    /// the qualifying earnings band, or every penny of pay
    pub earnings_basis: String,
    /// how the employee gets tax relief
    pub arrangement: String,
}

// PensionEarningsBasis is a string in Rust, one of: "qualifying-earnings", "all-earnings".
// Parameters take it as &str and results hold it as String.

// ReliefArrangement is a string in Rust, one of: "relief-at-source", "net-pay".
// Parameters take it as &str and results hold it as String.

/// One period's contributions and what they do to pay.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PensionContributions {
    /// earnings the percentages were applied to
    pub pensionable_earnings: Money,
    /// employee contribution going into the pension, including basic-rate relief
    pub employee_gross: Money,
    /// what payroll takes from pay: 80% of the gross under relief at source, the gross under net pay
    pub employee_deduction: Money,
    /// employer contribution, on top of pay
    pub employer: Money,
    /// true under a net pay arrangement: reduce taxable pay (not NI-able pay) by employeeDeduction
    pub deduct_before_tax: bool,
    /// earnings above the automatic enrolment trigger for the period (age is not checked)
    pub earnings_trigger_met: bool,
}

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

fune!(payroll.pension-auto-enrolment@^1);  // then call pension_contributions(…)
impl/rust.rs · 159 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::math_round_div::round_div;  ← from math.round-div ^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_pension_auto_enrolment_data::{
    AE_MINIMUMS, AE_MINIMUMS_HISTORY, AE_MINIMUMS_HORIZON, AE_THRESHOLDS, AE_THRESHOLDS_HISTORY,
    AE_THRESHOLDS_HORIZON,
};

fn is_iso_date(value: &str) -> bool {
    let bytes = value.as_bytes();
    bytes.len() == 10
        && bytes[4] == b'-'
        && bytes[7] == b'-'
        && bytes
            .iter()
            .enumerate()
            .all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit())
}

fn missing(what: &str, on_date: &str, history: &str, horizon: Option<&str>) -> ! {
    if let Some(horizon) = horizon {
        if history != "full" && on_date < horizon {
            panic!(
                "{} on {}: this build was installed with history={}, so it only carries rules from {}. Reinstall with history=full for earlier tax years.",
                what, on_date, history, horizon
            );
        }
    }
    panic!("{} on {}", what, on_date)
}

fn check_rate(name: &str, value: Option<i64>) {
    if let Some(v) = value {
        if v < 0 {
            panic!("{} must be a whole number of basis points, 0 or more, received {}", name, v);
        }
    }
}

fn in_force(valid_from: &str, valid_to: Option<&str>, on_date: &str) -> bool {
    on_date >= valid_from && valid_to.map_or(true, |to| on_date <= to)
}

/// Workplace pension contributions for one pay reference period.
///
/// The band is applied with the published per-period figures (£520 to £4,189 a
/// month, £120 to £967 a week), never the annual band divided on the fly. Each
/// contribution is rounded once to the nearest penny, half up; under relief at
/// source the deduction from pay is then 80% of the gross contribution, again
/// rounded half up, and the difference is the relief the provider claims.
///
/// # Panics
/// Panics on non-GBP or negative earnings, negative rates, an unknown basis,
/// arrangement or frequency, or a date no thresholds cover.
pub fn pension_contributions(earnings: &Money, frequency: &str, scheme: &PensionScheme, pay_date: &str) -> PensionContributions {
    if earnings.currency != "GBP" {
        panic!("pension contributions must be in GBP, received {}", earnings.currency);
    }
    if earnings.minor < 0 {
        panic!("earnings must not be negative, received {}", earnings.minor);
    }
    check_rate("employeeBasisPoints", scheme.employee_basis_points);
    check_rate("employerBasisPoints", scheme.employer_basis_points);
    if !matches!(scheme.earnings_basis.as_str(), "qualifying-earnings" | "all-earnings") {
        panic!("unknown earnings basis \"{}\"", scheme.earnings_basis);
    }
    if !matches!(scheme.arrangement.as_str(), "relief-at-source" | "net-pay") {
        panic!("unknown relief arrangement \"{}\"", scheme.arrangement);
    }
    if !matches!(frequency, "weekly" | "fortnightly" | "four-weekly" | "monthly") {
        panic!("unknown pay frequency \"{}\"", frequency);
    }
    if !is_iso_date(pay_date) {
        panic!("payDate must be an ISO date (YYYY-MM-DD), received \"{}\"", pay_date);
    }

    let t = match AE_THRESHOLDS.iter().find(|r| in_force(r.valid_from, r.valid_to, pay_date)) {
        Some(t) => t,
        None => missing("no auto-enrolment earnings thresholds", pay_date, AE_THRESHOLDS_HISTORY, AE_THRESHOLDS_HORIZON),
    };
    let m = match AE_MINIMUMS.iter().find(|r| in_force(r.valid_from, r.valid_to, pay_date)) {
        Some(m) => m,
        None => missing("no auto-enrolment minimum contributions", pay_date, AE_MINIMUMS_HISTORY, AE_MINIMUMS_HORIZON),
    };

    let (lower, trigger, upper) = match frequency {
        "weekly" => (t.lower_weekly, t.trigger_weekly, t.upper_weekly),
        "fortnightly" => (t.lower_fortnightly, t.trigger_fortnightly, t.upper_fortnightly),
        "four-weekly" => (t.lower_four_weekly, t.trigger_four_weekly, t.upper_four_weekly),
        _ => (t.lower_monthly, t.trigger_monthly, t.upper_monthly),
    };

    // Qualifying earnings are the part of pay above the lower level and not
    // above the upper one (Pensions Act 2008 s.13).
    let pensionable = if scheme.earnings_basis == "all-earnings" {
        earnings.minor
    } else {
        (earnings.minor.min(upper) - lower).max(0)
    };
    let employer_rate = scheme.employer_basis_points.unwrap_or(m.employer_basis_points);
    let employee_rate = scheme
        .employee_basis_points
        .unwrap_or_else(|| (m.total_basis_points - employer_rate).max(0));

    let employee_gross = round_div(pensionable * employee_rate, 10000, "half-up");
    let employer = round_div(pensionable * employer_rate, 10000, "half-up");
    let net_pay = scheme.arrangement == "net-pay";
    let deduction = if net_pay {
        employee_gross
    } else {
        round_div(employee_gross * (10000 - m.relief_at_source_basis_points), 10000, "half-up")
    };

    PensionContributions {
        pensionable_earnings: money(pensionable, "GBP"),
        employee_gross: money(employee_gross, "GBP"),
        employee_deduction: money(deduction, "GBP"),
        employer: money(employer, "GBP"),
        deduct_before_tax: net_pay,
        earnings_trigger_met: earnings.minor > trigger,
    }
}

fn optional_int(v: &Value) -> Option<i64> {
    if v.is_null() {
        None
    } else {
        Some(v.as_i64())
    }
}

pub fn pension_scheme_from_value(v: &Value) -> PensionScheme {
    PensionScheme {
        employee_basis_points: optional_int(v.get("employeeBasisPoints")),
        employer_basis_points: optional_int(v.get("employerBasisPoints")),
        earnings_basis: v.get("earningsBasis").as_str().to_string(),
        arrangement: v.get("arrangement").as_str().to_string(),
    }
}

pub fn pension_contributions_to_value(p: &PensionContributions) -> Value {
    Value::obj(vec![
        ("pensionableEarnings", money_to_value(&p.pensionable_earnings)),
        ("employeeGross", money_to_value(&p.employee_gross)),
        ("employeeDeduction", money_to_value(&p.employee_deduction)),
        ("employer", money_to_value(&p.employer)),
        ("deductBeforeTax", Value::Bool(p.deduct_before_tax)),
        ("earningsTriggerMet", Value::Bool(p.earnings_trigger_met)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    pension_contributions_to_value(&pension_contributions(
        &money_from_value(&args[0]),
        args[1].as_str(),
        &pension_scheme_from_value(&args[2]),
        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 payroll.pension-auto-enrolment
Download for Rust payroll.pension-auto-enrolment-1.0.1-rust.fune · 32,901 bytes sha256 6cd550e45190b443a4b673b29d8715ef67513e74391a90dc61c561c1a5648a19

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

The whole function, every language, is one file too: payroll.pension-auto-enrolment-1.0.1.fune, 43,012 bytes, sha256 fe586145b719778d7d0ef1f6d3840cb095c12b4c5d28caabf017c86489941826. 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 payroll.pension-auto-enrolment

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

// fune: after payroll.pension-auto-enrolment

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 math.round-div in payroll.pension-auto-enrolment
// fune: replace money.amount in payroll.pension-auto-enrolment
// fune: replace payroll.tax-period in payroll.pension-auto-enrolment

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 payroll.pension-auto-enrolment --steps.

// fune: step payroll.pension-auto-enrolment 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
statutory minimum, relief at source, monthly: 5% and 3% of pay between £520 and £4,189 £2,500.00, monthly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement relief-at-source, 2026-05-28 → pensionable earnings £1,980.00, employee gross £99.00, employee deduction £79.20, employer £59.40, deduct before tax false, earnings trigger met true
the same under a net pay arrangement: the whole 5% comes off before tax £2,500.00, monthly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement net-pay, 2026-05-28 → pensionable earnings £1,980.00, employee gross £99.00, employee deduction £99.00, employer £59.40, deduct before tax true, earnings trigger met true
pay above the upper level: contributions stop at £4,189 £5,000.00, monthly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement relief-at-source, 2026-05-28 → pensionable earnings £3,669.00, employee gross £183.45, employee deduction £146.76, employer £110.07, deduct before tax false, earnings trigger met true
pay exactly at the upper level £4,189.00, monthly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement relief-at-source, 2026-05-28 → pensionable earnings £3,669.00, employee gross £183.45, employee deduction £146.76, employer £110.07, deduct before tax false, earnings trigger met true
pay below the lower level: nothing to contribute £500.00, monthly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement relief-at-source, 2026-05-28 → pensionable earnings £0.00, employee gross £0.00, employee deduction £0.00, employer £0.00, deduct before tax false, earnings trigger met false
between the lower level and the trigger: contributions due if enrolled, but no automatic enrolment £700.00, monthly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement relief-at-source, 2026-05-28 → pensionable earnings £180.00, employee gross £9.00, employee deduction £7.20, employer £5.40, deduct before tax false, earnings trigger met false
exactly the trigger is not more than it £833.00, monthly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement relief-at-source, 2026-05-28 → pensionable earnings £313.00, employee gross £15.65, employee deduction £12.52, employer £9.39, deduct before tax false, earnings trigger met false
weekly in 2025-26 uses the £120 weekly lower level £400.00, weekly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement relief-at-source, 2025-07-04 → pensionable earnings £280.00, employee gross £14.00, employee deduction £11.20, employer £8.40, deduct before tax false, earnings trigger met true
fortnightly in 2024-25 uses the £240 fortnightly lower level £1,000.00, fortnightly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement relief-at-source, 2024-07-05 → pensionable earnings £760.00, employee gross £38.00, employee deduction £30.40, employer £22.80, deduct before tax false, earnings trigger met true
four-weekly in 2023-24 uses the £480 four-weekly lower level £2,000.00, four-weekly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement relief-at-source, 2023-07-07 → pensionable earnings £1,520.00, employee gross £76.00, employee deduction £60.80, employer £45.60, deduct before tax false, earnings trigger met true
Show the other 12 tests
CaseArgumentsExpected
all-earnings scheme with supplied rates £2,500.00, monthly, employee basis points 3%, employer basis points 5%, earnings basis all-earnings, arrangement relief-at-source, 2026-05-28 → pensionable earnings £2,500.00, employee gross £75.00, employee deduction £60.00, employer £125.00, deduct before tax false, earnings trigger met true
an employer paying 6% lets the employee pay 2% and still meet the 8% minimum £2,500.00, monthly, employee basis points —, employer basis points 6%, earnings basis qualifying-earnings, arrangement relief-at-source, 2026-05-28 → pensionable earnings £1,980.00, employee gross £39.60, employee deduction £31.68, employer £118.80, deduct before tax false, earnings trigger met true
an employer paying 10% leaves no minimum employee contribution £2,500.00, monthly, employee basis points —, employer basis points 10%, earnings basis qualifying-earnings, arrangement relief-at-source, 2026-05-28 → pensionable earnings £1,980.00, employee gross £0.00, employee deduction £0.00, employer £198.00, deduct before tax false, earnings trigger met true
half a penny rounds up, and 80% of the rounded gross is rounded again £1,000.10, monthly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement relief-at-source, 2026-05-28 → pensionable earnings £480.10, employee gross £24.01, employee deduction £19.21, employer £14.40, deduct before tax false, earnings trigger met true
a date before the thresholds this package carries £2,500.00, monthly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement relief-at-source, 2023-04-05 → error: no auto-enrolment earnings thresholds
negative earnings are refused -£0.01, monthly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement relief-at-source, 2026-05-28 → error: earnings must not be negative
a negative rate is refused £2,500.00, monthly, employee basis points -1%, employer basis points —, earnings basis qualifying-earnings, arrangement relief-at-source, 2026-05-28 → error: employeeBasisPoints must be a whole number of basis points
an unknown earnings basis is refused £2,500.00, monthly, employee basis points —, employer basis points —, earnings basis band, arrangement relief-at-source, 2026-05-28 → error: unknown earnings basis
an unknown relief arrangement is refused £2,500.00, monthly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement salary-sacrifice, 2026-05-28 → error: unknown relief arrangement
another currency is refused €2,500.00, monthly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement relief-at-source, 2026-05-28 → error: must be in GBP
a trailing newline is not part of an ISO date £2,500.00, monthly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement relief-at-source, 2026-05-28 → error: payDate must be an ISO date
Arabic-Indic digits are not an ISO date £2,500.00, monthly, employee basis points —, employer basis points —, earnings basis qualifying-earnings, arrangement relief-at-source, ٢٠٢٦-٠٥-٢٨ → error: payDate must be an ISO date

More from the author

- **Qualifying earnings** are the part of gross pay above the lower level and not above the upper level of the band (Pensions Act 2008 s.13), using The Pensions Regulator's published figures for the pay reference period: £120 to £967 a week, £240 to £1,934 a fortnight, £480 to £3,867 every four weeks, £520 to £4,189 a month (the same in all four tax years). A scheme on `all-earnings` applies its rates to every penny instead. - **Statutory minimums** when a rate is null: the employer pays 3% and the employee makes up the 8% total, so 5% by default, less if the employer pays more, never below zero. These minimums are for the qualifying earnings basis; a scheme certified on another basis should pass its own rates. Nothing checks that supplied rates meet the minimum. - **Tax relief.** Under **relief at source** the employee's contribution in the scheme is the gross figure, the employer deducts 80% of it from net pay, and the provider claims the other 20% (the basic rate, also for Scottish taxpayers). Under a **net pay arrangement** the whole gross contribution is deducted and taxable pay (not NI-able pay) is reduced by it: `deductBeforeTax` tells the payroll which. - **Rounding.** Each contribution is rounded to the nearest penny, half up; the relief-at-source deduction is 80% of the rounded gross, rounded half up. Schemes are free to round differently; this is the stated choice and a vector pins it. - **`earningsTriggerMet`** is true when earnings are more than the automatic enrolment trigger for the period (£833 a month). Whether the worker is an eligible jobholder also depends on age (22 to State Pension age), which this capability does not check.

Out of scope: salary sacrifice, postponement, opt-out refunds, and defined benefit schemes.

## Sources

- The Pensions Regulator, "Earnings thresholds" (2023-24 to 2026-27, per pay reference period): https://www.thepensionsregulator.gov.uk/employers/new-employers/im-an-employer-who-doesnt-have-to-provide-a-pension-now/declare-your-compliance/ongoing-duties-for-employers/earnings-thresholds - Pensions Act 2008, s.3 (trigger: "earnings of more than £10,000") and s.13 (qualifying earnings): https://www.legislation.gov.uk/ukpga/2008/30/section/13 - GOV.UK, "Workplace pensions: what you, your employer and the government pay" (minimum 3% employer, 8% total, from April 2019): https://www.gov.uk/workplace-pensions/what-you-your-employer-and-the-government-pay - GOV.UK, "Tax on your private pension contributions: tax relief" (relief at source at the basic 20% rate, including for Scottish taxpayers): https://www.gov.uk/tax-on-your-private-pension/pension-tax-relief

1.0.1 fixes Python accepting a trailing newline or non-ASCII digits in payDate; adds tests.

Files

PathBytes
README.md3,119
data/ae-minimums.json265
data/ae-thresholds.json2,154
impl/python.py4,976
impl/rust.rs6,526
impl/typescript.ts4,768
vectors.json12,745