payroll.national-insurance
Employee and employer Class 1 National Insurance for one payment by category letter, exact percentage method.
1.0.1 (not the latest) · published 2026-10-03 by charlie · Anterra
Pinned by 29 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
national_insurance(£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 secondarynational_insurance(£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.90national_insurance(£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.
pub fn national_insurance(earnings: &Money, category: &str, frequency: &str, pay_date: &str, director: Option<&DirectorNi>) -> 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 |
| pay_date | 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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirectorNi {
pub previous_earnings: Money,
pub previous_employee: Money,
pub previous_employer: Money,
}
/// Class 1 contributions due on this payment.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NationalInsurance {
/// primary Class 1, deducted from pay
pub employee: Money,
/// secondary Class 1, paid on top by the employer
pub employer: Money,
/// earnings at or above the lower earnings limit for the period, which builds State Pension entitlement
pub lower_earnings_limit_reached: bool,
}
Your code names it in one line, in the file that uses it
fune!(payroll.national-insurance@^1); // then call national_insurance(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_round_div::round_div; ← from math.round-div ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value, Money}; ← from money.amount ^1.0.0 · built alongside by fune
use super::payroll_national_insurance_data::{
NiPrimaryRate, NiSecondaryRate, NiThresholds, 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,
};
fn is_iso_date(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() == 10
&& bytes[4] == b'-'
&& bytes[7] == b'-'
&& bytes
.iter()
.enumerate()
.all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit())
}
fn in_force(valid_from: &str, valid_to: Option<&str>, on_date: &str) -> bool {
on_date >= valid_from && valid_to.map_or(true, |to| on_date <= to)
}
// A pruned build must refuse a date it no longer carries rules for rather than
// answer it with a later year's rates.
fn missing(what: &str, on_date: &str, history: &str, horizon: Option<&str>) -> ! {
if let Some(horizon) = horizon {
if history != "full" && on_date < horizon {
panic!(
"{} on {}: this build was installed with history={}, so it only carries rules from {}. Reinstall with history=full for earlier tax years.",
what, on_date, history, horizon
);
}
}
panic!("{} on {}", what, on_date)
}
/// Earnings falling in (lower, upper]; upper None means no ceiling.
fn slice(earnings: i64, lower: i64, upper: Option<i64>) -> i64 {
let top = match upper {
Some(u) => earnings.min(u),
None => earnings,
};
(top - lower).max(0)
}
/// (lel, pt, st, fust, ust, uel) for the earnings period.
fn limits(t: &NiThresholds, frequency: &str) -> [i64; 6] {
match frequency {
"annual" => [t.lel_annual, t.pt_annual, t.st_annual, t.fust_annual, t.ust_annual, t.uel_annual],
"monthly" => [t.lel_monthly, t.pt_monthly, t.st_monthly, t.fust_monthly, t.ust_monthly, t.uel_monthly],
_ => {
// HMRC's CA38: for pay in multiples of a week, work on the weekly
// figures and multiply by the number of weeks.
let k = match frequency {
"weekly" => 1,
"fortnightly" => 2,
"four-weekly" => 4,
other => panic!("unknown pay frequency \"{}\"", other),
};
[
t.lel_weekly * k,
t.pt_weekly * k,
t.st_weekly * k,
t.fust_weekly * k,
t.ust_weekly * k,
t.uel_weekly * k,
]
}
}
}
// 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.
fn contributions(earnings: i64, l: &[i64; 6], p: &NiPrimaryRate, s: &NiSecondaryRate) -> (i64, i64) {
let [_lel, pt, st, fust, ust, uel] = *l;
let primary = slice(earnings, pt, Some(uel)) * p.pt_to_uel_basis_points
+ slice(earnings, uel, None) * p.above_uel_basis_points;
let secondary = slice(earnings, st, Some(fust)) * s.st_to_fust_basis_points
+ slice(earnings, st.max(fust), Some(ust)) * s.fust_to_ust_basis_points
+ slice(earnings, st.max(ust), None) * s.above_ust_basis_points;
(
round_div(primary, 10000, "half-up"),
round_div(secondary, 10000, "half-up"),
)
}
/// Class 1 National Insurance on one payment, by the exact percentage method.
///
/// With `director` None 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.
///
/// # Panics
/// Panics on non-GBP or negative earnings, an unknown frequency or category,
/// or a date no rules cover.
pub fn national_insurance(
earnings: &Money,
category: &str,
frequency: &str,
pay_date: &str,
director: Option<&DirectorNi>,
) -> NationalInsurance {
if earnings.currency != "GBP" {
panic!("National Insurance must be in GBP, received {}", earnings.currency);
}
if earnings.minor < 0 {
panic!("earnings must not be negative, received {}", earnings.minor);
}
if !matches!(frequency, "weekly" | "fortnightly" | "four-weekly" | "monthly") {
panic!("unknown pay frequency \"{}\"", frequency);
}
if !is_iso_date(pay_date) {
panic!("payDate must be an ISO date (YYYY-MM-DD), received \"{}\"", pay_date);
}
let thresholds = match NI_THRESHOLDS.iter().find(|t| in_force(t.valid_from, t.valid_to, pay_date)) {
Some(t) => t,
None => missing("no National Insurance thresholds", pay_date, NI_THRESHOLDS_HISTORY, NI_THRESHOLDS_HORIZON),
};
let primary_for = |basis: &str| {
NI_PRIMARY_RATES
.iter()
.find(|r| r.category == category && r.basis == basis && in_force(r.valid_from, r.valid_to, pay_date))
};
// Directors only have rates of their own in a year the main rate changed
// mid-year (2023-24); otherwise they pay the ordinary rates.
let director_rate = if director.is_some() { primary_for("director") } else { None };
let primary = match director_rate.or_else(|| primary_for("standard")) {
Some(r) => r,
None => missing(
&format!("no National Insurance rates for category {}", category),
pay_date,
NI_PRIMARY_RATES_HISTORY,
NI_PRIMARY_RATES_HORIZON,
),
};
let secondary = match NI_SECONDARY_RATES
.iter()
.find(|r| r.category == category && in_force(r.valid_from, r.valid_to, pay_date))
{
Some(r) => r,
None => missing(
&format!("no National Insurance rates for category {}", category),
pay_date,
NI_SECONDARY_RATES_HISTORY,
NI_SECONDARY_RATES_HORIZON,
),
};
match director {
None => {
let l = limits(thresholds, frequency);
let (employee, employer) = contributions(earnings.minor, &l, primary, secondary);
NationalInsurance {
employee: money(employee, "GBP"),
employer: money(employer, "GBP"),
lower_earnings_limit_reached: earnings.minor >= l[0],
}
}
Some(d) => {
for (name, value) in [
("previousEarnings", &d.previous_earnings),
("previousEmployee", &d.previous_employee),
("previousEmployer", &d.previous_employer),
] {
if value.currency != "GBP" {
panic!("National Insurance must be in GBP, received {} for {}", value.currency, name);
}
}
let cumulative = d.previous_earnings.minor + earnings.minor;
if cumulative < 0 {
panic!("earnings must not be negative, received {} to date", cumulative);
}
let l = limits(thresholds, "annual");
let (employee, employer) = contributions(cumulative, &l, primary, secondary);
NationalInsurance {
employee: money(employee - d.previous_employee.minor, "GBP"),
employer: money(employer - d.previous_employer.minor, "GBP"),
lower_earnings_limit_reached: cumulative >= l[0],
}
}
}
}
pub fn director_ni_from_value(v: &Value) -> DirectorNi {
DirectorNi {
previous_earnings: money_from_value(v.get("previousEarnings")),
previous_employee: money_from_value(v.get("previousEmployee")),
previous_employer: money_from_value(v.get("previousEmployer")),
}
}
pub fn national_insurance_to_value(ni: &NationalInsurance) -> Value {
Value::obj(vec![
("employee", money_to_value(&ni.employee)),
("employer", money_to_value(&ni.employer)),
("lowerEarningsLimitReached", Value::Bool(ni.lower_earnings_limit_reached)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let director = if args[4].is_null() { None } else { Some(director_ni_from_value(&args[4])) };
national_insurance_to_value(&national_insurance(
&money_from_value(&args[0]),
args[1].as_str(),
args[2].as_str(),
args[3].as_str(),
director.as_ref(),
))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add payroll.national-insurance
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./payroll.national-insurance-1.0.1-rust.fune, or fetch it from a terminal with fune pull payroll.national-insurance@1.0.1:rust.
The whole function, every language, is one file too: payroll.national-insurance-1.0.1.fune, 69,377 bytes, sha256 06de374f855c96dcd6f9b9c58b5a5bd7c1b889b2387f9e54040307984a7fb214. 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 19 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 |
| 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.
Files
| Path | Bytes |
|---|---|
| README.md | 4,319 |
| data/ni-primary-rates.json | 11,985 |
| data/ni-secondary-rates.json | 7,246 |
| data/ni-thresholds.json | 2,388 |
| impl/python.py | 6,609 |
| impl/rust.rs | 8,533 |
| impl/typescript.ts | 6,871 |
| vectors.json | 10,638 |