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
holiday_entitlement(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 cappedholiday_entitlement(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 28holiday_entitlement(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.
pub fn holiday_entitlement(worker: &HolidayWorker, leave_year_start: &str) -> HolidayEntitlement
| worker | HolidayWorker | |
| leave_year_start | date | first day of the worker's leave year; it decides which rules apply |
| returns | HolidayEntitlement |
The types it declares, generated into your project
// HolidayWorkerKind is a string in Rust, one of: "regular", "irregular-hours", "part-year".
// Parameters take it as &str and results hold it as String.
/// How the worker works, as the Working Time Regulations sort them.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HolidayWorker {
pub kind: String,
/// regular workers: days worked a week in hundredths (500 = 5 days, 250 = 2.5 days); 0 otherwise
pub days_per_week_x100: i64,
/// regular workers: first day of the job, when it falls inside the leave year
pub employment_start: Option<String>,
/// regular workers: last day of the job, when it falls inside the leave year
pub employment_end: Option<String>,
/// irregular-hours and part-year workers: minutes worked in the pay period; 0 otherwise
pub minutes_worked: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HolidayEntitlement {
pub kind: String,
/// regular workers: statutory days for the leave year in hundredths, rounded up; 0 for accrual
pub days_x100: i64,
/// regular workers: 5.6 weeks came to more than 28 days
pub capped: bool,
/// irregular-hours and part-year workers: whole hours accrued in the pay period; 0 for regular workers
pub hours_accrued: i64,
}
Your code names it in one line, in the file that uses it
fune!(payroll.holiday-entitlement@^1); // then call holiday_entitlement(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::{add_days, days_between, parse_iso_date}; ← from dates.add-days ^1.0.0 · built alongside by fune
use super::dates_add_months::add_months; ← from dates.add-months ^1.0.0 · built alongside by fune
use super::math_round_div::round_div; ← from math.round-div ^1.0.0 · built alongside by fune
use super::payroll_holiday_entitlement_data::{HolidayRule, HOLIDAY_RULES, HOLIDAY_RULES_HISTORY}; ← this capability’s own data, compiled from data/holiday-rules.json into the same file by fune build
fn rule_for(basis: &str, leave_year_start: &str) -> &'static HolidayRule {
let mut best: Option<&'static HolidayRule> = None;
for rule in HOLIDAY_RULES {
if rule.basis != basis || leave_year_start < rule.valid_from {
continue;
}
if let Some(to) = rule.valid_to {
if leave_year_start > to {
continue;
}
}
if best.map_or(true, |b| rule.valid_from > b.valid_from) {
best = Some(rule);
}
}
match best {
Some(rule) => rule,
None => {
let what = if basis == "accrual" { "12.07% accrual rule" } else { "5.6-week rule" };
if HOLIDAY_RULES_HISTORY != "full" {
panic!(
"no holiday {} for a leave year beginning {}: this build was installed with history={}. Reinstall with history=full for older leave years.",
what, leave_year_start, HOLIDAY_RULES_HISTORY
);
}
panic!("no holiday {} for a leave year beginning {}", what, leave_year_start)
}
}
}
/// 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.
///
/// # Panics
/// Panics on an out-of-range input, a malformed date, or a leave year no rule covers.
pub fn holiday_entitlement(worker: &HolidayWorker, leave_year_start: &str) -> HolidayEntitlement {
parse_iso_date(leave_year_start);
if worker.kind == "irregular-hours" || worker.kind == "part-year" {
let rule = rule_for("accrual", leave_year_start);
if worker.minutes_worked < 0 {
panic!(
"minutesWorked must be a whole number of minutes, not negative, received {}",
worker.minutes_worked
);
}
// reg 15B(5): under 30 minutes of a fraction is dropped, 30 or more is a whole hour.
let scaled = worker.minutes_worked * rule.accrual_basis_points;
let hour = 60 * 10000;
let mut hours = scaled / hour;
if scaled - hours * hour >= hour / 2 {
hours += 1;
}
return HolidayEntitlement { kind: worker.kind.clone(), days_x100: 0, capped: false, hours_accrued: hours };
}
if worker.kind != "regular" {
panic!("unknown kind of worker \"{}\"", worker.kind);
}
let rule = rule_for("weeks", leave_year_start);
let per_week = worker.days_per_week_x100;
if !(1..=700).contains(&per_week) {
panic!("daysPerWeekX100 must be 1 to 700, received {}", per_week);
}
// In thousandths of a day: 5.6 weeks of 5 days is 28000.
let raw = rule.weeks_x10 * per_week;
let cap = rule.cap_days * 1000;
let full = raw.min(cap);
let next_year = add_months(leave_year_start, 12);
let year_end = add_days(&next_year, -1);
let from = match &worker.employment_start {
Some(s) if s.as_str() > leave_year_start => s.clone(),
_ => leave_year_start.to_string(),
};
let to = match &worker.employment_end {
Some(e) if *e < year_end => e.clone(),
_ => year_end.clone(),
};
parse_iso_date(&from);
parse_iso_date(&to);
if to < from {
panic!("employment {} to {} does not overlap the leave year", from, to);
}
let worked = days_between(&from, &to) + 1;
let year_days = days_between(leave_year_start, &next_year);
HolidayEntitlement {
kind: "regular".to_string(),
days_x100: round_div(full * worked, year_days * 10, "up"),
capped: raw > cap,
hours_accrued: 0,
}
}
pub fn holiday_entitlement_to_value(e: &HolidayEntitlement) -> Value {
Value::obj(vec![
("kind", Value::str(&e.kind)),
("daysX100", Value::Int(e.days_x100)),
("capped", Value::Bool(e.capped)),
("hoursAccrued", Value::Int(e.hours_accrued)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let w = &args[0];
let optional = |key: &str| {
let v = w.get(key);
if v.is_null() {
None
} else {
Some(v.as_str().to_string())
}
};
let worker = HolidayWorker {
kind: w.get("kind").as_str().to_string(),
days_per_week_x100: w.get("daysPerWeekX100").as_i64(),
employment_start: optional("employmentStart"),
employment_end: optional("employmentEnd"),
minutes_worked: w.get("minutesWorked").as_i64(),
};
holiday_entitlement_to_value(&holiday_entitlement(&worker, args[1].as_str()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add payroll.holiday-entitlement
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./payroll.holiday-entitlement-1.0.1-rust.fune, or fetch it from a terminal with fune pull payroll.holiday-entitlement@1.0.1:rust.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| NOTICE | 240 |
| README.md | 4,635 |
| data/holiday-rules.json | 524 |
| impl/python.py | 3,842 |
| impl/rust.rs | 5,072 |
| impl/typescript.ts | 3,694 |
| vectors.json | 7,435 |