Functional Weave
Code in TypeScript

payroll.holiday-entitlement@1.0.0

impl/typescript.ts

3,694 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 { 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 { roundDiv } from "./math_round_div.ts";  ← from math.round-div ^1.0.0 · built alongside by fune
import { HOLIDAY_RULES, HOLIDAY_RULES_HISTORY, type HolidayRule } from "./payroll_holiday_entitlement_data.ts";  ← this capability’s own data, compiled from data/holiday-rules.json into the same file by fune build
import { type HolidayEntitlement, type HolidayWorker } from "./payroll_holiday_entitlement_types.ts";

function ruleFor(basis: string, leaveYearStart: string): HolidayRule {
  let best: HolidayRule | null = null;
  for (const rule of HOLIDAY_RULES) {
    if (rule.basis !== basis || leaveYearStart < rule.validFrom) continue;
    if (rule.validTo !== null && leaveYearStart > rule.validTo) continue;
    if (best === null || rule.validFrom > best.validFrom) best = rule;
  }
  if (best === null) {
    const what = basis === "accrual" ? "12.07% accrual rule" : "5.6-week rule";
    if (HOLIDAY_RULES_HISTORY !== "full") {
      throw new RangeError(`no holiday ${what} for a leave year beginning ${leaveYearStart}: this build was installed with history=${HOLIDAY_RULES_HISTORY}. Reinstall with history=full for older leave years.`);
    }
    throw new RangeError(`no holiday ${what} for a leave year beginning ${leaveYearStart}`);
  }
  return best;
}

/**
 * Statutory annual leave under the Working Time Regulations 1998.
 *
 * Regular workers get 5.6 weeks of their working days, capped at 28 days, and
 * a proportion of it for a leave year they only partly work. Irregular-hours
 * and part-year workers, for leave years from 1 April 2024, instead accrue
 * 12.07% of the hours they work each pay period, to the nearest whole hour.
 */
export function holidayEntitlement(worker: HolidayWorker, leaveYearStart: string): HolidayEntitlement {
  parseIsoDate(leaveYearStart);
  if (worker.kind === "irregular-hours" || worker.kind === "part-year") {
    const rule = ruleFor("accrual", leaveYearStart);
    if (!Number.isInteger(worker.minutesWorked) || worker.minutesWorked < 0) {
      throw new RangeError(`minutesWorked must be a whole number of minutes, not negative, received ${worker.minutesWorked}`);
    }
    // reg 15B(5): under 30 minutes of a fraction is dropped, 30 or more is a whole hour.
    const scaled = worker.minutesWorked * rule.accrualBasisPoints;
    const hour = 60 * 10000;
    let hours = Math.floor(scaled / hour);
    if (scaled - hours * hour >= hour / 2) hours += 1;
    return { kind: worker.kind, daysX100: 0, capped: false, hoursAccrued: hours };
  }
  if (worker.kind !== "regular") throw new RangeError(`unknown kind of worker "${worker.kind}"`);

  const rule = ruleFor("weeks", leaveYearStart);
  const perWeek = worker.daysPerWeekX100;
  if (!Number.isInteger(perWeek) || perWeek < 1 || perWeek > 700) {
    throw new RangeError(`daysPerWeekX100 must be 1 to 700, received ${perWeek}`);
  }
  // In thousandths of a day: 5.6 weeks of 5 days is 28000.
  const raw = rule.weeksX10 * perWeek;
  const cap = rule.capDays * 1000;
  const full = Math.min(raw, cap);

  const nextYear = addMonths(leaveYearStart, 12);
  const yearEnd = addDays(nextYear, -1);
  const from = worker.employmentStart !== null && worker.employmentStart > leaveYearStart ? worker.employmentStart : leaveYearStart;
  const to = worker.employmentEnd !== null && worker.employmentEnd < yearEnd ? worker.employmentEnd : yearEnd;
  parseIsoDate(from);
  parseIsoDate(to);
  if (to < from) throw new RangeError(`employment ${from} to ${to} does not overlap the leave year`);
  const worked = daysBetween(from, to) + 1;
  const yearDays = daysBetween(leaveYearStart, nextYear);

  return { kind: "regular", daysX100: roundDiv(full * worked, yearDays * 10, "up"), capped: raw > cap, hoursAccrued: 0 };
}