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