payroll.statutory-sick-pay@1.0.0
impl/typescript.ts
6,270 bytes · the TypeScript implementation · view 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 { 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");
}