payroll.minimum-wage-check Unreviewed
Check pay for a pay reference period against the minimum or living wage for age and apprenticeship.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 19 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
Whether the pay for one pay reference period meets the National Minimum Wage or National Living Wage, and by how much it falls short if not.
**Everything is fixed at the start of the pay reference period.** GOV.UK's guidance: "When the minimum wage rates change, the increase applies to the first pay reference period starting on or after the date of any change ... The same approach applies when a worker reaches a birthday that makes them eligible for a higher minimum wage rate." So a monthly period starting 16 March 2026 uses the 2025 rates throughout, and a worker who turns 21 halfway through a period stays on the 18-20 rate until the next one. Using the pay date, or the age on the pay date, is the common mistake.
For example
minimum_wage_check(£508.40, 2,400, 1990-01-01, 2026-09-01, —)→ band national-living-wage, hourly rate £12.71, required £508.40, shortfall £0.00, compliant true 40 hours at exactly the 2026 National Living Wageminimum_wage_check(£508.39, 2,400, 1990-01-01, 2026-09-01, —)→ band national-living-wage, hourly rate £12.71, required £508.40, shortfall £0.01, compliant false a penny shortminimum_wage_check(£434.00, 2,400, 2006-01-01, 2026-09-01, —)→ band age-18-to-20, hourly rate £10.85, required £434.00, shortfall £0.00, compliant true a 20-year-old on the 18 to 20 rate
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 minimum_wage_check(pay: Money, minutes_worked: int, birth_date: str, period_start: str, apprenticeship_start: Optional[str]) -> MinimumWageCheck
| pay | Money | pay that counts for minimum wage purposes in the pay reference period |
| minutes_worked | int | working time in the pay reference period, in minutes |
| birth_date | date | |
| period_start | date | first day of the pay reference period: it fixes both the rate and the age |
| apprenticeship_start | date? | first day of the apprenticeship, or null for someone who is not an apprentice |
| returns | MinimumWageCheck |
The types it declares, generated into your project
WageBand = Literal["national-living-wage", "age-21-to-22", "age-18-to-20", "under-18", "apprentice"]
@dataclass(frozen=True)
class MinimumWageCheck:
"""The rate that applied and whether the pay meets it."""
band: WageBand
hourly_rate: Money
#: hourlyRate x minutes / 60, rounded up to the penny
required: Money
#: what is owed; 0 when compliant
shortfall: Money
compliant: bool
Your code names it in one line, in the file that uses it
from fune.payroll.minimum_wage_check import minimum_wage_check # payroll.minimum-wage-check@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import List, Optional
from .dates_add_months import add_months ← from dates.add-months ^1.0.0 · built alongside by fune
from .dates_age import age ← from dates.age ^1.0.0 · built alongside by fune
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_minimum_wage_check_data import NMW_RATES, NMW_RATES_HISTORY, NmwRate ← this capability’s own data, compiled from data/rates.json into the same file by fune build
from .payroll_minimum_wage_check_types import MinimumWageCheck
def _rates_in_force(period_start: str) -> List[NmwRate]:
rows = [r for r in NMW_RATES if period_start >= r.valid_from and (r.valid_to is None or period_start <= r.valid_to)]
if rows:
return rows
if NMW_RATES_HISTORY != "full":
earliest = min((r.valid_from for r in NMW_RATES), default=None)
if earliest is not None and period_start < earliest:
raise ValueError(
"no minimum wage rate for %s: this build was installed with history=%s, "
"so it only carries rates from %s. Reinstall with history=full to check older pay."
% (period_start, NMW_RATES_HISTORY, earliest)
)
raise ValueError("no minimum wage rate for %s" % (period_start,))
def minimum_wage_check(
pay: Money,
minutes_worked: int,
birth_date: str,
period_start: str,
apprenticeship_start: Optional[str],
) -> MinimumWageCheck:
"""Minimum wage check for one pay reference period. The rate and the age
are both taken on the first day of the period, never on the pay date.
"""
if pay.currency != "GBP":
raise ValueError("the minimum wage is in GBP, received %s" % (pay.currency,))
if isinstance(minutes_worked, bool) or not isinstance(minutes_worked, int) or minutes_worked < 0:
raise ValueError("minutesWorked must be a whole number of minutes, not negative, received %s" % (minutes_worked,))
years = age(birth_date, period_start)
if years < 16:
raise ValueError("a worker aged %d is below school leaving age: no minimum wage applies" % (years,))
if apprenticeship_start is not None and apprenticeship_start > period_start:
raise ValueError("apprenticeshipStart %s is after periodStart %s" % (apprenticeship_start, period_start))
rows = _rates_in_force(period_start)
apprentice_rate = apprenticeship_start is not None and (
years < 19 or period_start < add_months(apprenticeship_start, 12)
)
chosen: Optional[NmwRate] = None
for row in rows:
if apprentice_rate:
if row.band == "apprentice":
chosen = row
elif row.band != "apprentice" and row.min_age <= years and (chosen is None or row.min_age > chosen.min_age):
chosen = row
if chosen is None:
raise ValueError("no minimum wage rate for %s" % (period_start,))
required = round_div(chosen.rate_minor * minutes_worked, 60, "up")
shortfall = max(0, required - pay.minor)
return MinimumWageCheck(
band=chosen.band, # type: ignore[arg-type]
hourly_rate=money(chosen.rate_minor, "GBP"),
required=money(required, "GBP"),
shortfall=money(shortfall, "GBP"),
compliant=shortfall == 0,
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 4 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.minimum-wage-check
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./payroll.minimum-wage-check-1.0.1-python.fune, or fetch it from a terminal with fune pull payroll.minimum-wage-check@1.0.1:python.
The whole function, every language, is one file too: payroll.minimum-wage-check-1.0.1.fune, 29,454 bytes, sha256 fc3c52f01a77aa82e360aff88540ad9814e2f3bdffcb2d9b283ef1710c867cc4. 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.minimum-wage-check
after — your function gets the result and the arguments, and returns the final result.
# fune: after payroll.minimum-wage-check
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 dates.add-months in payroll.minimum-wage-check
# fune: replace dates.age in payroll.minimum-wage-check
# fune: replace math.round-div in payroll.minimum-wage-check
# fune: replace money.amount in payroll.minimum-wage-check
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.minimum-wage-check --steps.
# fune: step payroll.minimum-wage-check 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 | |
|---|---|---|---|
| 40 hours at exactly the 2026 National Living Wage | £508.40, 2,400, 1990-01-01, 2026-09-01, — | → | band national-living-wage, hourly rate £12.71, required £508.40, shortfall £0.00, compliant true |
| a penny short | £508.39, 2,400, 1990-01-01, 2026-09-01, — | → | band national-living-wage, hourly rate £12.71, required £508.40, shortfall £0.01, compliant false |
| a 20-year-old on the 18 to 20 rate | £434.00, 2,400, 2006-01-01, 2026-09-01, — | → | band age-18-to-20, hourly rate £10.85, required £434.00, shortfall £0.00, compliant true |
| turning 21 during the period keeps the 18 to 20 rate for that period | £1,736.00, 9,600, 2005-09-15, 2026-09-01, — | → | band age-18-to-20, hourly rate £10.85, required £1,736.00, shortfall £0.00, compliant true |
| a period that starts before 1 April uses last year's rate even if paid in April | £1,953.60, 9,600, 1990-01-01, 2026-03-16, — | → | band national-living-wage, hourly rate £12.21, required £1,953.60, shortfall £0.00, compliant true |
| 2023: a 22-year-old was on the separate 21 to 22 rate | £400.00, 2,400, 2001-06-01, 2023-09-01, — | → | band age-21-to-22, hourly rate £10.18, required £407.20, shortfall £7.20, compliant false |
| 2023: the National Living Wage started at 23 | £15.63, 90, 1990-01-01, 2023-09-01, — | → | band national-living-wage, hourly rate £10.42, required £15.63, shortfall £0.00, compliant true |
| 2024: a 22-year-old moved onto the National Living Wage | £457.60, 2,400, 2002-06-01, 2024-09-01, — | → | band national-living-wage, hourly rate £11.44, required £457.60, shortfall £0.00, compliant true |
| an 18-year-old apprentice in their second year is still on the apprentice rate | £320.00, 2,400, 2008-01-01, 2026-09-01, 2024-09-01 | → | band apprentice, hourly rate £8.00, required £320.00, shortfall £0.00, compliant true |
| a 25-year-old apprentice in the first year, one minute rounded up | £0.14, 1, 2001-01-01, 2026-09-01, 2026-01-10 | → | band apprentice, hourly rate £8.00, required £0.14, shortfall £0.00, compliant true |
Show the other 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a 25-year-old apprentice after the first year is owed the full rate | £320.00, 2,400, 2001-01-01, 2026-09-01, 2025-06-01 | → | band national-living-wage, hourly rate £12.71, required £508.40, shortfall £188.40, compliant false |
| a 16-year-old on the under-18 rate | £80.00, 600, 2009-09-02, 2026-09-01, — | → | band under-18, hourly rate £8.00, required £80.00, shortfall £0.00, compliant true |
| one minute's work needs 22p, not 21p | £0.21, 1, 1990-01-01, 2026-09-01, — | → | band national-living-wage, hourly rate £12.71, required £0.22, shortfall £0.01, compliant false |
| overpaying is compliant with no shortfall | £1,000.00, 2,400, 1990-01-01, 2025-06-01, — | → | band national-living-wage, hourly rate £12.21, required £488.40, shortfall £0.00, compliant true |
| a worker under 16 is an error | £10.00, 60, 2011-01-01, 2026-09-01, — | → | error: below school leaving age |
| a period before the rates this version carries is an error | £10.00, 60, 1990-01-01, 2023-03-31, — | → | error: no minimum wage rate |
| negative minutes are an error | £10.00, -1, 1990-01-01, 2026-09-01, — | → | error: not negative |
| pay in another currency is an error | €10.00, 60, 1990-01-01, 2026-09-01, — | → | error: minimum wage is in GBP |
| an apprenticeship that starts after the period is an error | £10.00, 60, 2001-01-01, 2026-09-01, 2026-10-01 | → | error: is after periodStart |
More from the author
**Apprentice rate** (NMW Regulations 2015 reg 5): applies to an apprentice who is under 19, or who is in the first 12 months of the apprenticeship. Pass `apprenticeshipStart` for apprentices, null otherwise. When the apprentice rate applies, the age rates do not (reg 4A(2)).
**Required pay** is `rate x minutes / 60` rounded **up** to the penny: pay in whole pence meets the exact requirement only if it reaches that figure.
Age bands: 2023-24 had the National Living Wage from 23 and a separate 21-22 rate; from 1 April 2024 the National Living Wage starts at 21. A worker under 16 (below school leaving age in this simplification) is an error; the precise school-leaving date differs across the UK and is the caller's to apply.
Not covered: what counts as pay or working time (salaried hours, sleep-ins, the accommodation offset, deductions that reduce pay for minimum wage purposes). Callers supply both numbers already adjusted.
Rates (per hour, from 1 April):
| Year | NLW | 21-22 | 18-20 | Under 18 | Apprentice | |---|---|---|---|---|---| | 2023 | £10.42 (23+) | £10.18 | £7.49 | £5.28 | £5.28 | | 2024 | £11.44 (21+) | - | £8.60 | £6.40 | £6.40 | | 2025 | £12.21 | - | £10.00 | £7.55 | £7.55 | | 2026 | £12.71 | - | £10.85 | £8.00 | £8.00 |
Sources: - GOV.UK, "National Minimum Wage and National Living Wage rates", https://www.gov.uk/national-minimum-wage-rates (current and previous rates) - GOV.UK, "Calculating the minimum wage: pay counted during a pay reference period", https://www.gov.uk/guidance/calculating-the-minimum-wage/calculating-the-minimum-wage - The National Minimum Wage Regulations 2015 (SI 2015/621) regs 4, 4A, 5, 6, https://www.legislation.gov.uk/uksi/2015/621 - The National Minimum Wage (Amendment) Regulations 2026 (SI 2026/357), https://www.legislation.gov.uk/uksi/2026/357/made - GOV.UK, "Rates and thresholds for employers" 2023-24 to 2026-27, https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2026-to-2027
## 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.1 marks it unreviewed and adds its attribution notices (NOTICE). The code and the tests are unchanged.
Files
| Path | Bytes |
|---|---|
| NOTICE | 239 |
| README.md | 4,109 |
| data/rates.json | 3,725 |
| impl/python.py | 3,065 |
| impl/rust.rs | 4,012 |
| impl/typescript.ts | 3,031 |
| vectors.json | 6,126 |