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,951incomeTax(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 bandsincomeTax(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
| taxCode | string | as issued by HMRC, e.g. 1257L, S1257L, K475 M1, BR |
| frequency | PayFrequency | |
| period | int | tax week (1-56) or tax month (1-12) of the payment, from payroll.tax-period |
| pay | Money | taxable pay for this period, after any net pay pension deduction |
| previousPayToDate | Money | taxable pay already paid this tax year (including a P45's figure); ignored on a week 1 / month 1 basis |
| previousTaxToDate | Money | tax already deducted this tax year (including a P45's figure); ignored on a week 1 / month 1 basis |
| payDate | date | decides the tax year's bands |
| returns | IncomeTax |
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";
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| NOTICE | 231 |
| README.md | 5,858 |
| data/income-tax-bands.json | 12,157 |
| data/overriding-limit.json | 208 |
| impl/python.py | 8,647 |
| impl/rust.rs | 10,693 |
| impl/typescript.ts | 8,644 |
| vectors.json | 18,718 |