use super::funejson::Value; use super::money_amount::{money, money_from_value, money_to_value, Money}; use super::payroll_student_loan_data::{ STUDENT_LOAN_THRESHOLDS, STUDENT_LOAN_THRESHOLDS_HISTORY, STUDENT_LOAN_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()) } /// The student or postgraduate loan deduction for one pay period. /// /// The period threshold is the annual one scaled exactly by weeks/52 or /// months/12 (Education (Student Loans) (Repayment) Regulations 2009 reg. /// 44(2)), not HMRC's printed weekly and monthly figures, which are that /// fraction cut to the penny. The deduction then drops its pence (reg. 44(3)), /// so the whole calculation stays in integers: pence times the period /// denominator. /// /// # Panics /// Panics on non-GBP or negative earnings, an unknown plan or frequency, or a /// date no threshold covers. pub fn student_loan(earnings: &Money, plan: &str, frequency: &str, pay_date: &str) -> Money { if earnings.currency != "GBP" { panic!("student loan deductions must be in GBP, received {}", earnings.currency); } if earnings.minor < 0 { panic!("earnings must not be negative, received {}", earnings.minor); } if !matches!(plan, "plan-1" | "plan-2" | "plan-4" | "plan-5" | "postgraduate") { panic!("unknown student loan plan \"{}\"", plan); } let (periods, per_year): (i128, i128) = match frequency { "weekly" => (1, 52), "fortnightly" => (2, 52), "four-weekly" => (4, 52), "monthly" => (1, 12), other => panic!("unknown pay frequency \"{}\"", other), }; if !is_iso_date(pay_date) { panic!("payDate must be an ISO date (YYYY-MM-DD), received \"{}\"", pay_date); } let rule = STUDENT_LOAN_THRESHOLDS.iter().find(|r| { r.plan == plan && pay_date >= r.valid_from && r.valid_to.map_or(true, |to| pay_date <= to) }); let rule = match rule { Some(r) => r, None => { if let Some(horizon) = STUDENT_LOAN_THRESHOLDS_HORIZON { if STUDENT_LOAN_THRESHOLDS_HISTORY != "full" && pay_date < horizon { panic!( "no student loan threshold for {} on {}: this build was installed with history={}, so it only carries rules from {}. Reinstall with history=full for earlier tax years.", plan, pay_date, STUDENT_LOAN_THRESHOLDS_HISTORY, horizon ); } } panic!("no student loan threshold for {} on {}", plan, pay_date) } }; // Everything below is in pence x per_year, so the threshold is exact. let excess = earnings.minor as i128 * per_year - rule.annual_threshold as i128 * periods; if excess <= 0 { return money(0, "GBP"); } let pounds = (excess * rule.basis_points as i128) / (10000 * per_year * 100); money((pounds * 100) as i64, "GBP") } pub fn fune_vector(args: &[Value]) -> Value { money_to_value(&student_loan( &money_from_value(&args[0]), args[1].as_str(), args[2].as_str(), args[3].as_str(), )) }