payroll.holiday-pay-accrual Unreviewed
UK holiday pay: 12.07% rolled-up holiday pay, or a week's pay averaged over the last 52 paid weeks.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 18 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
Holiday pay under the Working Time Regulations 1998 (WTR), by the two methods payroll actually uses.
## `rolled-up`: the 12.07% uplift
For example
holiday_pay(rolled-up, £300.00, , 2024-04-01)→ amount £36.21, weeks counted 0 rolled-up: 12.07% of £300 is £36.21holiday_pay(rolled-up, £123.45, , 2025-01-01)→ amount £14.90, weeks counted 0 rolled-up: 12.07% of £123.45 is £14.900415, rounded to £14.90holiday_pay(rolled-up, £50.00, , 2025-01-01)→ amount £6.04, weeks counted 0 rolled-up: half a penny rounds up (£50 gives £6.035)
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_pay(method: &str, period_pay: &Money, weekly_pay: &[Money], on_date: &str) -> HolidayPay
| method | HolidayPayMethod | rolled-up: 12.07% uplift on this pay period; average-week: a week's pay from the reference period |
| period_pay | Money | rolled-up: pay for work done in this pay period; ignored for average-week |
| weekly_pay | Money[] | average-week: pay for each past week, most recent first, a zero for a week with no pay; ignored for rolled-up |
| on_date | date | rolled-up: first day of the worker's leave year; average-week: first day of the leave being paid |
| returns | HolidayPay |
The types it declares, generated into your project
// HolidayPayMethod is a string in Rust, one of: "rolled-up", "average-week".
// Parameters take it as &str and results hold it as String.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HolidayPay {
pub amount: Money,
/// average-week: weeks the average was taken over; 0 for rolled-up
pub weeks_counted: i64,
}
Your code names it in one line, in the file that uses it
fune!(payroll.holiday-pay-accrual@^1); // then call holiday_pay(…)
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::parse_iso_date; ← from dates.add-days ^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::money_amount::{money, money_from_value, money_to_value, Money}; ← from money.amount ^1.0.0 · built alongside by fune
use super::payroll_holiday_pay_accrual_data::{HolidayPayRule, HOLIDAY_PAY_RULES, HOLIDAY_PAY_RULES_HISTORY}; ← this capability’s own data, compiled from data/holiday-pay-rules.json into the same file by fune build
fn rule_for(method: &str, on_date: &str) -> &'static HolidayPayRule {
let mut best: Option<&'static HolidayPayRule> = None;
for rule in HOLIDAY_PAY_RULES {
if rule.method != method || on_date < rule.valid_from {
continue;
}
if let Some(to) = rule.valid_to {
if on_date > to {
continue;
}
}
if best.map_or(true, |b| rule.valid_from > b.valid_from) {
best = Some(rule);
}
}
match best {
Some(rule) => rule,
None => {
if HOLIDAY_PAY_RULES_HISTORY != "full" {
panic!(
"no {} holiday pay rule for {}: this build was installed with history={}. Reinstall with history=full for older dates.",
method, on_date, HOLIDAY_PAY_RULES_HISTORY
);
}
panic!("no {} holiday pay rule for {}", method, on_date)
}
}
}
/// Holiday pay under the Working Time Regulations 1998, by one of two methods.
///
/// Rolled-up: a 12.07% uplift on pay for work done, paid with it, allowed only
/// for irregular-hours and part-year workers in leave years from 1 April 2024.
/// Average week: a week's pay as the average over the most recent 52 weeks in
/// which the worker was paid, skipping unpaid weeks but never looking back more
/// than 104 weeks.
///
/// # Panics
/// Panics on negative pay, mixed currencies, no paid week, a malformed date,
/// an unknown method, or a date no rule covers.
pub fn holiday_pay(method: &str, period_pay: &Money, weekly_pay: &[Money], on_date: &str) -> HolidayPay {
parse_iso_date(on_date);
if method == "rolled-up" {
let rule = rule_for(method, on_date);
if period_pay.minor < 0 {
panic!("periodPay must not be negative");
}
return HolidayPay {
amount: money(round_div(period_pay.minor * rule.accrual_basis_points, 10000, "half-up"), &period_pay.currency),
weeks_counted: 0,
};
}
if method != "average-week" {
panic!("unknown holiday pay method \"{}\"", method);
}
let rule = rule_for(method, on_date);
let mut total = 0i64;
let mut counted = 0i64;
let mut currency: Option<String> = None;
let window = weekly_pay.len().min(rule.lookback_weeks as usize);
for week in &weekly_pay[..window] {
if counted >= rule.reference_weeks {
break;
}
match ¤cy {
None => currency = Some(week.currency.clone()),
Some(c) => {
if *c != week.currency {
panic!("currency mismatch: {} and {}", c, week.currency);
}
}
}
if week.minor < 0 {
panic!("weekly pay must not be negative");
}
if week.minor == 0 {
continue;
}
total += week.minor;
counted += 1;
}
match currency {
Some(c) if counted > 0 => HolidayPay { amount: money(round_div(total, counted, "half-up"), &c), weeks_counted: counted },
_ => panic!("no week with pay in the last {} weeks", rule.lookback_weeks),
}
}
pub fn holiday_pay_to_value(pay: &HolidayPay) -> Value {
Value::obj(vec![
("amount", money_to_value(&pay.amount)),
("weeksCounted", Value::Int(pay.weeks_counted)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let weeks: Vec<Money> = args[2].as_arr().iter().map(money_from_value).collect();
holiday_pay_to_value(&holiday_pay(args[0].as_str(), &money_from_value(&args[1]), &weeks, args[3].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-pay-accrual
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./payroll.holiday-pay-accrual-1.0.1-rust.fune, or fetch it from a terminal with fune pull payroll.holiday-pay-accrual@1.0.1:rust.
The whole function, every language, is one file too: payroll.holiday-pay-accrual-1.0.1.fune, 40,071 bytes, sha256 728a430cc5fb2f72f87a2b6d207ae3bb653e159163886445e4e01d65cdfa8f69. 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-pay-accrual
after — your function gets the result and the arguments, and returns the final result.
// fune: after payroll.holiday-pay-accrual
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-pay-accrual
// fune: replace math.round-div in payroll.holiday-pay-accrual
// fune: replace money.amount in payroll.holiday-pay-accrual
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-pay-accrual --steps.
// fune: step payroll.holiday-pay-accrual 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 | |
|---|---|---|---|
| rolled-up: 12.07% of £300 is £36.21 | rolled-up, £300.00, , 2024-04-01 | → | amount £36.21, weeks counted 0 |
| rolled-up: 12.07% of £123.45 is £14.900415, rounded to £14.90 | rolled-up, £123.45, , 2025-01-01 | → | amount £14.90, weeks counted 0 |
| rolled-up: half a penny rounds up (£50 gives £6.035) | rolled-up, £50.00, , 2025-01-01 | → | amount £6.04, weeks counted 0 |
| rolled-up: nothing earned, nothing added | rolled-up, £0.00, , 2025-01-01 | → | amount £0.00, weeks counted 0 |
| rolled-up holiday pay is not lawful for a leave year that began before 1 April 2024 | rolled-up, £300.00, , 2024-03-31 | → | error: no rolled-up holiday pay rule for 2024-03-31 |
| 52 weeks at £400 average £400 | average-week, £0.00, £400.00, £400.00, £400.00, £400.00, £400.00, £400.00, £400.00, £400.00, £400.00, £400.00, £400.00, £400.00, £400.00, £400.00, £400.00, £400.00, £400.00, £400.… | → | amount £400.00, weeks counted 52 |
| unpaid weeks are skipped and earlier weeks brought in to make 52 | average-week, £0.00, £0.00, £0.00, £300.00, £500.00, £300.00, £500.00, £300.00, £500.00, £300.00, £500.00, £300.00, £500.00, £300.00, £500.00, £300.00, £500.00, £300.00, £500.00, … | → | amount £400.00, weeks counted 52 |
| fewer than 52 paid weeks: average over those there are | average-week, £0.00, £500.00, £500.00, £0.00, £300.00, £300.00, £300.00, £400.00, £400.00, £400.00, £500.00, 2025-06-02 | → | amount £400.00, weeks counted 9 |
| no week further back than 104 counts, even to make up 52 | average-week, £0.00, £0.00, £0.00, £0.00, £0.00, £0.00, £0.00, £0.00, £0.00, £0.00, £0.00, £0.00, £0.00, £0.00, £0.00, £0.00, £0.00, £0.00, £0.00, £0.00, £0.00, £0.00, £0.00, £0.0… | → | amount £200.00, weeks counted 44 |
| the average is rounded to the nearest penny, half up | average-week, £0.00, £100.01, £100.00, 2025-06-02 | → | amount £100.01, weeks counted 2 |
Show the other 8 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a third of a penny rounds down | average-week, £0.00, £100.01, £100.00, £100.00, 2025-06-02 | → | amount £100.00, weeks counted 3 |
| two thirds of a penny rounds up | average-week, £0.00, £100.02, £100.00, £100.00, 2025-06-02 | → | amount £100.01, weeks counted 3 |
| no paid week in the window is an error | average-week, £0.00, £0.00, £0.00, £0.00, 2025-06-02 | → | error: no week with pay in the last 104 weeks |
| an empty history is an error | average-week, £0.00, , 2025-06-02 | → | error: no week with pay |
| negative weekly pay is an error | average-week, £0.00, £100.00, -£5.00, 2025-06-02 | → | error: weekly pay must not be negative |
| mixed currencies are an error | average-week, £0.00, £100.00, €100.00, 2025-06-02 | → | error: currency mismatch |
| the 52-week reference period did not exist before 6 April 2020 | average-week, £0.00, £100.00, 2020-04-05 | → | error: no average-week holiday pay rule for 2020-04-05 |
| negative period pay is an error | rolled-up, -£0.01, , 2025-01-01 | → | error: periodPay must not be negative |
More from the author
For irregular-hours and part-year workers, in leave years beginning on or after **1 April 2024**, holiday pay may be paid as a **12.07% uplift** on pay for work done, at the same time as that pay (reg 16A). 12.07% is 5.6 weeks of leave over the 46.4 weeks a year left for working. The uplift is rounded to the nearest penny, half up (the regulations do not say; this is the usual payroll rounding, stated so it is testable). `periodPay` should be every kind of pay reg 16 counts for a week's pay (reg 16A(9)): basic pay plus regular overtime, commission and similar. `onDate` is the first day of the worker's leave year; an earlier leave year is refused, because rolled-up holiday pay was not lawful then.
## `average-week`: a week's pay over the reference period
Where a week's pay varies, it is the **average over the most recent 52 weeks in which the worker was paid**, counting back from the week before the leave starts; weeks with no pay are skipped and earlier weeks brought in, but nothing more than **104 weeks** back counts (WTR reg 16(3)(e) and (f), in force from 6 April 2020). A worker employed under 52 weeks is averaged over the weeks they have. The average is rounded to the nearest penny, half up.
`weeklyPay` is one entry per week, **most recent first**, with 0 for a week in which nothing was paid. Leave out entirely any week the worker spent on sick or statutory leave (reg 16(3ZE)(b)), and pass only weeks within the employment. Entries beyond the 104th are ignored. `onDate` is the first day of the leave (the calculation date); it only selects the rule. Deciding which payments are part of a week's pay (reg 16(3ZA)) and, for regular-hours workers, whether a fixed week's pay applies instead, is the caller's.
The 12.07%, 52 and 104 are dated rows in `data/holiday-pay-rules.json`.
Entitlement, rather than pay, is `payroll.holiday-entitlement`.
## Sources
- The Working Time Regulations 1998 (SI 1998/1833) reg 16 (a week's pay, 52-week reference period, 104-week limit) and reg 16A (rolled-up holiday pay at 12.07%): https://www.legislation.gov.uk/uksi/1998/1833/regulation/16, https://www.legislation.gov.uk/uksi/1998/1833/regulation/16A - The Employment Rights (Employment Particulars and Paid Annual Leave) (Amendment) Regulations 2018 (SI 2018/1378), in force 6 April 2020 (12 weeks became 52): https://www.legislation.gov.uk/uksi/2018/1378/made - The Employment Rights (Amendment, Revocation and Transitional Provision) Regulations 2023 (SI 2023/1426) (reg 16A, from leave years beginning 1 April 2024): https://www.legislation.gov.uk/uksi/2023/1426/made - GOV.UK, "Holiday pay and entitlement reforms from 1 January 2024": https://www.gov.uk/government/publications/simplifying-holiday-entitlement-and-holiday-pay-calculations/holiday-pay-and-entitlement-reforms-from-1-january-2024
## 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,367 |
| data/holiday-pay-rules.json | 558 |
| impl/python.py | 3,055 |
| impl/rust.rs | 3,923 |
| impl/typescript.ts | 2,915 |
| vectors.json | 18,272 |