Functional Weave
Code in Rust

payroll.income-tax

PAYE income tax for one pay period from a tax code, cumulative or week 1/month 1, by HMRC's tax table routines.

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

Pinned by 28 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 professional (CIPP) review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.

What it does

Status: needs review by a qualified payroll professional before it is published.

PAYE income tax for one payment. Give it the employee's tax code, the pay frequency and tax period (from `payroll.tax-period`), this period's taxable pay, the taxable pay and tax so far this tax year, and the pay date; it returns the tax to deduct (or refund) and the running totals.

For example

  • income_tax(1257L, monthly, 1, £3,000.00, £0.00, £0.00, 2026-04-28) → tax £390.20, tax to date £390.20, pay to date £3,000.00, allowance to date £1,048.26, taxable pay £1,951.00, cumulative true, limit applied false, tax not deducted £0.00 1257L month 1 on 3,000 pounds: free pay 1,048.26, tax 20% of 1,951
  • income_tax(S1257L, monthly, 1, £3,000.00, £0.00, £0.00, 2026-04-28) → tax £392.27, tax to date £392.27, pay to date £3,000.00, allowance to date £1,048.26, taxable pay £1,951.00, cumulative true, limit applied false, tax not deducted £0.00 the same pay for a Scottish taxpayer runs through starter, basic and intermediate bands
  • income_tax(C1257L, monthly, 1, £3,000.00, £0.00, £0.00, 2026-04-28) → tax £390.20, tax to date £390.20, pay to date £3,000.00, allowance to date £1,048.26, taxable pay £1,951.00, cumulative true, limit applied false, tax not deducted £0.00 a Welsh taxpayer pays the rest-of-UK rates

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 income_tax(tax_code: &str, frequency: &str, period: i64, pay: &Money, previous_pay_to_date: &Money, previous_tax_to_date: &Money, pay_date: &str) -> IncomeTax
tax_codestringas issued by HMRC, e.g. 1257L, S1257L, K475 M1, BR
frequencyPayFrequency
periodinttax week (1-56) or tax month (1-12) of the payment, from payroll.tax-period
payMoneytaxable pay for this period, after any net pay pension deduction
previous_pay_to_dateMoneytaxable pay already paid this tax year (including a P45's figure); ignored on a week 1 / month 1 basis
previous_tax_to_dateMoneytax already deducted this tax year (including a P45's figure); ignored on a week 1 / month 1 basis
pay_datedatedecides the tax year's bands
returnsIncomeTax

The type it declares, generated into your project

/// The tax for this period and the running totals a payslip and FPS need.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IncomeTax {
    /// to deduct this period; negative is a refund
    pub tax: Money,
    /// previousTaxToDate plus tax
    pub tax_to_date: Money,
    /// previousPayToDate plus pay
    pub pay_to_date: Money,
    /// free pay used in the calculation; negative is the additional pay of a K code
    pub allowance_to_date: Money,
    /// taxable pay the tax was worked out on, rounded down to whole pounds: to date when cumulative, this period otherwise
    pub taxable_pay: Money,
    /// false for a W1/M1/X code and for weeks 53, 54 and 56
    pub cumulative: bool,
    /// the 50% overriding limit held the deduction down
    pub limit_applied: bool,
    /// tax held back by the limit this period
    pub tax_not_deducted: Money,
}

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

fune!(payroll.income-tax@^1);  // then call income_tax(…)
impl/rust.rs · 267 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::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_income_tax_data::{
    IncomeTaxBand, INCOME_TAX_BANDS, INCOME_TAX_BANDS_HISTORY, OVERRIDING_LIMIT, OVERRIDING_LIMIT_HISTORY,
};
use super::payroll_tax_code_parse::parse_tax_code;  ← from payroll.tax-code-parse ^1.0.0 · built alongside by fune

// HMRC's routines work "to 4 decimal places of a pound without correcting the
// final place". Holding amounts as integer ten-thousandths of a pound (1 =
// £0.0001, 100 = 1p) makes every one of those truncations an exact integer
// division.
const UNITS_PER_PENNY: i64 = 100;
const UNITS_PER_POUND: i64 = 10000;

fn is_iso_date(value: &str) -> bool {
    let bytes = value.as_bytes();
    bytes.len() == 10
        && bytes[4] == b'-'
        && bytes[7] == b'-'
        && bytes
            .iter()
            .enumerate()
            .all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit())
}

