Functional Weave
Code in TypeScript

payroll.statutory-sick-pay@1.0.1

impl/rust.rs

8,616 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::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(&current, 1);
            let weekday = day_of_week(&current);
            if !qualifying.contains(&weekday) {
                continue;
            }
            let index = rule_for(&current);
            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(&current, -(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])))
}