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_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 { 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(), )) }