fn in_force(valid_from: &str, valid_to: Option<&str>, on_date: &str) -> bool {
    valid_from <= on_date && valid_to.map_or(true, |to| on_date <= to)
}

fn no_rule(what: &str, on_date: &str, history: &str, froms: &[&str]) -> ! {
    // A build installed with history=current only carries the rules still in
    // force; answering an older date with this year's bands would be a quiet,
    // plausible wrong answer, so say why there is nothing instead.
    if history != "full" {
        if let Some(earliest) = froms.iter().min() {
            if on_date < *earliest {
                panic!(
                    "no {} on {}: this build was installed with history={}, so it only carries rules from {}. Reinstall with history=full for earlier tax years.",
                    what, on_date, history, earliest
                );
            }
        }
    }
    panic!("no {} on {}", what, on_date)
}

fn bands_for(region: &str, on_date: &str) -> Vec<&'static IncomeTaxBand> {
    let mut rows: Vec<&'static IncomeTaxBand> = INCOME_TAX_BANDS
        .iter()
        .filter(|b| b.region == region && in_force(b.valid_from, b.valid_to, on_date))
        .collect();
    if rows.is_empty() {
        let froms: Vec<&str> = INCOME_TAX_BANDS
            .iter()
            .filter(|b| b.region == region)
            .map(|b| b.valid_from)
            .collect();
        no_rule(
            &format!("income tax bands for {}", region),
            on_date,
            INCOME_TAX_BANDS_HISTORY,
            &froms,
        );
    }
    rows.sort_by_key(|b| b.band);
    rows
}

fn overriding_limit(on_date: &str) -> i64 {
    match OVERRIDING_LIMIT
        .iter()
        .find(|r| in_force(r.valid_from, r.valid_to, on_date))
    {
        Some(row) => row.basis_points,
        None => {
            let froms: Vec<&str> = OVERRIDING_LIMIT.iter().map(|r| r.valid_from).collect();
            no_rule("PAYE overriding limit", on_date, OVERRIDING_LIMIT_HISTORY, &froms)
        }
    }
}

/// Free pay (or K-code additional pay) for one week or month, in pence: HMRC paragraph 4.3.1.
fn period_allowance(code_number: i64, periods_per_year: i64) -> i64 {
    if code_number == 0 {
        return 0;
    }
    // Codes above 500 are split into blocks of 500 and a remainder of 1-500,
    // each rounded up to the penny separately; that is how the printed tables
    // were built, and computing the whole code in one division is a penny out.
    let blocks = (code_number - 1) / 500;
    let remainder = (code_number - 1) % 500 + 1;
    let block_value = if periods_per_year == 52 { 9616 } else { 41667 };
    round_div((remainder * 10 + 9) * 100, periods_per_year, "up") + blocks * block_value
}

