use super::funejson::Value; use super::fleet_mileage_allowance_data::{AmapRate, AMAP_RATES, AMAP_RATES_HISTORY}; use super::money_add::subtract_money; use super::money_amount::{money, money_from_value, money_to_value, Money}; 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 rate_on(vehicle: &str, on_date: &str) -> &'static AmapRate { let mut best: Option<&'static AmapRate> = None; let mut earliest: Option<&'static str> = None; for rule in AMAP_RATES { if rule.vehicle != vehicle { continue; } if earliest.map_or(true, |e| rule.valid_from < e) { earliest = Some(rule.valid_from); } if on_date < rule.valid_from { continue; } if let Some(valid_to) = rule.valid_to { if on_date > valid_to { continue; } } if best.map_or(true, |b| rule.valid_from > b.valid_from) { best = Some(rule); } } if let Some(rule) = best { return rule; } // A history=current build has dropped the old rows; refuse rather than pay // an old claim at today's rate. if AMAP_RATES_HISTORY != "full" { if let Some(earliest) = earliest { if on_date < earliest { panic!( "no approved mileage rate for {} on {}: this build was installed with history={}, so it only carries rates from {}. Reinstall with history=full for older claims.", vehicle, on_date, AMAP_RATES_HISTORY, earliest ); } } } panic!("no approved mileage rate for {} on {}", vehicle, on_date) } fn whole_miles(name: &str, value: i64) { if value < 0 { panic!("{} must be a whole number of miles, not negative, received {}", name, value); } } fn gbp(name: &str, value: &Money) { if value.currency != "GBP" { panic!("{} must be in GBP, received {}", name, value.currency); } if value.minor < 0 { panic!("{} must not be negative, received {}", name, value.minor); } } fn excess_and_shortfall(paid: &Money, approved: &Money) -> (Money, Money) { let difference = subtract_money(paid, approved); if difference.minor > 0 { (difference, money(0, "GBP")) } else { (money(0, "GBP"), money(-difference.minor, "GBP")) } } /// HMRC approved mileage allowance payments for business miles in an /// employee's own vehicle. /// /// The 10,000-mile threshold is per tax year, so a claim that crosses it is /// split: `previous_miles` says where in the year this claim starts. Paying a /// flat 45p (or 55p) on every mile past 10,000 is the usual mistake; the /// excess over 25p is taxable. /// /// # Panics /// Panics on an unknown vehicle, negative miles or amounts, a currency other /// than GBP, passenger payments for a motorcycle or bicycle, a malformed date, /// or a date no rate covers. pub fn mileage_allowance( vehicle: &str, previous_miles: i64, miles: i64, passenger_miles: i64, paid: &Money, passenger_paid: &Money, on_date: &str, ) -> MileageAllowance { if vehicle != "car-van" && vehicle != "motorcycle" && vehicle != "bicycle" { panic!("unknown vehicle \"{}\": use car-van, motorcycle or bicycle", vehicle); } whole_miles("previousMiles", previous_miles); whole_miles("miles", miles); whole_miles("passengerMiles", passenger_miles); gbp("paid", paid); gbp("passengerPaid", passenger_paid); if !is_iso_date(on_date) { panic!("onDate must be an ISO date (YYYY-MM-DD), received \"{}\"", on_date); } if vehicle != "car-van" && (passenger_miles > 0 || passenger_paid.minor > 0) { panic!("passenger payments apply only to cars and vans"); } let rate = rate_on(vehicle, on_date); let mut miles_at_first_rate = miles; let mut miles_at_second_rate = 0; if let Some(threshold) = rate.threshold_miles { let first_left = (threshold - previous_miles).max(0); miles_at_first_rate = miles.min(first_left); miles_at_second_rate = miles - miles_at_first_rate; } let approved = money( miles_at_first_rate * rate.first_rate_pence + miles_at_second_rate * rate.second_rate_pence, "GBP", ); let (taxable_excess, relief) = excess_and_shortfall(paid, &approved); let passenger_approved = if vehicle == "car-van" { money(passenger_miles * rate_on("passenger", on_date).first_rate_pence, "GBP") } else { money(0, "GBP") }; // There is no relief for passenger payments below 5p, only tax above it. let (passenger_taxable_excess, _) = excess_and_shortfall(passenger_paid, &passenger_approved); MileageAllowance { approved, paid: paid.clone(), taxable_excess, relief, miles_at_first_rate, miles_at_second_rate, first_rate_pence: rate.first_rate_pence, second_rate_pence: rate.second_rate_pence, passenger_approved, passenger_taxable_excess, } } pub fn mileage_allowance_to_value(m: &MileageAllowance) -> Value { Value::obj(vec![ ("approved", money_to_value(&m.approved)), ("paid", money_to_value(&m.paid)), ("taxableExcess", money_to_value(&m.taxable_excess)), ("relief", money_to_value(&m.relief)), ("milesAtFirstRate", Value::Int(m.miles_at_first_rate)), ("milesAtSecondRate", Value::Int(m.miles_at_second_rate)), ("firstRatePence", Value::Int(m.first_rate_pence)), ("secondRatePence", Value::Int(m.second_rate_pence)), ("passengerApproved", money_to_value(&m.passenger_approved)), ("passengerTaxableExcess", money_to_value(&m.passenger_taxable_excess)), ]) } fn miles_arg(value: &Value, name: &str) -> i64 { match value { Value::Int(i) => *i, Value::Float(f) if f.fract() == 0.0 => *f as i64, other => panic!( "{} must be a whole number of miles, not negative, received {:?}", name, other ), } } pub fn fune_vector(args: &[Value]) -> Value { mileage_allowance_to_value(&mileage_allowance( args[0].as_str(), miles_arg(&args[1], "previousMiles"), miles_arg(&args[2], "miles"), miles_arg(&args[3], "passengerMiles"), &money_from_value(&args[4]), &money_from_value(&args[5]), args[6].as_str(), )) }