Functional Weave
Code in Rust

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

pub fn minimum_wage_check(pay: &Money, minutes_worked: i64, birth_date: &str, period_start: &str, apprenticeship_start: Option<&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 is a string in Rust, one of: "national-living-wage", "age-21-to-22", "age-18-to-20", "under-18", "apprentice".
// Parameters take it as &str and results hold it as String.

/// The rate that applied and whether the pay meets it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MinimumWageCheck {
    pub band: String,
    pub hourly_rate: Money,
    /// hourlyRate x minutes / 60, rounded up to the penny
    pub required: Money,
    /// what is owed; 0 when compliant
    pub shortfall: Money,
    pub compliant: bool,
}

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

fune!(payroll.minimum-wage-check@^1);  // then call minimum_wage_check(…)
impl/rust.rs · 103 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.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_months::add_months;  ← from dates.add-months ^1.0.0 · built alongside by fune
use super::dates_age::age;  ← from dates.age ^1.0.0 · built alongside by fune
use super::math_round_div::round_div;  ← from math.round-div ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune
use super::payroll_minimum_wage_check_data::{NmwRate, NMW_RATES, NMW_RATES_HISTORY};  ← this capability’s own data, compiled from data/rates.json into the same file by fune build

fn rates_in_force(period_start: &str) -> Vec<&'static NmwRate> {
    let rows: Vec<&'static NmwRate> = NMW_RATES
        .iter()
        .filter(|r| period_start >= r.valid_from && r.valid_to.map_or(true, |to| period_start <= to))
        .collect();
    if !rows.is_empty() {
        return rows;
    }
    if NMW_RATES_HISTORY != "full" {
        if let Some(earliest) = NMW_RATES.iter().map(|r| r.valid_from).min() {
            if period_start < earliest {
                panic!(
                    "no minimum wage rate for {}: this build was installed with history={}, so it only carries rates from {}. Reinstall with history=full to check older pay.",
                    period_start, NMW_RATES_HISTORY, earliest
                );
            }
        }
    }
    panic!("no minimum wage rate for {}", period_start)
}

/// 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.
///
/// # Panics
/// Panics on non-GBP pay, negative minutes, a worker under 16, an
/// apprenticeship starting after the period, or a date with no rates.
pub fn minimum_wage_check(
    pay: &Money,
    minutes_worked: i64,
    birth_date: &str,
    period_start: &str,
    apprenticeship_start: Option<&str>,
) -> MinimumWageCheck {
    if pay.currency != "GBP" {
        panic!("the minimum wage is in GBP, received {}", pay.currency);
    }
    if minutes_worked < 0 {
        panic!("minutesWorked must be a whole number of minutes, not negative, received {}", minutes_worked);
    }
    let years = age(birth_date, period_start);
    if years < 16 {
        panic!("a worker aged {} is below school leaving age: no minimum wage applies", years);
    }
    if let Some(start) = apprenticeship_start {
        if start > period_start {
            panic!("apprenticeshipStart {} is after periodStart {}", start, period_start);
        }
    }
    let rows = rates_in_force(period_start);
    let apprentice_rate = match apprenticeship_start {
        Some(start) => years < 19 || period_start < add_months(start, 12).as_str(),
        None => false,
    };
    let mut chosen: Option<&NmwRate> = None;
    for row in rows {
        if apprentice_rate {
            if row.band == "apprentice" {
                chosen = Some(row);
            }
        } else if row.band != "apprentice"
            && row.min_age <= years
            && chosen.map_or(true, |c| row.min_age > c.min_age)
        {
            chosen = Some(row);
        }
    }
    let chosen = match chosen {
        Some(c) => c,
        None => panic!("no minimum wage rate for {}", period_start),
    };
    let required = round_div(chosen.rate_minor * minutes_worked, 60, "up");
    let shortfall = (required - pay.minor).max(0);
    MinimumWageCheck {
        band: chosen.band.to_string(),
        hourly_rate: money(chosen.rate_minor, "GBP"),
        required: money(required, "GBP"),
        shortfall: money(shortfall, "GBP"),
        compliant: shortfall == 0,
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    if let Value::Float(f) = &args[1] {
        panic!("minutesWorked must be a whole number of minutes, not negative, received {}", f);
    }
    let apprenticeship = if args[4].is_null() { None } else { Some(args[4].as_str()) };
    let c = minimum_wage_check(&money_from_value(&args[0]), args[1].as_i64(), args[2].as_str(), args[3].as_str(), apprenticeship);
    Value::obj(vec![
        ("band", Value::str(&c.band)),
        ("hourlyRate", money_to_value(&c.hourly_rate)),
        ("required", money_to_value(&c.required)),
        ("shortfall", money_to_value(&c.shortfall)),
        ("compliant", Value::Bool(c.compliant)),
    ])
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add payroll.minimum-wage-check
Download for Rust payroll.minimum-wage-check-1.0.1-rust.fune · 23,127 bytes sha256 8747bcdcebe050f519faca72b19497459650c2a8ce0356735bffcd0dc0af6080

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

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.

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

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

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