/// Tax due to date on positive taxable pay by the banded Tax Formulae of paragraph 4.4, in pence.
fn banded_tax(taxable_pence: i64, bands: &[&IncomeTaxBand], n: i64, periods_per_year: i64) -> i64 {
    let taxable_pounds = round_div(taxable_pence, 100, "down");
    let mut previous_threshold = 0i64;
    let mut previous_threshold_tax = 0i64;
    let mut lower_pounds = 0i64;
    let mut cumulative_annual_tax = 0i64;
    for band in bands {
        if let Some(up_to) = band.up_to {
            let threshold = round_div(up_to * UNITS_PER_POUND * n, periods_per_year, "down");
            // The income test compares the unrounded pay with the threshold
            // rounded UP to a whole pound (the round-pound limits of Tables C),
            // while the formula itself uses the exact threshold.
            let cvalue = round_div(threshold, UNITS_PER_POUND, "up");
            if taxable_pence > cvalue * 100 {
                cumulative_annual_tax += (up_to - lower_pounds) * band.basis_points;
                lower_pounds = up_to;
                previous_threshold = threshold;
                previous_threshold_tax = round_div(cumulative_annual_tax * n, periods_per_year, "down");
                continue;
            }
        }
        let product = (taxable_pounds as i128 * UNITS_PER_POUND as i128 - previous_threshold as i128)
            * band.basis_points as i128
            / 10000;
        let at_this_rate = product as i64;
        return round_div(previous_threshold_tax + at_this_rate, UNITS_PER_PENNY, "down");
    }
    panic!("income tax bands have no top band")
}

fn check_gbp(name: &str, amount: &Money) {
    if amount.currency != "GBP" {
        panic!(
            "payroll amounts must be in GBP, received {} for {}",
            amount.currency, name
        );
    }
}

/// PAYE income tax for one payment, following HMRC's "Specification for PAYE
/// tax table routines" (the computerised form of the tax tables, used by
/// payroll software): cumulative or week 1 / month 1, suffix, K, BR, D and NT
/// codes, Scottish and Welsh bands, and the 50% overriding limit.
///
/// # Panics
/// Panics on a bad code, period, currency or date, or a date with no bands.
pub fn income_tax(
    tax_code: &str,
    frequency: &str,
    period: i64,
    pay: &Money,
    previous_pay_to_date: &Money,
    previous_tax_to_date: &Money,
    pay_date: &str,
) -> IncomeTax {
    check_gbp("pay", pay);
    check_gbp("previousPayToDate", previous_pay_to_date);
    check_gbp("previousTaxToDate", previous_tax_to_date);
    if !is_iso_date(pay_date) {
        panic!(
            "payDate must be an ISO date (YYYY-MM-DD), received \"{}\"",
            pay_date
        );
    }
    let (weeks_in_period, valid_period) = match frequency {
        "monthly" => (1, (1..=12).contains(&period)),
        "weekly" => (1, (1..=53).contains(&period)),
        "fortnightly" => (2, (1..=52).contains(&period) || period == 54),
        "four-weekly" => (4, (1..=52).contains(&period) || period == 56),
        other => panic!("unknown pay frequency \"{}\"", other),
    };
    if !valid_period {
        panic!("period {} is not a tax period for {} pay", period, frequency);
    }

    let code = parse_tax_code(tax_code);
    // Looked up even when no band is needed (NT, pay under the allowance), so
    // a date outside the tax years on file is always refused.
    let bands = bands_for(&code.region, pay_date);
    let periods_per_year = if frequency == "monthly" { 12 } else { 52 };
    // Weeks 53, 54 and 56 are always taxed on a week 1 basis (paragraph 14),
    // using the week 1, 2 or 4 figures.
    let cumulative = code.cumulative && period <= 52;
    let n = if cumulative { period } else { weeks_in_period };
    let pay_to_date_for_tax = if cumulative {
        previous_pay_to_date.minor + pay.minor
    } else {
        pay.minor
    };

    let mut liability = 0i64;
    let mut allowance_to_date = 0i64;
    let mut taxable_pay = 0i64;
    if code.kind == "allowance" || code.kind == "negative-allowance" {
        let per_period = period_allowance(code.number, periods_per_year) * n;
        allowance_to_date = if code.kind == "allowance" { per_period } else { -per_period };
        let taxable_pence = pay_to_date_for_tax - allowance_to_date;
        if taxable_pence > 0 {
            taxable_pay = round_div(taxable_pence, 100, "down") * 100;
            liability = banded_tax(taxable_pence, &bands, n, periods_per_year);
        }
    } else if code.kind == "basic-rate" || code.kind == "d-rate" {
        let basic = bands.iter().position(|b| b.basic_rate);
        let index = basic.map(|i| if code.kind == "basic-rate" { i } else { i + 1 + code.number as usize });
        let band = match index {
            Some(i) if i < bands.len() => bands[i],
            _ => panic!("no {} rate for {} on {}", code.code, code.region, pay_date),
        };
        let pounds = if pay_to_date_for_tax > 0 {
            round_div(pay_to_date_for_tax, 100, "down")
        } else {
            0
        };
        taxable_pay = pounds * 100;
        liability = round_div(pounds * band.basis_points, 100, "down");
    }

    // The overriding limit: no more than 50% of this payment may go in tax. It
    // never restricts a refund, and on negative pay it is zero (paragraph 4.5.4).
    let limit = if pay.minor > 0 {
        round_div(pay.minor * overriding_limit(pay_date), 10000, "down")
    } else {
        0
    };
    let due = if cumulative {
        liability - previous_tax_to_date.minor
    } else {
        liability
    };
    let tax = if due > limit { limit } else { due };

    IncomeTax {
        tax: money(tax, "GBP"),
        tax_to_date: money(previous_tax_to_date.minor + tax, "GBP"),
        pay_to_date: money(previous_pay_to_date.minor + pay.minor, "GBP"),
        allowance_to_date: money(allowance_to_date, "GBP"),
        taxable_pay: money(taxable_pay, "GBP"),
        cumulative,
        limit_applied: due > limit,
        tax_not_deducted: money(if due > limit { due - limit } else { 0 }, "GBP"),
    }
}

