use super::funejson::Value; use super::dates_add_days::{add_days, days_between, parse_iso_date}; use super::dates_add_months::add_months; use super::math_round_div::round_div; use super::payroll_holiday_entitlement_data::{HolidayRule, HOLIDAY_RULES, HOLIDAY_RULES_HISTORY}; 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())) }