Functional Weave
Code in Python

payroll.pension-auto-enrolment@1.0.2

impl/rust.rs

6,526 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_pension_auto_enrolment_data::{
    AE_MINIMUMS, AE_MINIMUMS_HISTORY, AE_MINIMUMS_HORIZON, AE_THRESHOLDS, AE_THRESHOLDS_HISTORY,
    AE_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 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)
}

fn check_rate(name: &str, value: Option<i64>) {
    if let Some(v) = value {
        if v < 0 {
            panic!("{} must be a whole number of basis points, 0 or more, received {}", name, v);
        }
    }
}

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)
}

/// Workplace pension contributions for one pay reference period.
///
/// The band is applied with the published per-period figures (£520 to £4,189 a
/// month, £120 to £967 a week), never the annual band divided on the fly. Each
/// contribution is rounded once to the nearest penny, half up; under relief at
/// source the deduction from pay is then 80% of the gross contribution, again
/// rounded half up, and the difference is the relief the provider claims.
///
/// # Panics
/// Panics on non-GBP or negative earnings, negative rates, an unknown basis,
/// arrangement or frequency, or a date no thresholds cover.
pub fn pension_contributions(earnings: &Money, frequency: &str, scheme: &PensionScheme, pay_date: &str) -> PensionContributions {
    if earnings.currency != "GBP" {
        panic!("pension contributions must be in GBP, received {}", earnings.currency);
    }
    if earnings.minor < 0 {
        panic!("earnings must not be negative, received {}", earnings.minor);
    }
    check_rate("employeeBasisPoints", scheme.employee_basis_points);
    check_rate("employerBasisPoints", scheme.employer_basis_points);
    if !matches!(scheme.earnings_basis.as_str(), "qualifying-earnings" | "all-earnings") {
        panic!("unknown earnings basis \"{}\"", scheme.earnings_basis);
    }
    if !matches!(scheme.arrangement.as_str(), "relief-at-source" | "net-pay") {
        panic!("unknown relief arrangement \"{}\"", scheme.arrangement);
    }
    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 t = match AE_THRESHOLDS.iter().find(|r| in_force(r.valid_from, r.valid_to, pay_date)) {
        Some(t) => t,
        None => missing("no auto-enrolment earnings thresholds", pay_date, AE_THRESHOLDS_HISTORY, AE_THRESHOLDS_HORIZON),
    };
    let m = match AE_MINIMUMS.iter().find(|r| in_force(r.valid_from, r.valid_to, pay_date)) {
        Some(m) => m,
        None => missing("no auto-enrolment minimum contributions", pay_date, AE_MINIMUMS_HISTORY, AE_MINIMUMS_HORIZON),
    };

    let (lower, trigger, upper) = match frequency {
        "weekly" => (t.lower_weekly, t.trigger_weekly, t.upper_weekly),
        "fortnightly" => (t.lower_fortnightly, t.trigger_fortnightly, t.upper_fortnightly),
        "four-weekly" => (t.lower_four_weekly, t.trigger_four_weekly, t.upper_four_weekly),
        _ => (t.lower_monthly, t.trigger_monthly, t.upper_monthly),
    };

    // Qualifying earnings are the part of pay above the lower level and not
    // above the upper one (Pensions Act 2008 s.13).
    let pensionable = if scheme.earnings_basis == "all-earnings" {
        earnings.minor
    } else {
        (earnings.minor.min(upper) - lower).max(0)
    };
    let employer_rate = scheme.employer_basis_points.unwrap_or(m.employer_basis_points);
    let employee_rate = scheme
        .employee_basis_points
        .unwrap_or_else(|| (m.total_basis_points - employer_rate).max(0));

    let employee_gross = round_div(pensionable * employee_rate, 10000, "half-up");
    let employer = round_div(pensionable * employer_rate, 10000, "half-up");
    let net_pay = scheme.arrangement == "net-pay";
    let deduction = if net_pay {
        employee_gross
    } else {
        round_div(employee_gross * (10000 - m.relief_at_source_basis_points), 10000, "half-up")
    };

    PensionContributions {
        pensionable_earnings: money(pensionable, "GBP"),
        employee_gross: money(employee_gross, "GBP"),
        employee_deduction: money(deduction, "GBP"),
        employer: money(employer, "GBP"),
        deduct_before_tax: net_pay,
        earnings_trigger_met: earnings.minor > trigger,
    }
}

fn optional_int(v: &Value) -> Option<i64> {
    if v.is_null() {
        None
    } else {
        Some(v.as_i64())
    }
}

pub fn pension_scheme_from_value(v: &Value) -> PensionScheme {
    PensionScheme {
        employee_basis_points: optional_int(v.get("employeeBasisPoints")),
        employer_basis_points: optional_int(v.get("employerBasisPoints")),
        earnings_basis: v.get("earningsBasis").as_str().to_string(),
        arrangement: v.get("arrangement").as_str().to_string(),
    }
}

pub fn pension_contributions_to_value(p: &PensionContributions) -> Value {
    Value::obj(vec![
        ("pensionableEarnings", money_to_value(&p.pensionable_earnings)),
        ("employeeGross", money_to_value(&p.employee_gross)),
        ("employeeDeduction", money_to_value(&p.employee_deduction)),
        ("employer", money_to_value(&p.employer)),
        ("deductBeforeTax", Value::Bool(p.deduct_before_tax)),
        ("earningsTriggerMet", Value::Bool(p.earnings_trigger_met)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    pension_contributions_to_value(&pension_contributions(
        &money_from_value(&args[0]),
        args[1].as_str(),
        &pension_scheme_from_value(&args[2]),
        args[3].as_str(),
    ))
}