Functional Weave
Code in Python

payroll.pension-auto-enrolment Unreviewed

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

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

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

Unreviewed. This capability’s implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified payroll specialist has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.

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.

def pension_contributions(earnings: Money, frequency: PayFrequency, 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

@dataclass(frozen=True)
class PensionScheme:
    """How the employer's scheme works out contributions."""

    #: null means the statutory minimum: 8% in total less the employer's share
    employee_basis_points: Optional[int]
    #: null means the statutory minimum 3%
    employer_basis_points: Optional[int]
    #: the qualifying earnings band, or every penny of pay
    earnings_basis: PensionEarningsBasis
    #: how the employee gets tax relief
    arrangement: ReliefArrangement

PensionEarningsBasis = Literal["qualifying-earnings", "all-earnings"]

ReliefArrangement = Literal["relief-at-source", "net-pay"]

@dataclass(frozen=True)
class PensionContributions:
    """One period's contributions and what they do to pay."""

    #: earnings the percentages were applied to
    pensionable_earnings: Money
    #: employee contribution going into the pension, including basic-rate relief
    employee_gross: Money
    #: what payroll takes from pay: 80% of the gross under relief at source, the gross under net pay
    employee_deduction: Money
    #: employer contribution, on top of pay
    employer: Money
    #: true under a net pay arrangement: reduce taxable pay (not NI-able pay) by employeeDeduction
    deduct_before_tax: bool
    #: earnings above the automatic enrolment trigger for the period (age is not checked)
    earnings_trigger_met: bool

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

from fune.payroll.pension_auto_enrolment import pension_contributions  # payroll.pension-auto-enrolment@^1
impl/python.py · 93 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 re
from typing import NoReturn, Optional

from .math_round_div import round_div  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import Money, money  ← from money.amount ^1.0.0 · built alongside by fune
from .payroll_pension_auto_enrolment_data import AE_MINIMUMS, AE_MINIMUMS_HISTORY, AE_MINIMUMS_HORIZON, AE_THRESHOLDS, AE_THRESHOLDS_HISTORY, AE_THRESHOLDS_HORIZON  ← this capability’s own data, compiled from data/ae-thresholds.json into the same file by fune build
from .payroll_pension_auto_enrolment_types import PensionContributions, PensionScheme
from .payroll_tax_period_types import PayFrequency

ISO_DATE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")


def _missing(what: str, on_date: str, history: str, horizon: Optional[str]) -> NoReturn:
    if history != "full" and horizon is not None and on_date < horizon:
        raise ValueError(
            "%s on %s: this build was installed with history=%s, so it only carries rules from %s. "
            "Reinstall with history=full for earlier tax years." % (what, on_date, history, horizon)
        )
    raise ValueError("%s on %s" % (what, on_date))


def _check_rate(name: str, value: Optional[int]) -> None:
    if value is not None and (isinstance(value, bool) or not isinstance(value, int) or value < 0):
        raise ValueError("%s must be a whole number of basis points, 0 or more, received %s" % (name, value))


def _in_force(r, on_date: str) -> bool:
    return on_date >= r.valid_from and (r.valid_to is None or on_date <= r.valid_to)


def pension_contributions(earnings: Money, frequency: PayFrequency, scheme: PensionScheme, pay_date: str) -> PensionContributions:
    """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.
    """
    if earnings.currency != "GBP":
        raise ValueError("pension contributions must be in GBP, received %s" % (earnings.currency,))
    if earnings.minor < 0:
        raise ValueError("earnings must not be negative, received %s" % (earnings.minor,))
    _check_rate("employeeBasisPoints", scheme.employee_basis_points)
    _check_rate("employerBasisPoints", scheme.employer_basis_points)
    if scheme.earnings_basis not in ("qualifying-earnings", "all-earnings"):
        raise ValueError('unknown earnings basis "%s"' % (scheme.earnings_basis,))
    if scheme.arrangement not in ("relief-at-source", "net-pay"):
        raise ValueError('unknown relief arrangement "%s"' % (scheme.arrangement,))
    if frequency not in ("weekly", "fortnightly", "four-weekly", "monthly"):
        raise ValueError('unknown pay frequency "%s"' % (frequency,))
    if not isinstance(pay_date, str) or not ISO_DATE.fullmatch(pay_date):
        raise ValueError('payDate must be an ISO date (YYYY-MM-DD), received "%s"' % (pay_date,))

    t = next((r for r in AE_THRESHOLDS if _in_force(r, pay_date)), None)
    if t is None:
        _missing("no auto-enrolment earnings thresholds", pay_date, AE_THRESHOLDS_HISTORY, AE_THRESHOLDS_HORIZON)
    m = next((r for r in AE_MINIMUMS if _in_force(r, pay_date)), None)
    if m is None:
        _missing("no auto-enrolment minimum contributions", pay_date, AE_MINIMUMS_HISTORY, AE_MINIMUMS_HORIZON)

    lower, trigger, upper = {
        "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),
        "monthly": (t.lower_monthly, t.trigger_monthly, t.upper_monthly),
    }[frequency]

    # Qualifying earnings are the part of pay above the lower level and not
    # above the upper one (Pensions Act 2008 s.13).
    if scheme.earnings_basis == "all-earnings":
        pensionable = earnings.minor
    else:
        pensionable = max(0, min(earnings.minor, upper) - lower)
    employer_rate = scheme.employer_basis_points if scheme.employer_basis_points is not None else m.employer_basis_points
    employee_rate = (
        scheme.employee_basis_points if scheme.employee_basis_points is not None else max(0, m.total_basis_points - employer_rate)
    )

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

    return 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,
    )

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Python 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 Python payroll.pension-auto-enrolment-1.0.2-python.fune · 32,938 bytes sha256 3422efc193d54e2f55fb44f823e75e9e13ca0db7d0c4ae040ac636441110bd5d

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

The whole function, every language, is one file too: payroll.pension-auto-enrolment-1.0.2.fune, 44,685 bytes, sha256 f321e0f6c141f121f7bd4bac66d1b2db1ff5166a43dbd8180060576f5b954433. 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.

## Before you rely on this

**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 above, and have a payroll specialist review how you use it, before anyone relies on the output. Provided "as is" under its licence, without warranty.

**Unreviewed.** This capability's implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified payroll specialist has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.

## Notices

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

Legislation: Crown copyright and database right.

1.0.2 marks it unreviewed and adds its attribution notices (NOTICE). The code and the tests are unchanged.

Files

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