payroll.national-insurance
Employee and employer Class 1 National Insurance for one payment by category letter, exact percentage method.
1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra
Pinned by 27 tests, run in TypeScript, Python and Rust.
Not professional advice. This capability calculates payroll figures from published rules. It is a software component for developers, not tax or legal advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a payroll specialist review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.
What it does
Status: needs review by a qualified payroll professional before it is published.
Employee (primary) and employer (secondary) Class 1 National Insurance on one payment, by HMRC's **exact percentage method**, for any category letter HMRC published for the tax year of the pay date. Tax years 2023-24 to 2026-27 are carried as dated data.
For example
nationalInsurance(£3,000.00, A, monthly, 2026-05-28, —)→ employee £156.16, employer £387.45, lower earnings limit reached true category A, monthly, 2026-27: 8% over the primary threshold, 15% over the secondarynationalInsurance(£5,483.29, A, monthly, 2025-06-27, —)→ employee £277.17, employer £759.94, lower earnings limit reached true above the UEL: exact method gives 277.17 and 759.94 where HMRC's printed tables give 277.16 and 759.90nationalInsurance(£4,189.25, A, monthly, 2026-06-26, —)→ employee £251.29, employer £565.84, lower earnings limit reached true a half penny rounds up, once, on the total: 251.285 becomes 251.29
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 nationalInsurance(earnings: Money, category: string, frequency: PayFrequency, payDate: string, director: DirectorNi | null): NationalInsurance
| earnings | Money | NI-able gross pay for this pay period; for the director annual method, this payment only |
| category | string | HMRC category letter A B C D E F H I J K L M N S V Z, as published for that tax year |
| frequency | PayFrequency | the earnings period; ignored by the director annual method, which always uses the year |
| payDate | date | the date the earnings are paid, which picks the tax year's thresholds and rates |
| director | DirectorNi? | null for the ordinary per-period method; given, the annual earnings period method for directors |
| returns | NationalInsurance |
The types it declares, generated into your project
/** What a director has already been paid, and paid in NI, earlier in this tax year. */
export interface DirectorNi {
readonly previousEarnings: Money;
readonly previousEmployee: Money;
readonly previousEmployer: Money;
}
/** Class 1 contributions due on this payment. */
export interface NationalInsurance {
/** primary Class 1, deducted from pay */
readonly employee: Money;
/** secondary Class 1, paid on top by the employer */
readonly employer: Money;
/** earnings at or above the lower earnings limit for the period, which builds State Pension entitlement */
readonly lowerEarningsLimitReached: boolean;
}
Your code names it in one line, in the file that uses it
import { nationalInsurance } from "#fune/payroll.national-insurance@^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 { NI_PRIMARY_RATES, NI_PRIMARY_RATES_HISTORY, NI_PRIMARY_RATES_HORIZON, NI_SECONDARY_RATES, NI_SECONDARY_RATES_HISTORY, NI_SECONDARY_RATES_HORIZON, NI_THRESHOLDS, NI_THRESHOLDS_HISTORY, NI_THRESHOLDS_HORIZON, type NiPrimaryRate, type NiSecondaryRate, type NiThresholds } from "./payroll_national_insurance_data.ts"; ← this capability’s own data, compiled from data/ni-thresholds.json into the same file by fune build
import { type DirectorNi, type NationalInsurance } from "./payroll_national_insurance_types.ts";
import { type PayFrequency } from "./payroll_tax_period_types.ts";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
function inForce(validFrom: string, validTo: string | null, onDate: string): boolean {
return onDate >= validFrom && (validTo === null || onDate <= validTo);
}
// A pruned build must refuse a date it no longer carries rules for rather than
// answer it with a later year's rates.
function missing(what: string, onDate: string, history: string, horizon: string | null): never {
if (history !== "full" && horizon !== null && onDate < horizon) {
throw new RangeError(
`${what} on ${onDate}: this build was installed with history=${history}, so it only carries rules from ${horizon}. Reinstall with history=full for earlier tax years.`
);
}
throw new RangeError(`${what} on ${onDate}`);
}
/** Earnings falling in (lower, upper]; upper null means no ceiling. */
function slice(earnings: number, lower: number, upper: number | null): number {
const top = upper === null ? earnings : Math.min(earnings, upper);
return Math.max(0, top - lower);
}
interface Limits {
lel: number;
pt: number;
st: number;
fust: number;
ust: number;
uel: number;
}
function limitsFor(t: NiThresholds, frequency: PayFrequency | "annual"): Limits {
switch (frequency) {
case "annual":
return { lel: t.lelAnnual, pt: t.ptAnnual, st: t.stAnnual, fust: t.fustAnnual, ust: t.ustAnnual, uel: t.uelAnnual };
case "monthly":
return { lel: t.lelMonthly, pt: t.ptMonthly, st: t.stMonthly, fust: t.fustMonthly, ust: t.ustMonthly, uel: t.uelMonthly };
case "weekly":
case "fortnightly":
case "four-weekly": {
// HMRC's CA38: for pay in multiples of a week, work on the weekly figures
// and multiply by the number of weeks.
const k = frequency === "weekly" ? 1 : frequency === "fortnightly" ? 2 : 4;
return { lel: t.lelWeekly * k, pt: t.ptWeekly * k, st: t.stWeekly * k, fust: t.fustWeekly * k, ust: t.ustWeekly * k, uel: t.uelWeekly * k };
}
default:
throw new RangeError(`unknown pay frequency "${frequency}"`);
}
}
interface Due {
employee: number;
employer: number;
}
// Regulation 12(1) SSCR 2001: primary and secondary are worked out separately
// and each total is rounded to the nearest penny, a half penny going up.
function contributions(earnings: number, l: Limits, p: NiPrimaryRate, s: NiSecondaryRate): Due {
const primary = slice(earnings, l.pt, l.uel) * p.ptToUelBasisPoints + slice(earnings, l.uel, null) * p.aboveUelBasisPoints;
const secondary =
slice(earnings, l.st, l.fust) * s.stToFustBasisPoints +
slice(earnings, Math.max(l.st, l.fust), l.ust) * s.fustToUstBasisPoints +
slice(earnings, Math.max(l.st, l.ust), null) * s.aboveUstBasisPoints;
return { employee: roundDiv(primary, 10000, "half-up"), employer: roundDiv(secondary, 10000, "half-up") };
}
/**
* Class 1 National Insurance on one payment, by the exact percentage method.
*
* With `director` null this is the ordinary earnings-period calculation. With
* it, the director's annual earnings period: contributions on everything paid
* so far this tax year at the annual thresholds, less what has already been
* paid, so the amount can go down (or negative) as well as up.
*/
export function nationalInsurance(earnings: Money, category: string, frequency: PayFrequency, payDate: string, director: DirectorNi | null): NationalInsurance {
if (earnings.currency !== "GBP") {
throw new RangeError(`National Insurance must be in GBP, received ${earnings.currency}`);
}
if (!Number.isInteger(earnings.minor) || earnings.minor < 0) {
throw new RangeError(`earnings must not be negative, received ${earnings.minor}`);
}
if (frequency !== "weekly" && frequency !== "fortnightly" && frequency !== "four-weekly" && frequency !== "monthly") {
throw new RangeError(`unknown pay frequency "${frequency}"`);
}
if (!ISO_DATE.test(payDate)) {
throw new RangeError(`payDate must be an ISO date (YYYY-MM-DD), received "${payDate}"`);
}
const thresholds = NI_THRESHOLDS.find((t) => inForce(t.validFrom, t.validTo, payDate));
if (thresholds === undefined) {
missing("no National Insurance thresholds", payDate, NI_THRESHOLDS_HISTORY, NI_THRESHOLDS_HORIZON);
}
const primaryFor = (basis: string) =>
NI_PRIMARY_RATES.find((r) => r.category === category && r.basis === basis && inForce(r.validFrom, r.validTo, payDate));
// Directors only have rates of their own in a year the main rate changed
// mid-year (2023-24); otherwise they pay the ordinary rates.
const primary = (director !== null ? primaryFor("director") : undefined) ?? primaryFor("standard");
if (primary === undefined) {
missing(`no National Insurance rates for category ${category}`, payDate, NI_PRIMARY_RATES_HISTORY, NI_PRIMARY_RATES_HORIZON);
}
const secondary = NI_SECONDARY_RATES.find((r) => r.category === category && inForce(r.validFrom, r.validTo, payDate));
if (secondary === undefined) {
missing(`no National Insurance rates for category ${category}`, payDate, NI_SECONDARY_RATES_HISTORY, NI_SECONDARY_RATES_HORIZON);
}
if (director === null) {
const limits = limitsFor(thresholds, frequency);
const due = contributions(earnings.minor, limits, primary, secondary);
return {
employee: money(due.employee, "GBP"),
employer: money(due.employer, "GBP"),
lowerEarningsLimitReached: earnings.minor >= limits.lel,
};
}
for (const [name, value] of [["previousEarnings", director.previousEarnings], ["previousEmployee", director.previousEmployee], ["previousEmployer", director.previousEmployer]] as const) {
if (value.currency !== "GBP") {
throw new RangeError(`National Insurance must be in GBP, received ${value.currency} for ${name}`);
}
}
const cumulative = director.previousEarnings.minor + earnings.minor;
if (cumulative < 0) {
throw new RangeError(`earnings must not be negative, received ${cumulative} to date`);
}
const limits = limitsFor(thresholds, "annual");
const due = contributions(cumulative, limits, primary, secondary);
return {
employee: money(due.employee - director.previousEmployee.minor, "GBP"),
employer: money(due.employer - director.previousEmployer.minor, "GBP"),
lowerEarningsLimitReached: cumulative >= limits.lel,
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 3 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.national-insurance
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./payroll.national-insurance-1.0.0-typescript.fune, or fetch it from a terminal with fune pull payroll.national-insurance@1.0.0:typescript.
The whole function, every language, is one file too: payroll.national-insurance-1.0.0.fune, 68,733 bytes, sha256 1c46066b49a90917503fa98767a47d4a066ad4a19c91b1daed261dc9dcd6a69e. 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.national-insurance
after — your function gets the result and the arguments, and returns the final result.
// fune: after payroll.national-insurance
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.national-insurance
// fune: replace money.amount in payroll.national-insurance
// fune: replace payroll.tax-period in payroll.national-insurance
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.national-insurance --steps.
// fune: step payroll.national-insurance 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 | |
|---|---|---|---|
| category A, monthly, 2026-27: 8% over the primary threshold, 15% over the secondary | £3,000.00, A, monthly, 2026-05-28, — | → | employee £156.16, employer £387.45, lower earnings limit reached true |
| above the UEL: exact method gives 277.17 and 759.94 where HMRC's printed tables give 277.16 and 759.90 | £5,483.29, A, monthly, 2025-06-27, — | → | employee £277.17, employer £759.94, lower earnings limit reached true |
| a half penny rounds up, once, on the total: 251.285 becomes 251.29 | £4,189.25, A, monthly, 2026-06-26, — | → | employee £251.29, employer £565.84, lower earnings limit reached true |
| below the lower earnings limit: no employee NI, but employer NI above the £96 secondary threshold | £100.00, A, weekly, 2026-06-05, — | → | employee £0.00, employer £0.60, lower earnings limit reached false |
| exactly the lower earnings limit counts as reaching it | £129.00, A, weekly, 2026-06-05, — | → | employee £0.00, employer £4.95, lower earnings limit reached true |
| fortnightly uses twice the weekly thresholds | £1,000.00, A, fortnightly, 2026-06-05, — | → | employee £41.28, employer £121.20, lower earnings limit reached true |
| four-weekly uses four times the weekly thresholds | £2,000.00, A, four-weekly, 2026-06-05, — | → | employee £82.56, employer £242.40, lower earnings limit reached true |
| category B married women's reduced rate 1.85% | £2,000.00, B, monthly, 2026-07-28, — | → | employee £17.61, employer £237.45, lower earnings limit reached true |
| category C over State Pension age: no employee NI, employer still pays | £500.00, C, weekly, 2026-07-03, — | → | employee £0.00, employer £60.60, lower earnings limit reached true |
| category M under 21: employer pays only above the upper secondary threshold | £5,000.00, M, monthly, 2026-07-28, — | → | employee £267.50, employer £121.65, lower earnings limit reached true |
Show the other 17 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| category F freeport: employer pays only above the £25,000 freeport threshold | £3,000.00, F, monthly, 2026-07-28, — | → | employee £156.16, employer £137.55, lower earnings limit reached true |
| category H apprentice under 25 in 2024-25: no employer NI below the upper secondary threshold | £600.00, H, weekly, 2024-10-04, — | → | employee £28.64, employer £0.00, lower earnings limit reached true |
| category J deferment in 2025-26: 2% main rate | £500.00, J, weekly, 2025-07-04, — | → | employee £5.16, employer £60.60, lower earnings limit reached true |
| category Z in 2025-26: 2% employee, no employer NI below the UST | £3,000.00, Z, monthly, 2025-07-28, — | → | employee £39.04, employer £0.00, lower earnings limit reached true |
| 2024-25: 8% employee, 13.8% employer over a £758 monthly secondary threshold | £2,500.00, A, monthly, 2024-07-26, — | → | employee £116.16, employer £240.40, lower earnings limit reached true |
| 2023-24 before 6 January 2024: 12% main rate | £2,500.00, A, monthly, 2023-12-28, — | → | employee £174.24, employer £240.40, lower earnings limit reached true |
| 2023-24 from 6 January 2024: 10% main rate | £2,500.00, A, monthly, 2024-01-26, — | → | employee £145.20, employer £240.40, lower earnings limit reached true |
| the day the 2023-24 main rate fell: 10% to the weekly UEL, 2% above it | £1,000.00, A, weekly, 2024-01-06, — | → | employee £73.16, employer £113.85, lower earnings limit reached true |
| director annual method 2026-27: NI on the year to date less what was already paid | £20,000.00, A, monthly, 2026-09-30, previous earnings £10,000.00, previous employee £0.00, previous employer £750.00 | → | employee £1,394.40, employer £3,000.00, lower earnings limit reached true |
| director annual method 2023-24 uses the whole-year 11.5% rate | £60,000.00, A, monthly, 2023-06-30, previous earnings £0.00, previous employee £0.00, previous employer £0.00 | → | employee £4,530.10, employer £7,024.20, lower earnings limit reached true |
| director below the annual thresholds pays nothing yet | £5,000.00, A, monthly, 2026-05-28, previous earnings £0.00, previous employee £0.00, previous employer £0.00 | → | employee £0.00, employer £0.00, lower earnings limit reached false |
| category N did not exist in 2023-24 | £2,500.00, N, monthly, 2023-12-28, — | → | error: no National Insurance rates for category N |
| an unknown category letter is an error | £2,500.00, Q, monthly, 2026-05-28, — | → | error: no National Insurance rates for category Q |
| a date before the rules this package carries | £2,500.00, A, monthly, 2023-04-05, — | → | error: no National Insurance thresholds |
| negative earnings are refused | -£1.00, A, monthly, 2026-05-28, — | → | error: earnings must not be negative |
| another currency is refused | €1.00, A, monthly, 2026-05-28, — | → | error: must be in GBP |
| an unknown frequency is refused | £1.00, A, daily, 2026-05-28, — | → | error: unknown pay frequency |
More from the author
## How it is worked out
- **Thresholds for the earnings period.** Weekly and monthly figures are the ones HMRC publishes. Fortnightly and four-weekly pay use two and four times the weekly figures, which is HMRC's instruction for pay in multiples of a week (CA38, "Adapting these tables for pay intervals other than weekly or monthly"). Note that regulation 11 of the Social Security (Contributions) Regulations 2001 derives multiples of a week from the annual figure (annual / 52 x weeks, rounded up to a pound), which for the four-weekly primary threshold gives £967 rather than 4 x £242 = £968. This package follows CA38; a reviewer should confirm which one HMRC's own software uses. - **Employee:** the main rate on earnings above the primary threshold up to the upper earnings limit, and the additional rate above it. Earnings between the lower earnings limit and the primary threshold are charged at 0% but still count for State Pension, which is what `lowerEarningsLimitReached` reports (earnings at or above the LEL). - **Employer:** three bands above the secondary threshold: up to the Freeport / Investment Zone upper secondary threshold, from there to the upper secondary threshold (the same figure as the UEL, and the one that applies to under-21s, apprentices under 25 and veterans), and above it. Each category letter carries its own rate for each band; for letters A, B, C and J they are all the same. - **Rounding:** primary and secondary are worked out separately on the exact earnings, and each total is rounded once to the nearest penny with less than half a penny disregarded (so exactly half a penny goes up): SSCR 2001 reg. 12(1), NIM11002. HMRC's printed tables round to table steps and can differ by a few pence; the vectors include a case where they do. - **2023-24** had a mid-year change: the main primary rate fell from 12% to 10% (and 5.85% to 3.85% for B and I) for payments made on or after 6 January 2024. The pay date chooses.
## Directors
Pass `director` (earnings and NI already paid this tax year) to use the annual earnings period: contributions are worked out on the year-to-date earnings at the annual thresholds, and the result is that total less what was already paid. In 2023-24 directors on the annual method pay the published whole-year blended rates (11.5% main, 5.35% reduced) instead of the two part-year rates. Out of scope: the pro-rata annual earnings period for a director appointed during the year, and the "alternative arrangements" year-end recalculation (run the annual method on the final payment to get it).
## Not covered
Category X (no liability), Class 1A and 1B, the Employment Allowance, the Apprenticeship Levy, aggregation of several jobs, and category letter changes in the middle of a period. Negative earnings (corrections) are refused.
## Sources
- HMRC, "Rates and thresholds for employers 2023 to 2024", "... 2024 to 2025", "... 2025 to 2026", "... 2026 to 2027": https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2023-to-2024 (and the -2024-to-2025, -2025-to-2026, -2026-to-2027 pages). Every threshold, rate and category letter in data/ comes from these pages, including the 2023-24 director rates and the 6 January 2024 change. - HMRC, NIM11002 "Class 1: calculating & recording earnings, NICs & NIC rebates: exact percentage method": https://www.gov.uk/hmrc-internal-manuals/national-insurance-manual/nim11002 - The Social Security (Contributions) Regulations 2001, regs. 11 and 12: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11 - HMRC, CA38 "National Insurance contributions tables A, D, F, H, J, L, M, N, V and Z" 2025 to 2026 (multiples of a week; the worked example above the UEL): https://www.gov.uk/government/publications/ca38-national-insurance-contributions-tables-a-and-j
Files
| Path | Bytes |
|---|---|
| README.md | 4,226 |
| data/ni-primary-rates.json | 11,985 |
| data/ni-secondary-rates.json | 7,246 |
| data/ni-thresholds.json | 2,388 |
| impl/python.py | 6,598 |
| impl/rust.rs | 8,533 |
| impl/typescript.ts | 6,871 |
| vectors.json | 10,171 |