Functional Weave
Code in TypeScript

payroll.national-insurance Unreviewed

Employee and employer Class 1 National Insurance for one payment by category letter, exact percentage method.

1.0.2 · published 2026-10-03 by charlie · Anterra

Pinned by 29 tests, run in TypeScript, Python and Rust.

Unreviewed. This capability’s implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified payroll specialist has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.

Not professional advice. This capability calculates payroll figures from published rules. It is a software component for developers, not tax or legal advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a payroll specialist review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.

What it does

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 secondary
  • nationalInsurance(£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.90
  • nationalInsurance(£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
earningsMoneyNI-able gross pay for this pay period; for the director annual method, this payment only
categorystringHMRC category letter A B C D E F H I J K L M N S V Z, as published for that tax year
frequencyPayFrequencythe earnings period; ignored by the director annual method, which always uses the year
payDatedatethe date the earnings are paid, which picks the tax year's thresholds and rates
directorDirectorNi?null for the ordinary per-period method; given, the annual earnings period method for directors
returnsNationalInsurance

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";
impl/typescript.ts · 139 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 { 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
Download for TypeScript payroll.national-insurance-1.0.2-typescript.fune · 55,358 bytes sha256 23db6a45ef11613cc871f25c69809f43bc7789e8695ff9df3f4ba8250dffdc92

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

The whole function, every language, is one file too: payroll.national-insurance-1.0.2.fune, 71,050 bytes, sha256 3e144d3220404676150707f02c616973a6c75a01b23121c5ffebe3dd5dfbbfec. 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.

CaseArgumentsExpected
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 19 tests
CaseArgumentsExpected
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
a trailing newline is not part of an ISO date £3,000.00, A, monthly, 2026-05-28 , — → error: payDate must be an ISO date
Arabic-Indic digits are not an ISO date £3,000.00, A, monthly, ٢٠٢٦-٠٥-٢٨, — → error: payDate must be an ISO date

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

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 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 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
NOTICE239
README.md5,684
data/ni-primary-rates.json11,985
data/ni-secondary-rates.json7,246
data/ni-thresholds.json2,388
impl/python.py6,609
impl/rust.rs8,533
impl/typescript.ts6,871
vectors.json10,638