Functional Weave
Code in TypeScript

payroll.holiday-entitlement Unreviewed

UK statutory holiday: 5.6 weeks capped at 28 days and pro-rated, or 12.07% accrual for irregular hours.

1.0.1 · published 2026-10-03 by charlie · Anterra

Pinned by 22 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 annual leave in Great Britain under the Working Time Regulations 1998 (WTR). The regulations sort workers into two groups, and so does this function's `kind`.

## Regular workers (`regular`)

For example

  • holidayEntitlement(kind regular, days per week x100 500, employment start —, employment end —, minutes worked 0, 2025-01-01) → kind regular, days x100 2,800, capped false, hours accrued 0 five days a week for a full leave year is exactly 28 days, not capped
  • holidayEntitlement(kind regular, days per week x100 600, employment start —, employment end —, minutes worked 0, 2025-01-01) → kind regular, days x100 2,800, capped true, hours accrued 0 six days a week would be 33.6 days: capped at 28
  • holidayEntitlement(kind regular, days per week x100 250, employment start —, employment end —, minutes worked 0, 2025-01-01) → kind regular, days x100 1,400, capped false, hours accrued 0 two and a half days a week is 14 days

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 holidayEntitlement(worker: HolidayWorker, leaveYearStart: string): HolidayEntitlement
workerHolidayWorker
leaveYearStartdatefirst day of the worker's leave year; it decides which rules apply
returnsHolidayEntitlement

The types it declares, generated into your project

export type HolidayWorkerKind = "regular" | "irregular-hours" | "part-year";

/** How the worker works, as the Working Time Regulations sort them. */
export interface HolidayWorker {
  readonly kind: HolidayWorkerKind;
  /** regular workers: days worked a week in hundredths (500 = 5 days, 250 = 2.5 days); 0 otherwise */
  readonly daysPerWeekX100: number;
  /** regular workers: first day of the job, when it falls inside the leave year */
  readonly employmentStart: string | null;
  /** regular workers: last day of the job, when it falls inside the leave year */
  readonly employmentEnd: string | null;
  /** irregular-hours and part-year workers: minutes worked in the pay period; 0 otherwise */
  readonly minutesWorked: number;
}

export interface HolidayEntitlement {
  readonly kind: HolidayWorkerKind;
  /** regular workers: statutory days for the leave year in hundredths, rounded up; 0 for accrual */
  readonly daysX100: number;
  /** regular workers: 5.6 weeks came to more than 28 days */
  readonly capped: boolean;
  /** irregular-hours and part-year workers: whole hours accrued in the pay period; 0 for regular workers */
  readonly hoursAccrued: number;
}

Your code names it in one line, in the file that uses it

import { holidayEntitlement } from "#fune/payroll.holiday-entitlement@^1";
impl/typescript.ts · 69 lines · open · 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 };
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 3 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.holiday-entitlement
Download for TypeScript payroll.holiday-entitlement-1.0.1-typescript.fune · 21,860 bytes sha256 8364842493231d1bf6a38cfcae46975b069fd7e7dced904fbf380f5526c025ee

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./payroll.holiday-entitlement-1.0.1-typescript.fune, or fetch it from a terminal with fune pull payroll.holiday-entitlement@1.0.1:typescript.

The whole function, every language, is one file too: payroll.holiday-entitlement-1.0.1.fune, 31,100 bytes, sha256 f228bcbb8c052af0c0e89ea873ca50eca2da8288312857b44074edd44cbba9dc. 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.holiday-entitlement

after — your function gets the result and the arguments, and returns the final result.

// fune: after payroll.holiday-entitlement

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.holiday-entitlement
// fune: replace dates.add-months in payroll.holiday-entitlement
// fune: replace math.round-div in payroll.holiday-entitlement

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.holiday-entitlement --steps.

// fune: step payroll.holiday-entitlement 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.

