Functional Weave
Code in TypeScript

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,189
  • pensionContributions(£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
  • pensionContributions(£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
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
payDatedatethe 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. */
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";
impl/typescript.ts · 85 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.

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
Download for TypeScript payroll.pension-auto-enrolment-1.0.0-typescript.fune · 30,100 bytes sha256 513cb164f63fda649b5f9047aee0bf3c22edac262fb8cc207cfc78a833f1a7d6

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.

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 10 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

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

PathBytes
README.md3,026
data/ae-minimums.json265
data/ae-thresholds.json2,154
impl/python.py4,965
impl/rust.rs6,526
impl/typescript.ts4,768
vectors.json11,994