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::dates_day_of_week::day_of_week; ← from dates.day-of-week ^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_statutory_sick_pay_data::{SspRule, SSP_RULES, SSP_RULES_HISTORY}; ← this capability’s own data, compiled from data/ssp-rules.json into the same file by fune build
/// 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<Line>, 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<i64> = 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<Line> = Vec::new();
let mut previous_end: Option<String> = None;
let mut last_piw_end: Option<String> = 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<SickPayWeek> = 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<SicknessPeriod> = 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<i64> = 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])))
}