@casomoltd/nhs-pay
v0.19.4
Published
NHS Agenda for Change pay scales, pension tiers, regions, and take-home calculator
Downloads
2,744
Maintainers
Readme
@casomoltd/nhs-pay
NHS pay scales — Agenda for Change plus medical & dental —
with pension tiers, regions, HCAS supplements, and a take-home
calculator. Built on top of @casomoltd/paye-calc.
See docs/pay-frameworks.md for the domain
model — the pay frameworks (AfC, medical, dental, VSM), the shared
scale-resolver core, and where the consumer boundary sits.
About Casomo
Casomo is a registered UK limited company founded and directed by David Mohamad. It builds focused software tools and offers engineering consulting. This library powers the NHS pay calculators and explainers on casomo.co.uk — where the differentiator is showing take-home pay (after tax, NI and pension), not just the gross figures most published sources stop at.
- Portfolio — casomo.co.uk
- Consulting — casomoltd.com
Install
npm install @casomoltd/nhs-payPublic on the npm registry — no auth or .npmrc config needed.
Requires @casomoltd/paye-calc (>=0.5.0) as a peer
dependency.
Usage
Look up a band's salary range
import {getAfcScales} from '@casomoltd/nhs-pay';
const {bands} = getAfcScales();
const band5 = bands.find((b) => b.band === '5')!;
console.log(band5.salaryMin); // entry salary
console.log(band5.salaryMax); // top of band
console.log(band5.points); // all pay pointsCalculate take-home for a Band 5 nurse
import {
nhsTakeHome,
getAfcScales,
pensionTierRate,
} from '@casomoltd/nhs-pay';
const {bands, pensionTiers} = getAfcScales();
const band5 = bands.find((b) => b.band === '5')!;
const salary = band5.salaryMin;
const rate = pensionTierRate(salary, pensionTiers);
const thp = nhsTakeHome(salary, rate / 100);
console.log(thp.net); // annual net
console.log(thp.incomeTax); // annual tax
console.log(thp.nationalInsurance); // annual NI
console.log(thp.pensionDeduction); // annual pensionApply HCAS supplement
import {
getAfcScales,
calculateHcasSupplement,
} from '@casomoltd/nhs-pay';
const {bands, hcas} = getAfcScales();
const base = bands[0].salaryMin;
const supplement = calculateHcasSupplement(
base, hcas.innerLondon,
);
console.log(base + supplement); // London-adjustedFormat salary for display
import {fmtSalary, fmtPct} from '@casomoltd/nhs-pay';
fmtSalary(31049); // '£31,049'
fmtPct(8.3); // '8.3%'Take-home for a doctor or dentist
Medical and dental grades resolve the same way as AfC, via a
per-family resolver. getMedicalScales(year, nation) lists the
grades published for a nation and year; fromPoint builds a
Post (gross, pension tier, tax, NI, take-home) from one point.
import {getMedicalScales, medicalResolver} from '@casomoltd/nhs-pay';
const grades = getMedicalScales('2026-27', 'england');
const consultant = grades.find((g) => g.grade === 'consultant')!;
const top = consultant.points.at(-1)!;
const post = medicalResolver.fromPoint(
'consultant', top, 'england', '2026-27',
);
console.log(post.salary); // basic pay
console.log(post.pensionRate); // member contribution %
console.log(post.takeHome.net); // annual net after tax + NI + pension
console.log(consultant.source.reference); // the circular it came fromfromPoint takes the point itself. There is also
fromScalePoint(grade, label, …) for a caller holding only a label,
but a label does not identify a point on every scale — England's
consultant scale is 20 payroll steps over 5 threshold labels, so
'Threshold 4' names six of them and that call throws
AmbiguousScalePoint rather than picking one.
getDentalScales / dentalResolver mirror this for salaried
dental grades. Both fail loud (ScaleUnavailable) for an
unpublished nation/year or grade rather than defaulting.
Project a 2015-scheme pension
import {
COMMUTATION_FACTOR,
LUMP_SUM_ALLOWANCE,
commute,
projectPension,
} from '@casomoltd/nhs-pay';
const projection = projectPension({
kind: 'statement',
accruedPension: 5000, // from the Annual Benefit Statement
statementDate: new Date(2026, 2, 31), // the date it names
currentSalary: 54000,
dateOfBirth: new Date(1990, 0, 1),
exitDate: new Date(2035, 0, 1),
retirementDate: new Date(2053, 0, 1), // before NPA → ERF
npa: 67,
assumedCpi: 0.02,
});
console.log(projection.annualPension); // after ERF/LRF
console.log(projection.factorType); // 'erf'
console.log(projection.curve); // chart-ready points
// Commutation takes the whole {nominal, real, asAt} figure, not
// one side of it: the lump sum allowance is a cash amount tested
// on a date, so a dateless number could not say which ruler it
// was in. Every limit is required for the same reason — a
// default would answer a question the caller did not ask.
const {lumpSum, residualPension, limit} = commute(
projection.annualPension,
1, // take the permitted maximum
{
commutationFactor: COMMUTATION_FACTOR,
allowance: {
amount: LUMP_SUM_ALLOWANCE,
asAt: new Date(2026, 2, 31),
},
// The run's OWN price series, so the allowance is carried
// forward at the rate the pension was projected at.
prices: projection.prices,
},
);
console.log(lumpSum.real, lumpSum.nominal); // both rulers
// The maximum, and which cap stopped it — PER RULER.
console.log(limit.real.amount, limit.real.binding);
console.log(limit.nominal.amount, limit.nominal.binding);The maximum is the lower of two caps, and both are
statutory: 25% of the capital value of the benefits, and the
Lump Sum Allowance. The scheme's own contribution to the swap is
the 12:1 rate and nothing else, so a consumer must not attribute
the 25% to the scheme — LUMP_SUM_CAPS.Scheme names the
discriminant, not the rule's author. binding says which cap
stopped it, so a consumer can explain the figure rather than
restate the rule.
Why that name is left as it is, and what moves where when it changes: The two caps on tax-free cash.
There is one limit per ruler, and no single flag, because the
two can genuinely disagree: real is the model run at zero CPI
while the allowance is carried forward at CPI, so around the
crossover the allowance binds in today's money while the scheme
limb still binds in cash. Show the limit belonging to the ruler
on screen. See
The two caps on tax-free cash.
Without a statement figure, kind: 'estimation' accrues from a
joinDate instead. ERF/LRF factors are transcribed verbatim from
the GAD consolidated factor workbook (30 June 2023 issue) with
GAD's own rounding rules; a retirement date beyond the printed
tables throws RetirementFactorOutOfRange.
How it works
docs/how-it-works.md — the model this
library implements: the recurrence it walks, what a scheme year
and an exit date mean to it, why cash and today's money are two
runs rather than one and a deflator, and every assumption it
makes, declared. Read it before trusting a figure.
API reference
docs/api.md — every export, organised by
domain, with typed signatures and short notes. Kept in sync with
the code by a check-time drift gate. Agents may prefer the
shipped dist/*.d.ts, which carry the full JSDoc per module.
Data sources
AfC pay scales
| Year | Nations | Source | | ------- | -------------------- | ------ | | 2025-26 | England, NI, Wales | NHS Employers pay scales 2025/26 | | 2026-27 | England, NI | NHS Employers pay scales 2026/27 |
Cross-checked against NHSPRB 39th Report (2026), Table A25 (AfC 2025/26 data for England, NI, Wales).
2015 CARE pension projection
| Data | Source |
| ---- | ------ |
| September CPI + in-service rate | HM Treasury Revaluation Orders, one SI per year |
| Accrual rate, NPA | NHSBSA 2015 Members' Guide (V13) |
| ERF / LRF factors | GAD NHS EW consolidated factor workbook |
| Commutation rate (£12 : £1) | NHSBSA Key Notes — 2015 Scheme Estimates (V2) |
| Permitted maximum (lowest of three limbs) | Sch 29 Finance Act 2004 para 2 |
| Applicable amount, defined benefits, (A + (B × C)) / 4 | Sch 29 Finance Act 2004 para 2C |
| Relevant valuation factor (20) | Finance Act 2004 s.276 |
| Lump Sum Allowance (£268,275, frozen) | ITEPA 2003 s.637P |
Reconciled line by line against a real Annual Benefit Statement (2015 Section, updated to 31/03/2025, name and membership number redacted), and against a ten-year projection built by hand from it.
Both are archived and inventoried in
docs/source-archive.md, with the
links themselves in the header of
tests/golden-abs.test.ts — beside
the assertions they justify, which is where a citation belongs
and where it cannot drift from what it is citing.
The statement is the source of truth and the library never restates it: enter its figure and ask for its own date, and you are handed back exactly what it says. See Two rulers, one model for how cash and today's money are produced, and A projection never applies a published Order for why a legislated rate is declined even where one exists.
That promise is exact for a statement handed over with its date. Passing the figure undated instead asks a different question — "this is my balance today" — and the library must then work backwards to the year end, at its own revaluation rule rather than the scheme's. For a member who left at a year end the two differ; see Reading a statement back applies the SAME rule for the size of it and why passing the date avoids it.
Scotland
Scotland negotiates its own AfC award independently and
has completely different base salaries from England.
Separate scale tables are stored for each tax year —
getAfcScales(year, 'scotland') returns Scotland figures
directly. Scotland also has structural differences: Band 2
has 2 points (vs 1 in England) and Bands 8a–9 have 2
points each (vs 3).
Source: PCS(AFC)2026/1 — Scottish Health Workforce Directorate circular (23 Jan 2026). Annex B holds both years' rate tables; Annex C sets them out increment by increment and corroborates Annex B point for point.
Scotland's 2025-26 settlement was 4.25%, but carried an inflation guarantee — at least one percentage point above average CPI for the calendar year of the uplift. 2025 CPI confirmed at 3.4%, so the guarantee triggered and the rate was revised to 4.4%, backdated to 1 April 2025 with arrears. 2026-27's 3.75% then applies to the revised base, so both years moved. Take both from Annex B rather than the MSG consolidated table, which still prints the original 4.25% rates.
Wales
Wales runs its own pay ladder and both years are transcribed from it: AfC(W) 02/2025 (29 May 2025) and AfC(W) 02/2026 (12 Feb 2026), Annex 1 of each.
Wales is not England's table with a floor applied. The two ladders differ at every band from 4 upward, where no floor reaches: Band 9 tops out at £131,732 in Wales against £129,783 in England. A floor lifts the bottom of a ladder and cannot raise its top. Wales also carries two Band 2 points where England has one.
Point labels come from each circular's own "years until eligible for pay progression" column, so they are stated by the Welsh source rather than borrowed from England's table, and they do not always agree with England's for the same band.
The living-wage floor is real but separate: it is an advance
payment that lifts the lowest points, which is why the 2026/27
uplift explicitly does not apply to them. WALES_LIVING_WAGE
carries both published figures — the annual floor and the hourly
rate it is set against — for reference; nothing derives a scale
from it, and neither figure derives from the other.
Source: AfC(W) 01/2026 (6 Jan 2026).
Band 1 is published in both years (£24,833, then £26,300) and
closed to new entrants. It is absent from AFC_BANDS, the same
gap Scotland has.
Both Welsh years were transcribed during an outage of the NHS Wales
web estate (2 September 2026), so the circulars were read from NHS
Wales Employers' copy rather than the publisher's own. Each file was
checked byte-for-byte against a copy downloaded from www.nhs.wales
the previous day, before the outage, and both matched. The links
above are the publisher's, which is where these documents live.
Other data
| Data | Source | | ---------------------- | ------ | | Pension contribution rates 2025/26 | NHSBSA contribution rates | | Income tax / NI rates | gov.uk (via paye-calc) | | National Living Wage | gov.uk NLW rates | | HCAS PCT zones | NHS Employers Annex 8 Table 12 |
Medical & dental pay scales
Doctors and dentists are paid on a separate set of pay circulars — one per nation — from Agenda for Change. This library encodes them so it can render take-home (not just gross) for medical and dental grades, which is what most published sources omit.
| Nation | Circular | Year | Source | | ------ | -------- | ---- | ------ | | England | PC(M&D) 1/2026 R2 | 2026/27 | NHS Employers | | Scotland | PCS(DD)2026/01 | 2026/27 (training grades only) | NHS Scotland | | Scotland | PCS(DD)2025/01 + addendum | 2025/26 (complete round) | NHS Scotland | | Wales | M&D(W) 01/2026 | 2026/27 | NHS Wales | | Wales | M&D(W) 01/2025 | 2025/26 | NHS Wales | | Northern Ireland | HSC(TC8) 05/2025 | 2025/26 | DoH NI |
Wales's 2026/27 round is a 3.5% uplift (3.75% for salaried dentists) and removes the closed Associate Specialist (MC01) code, so that grade resolves only at 2025/26.
NI 2025/26 pension member tiers (HSC — distinct thresholds and rates from NHSBSA) are sourced from HSC Pensions.
How the data is modelled
The circulars vary widely in structure (nodal training points, consultant thresholds, SAS experience bands, GP ranges, dental spines), so the data is built in three layers that decouple transcription fidelity from the uniform domain model:
- Verbatim circular (
src/circulars/*.ts) — one file per PDF, each table transcribed 1:1 in source order with a row shape that mirrors that table's own columns, under a comment citing the Annex / section / page. Every table in the circular is either transcribed or recorded with a reason for skipping it, so nothing is silently dropped and each file diffs against the PDF top-to-bottom. - Translation layer (
src/medical-scales.ts,src/dental-scales.ts) — selects which scales feed the calculator and maps each verbatim row to a canonical scale point. Inclusive by default: closed-to-new-entrant grades, devolved training variants, GP registrars and the Community Dental Service are all wired. - Canonical domain (
getMedicalScales/getDentalScales+medicalResolver/dentalResolver) — a uniform(grade, nation, year) → scale pointsview, identical in shape to the AfC resolver.
Scope. (a) every basic-pay salary scale, including closed-to-new-entrant grades still paid to incumbents, plus (b) earnings-affecting supplements expressed as an annual £ (Clinical Impact / Excellence awards, DPH and intensity supplements). Pure expense tables (mileage, fees) and self-employed GDS/UDA dentist contract income are out of scope.
Unpublished data fails loud. England and Wales publish complete
2026/27 rounds. Scotland's 2026/27 circular uplifts training grades
only, so its consultant / SAS / GP / dental scales resolve to the
complete 2025/26 round (PCS(DD)2025/01) until the Scottish
Government publishes the non-training uplift; Northern Ireland's
latest circular is 2025/26 throughout. Each grade resolves at its
own cited year and latestYearFor(grade, nation) reports it —
figures are never silently carried forward, and a query for an
unpublished (year, nation) throws rather than defaulting to another
nation's or year's figures.
Test fixtures
tests/fixtures/band-take-home.csv — 90 golden-value rows
covering bands 5, 7, 8a across all 4 nations (England,
Scotland, Wales, NI), both tax years, 3 HCAS zones, and
part-time (0.6 FTE). Gross figures are derived from
getAfcScales(year, nation) using the sources above.
Pension, tax, NI and net values are computed by
nhsTakeHome and should be cross-checked against:
- NHS Employers pay scales 2025/26 and 2026/27 for England/NI gross
- PCS(AFC)2026/1 Annex B for Scotland gross
- NHSBSA pension contribution rates 2025/26 for pension tier rates
Tax years
AfC scale + pension coverage (all four nations):
| Year | Scales | Pension | | ------- | ------ | ------- | | 2023-24 | Yes | Yes | | 2024-25 | Yes | Yes | | 2025-26 | Yes | Yes | | 2026-27 | Yes | Yes |
Medical & dental coverage is per-nation (England/Wales 2026/27; Scotland 2026/27 for training grades and 2025/26 for the rest; NI 2025/26) — see Medical & dental pay scales.
Development
npm run check # lint + typecheck + knip + jscpd
# + api-docs drift gate + test
npm run build # compile to dist/
npm test # vitest
npm run test:watch # vitest watch modeLicense
AGPL-3.0-only — see LICENSE. Copyright © 2026 Casomo Ltd.
There is no linking exception: combine this library with your own code and the combined work is AGPL too, and letting people use that work over a network obliges you to offer them its source. Charging money is not what triggers this, and not charging is no exemption. A commercial licence is available for use that needs to stay closed — enquire via casomoltd.com.
