payroll.pension-auto-enrolment
Workplace pension contributions for one pay period: qualifying earnings band, employee and employer shares, tax relief.
1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra
Pinned by 20 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
pensionContributions(£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,189pensionContributions(£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 taxpensionContributions(£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.
export function pensionContributions(earnings: Money, frequency: PayFrequency, scheme: PensionScheme, payDate: string): PensionContributions
| earnings | Money | gross earnings for the pay reference period, before any pension deduction |
| frequency | PayFrequency | the pay reference period, which picks the band's weekly, fortnightly, four-weekly or monthly figures |
| scheme | PensionScheme | the contribution rates and how the scheme gives tax relief |
| payDate | date | the date the earnings are paid, which picks the tax year's thresholds |
| returns | PensionContributions |
The types it declares, generated into your project
/** How the employer's scheme works out contributions. */
export interface PensionScheme {
/** null means the statutory minimum: 8% in total less the employer's share */
readonly employeeBasisPoints: number | null;
/** null means the statutory minimum 3% */
readonly employerBasisPoints: number | null;
/** the qualifying earnings band, or every penny of pay */
readonly earningsBasis: PensionEarningsBasis;
/** how the employee gets tax relief */
readonly arrangement: ReliefArrangement;
}
export type PensionEarningsBasis = "qualifying-earnings" | "all-earnings";
export type ReliefArrangement = "relief-at-source" | "net-pay";
/** One period's contributions and what they do to pay. */
export interface PensionContributions {
/** earnings the percentages were applied to */
readonly pensionableEarnings: Money;
/** employee contribution going into the pension, including basic-rate relief */
readonly employeeGross: Money;
/** what payroll takes from pay: 80% of the gross under relief at source, the gross under net pay */
readonly employeeDeduction: Money;
/** employer contribution, on top of pay */
readonly employer: Money;
/** true under a net pay arrangement: reduce taxable pay (not NI-able pay) by employeeDeduction */
readonly deductBeforeTax: boolean;
/** earnings above the automatic enrolment trigger for the period (age is not checked) */
readonly earningsTriggerMet: boolean;
}
Your code names it in one line, in the file that uses it
import { pensionContributions } from "#fune/payroll.pension-auto-enrolment@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { roundDiv } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { type Money, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { AE_MINIMUMS, AE_MINIMUMS_HISTORY, AE_MINIMUMS_HORIZON, AE_THRESHOLDS, AE_THRESHOLDS_HISTORY, AE_THRESHOLDS_HORIZON } from "./payroll_pension_auto_enrolment_data.ts"; ← this capability’s own data, compiled from data/ae-thresholds.json into the same file by fune build
import { type PensionContributions, type PensionScheme } from "./payroll_pension_auto_enrolment_types.ts";
import { type PayFrequency } from "./payroll_tax_period_types.ts";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
function missing(what: string, onDate: string, history: string, horizon: string | null): never {
if (history !== "full" && horizon !== null && onDate < horizon) {
throw new RangeError(
`${what} on ${onDate}: this build was installed with history=${history}, so it only carries rules from ${horizon}. Reinstall with history=full for earlier tax years.`
);
}
throw new RangeError(`${what} on ${onDate}`);
}
function checkRate(name: string, value: number | null): void {
if (value !== null && (!Number.isInteger(value) || value < 0)) {
throw new RangeError(`${name} must be a whole number of basis points, 0 or more, received ${value}`);
}
}
/**
* 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.
*/
export function pensionContributions(earnings: Money, frequency: PayFrequency, scheme: PensionScheme, payDate: string): PensionContributions {
if (earnings.currency !== "GBP") {
throw new RangeError(`pension contributions must be in GBP, received ${earnings.currency}`);
}
if (!Number.isInteger(earnings.minor) || earnings.minor < 0) {
throw new RangeError(`earnings must not be negative, received ${earnings.minor}`);
}
checkRate("employeeBasisPoints", scheme.employeeBasisPoints);
checkRate("employerBasisPoints", scheme.employerBasisPoints);
if (scheme.earningsBasis !== "qualifying-earnings" && scheme.earningsBasis !== "all-earnings") {
throw new RangeError(`unknown earnings basis "${scheme.earningsBasis}"`);
}
if (scheme.arrangement !== "relief-at-source" && scheme.arrangement !== "net-pay") {
throw new RangeError(`unknown relief arrangement "${scheme.arrangement}"`);
}
if (frequency !== "weekly" && frequency !== "fortnightly" && frequency !== "four-weekly" && frequency !== "monthly") {
throw new RangeError(`unknown pay frequency "${frequency}"`);
}
if (!ISO_DATE.test(payDate)) {
throw new RangeError(`payDate must be an ISO date (YYYY-MM-DD), received "${payDate}"`);
}
const t = AE_THRESHOLDS.find((r) => payDate >= r.validFrom && (r.validTo === null || payDate <= r.validTo));
if (t === undefined) missing("no auto-enrolment earnings thresholds", payDate, AE_THRESHOLDS_HISTORY, AE_THRESHOLDS_HORIZON);
const m = AE_MINIMUMS.find((r) => payDate >= r.validFrom && (r.validTo === null || payDate <= r.validTo));
if (m === undefined) missing("no auto-enrolment minimum contributions", payDate, AE_MINIMUMS_HISTORY, AE_MINIMUMS_HORIZON);
const [lower, trigger, upper] =
frequency === "weekly" ? [t.lowerWeekly, t.triggerWeekly, t.upperWeekly]
: frequency === "fortnightly" ? [t.lowerFortnightly, t.triggerFortnightly, t.upperFortnightly]
: frequency === "four-weekly" ? [t.lowerFourWeekly, t.triggerFourWeekly, t.upperFourWeekly]
: [t.lowerMonthly, t.triggerMonthly, t.upperMonthly];
// Qualifying earnings are the part of pay above the lower level and not
// above the upper one (Pensions Act 2008 s.13).
const pensionable = scheme.earningsBasis === "all-earnings" ? earnings.minor : Math.max(0, Math.min(earnings.minor, upper) - lower);
const employerRate = scheme.employerBasisPoints ?? m.employerBasisPoints;
const employeeRate = scheme.employeeBasisPoints ?? Math.max(0, m.totalBasisPoints - employerRate);
const employeeGross = roundDiv(pensionable * employeeRate, 10000, "half-up");
const employer = roundDiv(pensionable * employerRate, 10000, "half-up");
const netPay = scheme.arrangement === "net-pay";
const deduction = netPay ? employeeGross : roundDiv(employeeGross * (10000 - m.reliefAtSourceBasisPoints), 10000, "half-up");
return {
pensionableEarnings: money(pensionable, "GBP"),
employeeGross: money(employeeGross, "GBP"),
employeeDeduction: money(deduction, "GBP"),
employer: money(employer, "GBP"),
deductBeforeTax: netPay,
earningsTriggerMet: earnings.minor > trigger,
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the TypeScript 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 payroll.pension-auto-enrolment
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./payroll.pension-auto-enrolment-1.0.0-typescript.fune, or fetch it from a terminal with fune pull payroll.pension-auto-enrolment@1.0.0:typescript.
The whole function, every language, is one file too: payroll.pension-auto-enrolment-1.0.0.fune, 42,056 bytes, sha256 2f5e024580030156c506808f225a871847e8a92150652881ef91bb8e6e6a9486. 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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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 10 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| 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 |
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
Files
| Path | Bytes |
|---|---|
| README.md | 3,026 |
| data/ae-minimums.json | 265 |
| data/ae-thresholds.json | 2,154 |
| impl/python.py | 4,965 |
| impl/rust.rs | 6,526 |
| impl/typescript.ts | 4,768 |
| vectors.json | 11,994 |