@casomoltd/nhs-pay

中文文档
0.32.0发布于 2026/11/08AGPL-3.0-onlyTypeScriptES Module零依赖构建来源已验证近 30 天下载 2417

NHS pay scales and take-home pay, and a member's NHS pension across the 1995, 2008 and 2015 Sections

原始使用文档
文档目录24 节
  1. About Casomo
  2. Install
  3. Usage
  4. Look up a band's salary range
  5. Calculate take-home for a Band 5 nurse
  6. Apply HCAS supplement
  7. Format salary for display
  8. Take-home for a doctor or dentist
  9. Value a member's pension across sections
  10. Project a 2015 Section pension on flat pay
  11. How it works
  12. API reference
  13. Data sources
  14. AfC pay scales
  15. NHS pension
  16. Scotland
  17. Wales
  18. Other data
  19. Medical & dental pay scales
  20. How the data is modelled
  21. Test fixtures
  22. Tax years
  23. Development
  24. License

@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. And a member's NHS pension across the 1995, 2008 and 2015 Sections: final salary and career average, the McCloud remedy window valued on either basis, early and late retirement factors, and tax-free cash across the whole drawing.

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.

Install

npm install @casomoltd/nhs-pay

Public on the npm registry — no auth or .npmrc config needed.

Requires @casomoltd/paye-calc as a peer dependency; the range it accepts is peerDependencies in package.json.

Usage

Look up a band's salary range

import {AFC_CURRENT_YEAR, getAfcScales} from '@casomoltd/nhs-pay';

// A scale belongs to a pay year and a nation; neither has a default.
const {bands} = getAfcScales(AFC_CURRENT_YEAR, 'england');
const band5 = bands.find((b) => b.band === '5')!;
console.log(band5.salary.min); // entry salary
console.log(band5.salary.max); // top of band
console.log(band5.points);     // all pay points

Calculate take-home for a Band 5 nurse

import {
  AFC_CURRENT_YEAR,
  getAfcScales,
  getPensionTiers,
  nationToTaxRegion,
  nhsTakeHome,
  pensionTierRate,
} from '@casomoltd/nhs-pay';
import {taxYear} from '@casomoltd/paye-calc';

const year = AFC_CURRENT_YEAR;
const {bands} = getAfcScales(year, 'england');
const band5 = bands.find((b) => b.band === '5')!;
const salary = band5.salary.min;
// The contribution tiers are their own dataset, fetched by year and nation.
const tax = taxYear(year);
const rate = pensionTierRate(salary, getPensionTiers(tax, 'england'));

// The tax year and region are required: a default would silently tax a
// Scottish member at rUK rates.
const thp = nhsTakeHome(
  salary, rate / 100, tax, nationToTaxRegion('england'),
);
console.log(thp.net);               // annual net
console.log(thp.incomeTax);          // annual tax
console.log(thp.nationalInsurance);  // annual NI
console.log(thp.pensionDeduction);   // annual pension

Apply HCAS supplement

import {
  AFC_CURRENT_YEAR,
  getAfcScales,
  calculateHcasSupplement,
} from '@casomoltd/nhs-pay';

const {bands, hcas} = getAfcScales(AFC_CURRENT_YEAR, 'england');
const base = bands[0].salary.min;
const supplement = calculateHcasSupplement(
  base, hcas.innerLondon,
);
console.log(base + supplement); // London-adjusted

Format 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 from

fromPoint 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.

Value a member's pension across sections

import {
  PAST_YEARS, SECTIONS, isoDate, memberBenefits,
} from '@casomoltd/nhs-pay';
import {definedBenefitInput, taxYearEnd} from '@casomoltd/paye-calc';

// Built once from the member; `today` pins the date the figures
// are as at, so they reproduce.
const benefits = memberBenefits(
  {
    dateOfBirth: isoDate('1979-01-01'),
    service: [
      {section: SECTIONS.s1995, joined: isoDate('2004-04-01'),
        left: isoDate('2022-03-31')},
      {section: SECTIONS.s2015, joined: isoDate('2022-04-01'),
        left: null},
    ],
    pay: {
      declared: [],
      current: {todaysMoney: 51932, asAt: isoDate('2026-09-01')},
      ladder: null, // or the member's AfC band points and current point
      partTime: false, // true: pay above is for fewer than full-time hours
    },
    statement: null, // or a 2015 Section balance off a statement
  },
  // `published` prices the past on each year's Revaluation Order,
  // as an Annual Benefit Statement does; `assumed` on the CPI below.
  {assumedCpi: 0.02, pastYears: PAST_YEARS.published},
  new Date(2026, 8, 23),
);

benefits.remedy;  // true: in service by 31 March 2012 and after 2015
benefits.now();   // held today, the remedy window on the legacy basis

// Asked once per election the member is compared on: both bases here,
// because they have a remedy window. Every section is drawn on one date.
for (const kind of benefits.elections) {
  const position = benefits.at({
    leaving: isoDate('2039-09-30'),
    drawing: isoDate('2039-09-30'),
    remedy: {kind},
    cash: {kind: 'maximum'},
  });
  position.awards;       // one per section and basis, each factored
  position.section2015;  // the 2015 Section's ledgers, estimated history, curve
  position.crystallised; // the pension and automatic lump sum, summed
  position.cash;         // the cash taken across the whole
}