pub fn income_tax_to_value(t: &IncomeTax) -> Value {
    Value::obj(vec![
        ("tax", money_to_value(&t.tax)),
        ("taxToDate", money_to_value(&t.tax_to_date)),
        ("payToDate", money_to_value(&t.pay_to_date)),
        ("allowanceToDate", money_to_value(&t.allowance_to_date)),
        ("taxablePay", money_to_value(&t.taxable_pay)),
        ("cumulative", Value::Bool(t.cumulative)),
        ("limitApplied", Value::Bool(t.limit_applied)),
        ("taxNotDeducted", money_to_value(&t.tax_not_deducted)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    if let Value::Float(_) = args[2] {
        panic!("period {} is not a tax period for {} pay", args[2].as_f64(), args[1].as_str());
    }
    income_tax_to_value(&income_tax(
        args[0].as_str(),
        args[1].as_str(),
        args[2].as_i64(),
        &money_from_value(&args[3]),
        &money_from_value(&args[4]),
        &money_from_value(&args[5]),
        args[6].as_str(),
    ))
}

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.income-tax
Download for Rust payroll.income-tax-1.0.0-rust.fune · 55,789 bytes sha256 0cba20d487273320166042d96ebe5c954aeeb685bccb14b27b9a2eaec4095e02

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

The whole function, every language, is one file too: payroll.income-tax-1.0.0.fune, 73,674 bytes, sha256 32cd4cbaf68c99ed34b25bc60d367c45a37187f2d962f96ebd5d38ff8a5f9319. 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.income-tax

after — your function gets the result and the arguments, and returns the final result.

// fune: after payroll.income-tax

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.income-tax
// fune: replace money.amount in payroll.income-tax
// fune: replace payroll.tax-code-parse in payroll.income-tax
// fune: replace payroll.tax-period in payroll.income-tax

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.income-tax --steps.

// fune: step payroll.income-tax 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
1257L month 1 on 3,000 pounds: free pay 1,048.26, tax 20% of 1,951 1257L, monthly, 1, £3,000.00, £0.00, £0.00, 2026-04-28 → tax £390.20, tax to date £390.20, pay to date £3,000.00, allowance to date £1,048.26, taxable pay £1,951.00, cumulative true, limit applied false, tax not deducted £0.00
the same pay for a Scottish taxpayer runs through starter, basic and intermediate bands S1257L, monthly, 1, £3,000.00, £0.00, £0.00, 2026-04-28 → tax £392.27, tax to date £392.27, pay to date £3,000.00, allowance to date £1,048.26, taxable pay £1,951.00, cumulative true, limit applied false, tax not deducted £0.00
a Welsh taxpayer pays the rest-of-UK rates C1257L, monthly, 1, £3,000.00, £0.00, £0.00, 2026-04-28 → tax £390.20, tax to date £390.20, pay to date £3,000.00, allowance to date £1,048.26, taxable pay £1,951.00, cumulative true, limit applied false, tax not deducted £0.00
higher rate in month 1 uses the exact threshold of 3,141.6666 1257L, monthly, 1, £6,000.00, £0.00, £0.00, 2025-04-28 → tax £1,352.06, tax to date £1,352.06, pay to date £6,000.00, allowance to date £1,048.26, taxable pay £4,951.00, cumulative true, limit applied false, tax not deducted £0.00
additional rate on a weekly payroll 1257L, weekly, 1, £3,000.00, £0.00, £0.00, 2024-04-12 → tax £975.77, tax to date £975.77, pay to date £3,000.00, allowance to date £241.92, taxable pay £2,758.00, cumulative true, limit applied false, tax not deducted £0.00
month 12 of a 60,000 pound year settles to the annual tax 1257L, monthly, 12, £5,000.00, £55,000.00, £10,470.00, 2027-03-31 → tax £958.00, tax to date £11,428.00, pay to date £60,000.00, allowance to date £12,579.12, taxable pay £47,420.00, cumulative true, limit applied false, tax not deducted £0.00
a refund when pay stops: month 3 with no pay 1257L, monthly, 3, £0.00, £6,000.00, £780.40, 2025-06-30 → tax -£209.40, tax to date £571.00, pay to date £6,000.00, allowance to date £3,144.78, taxable pay £2,855.00, cumulative true, limit applied false, tax not deducted £0.00
K1000 month 1: the 50% overriding limit holds the deduction to 250 pounds K1000, monthly, 1, £500.00, £0.00, £0.00, 2026-04-30 → tax £250.00, tax to date £250.00, pay to date £500.00, allowance to date -£834.09, taxable pay £1,334.00, cumulative true, limit applied true, tax not deducted £16.80
K1000 month 2 recovers what the limit held back K1000, monthly, 2, £3,000.00, £500.00, £250.00, 2026-05-29 → tax £783.60, tax to date £1,033.60, pay to date £3,500.00, allowance to date -£1,668.18, taxable pay £5,168.00, cumulative true, limit applied false, tax not deducted £0.00
a K code with negative pay deducts nothing: the limit is zero K100, monthly, 2, -£500.00, £1,000.00, £50.00, 2026-05-29 → tax £0.00, tax to date £50.00, pay to date £500.00, allowance to date -£168.18, taxable pay £668.00, cumulative true, limit applied true, tax not deducted £83.60
Show the other 18 tests
CaseArgumentsExpected
BR taxes all pay, rounded down to the pound BR, monthly, 1, £1,234.56, £0.00, £0.00, 2026-04-30 → tax £246.80, tax to date £246.80, pay to date £1,234.56, allowance to date £0.00, taxable pay £1,234.00, cumulative true, limit applied false, tax not deducted £0.00
BR on a week 1 basis ignores the year to date BR W1, weekly, 10, £300.99, £3,000.00, £500.00, 2026-06-12 → tax £60.00, tax to date £560.00, pay to date £3,300.99, allowance to date £0.00, taxable pay £300.00, cumulative false, limit applied false, tax not deducted £0.00
D0 is 40% on everything in England D0, monthly, 1, £2,000.00, £0.00, £0.00, 2026-04-30 → tax £800.00, tax to date £800.00, pay to date £2,000.00, allowance to date £0.00, taxable pay £2,000.00, cumulative true, limit applied false, tax not deducted £0.00
SD0 is the 21% intermediate rate in Scotland SD0, monthly, 1, £1,000.50, £0.00, £0.00, 2026-04-30 → tax £210.00, tax to date £210.00, pay to date £1,000.50, allowance to date £0.00, taxable pay £1,000.00, cumulative true, limit applied false, tax not deducted £0.00
SD3 is the 48% top rate from 2024-25 SD3, monthly, 1, £1,000.00, £0.00, £0.00, 2025-04-30 → tax £480.00, tax to date £480.00, pay to date £1,000.00, allowance to date £0.00, taxable pay £1,000.00, cumulative true, limit applied false, tax not deducted £0.00
SD2 was the 47% top rate in 2023-24 SD2, monthly, 1, £1,000.00, £0.00, £0.00, 2023-04-28 → tax £470.00, tax to date £470.00, pay to date £1,000.00, allowance to date £0.00, taxable pay £1,000.00, cumulative true, limit applied false, tax not deducted £0.00
NT on a cumulative basis refunds everything deducted so far NT, monthly, 4, £2,000.00, £6,000.00, £600.00, 2026-07-31 → tax -£600.00, tax to date £0.00, pay to date £8,000.00, allowance to date £0.00, taxable pay £0.00, cumulative true, limit applied false, tax not deducted £0.00
0T on a week 1 basis: no allowance at all 0T W1, weekly, 5, £500.00, £2,000.00, £200.00, 2026-05-08 → tax £100.00, tax to date £300.00, pay to date £2,500.00, allowance to date £0.00, taxable pay £500.00, cumulative false, limit applied false, tax not deducted £0.00
week 53 is taxed non-cumulatively on the week 1 figures 1257L, weekly, 53, £500.00, £26,000.00, £3,000.00, 2027-04-05 → tax £51.60, tax to date £3,051.60, pay to date £26,500.00, allowance to date £241.92, taxable pay £258.00, cumulative false, limit applied false, tax not deducted £0.00
fortnightly on a week 1 basis uses the week 2 figures 1257L W1, fortnightly, 10, £1,000.00, £0.00, £0.00, 2026-08-07 → tax £103.20, tax to date £103.20, pay to date £1,000.00, allowance to date £483.84, taxable pay £516.00, cumulative false, limit applied false, tax not deducted £0.00
four-weekly, second payment in week 8 1257L, four-weekly, 8, £2,000.00, £2,000.00, £206.40, 2026-05-29 → tax £206.40, tax to date £412.80, pay to date £4,000.00, allowance to date £1,935.36, taxable pay £2,064.00, cumulative true, limit applied false, tax not deducted £0.00
SD3 did not exist in 2023-24 SD3, monthly, 1, £1,000.00, £0.00, £0.00, 2023-04-28 → error: no SD3 rate for scotland
a date before the bands on file 1257L, monthly, 1, £1,000.00, £0.00, £0.00, 2022-04-28 → error: no income tax bands for rest-of-uk
pay in euros is refused 1257L, monthly, 1, €1,000.00, £0.00, £0.00, 2026-04-28 → error: payroll amounts must be in GBP
there is no month 13 1257L, monthly, 13, £1,000.00, £0.00, £0.00, 2026-04-28 → error: is not a tax period for monthly pay
fortnightly pay has week 54, not week 53 1257L, fortnightly, 53, £1,000.00, £0.00, £0.00, 2027-04-05 → error: is not a tax period for fortnightly pay
an unrecognised code is refused 1257X9, monthly, 1, £1,000.00, £0.00, £0.00, 2026-04-28 → error: unrecognised tax code
a malformed date is refused 1257L, monthly, 1, £1,000.00, £0.00, £0.00, 28/04/2026 → error: ISO date

More from the author

## Which method

This follows HMRC's **"Specification for PAYE tax table routines"** (version 24.0, February 2026), the computerised form of the printed tax tables that payroll software is expected to implement. It is not a "divide the annual bands by 12" approximation, and the difference shows up in pence:

- **Free pay** for a code is worked out per week or month from `number x £10 + £9`, rounded *up* to the penny, with codes over 500 split into blocks of 500 (£96.16 a week, £416.67 a month each) plus a remainder, exactly as paragraph 4.3.1 says. 1257L is £241.92 a week and £1,048.26 a month. - **Taxable pay** is rounded *down* to the whole pound. - The **band thresholds to date** are `annual band x n / 52` (or `/ 12`) held to four decimal places without rounding; the test of which band applies uses that figure rounded *up* to the pound, the tax formula uses it exactly. - Every tax formula is worked to four decimal places and the result is rounded *down* to the penny.

All of that is done in integer ten-thousandths of a pound, so the three languages agree to the penny. HMRC notes (paragraph 16) that the printed tables can differ from this specification by 1p, exceptionally 2p; this capability matches the specification, not the printed tables.

## What it handles

- Cumulative codes: tax due to date on pay to date, less tax already deducted. A negative result is a refund. - Week 1 / month 1 codes (`W1`, `M1`, `X`): each payment on its own, with the week 1 figures (week 2 for fortnightly, week 4 for four-weekly pay). The year-to-date arguments are only added to for the running totals. - Weeks 53, 54 and 56 are always non-cumulative (paragraph 14). - Suffix codes, K codes, 0T, BR, D codes (`D0` 40%, `D1` 45%; Scotland `SD0` 21% to `SD3` 48%, `SD2` 47% in 2023-24) and NT. NT on a cumulative basis refunds all tax deducted so far this year. - Scottish (S), Welsh (C) and rest-of-UK bands for 2023-24 to 2026-27. Welsh rates are held as their own rows even though they currently equal the rest-of-UK rates, because the Senedd can set them separately. - **The overriding limit** (the "K-code 50% rule"): tax deducted from a payment may not exceed 50% of that payment. Under a cumulative code the tax held back is recovered in later periods automatically, because the next calculation compares tax due to date with tax actually deducted (`taxNotDeducted` reports what was held back). The limit never restricts a refund, and on negative pay it is zero (paragraph 4.5.4).

## What it does not do

- Payrolled benefits in kind: the limit is applied to the whole of `pay`; if you payroll benefits, the limit should be applied to cash pay only. - Mid-year changes of region: supply the code in force for this payment; the pay and tax to date carry over, as the specification says. - It trusts `previousTaxToDate` to be tax actually deducted, as HMRC's routine requires. - The newest band rows have no end date. They stay in force until a new version adds the next tax year, so a 2027-28 pay date is answered with 2026-27 bands until then.

## Sources

- HMRC, "Specification for PAYE tax table routines", version 24.0 (February 2026), paragraphs 3-16 and Appendices A-C (band widths, rates, Maxrate 50%): https://www.gov.uk/government/publications/payroll-technical-specifications-income-tax - HMRC, "Rates and thresholds for employers" for each year (rest-of-UK, Scottish and Welsh bands and the 1257L emergency code): https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2023-to-2024, https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2024-to-2025, https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2025-to-2026, https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2026-to-2027 - The Income Tax (Pay As You Earn) Regulations 2003 (SI 2003/2682), regulation 23 (cumulative basis, overriding limit): https://www.legislation.gov.uk/uksi/2003/2682/regulation/23

Files

PathBytes
README.md4,386
data/income-tax-bands.json12,157
data/overriding-limit.json208
impl/python.py8,636
impl/rust.rs10,693
impl/typescript.ts8,644
vectors.json18,091