use super::funejson::Value; use super::math_round_div::round_div; use super::money_amount::{money, money_from_value, money_to_value, Money}; 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) { 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 { 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(), )) }