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::dates_day_of_week::day_of_week; use super::math_round_div::round_div; use super::money_amount::{money, money_from_value, money_to_value, Money}; use super::payroll_statutory_sick_pay_data::{SspRule, SSP_RULES, SSP_RULES_HISTORY}; /// Periods of incapacity separated by no more than 8 weeks are one period (SSCBA 1992 s.152(3)). const LINK_GAP_DAYS: i64 = 56; /// 28 weeks' entitlement, counted in 1/420ths of a week so any 1-7 qualifying-day week divides exactly. const WEEK_UNITS: i64 = 420; const MAX_UNITS: i64 = 28 * WEEK_UNITS; fn rule_for(date: &str) -> usize { for (i, rule) in SSP_RULES.iter().enumerate() { if date >= rule.valid_from && rule.valid_to.map_or(true, |to| date <= to) { return i; } } if SSP_RULES_HISTORY != "full" { if let Some(earliest) = SSP_RULES.iter().map(|r| r.valid_from).min() { if date < earliest { panic!( "no SSP rule for {}: this build was installed with history={}, so it only carries rules from {}. Reinstall with history=full for older sickness.", date, SSP_RULES_HISTORY, earliest ); } } } panic!("no SSP rule for {}", date) } struct Line { week_start: String, rule: usize, paid: i64, waiting: i64, } fn line_for<'a>(lines: &'a mut Vec, week_start: &str, rule: usize) -> &'a mut Line { let position = lines.iter().position(|l| l.week_start == week_start && l.rule == rule); match position { Some(i) => &mut lines[i], None => { lines.push(Line { week_start: week_start.to_string(), rule, paid: 0, waiting: 0 }); let last = lines.len() - 1; &mut lines[last] } } } /// Statutory Sick Pay due for a run of sickness spells. /// /// Every day is judged by the rules in force on that day, which is how the /// 6 April 2026 reforms land: a spell straddling that date serves waiting days /// before it and is paid from the first qualifying day after it. /// /// # Panics /// Panics on bad qualifying days, malformed or out-of-order periods, negative /// or non-GBP earnings, or a day no rule covers. pub fn statutory_sick_pay(periods: &[SicknessPeriod], qualifying_weekdays: &[i64], normal_weekly_earnings: &Money) -> SickPay { if qualifying_weekdays.is_empty() { panic!("there must be at least one qualifying day a week"); } let mut qualifying: Vec = Vec::new(); for &d in qualifying_weekdays { if !(1..=7).contains(&d) { panic!("qualifying weekdays must be 1 (Monday) to 7 (Sunday), received {}", d); } if qualifying.contains(&d) { panic!("qualifying weekday {} is listed twice", d); } qualifying.push(d); } let per_week = qualifying.len() as i64; if normal_weekly_earnings.currency != "GBP" { panic!("SSP is paid in GBP, received {}", normal_weekly_earnings.currency); } if normal_weekly_earnings.minor < 0 { panic!("normalWeeklyEarnings must not be negative"); } let mut lines: Vec = Vec::new(); let mut previous_end: Option = None; let mut last_piw_end: Option = None; let mut series_end = String::new(); let mut waiting_served = 0i64; let mut units_used = 0i64; let mut exhausted = false; for period in periods { parse_iso_date(&period.start); parse_iso_date(&period.end); if period.end < period.start { panic!("sickness period {} to {} ends before it starts", period.start, period.end); } if let Some(prev) = &previous_end { if period.start <= *prev { panic!("sickness periods must be in date order and must not overlap"); } } previous_end = Some(period.end.clone()); // Too short to be a period of incapacity under the rules of its first // day: it is not paid and does not link anything. let length = days_between(&period.start, &period.end) + 1; if length < SSP_RULES[rule_for(&period.start)].minimum_period_days { continue; } let new_series = match &last_piw_end { None => true, Some(end) => days_between(end, &period.start) - 1 > LINK_GAP_DAYS, }; if new_series { // A new period of entitlement: waiting days and the 28 weeks start // again, and it ends 3 years on at the latest (SSP (General) Regs 1982 reg 3(3)). waiting_served = 0; units_used = 0; series_end = add_months(&period.start, 36); } last_piw_end = Some(period.end.clone()); let mut day = period.start.clone(); while day <= period.end { let current = day.clone(); day = add_days(¤t, 1); let weekday = day_of_week(¤t); if !qualifying.contains(&weekday) { continue; } let index = rule_for(¤t); let rule = &SSP_RULES[index]; if let Some(lel) = rule.lower_earnings_limit { if normal_weekly_earnings.minor < lel { continue; } } let week_start = add_days(¤t, -(weekday % 7)); if waiting_served < rule.waiting_days { waiting_served += 1; line_for(&mut lines, &week_start, index).waiting += 1; continue; } let units = WEEK_UNITS / per_week; if current >= series_end || units_used + units > MAX_UNITS { exhausted = true; continue; } units_used += units; line_for(&mut lines, &week_start, index).paid += 1; } } lines.sort_by(|a, b| a.week_start.cmp(&b.week_start).then(a.rule.cmp(&b.rule))); let weeks: Vec = lines .iter() .map(|l| SickPayWeek { week_start: l.week_start.clone(), paid_days: l.paid, waiting_days: l.waiting, amount: money(week_amount(&SSP_RULES[l.rule], normal_weekly_earnings.minor, l.paid, per_week), "GBP"), }) .collect(); SickPay { total: money(weeks.iter().map(|w| w.amount.minor).sum(), "GBP"), paid_days: weeks.iter().map(|w| w.paid_days).sum(), waiting_days: weeks.iter().map(|w| w.waiting_days).sum(), weeks, entitlement_exhausted: exhausted, } } /// SSP for some days of one week: the weekly rate times days over qualifying /// days in the week, rounded up to the penny, as HMRC's daily-rate tables do. /// The weekly rate is kept exact (in ten-thousandths of a penny) so that 80% of /// earnings is never rounded twice. fn week_amount(rule: &SspRule, earnings: i64, days: i64, per_week: i64) -> i64 { let mut rate = rule.weekly_rate * 10000; if let Some(cap) = rule.earnings_cap_basis_points { rate = rate.min(earnings * cap); } round_div(rate * days, 10000 * per_week, "up") } pub fn sick_pay_to_value(pay: &SickPay) -> Value { Value::obj(vec![ ("total", money_to_value(&pay.total)), ("paidDays", Value::Int(pay.paid_days)), ("waitingDays", Value::Int(pay.waiting_days)), ( "weeks", Value::Arr( pay.weeks .iter() .map(|w| { Value::obj(vec![ ("weekStart", Value::str(&w.week_start)), ("paidDays", Value::Int(w.paid_days)), ("waitingDays", Value::Int(w.waiting_days)), ("amount", money_to_value(&w.amount)), ]) }) .collect(), ), ), ("entitlementExhausted", Value::Bool(pay.entitlement_exhausted)), ]) } pub fn fune_vector(args: &[Value]) -> Value { let periods: Vec = args[0] .as_arr() .iter() .map(|p| SicknessPeriod { start: p.get("start").as_str().to_string(), end: p.get("end").as_str().to_string(), }) .collect(); let weekdays: Vec = args[1].as_arr().iter().map(|d| d.as_i64()).collect(); sick_pay_to_value(&statutory_sick_pay(&periods, &weekdays, &money_from_value(&args[2]))) }