Functional Weave
Code in TypeScript

payroll.national-insurance@1.0.2

impl/rust.rs

8,533 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::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_national_insurance_data::{
    NiPrimaryRate, NiSecondaryRate, NiThresholds, NI_PRIMARY_RATES, NI_PRIMARY_RATES_HISTORY,
    NI_PRIMARY_RATES_HORIZON, NI_SECONDARY_RATES, NI_SECONDARY_RATES_HISTORY,
    NI_SECONDARY_RATES_HORIZON, NI_THRESHOLDS, NI_THRESHOLDS_HISTORY, NI_THRESHOLDS_HORIZON,
};

fn is_iso_date(value: &str) -> bool {
    let bytes = value.as_bytes();
    bytes.len() == 10
        && bytes[4] == b'-'
        && bytes[7] == b'-'
        && bytes
            .iter()
            .enumerate()
            .all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit())
}

fn in_force(valid_from: &str, valid_to: Option<&str>, on_date: &str) -> bool {
    on_date >= valid_from && valid_to.map_or(true, |to| on_date <= to)
}

// A pruned build must refuse a date it no longer carries rules for rather than
// answer it with a later year's rates.
fn missing(what: &str, on_date: &str, history: &str, horizon: Option<&str>) -> ! {
    if let Some(horizon) = horizon {
        if history != "full" && on_date < horizon {
            panic!(
                "{} on {}: this build was installed with history={}, so it only carries rules from {}. Reinstall with history=full for earlier tax years.",
                what, on_date, history, horizon
            );
        }
    }
    panic!("{} on {}", what, on_date)
}

/// Earnings falling in (lower, upper]; upper None means no ceiling.
fn slice(earnings: i64, lower: i64, upper: Option<i64>) -> i64 {
    let top = match upper {
        Some(u) => earnings.min(u),
        None => earnings,
    };
    (top - lower).max(0)
}

/// (lel, pt, st, fust, ust, uel) for the earnings period.
fn limits(t: &NiThresholds, frequency: &str) -> [i64; 6] {
    match frequency {
        "annual" => [t.lel_annual, t.pt_annual, t.st_annual, t.fust_annual, t.ust_annual, t.uel_annual],
        "monthly" => [t.lel_monthly, t.pt_monthly, t.st_monthly, t.fust_monthly, t.ust_monthly, t.uel_monthly],
        _ => {
            // HMRC's CA38: for pay in multiples of a week, work on the weekly
            // figures and multiply by the number of weeks.
            let k = match frequency {
                "weekly" => 1,
                "fortnightly" => 2,
                "four-weekly" => 4,
                other => panic!("unknown pay frequency \"{}\"", other),
            };
            [
                t.lel_weekly * k,
                t.pt_weekly * k,
                t.st_weekly * k,
                t.fust_weekly * k,
                t.ust_weekly * k,
                t.uel_weekly * k,
            ]
        }
    }
}

// Regulation 12(1) SSCR 2001: primary and secondary are worked out separately
// and each total is rounded to the nearest penny, a half penny going up.
fn contributions(earnings: i64, l: &[i64; 6], p: &NiPrimaryRate, s: &NiSecondaryRate) -> (i64, i64) {
    let [_lel, pt, st, fust, ust, uel] = *l;
    let primary = slice(earnings, pt, Some(uel)) * p.pt_to_uel_basis_points
        + slice(earnings, uel, None) * p.above_uel_basis_points;
    let secondary = slice(earnings, st, Some(fust)) * s.st_to_fust_basis_points
        + slice(earnings, st.max(fust), Some(ust)) * s.fust_to_ust_basis_points
        + slice(earnings, st.max(ust), None) * s.above_ust_basis_points;
    (
        round_div(primary, 10000, "half-up"),
        round_div(secondary, 10000, "half-up"),
    )
}

