Functional Weave
Code in TypeScript

payroll.holiday-entitlement@1.0.1

impl/rust.rs

5,072 bytes · the Rust 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.

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()))
}