payroll.statutory-sick-pay Unreviewed
UK Statutory Sick Pay for sickness spells: waiting days, 8-week linking, 28-week limit and the rate by date.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 19 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
Statutory Sick Pay (SSP) due from one employer for a run of sickness spells. Pass every spell of sickness in date order (earlier ones matter: they can link to later ones), the employee's qualifying days as ISO weekdays, and their normal weekly earnings. The answer is split into SSP weeks, which run Sunday to Saturday, because that is how the daily amount is set: the weekly rate divided by the number of qualifying days in the week.
## The rules, and when they changed
For example
statutorySickPay(periods ×1, 1, 2, 3, 4, 5, £500.00)→ total £166.25, paid days 7, waiting days 3, weeks ×2, entitlement exhausted false two working weeks off in 2025-26: three waiting days, then the daily ratestatutorySickPay(periods ×1, 1, 2, 3, 4, 5, £500.00)→ total £0.00, paid days 0, waiting days 0, weeks , entitlement exhausted false a three-day spell before April 2026 is not a period of incapacity and pays nothingstatutorySickPay(periods ×1, 1, 2, 3, 4, 5, £500.00)→ total £73.95, paid days 3, waiting days 0, weeks ×1, entitlement exhausted false the same three-day spell after the 2026 reforms is paid from day one
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 statutorySickPay(periods: readonly SicknessPeriod[], qualifyingWeekdays: readonly number[], normalWeeklyEarnings: Money): SickPay
| periods | SicknessPeriod[] | every spell of sickness with this employer, oldest first, including earlier spells that may link |
| qualifyingWeekdays | int[] | ISO weekdays the employee is required to work, 1 = Monday ... 7 = Sunday |
| normalWeeklyEarnings | Money | average weekly earnings over the 8-week relevant period, worked out by the caller |
| returns | SickPay |
The types it declares, generated into your project
/** A spell of days of incapacity for work, both ends inclusive. */
export interface SicknessPeriod {
readonly start: string;
readonly end: string;
}
/** What is due for one SSP week (Sunday to Saturday) under one set of rules. */
export interface SickPayWeek {
/** the Sunday the week begins */
readonly weekStart: string;
/** qualifying days paid in this week */
readonly paidDays: number;
/** qualifying days served as waiting days in this week */
readonly waitingDays: number;
readonly amount: Money;
}
export interface SickPay {
readonly total: Money;
readonly paidDays: number;
readonly waitingDays: number;
/** in date order; a week spanning a rate change has one line per rule */
readonly weeks: readonly SickPayWeek[];
/** some qualifying day went unpaid because 28 weeks or 3 years were used up */
readonly entitlementExhausted: boolean;
}
Your code names it in one line, in the file that uses it
import { statutorySickPay } from "#fune/payroll.statutory-sick-pay@^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 { addDays, daysBetween, parseIsoDate } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { addMonths } from "./dates_add_months.ts"; ← from dates.add-months ^1.0.0 · built alongside by fune
import { dayOfWeek } from "./dates_day_of_week.ts"; ← from dates.day-of-week ^1.0.0 · built alongside by fune
import { SSP_RULES, SSP_RULES_HISTORY, type SspRule } from "./payroll_statutory_sick_pay_data.ts"; ← this capability’s own data, compiled from data/ssp-rules.json into the same file by fune build
import { type SicknessPeriod, type SickPay, type SickPayWeek } from "./payroll_statutory_sick_pay_types.ts";
/** Periods of incapacity separated by no more than 8 weeks are one period (SSCBA 1992 s.152(3)). */
const LINK_GAP_DAYS = 56;
/** 28 weeks' entitlement, counted in 1/420ths of a week so any 1-7 qualifying-day week divides exactly. */
const WEEK_UNITS = 420;
const MAX_UNITS = 28 * WEEK_UNITS;
function ruleFor(date: string): number {
for (let i = 0; i < SSP_RULES.length; i++) {
const rule = SSP_RULES[i];
if (date >= rule.validFrom && (rule.validTo === null || date <= rule.validTo)) return i;
}
if (SSP_RULES_HISTORY !== "full") {
const earliest = SSP_RULES.reduce<string | null>((e, r) => (e === null || r.validFrom < e ? r.validFrom : e), null);
if (earliest !== null && date < earliest) {
throw new RangeError(
`no SSP rule for ${date}: this build was installed with history=${SSP_RULES_HISTORY}, so it only carries rules from ${earliest}. Reinstall with history=full for older sickness.`
);
}
}
throw new RangeError(`no SSP rule for ${date}`);
}
/**
* Statutory Sick Pay due for a run of sickness spells.
*
* Every day is judged by the rules in force on that day, which is how the
* 6 April 2026 reforms land: a spell straddling that date serves waiting days
* before it and is paid from the first qualifying day after it.
*/
export function statutorySickPay(periods: readonly SicknessPeriod[], qualifyingWeekdays: readonly number[], normalWeeklyEarnings: Money): SickPay {
if (qualifyingWeekdays.length === 0) throw new RangeError("there must be at least one qualifying day a week");
const qualifying = new Set<number>();
for (const d of qualifyingWeekdays) {
if (!Number.isInteger(d) || d < 1 || d > 7) throw new RangeError(`qualifying weekdays must be 1 (Monday) to 7 (Sunday), received ${d}`);
if (qualifying.has(d)) throw new RangeError(`qualifying weekday ${d} is listed twice`);
qualifying.add(d);
}
const perWeek = qualifying.size;
if (normalWeeklyEarnings.currency !== "GBP") throw new RangeError(`SSP is paid in GBP, received ${normalWeeklyEarnings.currency}`);
if (normalWeeklyEarnings.minor < 0) throw new RangeError("normalWeeklyEarnings must not be negative");
const lines: { weekStart: string; rule: number; paidDays: number; waitingDays: number }[] = [];
const lineFor = (weekStart: string, rule: number) => {
let line = lines.find((l) => l.weekStart === weekStart && l.rule === rule);
if (!line) {
line = { weekStart, rule, paidDays: 0, waitingDays: 0 };
lines.push(line);
}
return line;
};
let previousEnd: string | null = null;
let lastPiwEnd: string | null = null;
let seriesEnd = "";
let waitingServed = 0;
let unitsUsed = 0;
let exhausted = false;
for (const period of periods) {
parseIsoDate(period.start);
parseIsoDate(period.end);
if (period.end < period.start) throw new RangeError(`sickness period ${period.start} to ${period.end} ends before it starts`);
if (previousEnd !== null && period.start <= previousEnd) throw new RangeError("sickness periods must be in date order and must not overlap");
previousEnd = period.end;
// Too short to be a period of incapacity under the rules of its first day:
// it is not paid and does not link anything.
const length = daysBetween(period.start, period.end) + 1;
if (length < SSP_RULES[ruleFor(period.start)].minimumPeriodDays) continue;
if (lastPiwEnd === null || daysBetween(lastPiwEnd, period.start) - 1 > LINK_GAP_DAYS) {
// A new period of entitlement: waiting days and the 28 weeks start again,
// and it ends 3 years on at the latest (SSP (General) Regs 1982 reg 3(3)).
waitingServed = 0;
unitsUsed = 0;
seriesEnd = addMonths(period.start, 36);
}
lastPiwEnd = period.end;
for (let day = period.start; day <= period.end; day = addDays(day, 1)) {
const weekday = dayOfWeek(day);
if (!qualifying.has(weekday)) continue;
const index = ruleFor(day);
const rule = SSP_RULES[index];
if (rule.lowerEarningsLimit !== null && normalWeeklyEarnings.minor < rule.lowerEarningsLimit) continue;
const weekStart = addDays(day, -(weekday % 7));
if (waitingServed < rule.waitingDays) {
waitingServed += 1;
lineFor(weekStart, index).waitingDays += 1;
continue;
}
const units = WEEK_UNITS / perWeek;
if (day >= seriesEnd || unitsUsed + units > MAX_UNITS) {
exhausted = true;
continue;
}
unitsUsed += units;
lineFor(weekStart, index).paidDays += 1;
}
}
lines.sort((a, b) => (a.weekStart < b.weekStart ? -1 : a.weekStart > b.weekStart ? 1 : a.rule - b.rule));
const weeks: SickPayWeek[] = lines.map((line) => ({
weekStart: line.weekStart,
paidDays: line.paidDays,
waitingDays: line.waitingDays,
amount: money(weekAmount(SSP_RULES[line.rule], normalWeeklyEarnings.minor, line.paidDays, perWeek), "GBP"),
}));
return {
total: money(weeks.reduce((sum, w) => sum + w.amount.minor, 0), "GBP"),
paidDays: weeks.reduce((sum, w) => sum + w.paidDays, 0),
waitingDays: weeks.reduce((sum, w) => sum + w.waitingDays, 0),
weeks,
entitlementExhausted: exhausted,
};
}
/**
* SSP for some days of one week: the weekly rate times days over qualifying
* days in the week, rounded up to the penny, as HMRC's daily-rate tables do.
* The weekly rate is kept exact (in ten-thousandths of a penny) so that 80% of
* earnings is never rounded twice.
*/
function weekAmount(rule: SspRule, earnings: number, days: number, perWeek: number): number {
let rate = rule.weeklyRate * 10000;
if (rule.earningsCapBasisPoints !== null) rate = Math.min(rate, earnings * rule.earningsCapBasisPoints);
return roundDiv(rate * days, 10000 * perWeek, "up");
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 5 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.statutory-sick-pay
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./payroll.statutory-sick-pay-1.0.1-typescript.fune, or fetch it from a terminal with fune pull payroll.statutory-sick-pay@1.0.1:typescript.
The whole function, every language, is one file too: payroll.statutory-sick-pay-1.0.1.fune, 52,251 bytes, sha256 aee3a09d679043e90bea8a61646a83de75fe303badb083f88bacf8051e8abbcf. 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.statutory-sick-pay
after — your function gets the result and the arguments, and returns the final result.
// fune: after payroll.statutory-sick-pay
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 dates.add-days in payroll.statutory-sick-pay
// fune: replace dates.add-months in payroll.statutory-sick-pay
// fune: replace dates.day-of-week in payroll.statutory-sick-pay
// fune: replace math.round-div in payroll.statutory-sick-pay
// fune: replace money.amount in payroll.statutory-sick-pay
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.statutory-sick-pay --steps.
// fune: step payroll.statutory-sick-pay 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 | |
|---|---|---|---|
| two working weeks off in 2025-26: three waiting days, then the daily rate | periods ×1, 1, 2, 3, 4, 5, £500.00 | → | total £166.25, paid days 7, waiting days 3, weeks ×2, entitlement exhausted false |
| a three-day spell before April 2026 is not a period of incapacity and pays nothing | periods ×1, 1, 2, 3, 4, 5, £500.00 | → | total £0.00, paid days 0, waiting days 0, weeks , entitlement exhausted false |
| the same three-day spell after the 2026 reforms is paid from day one | periods ×1, 1, 2, 3, 4, 5, £500.00 | → | total £73.95, paid days 3, waiting days 0, weeks ×1, entitlement exhausted false |
| from April 2026 low earners get 80% of earnings, kept exact and rounded up once | periods ×1, 1, 2, 3, 4, 5, £123.33 | → | total £19.74, paid days 1, waiting days 0, weeks ×1, entitlement exhausted false |
| earnings below the lower earnings limit before April 2026 earn no SSP | periods ×1, 1, 2, 3, 4, 5, £124.99 | → | total £0.00, paid days 0, waiting days 0, weeks , entitlement exhausted false |
| a spell 52 days after the last links to it and serves no new waiting days | periods ×2, 1, 2, 3, 4, 5, £500.00 | → | total £118.75, paid days 5, waiting days 3, weeks ×2, entitlement exhausted false |
| a gap of exactly 8 weeks still links | periods ×2, 1, 2, 3, 4, 5, £500.00 | → | total £71.25, paid days 3, waiting days 3, weeks ×3, entitlement exhausted false |
| a gap of 59 days does not link, so waiting days are served again | periods ×2, 1, 2, 3, 4, 5, £500.00 | → | total £47.50, paid days 2, waiting days 6, weeks ×2, entitlement exhausted false |
| a short spell between two linked ones is ignored before 2026 | periods ×3, 1, 2, 3, 4, 5, £500.00 | → | total £118.75, paid days 5, waiting days 3, weeks ×2, entitlement exhausted false |
| a spell across 6 April 2026 serves two old waiting days, then pays at the new rate | periods ×1, 1, 2, 3, 4, 5, £500.00 | → | total £123.25, paid days 5, waiting days 2, weeks ×2, entitlement exhausted false |
Show the other 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| seven qualifying days a week: one day is a seventh of the week, rounded up | periods ×1, 1, 2, 3, 4, 5, 6, 7, £500.00 | → | total £66.72, paid days 4, waiting days 3, weeks ×2, entitlement exhausted false |
| 2023-24 rate on a Tuesday-and-Thursday pattern: waiting days run into the second week | periods ×1, 2, 4, £400.00 | → | total £164.10, paid days 3, waiting days 3, weeks ×3, entitlement exhausted false |
| 28 weeks is the most SSP a period of entitlement pays | periods ×1, 1, 2, 3, 4, 5, £500.00 | → | total £3,451.00, paid days 140, waiting days 0, weeks ×28, entitlement exhausted true |
| no qualifying days is an error | periods ×1, , £500.00 | → | error: at least one qualifying day |
| a weekday outside 1-7 is an error | periods ×1, 0, 1, £500.00 | → | error: qualifying weekdays must be 1 (Monday) to 7 (Sunday) |
| a period that ends before it starts is an error | periods ×1, 1, 2, 3, 4, 5, £500.00 | → | error: ends before it starts |
| overlapping periods are an error | periods ×2, 1, 2, 3, 4, 5, £500.00 | → | error: must be in date order and must not overlap |
| sickness before the rules this capability carries is an error | periods ×1, 1, 2, 3, 4, 5, £500.00 | → | error: no SSP rule for 2020-06-01 |
| negative earnings are an error | periods ×1, 1, 2, 3, 4, 5, -£0.01 | → | error: must not be negative |
More from the author
Every day is judged by the rule row in force on that day (`data/ssp-rules.json`).
Up to 5 April 2026: - A spell is only a **period of incapacity for work** (PIW) if it lasts at least 4 consecutive days, counting every calendar day. Shorter spells pay nothing and link nothing. - The first **3 qualifying days** of a period of entitlement are waiting days and are not paid. - Earnings below the **lower earnings limit** (£123 in 2023-24 and 2024-25, £125 in 2025-26) mean no SSP at all. - The weekly rate is a flat £109.40 (2023-24), £116.75 (2024-25), £118.75 (2025-26).
From 6 April 2026 (Employment Rights Act 2025 ss.10 and 11, commenced by SI 2026/373 reg 2): - A PIW is **one day or more**; there are **no waiting days**; there is **no lower earnings limit**. - The weekly rate is the **lower of £123.25 and 80% of normal weekly earnings**. The 80% figure is kept exact and only the day amount is rounded, so a £123.33 earner gets £98.664 a week and £19.74 for one day of a five-day week.
Throughout: - PIWs separated by **8 weeks (56 days) or less** link into one (SSCBA 1992 s.152(3)); a gap of 57 days starts a new period of entitlement with fresh waiting days. "Separated by" counts the days in between: a spell ending on 5 June links to one starting on 1 August but not 2 August. - At most **28 weeks** of SSP per period of entitlement (s.155), counted as weeks of qualifying days, and a linked period of entitlement ends after **3 years** (SSP (General) Regulations 1982 reg 3(3)). `entitlementExhausted` says a qualifying day went unpaid for either reason. - The amount for k paid days in a week of q qualifying days is `ceil(weekly rate x k / q)` to the penny. That reproduces HMRC's published daily tables exactly (for example £47.50 for 2 of 5 days at £118.75, £16.68 for 1 of 7 days at £116.75).
## Spells across 6 April 2026
SI 2026/373 regs 3 to 5 have transitional rules. This capability models them only as far as "each day uses the rule in force on that day": waiting days served on or before 5 April stay waiting days, and qualifying days from 6 April are paid (which is what reg 3 provides). Reg 4 (employees earning £125 to £154.05 whose entitlement began before 6 April keep the flat £123.25 rather than 80%) and reg 5 (a period that did not qualify because of the LEL) are **not** modelled: the caller must handle those cases.
## What the caller must supply or decide
- Normal weekly earnings, worked out over the 8-week relevant period (SSP (General) Regulations 1982 reg 19). One figure is used for every spell. - The qualifying days: one fixed weekly pattern (at least one a week, s.154(3)). - Whether the employee qualifies at all in other respects (maternity disqualification, custody, contract ended, 12-week ESA link, agency rules): none of that is checked here. - A week in which the rule changes gets one line per rule, so a rate rise on a Monday gives the Sunday line under the old rule and the rest under the new.
A build installed with `history=current` keeps only the rule still in force and refuses earlier dates rather than paying them at today's rate.
## Sources
- HMRC, "Rates and thresholds for employers" 2023 to 2024, 2024 to 2025, 2025 to 2026 and 2026 to 2027 (SSP weekly rates, LELs and the daily-rate tables): https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2023-to-2024, https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2024-to-2025, https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2025-to-2026, https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2026-to-2027 - GOV.UK, "Statutory Sick Pay (SSP): employer guide" (£123.25 or 80% of AWE; linked periods 8 weeks or less apart; paid from the first full working day): https://www.gov.uk/employers-sick-pay - Social Security Contributions and Benefits Act 1992 ss.152-155 and 157 as amended: https://www.legislation.gov.uk/ukpga/1992/4/section/152, https://www.legislation.gov.uk/ukpga/1992/4/section/155 - Employment Rights Act 2025 ss.10 and 11: https://www.legislation.gov.uk/ukpga/2025/36/section/10, https://www.legislation.gov.uk/ukpga/2025/36/section/11 - The Employment Rights Act 2025 (Commencement No. 3 and Transitional Provisions) Regulations 2026 (SI 2026/373): https://www.legislation.gov.uk/uksi/2026/373/made - The Social Security Benefits Up-rating Order 2026 (SI 2026/148) art. 8 (£123.25): https://www.legislation.gov.uk/uksi/2026/148/made - Statutory Sick Pay (General) Regulations 1982 regs 3(3) and 19: https://www.legislation.gov.uk/uksi/1982/894/regulation/3
## 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 specialist 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 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.
## 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.1 marks it unreviewed and adds its attribution notices (NOTICE). The code and the tests are unchanged.
Files
| Path | Bytes |
|---|---|
| NOTICE | 239 |
| README.md | 6,495 |
| data/ssp-rules.json | 1,166 |
| impl/python.py | 6,359 |
| impl/rust.rs | 8,616 |
| impl/typescript.ts | 6,270 |
| vectors.json | 15,544 |