Functional Weave
Code in TypeScript

payroll.statutory-sick-pay@1.0.1

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");
}