// One tax year's pension input, for the annual allowance: 2027 is
// 2026-27 (paye-calc's `taxYearEnd`), for the same leaving and election,
// valued in that year's cash at 2% CPI.
const period = benefits.pensionInputPeriod(taxYearEnd(2027), {
  leaving: isoDate('2039-09-30'), remedy: {kind: 'legacy-basis'},
}, 0.02);
const input = definedBenefitInput(period); // the year's input amount

Each section keeps its own rules — denominator, pay measure, pension age, lump sum, factors — so no caller can combine them into one the scheme lacks. Pay comes from the member's pay path (promotionalIndex is GAD's curve behind it). Service the library does not model, such as a break, is refused with BenefitNotModelled and a code to branch on; NOT_MODELLED links each code to the issue tracking it. See A member's benefits across sections for the model. How each arrangement's pension input is valued is A year's pension input.

Project a 2015 Section pension on flat pay

Deprecated. projectPension is kept for this flat-pay question until #21; for a member across sections, use memberBenefits above.

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

YearNationsSource
2025-26England, NI, WalesNHS Employers pay scales 2025/26
2026-27England, NINHS Employers pay scales 2026/27

Cross-checked against NHSPRB 39th Report (2026), Table A25 (AfC 2025/26 data for England, NI, Wales).

NHS pension

DataSource
September CPI + in-service rateHM Treasury Revaluation Orders, one SI per year
2015 Section accrual rate, NPANHSBSA 2015 Members' Guide (V13)
1995 and 2008 Sections' denominators, pay measures, lump sumSI 1995/300 and SI 2008/653, as how-it-works.md cites them
McCloud remedy eligibility and windowPublic Service Pensions and Judicial Offices Act 2022 ss.1–2; rollback by SI 2023/985
ERF / LRF factors: 0-420, 0-421, 1-401, 1-402, 1-407, 2-416GAD NHS EW consolidated factor workbook
Promotional pay indexGAD, summary of the 2020 valuation assumptions, page 3
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)) / 4Sch 29 Finance Act 2004 para 2C
Relevant valuation factor (20)Finance Act 2004 s.276
Lump Sum Allowance (LUMP_SUM_ALLOWANCE, 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 Which series applies a published Order for when a legislated rate is applied and why today's money never takes one.

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

DataSource
Pension contribution rates 2025/26NHSBSA contribution rates
Income tax / NI ratesgov.uk (via paye-calc)
National Living Wagegov.uk NLW rates
HCAS PCT zonesNHS 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.

NationCircularYearSource
EnglandPC(M&D) 1/2026 R22026/27NHS Employers
ScotlandPCS(DD)2026/012026/27 (training grades only)NHS Scotland
ScotlandPCS(DD)2025/01 + addendum2025/26 (complete round)NHS Scotland
WalesM&D(W) 01/20262026/27NHS Wales
WalesM&D(W) 01/20252025/26NHS Wales
Northern IrelandHSC(TC8) 05/20252025/26DoH 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:

  1. 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.
  2. 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.
  3. Canonical domain (getMedicalScales / getDentalScales + medicalResolver / dentalResolver) — a uniform (grade, nation, year) → scale points view, 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/working-week.test.ts pins the AfC week and its 365/7 year against every hourly rate Scotland's circular prints beside a salary, and the Welsh living-wage pair.

tests/fixtures/band-take-home.csv — 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:

Tax years

AfC scale + pension coverage (all four nations):

YearScalesPension
2023-24YesYes
2024-25YesYes
2025-26YesYes
2026-27YesYes

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 mode

License

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.

版本与发布记录

@casomoltd/nhs-pay 共发布 62 个版本,首次发布于 2026/06/23;过去 12 个月发布了 62 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.32.0latest2026/11/081 天前
0.31.02026/11/081 天前
0.30.02026/11/072 天前
0.29.02026/11/072 天前
0.28.02026/11/063 天前
0.27.02026/11/054 天前

关于 @casomoltd/nhs-pay 的常见问题

@casomoltd/nhs-pay 是什么?

NHS pay scales and take-home pay, and a member's NHS pension across the 1995, 2008 and 2015 Sections

如何安装 @casomoltd/nhs-pay?

在项目目录运行 npm install @casomoltd/nhs-pay,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@casomoltd/paye-calc(>=0.15.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@casomoltd/nhs-pay 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@casomoltd/nhs-pay 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@casomoltd/nhs-pay 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@casomoltd/nhs-pay 可以商用吗?

@casomoltd/nhs-pay 使用 AGPL-3.0-only 许可。最严格的 Copyleft:除分发外,通过网络向用户提供基于它的服务也需要开源对应源码。闭源 SaaS 使用前务必评估。以上为概括,不构成法律意见,请以许可原文为准。

@casomoltd/nhs-pay 的下载量有多少?

根据 npm 官方下载统计,@casomoltd/nhs-pay 在 2026/10/09 – 2026/11/07 这 30 天内被下载 2,417 次,日均约 81 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@casomoltd/nhs-pay 还在维护吗?

最新版本 0.32.0 发布于 2026/11/08(1 天前);至今共发布 62 个版本,其中过去 12 个月发布了 62 个;首次发布于 2026/06/23。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。