CaseArgumentsExpected
five days a week for a full leave year is exactly 28 days, not capped kind regular, days per week x100 500, employment start —, employment end —, minutes worked 0, 2025-01-01 → kind regular, days x100 2,800, capped false, hours accrued 0
six days a week would be 33.6 days: capped at 28 kind regular, days per week x100 600, employment start —, employment end —, minutes worked 0, 2025-01-01 → kind regular, days x100 2,800, capped true, hours accrued 0
two and a half days a week is 14 days kind regular, days per week x100 250, employment start —, employment end —, minutes worked 0, 2025-01-01 → kind regular, days x100 1,400, capped false, hours accrued 0
three days a week is 16.8 days kind regular, days per week x100 300, employment start —, employment end —, minutes worked 0, 2025-01-01 → kind regular, days x100 1,680, capped false, hours accrued 0
four and a half days a week is 25.2 days kind regular, days per week x100 450, employment start —, employment end —, minutes worked 0, 2025-04-01 → kind regular, days x100 2,520, capped false, hours accrued 0
a starter on 13 January gets 353/365 of 28 days, rounded up to 27.08 kind regular, days per week x100 500, employment start 2025-01-13, employment end —, minutes worked 0, 2025-01-01 → kind regular, days x100 2,708, capped false, hours accrued 0
a leaver on 30 September gets 183/365 of 28 days, rounded up to 14.04 kind regular, days per week x100 500, employment start —, employment end 2025-09-30, minutes worked 0, 2025-04-01 → kind regular, days x100 1,404, capped false, hours accrued 0
a leap-year leave year has 366 days kind regular, days per week x100 500, employment start 2024-07-01, employment end —, minutes worked 0, 2024-01-01 → kind regular, days x100 1,408, capped false, hours accrued 0
starting and leaving in the same year: 92 days of 16.8 kind regular, days per week x100 300, employment start 2025-03-01, employment end 2025-05-31, minutes worked 0, 2025-01-01 → kind regular, days x100 424, capped false, hours accrued 0
a start date before the leave year counts from the leave year kind regular, days per week x100 500, employment start 2020-06-15, employment end —, minutes worked 0, 2025-01-01 → kind regular, days x100 2,800, capped false, hours accrued 0
Show the other 12 tests
CaseArgumentsExpected
30 hours worked accrues 3.621 hours: rounds to 4 kind irregular-hours, days per week x100 0, employment start —, employment end —, minutes worked 1,800, 2024-04-01 → kind irregular-hours, days x100 0, capped false, hours accrued 4
20 hours worked accrues 2.414 hours: rounds to 2 kind irregular-hours, days per week x100 0, employment start —, employment end —, minutes worked 1,200, 2025-01-01 → kind irregular-hours, days x100 0, capped false, hours accrued 2
a part-year worker accrues the same way kind part-year, days per week x100 0, employment start —, employment end —, minutes worked 1,500, 2025-09-01 → kind part-year, days x100 0, capped false, hours accrued 3
exactly half an hour over rounds up kind irregular-hours, days per week x100 0, employment start —, employment end —, minutes worked 300,000, 2025-01-01 → kind irregular-hours, days x100 0, capped false, hours accrued 604
just under half an hour over rounds down kind irregular-hours, days per week x100 0, employment start —, employment end —, minutes worked 299,999, 2025-01-01 → kind irregular-hours, days x100 0, capped false, hours accrued 603
no hours worked accrues nothing kind irregular-hours, days per week x100 0, employment start —, employment end —, minutes worked 0, 2025-01-01 → kind irregular-hours, days x100 0, capped false, hours accrued 0
12.07% accrual does not apply to a leave year that began before 1 April 2024 kind irregular-hours, days per week x100 0, employment start —, employment end —, minutes worked 1,800, 2024-03-31 → error: no holiday 12.07% accrual rule for a leave year beginning 2024-03-31
zero days a week is an error kind regular, days per week x100 0, employment start —, employment end —, minutes worked 0, 2025-01-01 → error: daysPerWeekX100 must be 1 to 700
more than seven days a week is an error kind regular, days per week x100 800, employment start —, employment end —, minutes worked 0, 2025-01-01 → error: daysPerWeekX100 must be 1 to 700
employment outside the leave year is an error kind regular, days per week x100 500, employment start 2026-02-01, employment end —, minutes worked 0, 2025-01-01 → error: does not overlap the leave year
negative minutes are an error kind irregular-hours, days per week x100 0, employment start —, employment end —, minutes worked -60, 2025-01-01 → error: minutesWorked must be a whole number of minutes
a malformed leave-year date is an error kind regular, days per week x100 500, employment start —, employment end —, minutes worked 0, 2025-13-01 → error: is not a real calendar date

