Functional Weave
Code in Python

payroll.minimum-wage-check

Check pay for a pay reference period against the minimum or living wage for age and apprenticeship.

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

Pinned by 19 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

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 Wage
  • minimum_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 short
  • minimum_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
payMoneypay that counts for minimum wage purposes in the pay reference period
minutes_workedintworking time in the pay reference period, in minutes
birth_datedate
period_startdatefirst day of the pay reference period: it fixes both the rate and the age
apprenticeship_startdate?first day of the apprenticeship, or null for someone who is not an apprentice
returnsMinimumWageCheck

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
impl/python.py · 66 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.

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
Download for Python payroll.minimum-wage-check-1.0.0-python.fune · 20,474 bytes sha256 ab7d55e604c331a82e721b763765fa27e7a14dd6a45e63b1764b11bc7a4a6281

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./payroll.minimum-wage-check-1.0.0-python.fune, or fetch it from a terminal with fune pull payroll.minimum-wage-check@1.0.0:python.

The whole function, every language, is one file too: payroll.minimum-wage-check-1.0.0.fune, 27,785 bytes, sha256 bce0ed874279e24be9bfda46cc1f4ed1bc4e435fcd742f3acc97b46d688bde6e. 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.

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

Files

PathBytes
README.md2,748
data/rates.json3,725
impl/python.py3,065
impl/rust.rs4,012
impl/typescript.ts3,031
vectors.json6,126