/// Class 1 National Insurance on one payment, by the exact percentage method.
///
/// With `director` None this is the ordinary earnings-period calculation. With
/// it, the director's annual earnings period: contributions on everything paid
/// so far this tax year at the annual thresholds, less what has already been
/// paid, so the amount can go down (or negative) as well as up.
///
/// # Panics
/// Panics on non-GBP or negative earnings, an unknown frequency or category,
/// or a date no rules cover.
pub fn national_insurance(
    earnings: &Money,
    category: &str,
    frequency: &str,
    pay_date: &str,
    director: Option<&DirectorNi>,
) -> NationalInsurance {
    if earnings.currency != "GBP" {
        panic!("National Insurance must be in GBP, received {}", earnings.currency);
    }
    if earnings.minor < 0 {
        panic!("earnings must not be negative, received {}", earnings.minor);
    }
    if !matches!(frequency, "weekly" | "fortnightly" | "four-weekly" | "monthly") {
        panic!("unknown pay frequency \"{}\"", frequency);
    }
    if !is_iso_date(pay_date) {
        panic!("payDate must be an ISO date (YYYY-MM-DD), received \"{}\"", pay_date);
    }

    let thresholds = match NI_THRESHOLDS.iter().find(|t| in_force(t.valid_from, t.valid_to, pay_date)) {
        Some(t) => t,
        None => missing("no National Insurance thresholds", pay_date, NI_THRESHOLDS_HISTORY, NI_THRESHOLDS_HORIZON),
    };
    let primary_for = |basis: &str| {
        NI_PRIMARY_RATES
            .iter()
            .find(|r| r.category == category && r.basis == basis && in_force(r.valid_from, r.valid_to, pay_date))
    };
    // Directors only have rates of their own in a year the main rate changed
    // mid-year (2023-24); otherwise they pay the ordinary rates.
    let director_rate = if director.is_some() { primary_for("director") } else { None };
    let primary = match director_rate.or_else(|| primary_for("standard")) {
        Some(r) => r,
        None => missing(
            &format!("no National Insurance rates for category {}", category),
            pay_date,
            NI_PRIMARY_RATES_HISTORY,
            NI_PRIMARY_RATES_HORIZON,
        ),
    };
    let secondary = match NI_SECONDARY_RATES
        .iter()
        .find(|r| r.category == category && in_force(r.valid_from, r.valid_to, pay_date))
    {
        Some(r) => r,
        None => missing(
            &format!("no National Insurance rates for category {}", category),
            pay_date,
            NI_SECONDARY_RATES_HISTORY,
            NI_SECONDARY_RATES_HORIZON,
        ),
    };

    match director {
        None => {
            let l = limits(thresholds, frequency);
            let (employee, employer) = contributions(earnings.minor, &l, primary, secondary);
            NationalInsurance {
                employee: money(employee, "GBP"),
                employer: money(employer, "GBP"),
                lower_earnings_limit_reached: earnings.minor >= l[0],
            }
        }
        Some(d) => {
            for (name, value) in [
                ("previousEarnings", &d.previous_earnings),
                ("previousEmployee", &d.previous_employee),
                ("previousEmployer", &d.previous_employer),
            ] {
                if value.currency != "GBP" {
                    panic!("National Insurance must be in GBP, received {} for {}", value.currency, name);
                }
            }
            let cumulative = d.previous_earnings.minor + earnings.minor;
            if cumulative < 0 {
                panic!("earnings must not be negative, received {} to date", cumulative);
            }
            let l = limits(thresholds, "annual");
            let (employee, employer) = contributions(cumulative, &l, primary, secondary);
            NationalInsurance {
                employee: money(employee - d.previous_employee.minor, "GBP"),
                employer: money(employer - d.previous_employer.minor, "GBP"),
                lower_earnings_limit_reached: cumulative >= l[0],
            }
        }
    }
}

pub fn director_ni_from_value(v: &Value) -> DirectorNi {
    DirectorNi {
        previous_earnings: money_from_value(v.get("previousEarnings")),
        previous_employee: money_from_value(v.get("previousEmployee")),
        previous_employer: money_from_value(v.get("previousEmployer")),
    }
}

pub fn national_insurance_to_value(ni: &NationalInsurance) -> Value {
    Value::obj(vec![
        ("employee", money_to_value(&ni.employee)),
        ("employer", money_to_value(&ni.employer)),
        ("lowerEarningsLimitReached", Value::Bool(ni.lower_earnings_limit_reached)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let director = if args[4].is_null() { None } else { Some(director_ni_from_value(&args[4])) };
    national_insurance_to_value(&national_insurance(
        &money_from_value(&args[0]),
        args[1].as_str(),
        args[2].as_str(),
        args[3].as_str(),
        director.as_ref(),
    ))
}