More from the author

Regulations 13 and 13A: 4 weeks plus 1.6 weeks, so **5.6 weeks of the days the worker works**, with a **maximum of 28 days**. Five days a week is exactly 28 days; six days a week would be 33.6 and is capped to 28 (`capped` is true); 2.5 days a week is 14 days.

Days a week is given in hundredths (`daysPerWeekX100`, 250 = 2.5 days) and the answer is in hundredths of a day (`daysX100`), so part-time patterns are exact.

When employment starts or ends inside the leave year, the worker gets the proportion of the (capped) entitlement that matches the part of the leave year they are employed: days employed over days in the leave year, both ends inclusive (regs 13(5) and 13A(5) for starters; reg 14 uses the same proportion for leavers). The result is **rounded up** to the next hundredth of a day, because an employer may round a worker's leave up but never down. The leave year is 12 calendar months from `leaveYearStart`, so a leave year containing 29 February has 366 days.

Employers often use GOV.UK's simpler monthly accrual for a first year (one twelfth a month, rounded up to the next half day). That is a permitted alternative, not the statutory proportion, and is not what this returns.

## Irregular-hours and part-year workers (`irregular-hours`, `part-year`)

For **leave years beginning on or after 1 April 2024**, regulation 15B: on the last day of each pay period the worker accrues **12.07% of the hours worked in that period**. A fraction of an hour under 30 minutes counts as nothing; 30 minutes or more counts as a whole hour (reg 15B(5)). 30 hours worked accrues 3.621 hours, so 4 hours, which is GOV.UK's own example. The answer is whole hours for the pay period (`hoursAccrued`); keep the running balance yourself.

For an irregular-hours worker in a leave year that began before 1 April 2024 this refuses to answer: those years use the 5.6-week rule, averaged over a reference period, which needs a history this function does not take.

Not modelled for accrual workers: the 28-day annual ceiling (reg 15B(4)), which needs the employer's length of a day to convert into hours, and accrual during sick or statutory leave (reg 15C). Both are the caller's.

The weeks, the 28-day cap, the 12.07% and the 1 April 2024 start are dated rows in `data/holiday-rules.json`.

## Sources

- The Working Time Regulations 1998 (SI 1998/1833), regs 13, 13A, 15B and 15C as amended from 1 January 2024 by SI 2023/1426: https://www.legislation.gov.uk/uksi/1998/1833/regulation/13, https://www.legislation.gov.uk/uksi/1998/1833/regulation/13A, https://www.legislation.gov.uk/uksi/1998/1833/regulation/15B - The Employment Rights (Amendment, Revocation and Transitional Provision) Regulations 2023 (SI 2023/1426): https://www.legislation.gov.uk/uksi/2023/1426/made - GOV.UK, "Holiday entitlement: calculate leave entitlement" (12.07% of hours in the pay period, rounded to the nearest hour at 0.5; the 30-hour example; the 1 April 2024 start): https://www.gov.uk/holiday-entitlement-rights/calculate-leave-entitlement

## 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

PathBytes
NOTICE240
README.md4,635
data/holiday-rules.json524
impl/python.py3,842
impl/rust.rs5,072
impl/typescript.ts3,694
vectors.json7,435