Functional Weave
Code in TypeScript

payroll.income-tax Unreviewed

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.2 · published 2026-10-03 by charlie · Anterra

Pinned by 30 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 professional (CIPP) 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 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

  • incomeTax(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
  • incomeTax(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
  • incomeTax(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.

export function incomeTax(taxCode: string, frequency: PayFrequency, period: number, pay: Money, previousPayToDate: Money, previousTaxToDate: Money, payDate: string): IncomeTax
taxCodestringas 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
previousPayToDateMoneytaxable pay already paid this tax year (including a P45's figure); ignored on a week 1 / month 1 basis
previousTaxToDateMoneytax already deducted this tax year (including a P45's figure); ignored on a week 1 / month 1 basis
payDatedatedecides 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. */
export interface IncomeTax {
  /** to deduct this period; negative is a refund */
  readonly tax: Money;
  /** previousTaxToDate plus tax */
  readonly taxToDate: Money;
  /** previousPayToDate plus pay */
  readonly payToDate: Money;
  /** free pay used in the calculation; negative is the additional pay of a K code */
  readonly allowanceToDate: Money;
  /** taxable pay the tax was worked out on, rounded down to whole pounds: to date when cumulative, this period otherwise */
  readonly taxablePay: Money;
  /** false for a W1/M1/X code and for weeks 53, 54 and 56 */
  readonly cumulative: boolean;
  /** the 50% overriding limit held the deduction down */
  readonly limitApplied: boolean;
  /** tax held back by the limit this period */
  readonly taxNotDeducted: Money;
}

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

import { incomeTax } from "#fune/payroll.income-tax@^1";
impl/typescript.ts · 186 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 { roundDiv } from "./math_round_div.ts";  ← from math.round-div ^1.0.0 · built alongside by fune
import { type Money, money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { type IncomeTax } from "./payroll_income_tax_types.ts";
import { type IncomeTaxBand, INCOME_TAX_BANDS, INCOME_TAX_BANDS_HISTORY, OVERRIDING_LIMIT, OVERRIDING_LIMIT_HISTORY } from "./payroll_income_tax_data.ts";  ← this capability’s own data, compiled from data/income-tax-bands.json into the same file by fune build
import { parseTaxCode } from "./payroll_tax_code_parse.ts";  ← from payroll.tax-code-parse ^1.0.0 · built alongside by fune
import { type PayFrequency } from "./payroll_tax_period_types.ts";

const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;

// 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 = 100;
const UNITS_PER_POUND = 10000;

function inForce<T extends { validFrom: string; validTo: string | null }>(row: T, onDate: string): boolean {
  return row.validFrom <= onDate && (row.validTo === null || onDate <= row.validTo);
}

function noRule(what: string, onDate: string, history: string, froms: string[]): never {
  // 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" && froms.length > 0) {
    const earliest = froms.reduce((a, b) => (b < a ? b : a));
    if (onDate < earliest) {
      throw new RangeError(
        `no ${what} on ${onDate}: this build was installed with history=${history}, so it only carries rules from ${earliest}. Reinstall with history=full for earlier tax years.`
      );
    }
  }
  throw new RangeError(`no ${what} on ${onDate}`);
}

function bandsFor(region: string, onDate: string): IncomeTaxBand[] {
  const rows = INCOME_TAX_BANDS.filter((b) => b.region === region && inForce(b, onDate));
  if (rows.length === 0) {
    noRule(`income tax bands for ${region}`, onDate, INCOME_TAX_BANDS_HISTORY, INCOME_TAX_BANDS.filter((b) => b.region === region).map((b) => b.validFrom));
  }
  return [...rows].sort((a, b) => a.band - b.band);
}

function overridingLimit(onDate: string): number {
  const row = OVERRIDING_LIMIT.find((r) => inForce(r, onDate));
  if (row === undefined) noRule("PAYE overriding limit", onDate, OVERRIDING_LIMIT_HISTORY, OVERRIDING_LIMIT.map((r) => r.validFrom));
  return row.basisPoints;
}

/** Free pay (or K-code additional pay) for one week or month, in pence: HMRC paragraph 4.3.1. */
function periodAllowance(codeNumber: number, periodsPerYear: number): number {
  if (codeNumber === 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.
  const blocks = Math.floor((codeNumber - 1) / 500);
  const remainder = ((codeNumber - 1) % 500) + 1;
  const blockValue = periodsPerYear === 52 ? 9616 : 41667;
  return roundDiv((remainder * 10 + 9) * 100, periodsPerYear, "up") + blocks * blockValue;
}

/** Tax due to date on positive taxable pay by the banded Tax Formulae of paragraph 4.4, in pence. */
function bandedTax(taxablePence: number, bands: IncomeTaxBand[], n: number, periodsPerYear: number): number {
  const taxablePounds = roundDiv(taxablePence, 100, "down");
  let previousThreshold = 0; // c(i-1), units
  let previousThresholdTax = 0; // k(i-1), units
  let lowerPounds = 0;
  let cumulativeAnnualTax = 0; // K(i), units
  for (const band of bands) {
    if (band.upTo !== null) {
      const threshold = roundDiv(band.upTo * UNITS_PER_POUND * n, periodsPerYear, "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.
      const cvalue = roundDiv(threshold, UNITS_PER_POUND, "up");
      if (taxablePence > cvalue * 100) {
        cumulativeAnnualTax += (band.upTo - lowerPounds) * band.basisPoints;
        lowerPounds = band.upTo;
        previousThreshold = threshold;
        previousThresholdTax = roundDiv(cumulativeAnnualTax * n, periodsPerYear, "down");
        continue;
      }
    }
    const atThisRate = roundDiv((taxablePounds * UNITS_PER_POUND - previousThreshold) * band.basisPoints, 10000, "down");
    return roundDiv(previousThresholdTax + atThisRate, UNITS_PER_PENNY, "down");
  }
  throw new RangeError("income tax bands have no top band");
}

function checkGbp(name: string, amount: Money): void {
  if (amount.currency !== "GBP") {
    throw new RangeError(`payroll amounts must be in GBP, received ${amount.currency} for ${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.
 */
export function incomeTax(
  taxCode: string,
  frequency: PayFrequency,
  period: number,
  pay: Money,
  previousPayToDate: Money,
  previousTaxToDate: Money,
  payDate: string
): IncomeTax {
  checkGbp("pay", pay);
  checkGbp("previousPayToDate", previousPayToDate);
  checkGbp("previousTaxToDate", previousTaxToDate);
  if (!ISO_DATE.test(payDate)) {
    throw new RangeError(`payDate must be an ISO date (YYYY-MM-DD), received "${payDate}"`);
  }
  let weeksInPeriod: number;
  let validPeriod: boolean;
  if (frequency === "monthly") {
    weeksInPeriod = 1;
    validPeriod = period >= 1 && period <= 12;
  } else if (frequency === "weekly") {
    weeksInPeriod = 1;
    validPeriod = period >= 1 && period <= 53;
  } else if (frequency === "fortnightly") {
    weeksInPeriod = 2;
    validPeriod = (period >= 1 && period <= 52) || period === 54;
  } else if (frequency === "four-weekly") {
    weeksInPeriod = 4;
    validPeriod = (period >= 1 && period <= 52) || period === 56;
  } else {
    throw new RangeError(`unknown pay frequency "${frequency}"`);
  }
  if (!Number.isInteger(period) || !validPeriod) {
    throw new RangeError(`period ${period} is not a tax period for ${frequency} pay`);
  }

  const code = parseTaxCode(taxCode);
  // 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.
  const bands = bandsFor(code.region, payDate);
  const periodsPerYear = frequency === "monthly" ? 12 : 52;
  // Weeks 53, 54 and 56 are always taxed on a week 1 basis (paragraph 14),
  // using the week 1, 2 or 4 figures.
  const cumulative = code.cumulative && period <= 52;
  const n = cumulative ? period : weeksInPeriod;
  const payToDateForTax = cumulative ? previousPayToDate.minor + pay.minor : pay.minor;

  let liability = 0;
  let allowanceToDate = 0;
  let taxablePay = 0;
  if (code.kind === "allowance" || code.kind === "negative-allowance") {
    const perPeriod = periodAllowance(code.number, periodsPerYear) * n;
    allowanceToDate = code.kind === "allowance" ? perPeriod : -perPeriod;
    const taxablePence = payToDateForTax - allowanceToDate;
    if (taxablePence > 0) {
      taxablePay = roundDiv(taxablePence, 100, "down") * 100;
      liability = bandedTax(taxablePence, bands, n, periodsPerYear);
    }
  } else if (code.kind === "basic-rate" || code.kind === "d-rate") {
    const basic = bands.findIndex((b) => b.basicRate);
    const index = code.kind === "basic-rate" ? basic : basic + 1 + code.number;
    if (basic < 0 || index >= bands.length) {
      throw new RangeError(`no ${code.code} rate for ${code.region} on ${payDate}`);
    }
    const pounds = payToDateForTax > 0 ? roundDiv(payToDateForTax, 100, "down") : 0;
    taxablePay = pounds * 100;
    liability = roundDiv(pounds * bands[index].basisPoints, 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).
  const limit = pay.minor > 0 ? roundDiv(pay.minor * overridingLimit(payDate), 10000, "down") : 0;
  const due = cumulative ? liability - previousTaxToDate.minor : liability;
  const tax = due > limit ? limit : due;

  return {
    tax: money(tax, "GBP"),
    taxToDate: money(previousTaxToDate.minor + tax, "GBP"),
    payToDate: money(previousPayToDate.minor + pay.minor, "GBP"),
    allowanceToDate: money(allowanceToDate, "GBP"),
    taxablePay: money(taxablePay, "GBP"),
    cumulative,
    limitApplied: due > limit,
    taxNotDeducted: money(due > limit ? due - limit : 0, "GBP"),
  };
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the TypeScript 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.income-tax
Download for TypeScript payroll.income-tax-1.0.2-typescript.fune · 56,165 bytes sha256 d6cc6e195c4ca8ab7021d8482c6b57be922cf4b709758f17dfd94b149c657b8b

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

The whole function, every language, is one file too: payroll.income-tax-1.0.2.fune, 76,197 bytes, sha256 5645c1d0fd0808d3cf2a019746437faa0df2fcd64144ed99be1917b890da1b9d. 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 20 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
a trailing newline is not part of an ISO date 1257L, monthly, 1, £3,000.00, £0.00, £0.00, 2026-04-28 → error: ISO date
Arabic-Indic digits are not an ISO date 1257L, monthly, 1, £3,000.00, £0.00, £0.00, ٢٠٢٦-٠٤-٢٨ → 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

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 professional (CIPP) 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 professional (CIPP) 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
NOTICE231
README.md5,858
data/income-tax-bands.json12,157
data/overriding-limit.json208
impl/python.py8,647
impl/rust.rs10,693
impl/typescript.ts8,644
vectors.json18,718