use super::funejson::Value; use super::dates_days_between::days_between; use super::dates_uk_tax_year::uk_tax_year; use super::fleet_company_car_bik_data::{ BIK_BANDS, BIK_BANDS_HISTORY, BIK_LIMITS, BIK_LIMITS_HISTORY, FUEL_MULTIPLIERS, FUEL_MULTIPLIERS_HISTORY, }; use super::math_round_div::round_div; use super::money_add::add_money; use super::money_amount::{money, money_from_value, money_to_value, Money}; const MAX_SAFE: i128 = 9007199254740991; // Cars first registered before this date keep their NEDC figures, and in // 2021-22 their own (one point higher) column of the table. const WLTP_FROM: &str = "2020-04-06"; 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 { valid_from <= on_date && valid_to.map_or(true, |to| on_date <= to) } // A history=current build has dropped old rows; say so rather than answering // an old tax year with today's figures. fn missing(what: &str, label: &str, earliest: Option<&str>, history: &str, on_date: &str) -> ! { if history != "full" { if let Some(earliest) = earliest { if on_date < earliest { panic!( "no {} for tax year {}: this build was installed with history={}, so it only carries figures from {}. Reinstall with history=full for earlier years.", what, label, history, earliest ); } } } panic!("no {} for tax year {}", what, label) } fn check_gbp(name: &str, amount: &Money) { if amount.currency != "GBP" { panic!("company car benefit is worked in GBP; {} is in {}", name, amount.currency); } if amount.minor < 0 { panic!("{} must not be negative, received {}", name, amount.minor); } } fn check_whole(name: &str, value: i64, unit: &str) { if value < 0 { panic!("{} must be a whole number of {}, not negative, received {}", name, unit, value); } } fn check_date(name: &str, value: &str) { if !is_iso_date(value) { panic!("{} must be an ISO date (YYYY-MM-DD), received \"{}\"", name, value); } } // price x basis points x days available / (10000 x days in year), rounded down // to the penny: one rounding, and never more benefit than the law charges. fn proportion(minor: i64, basis_points: i64, days_available: i64, days_in_year: i64) -> i64 { let product = minor as i128 * basis_points as i128 * days_available as i128; // Rust would carry on in i128, but TypeScript cannot, and all three must agree. if product > MAX_SAFE { panic!("the benefit calculation exceeds 2^53 - 1; the list price is too large"); } round_div(product as i64, 10000 * days_in_year, "down") } /// The taxable benefit of a company car for one UK tax year (ITEPA 2003 s121). /// /// Price less capital contributions (at most 5,000.00), times the appropriate /// percentage for the car's CO2 figure and electric range in that year, cut /// pro rata for days the car was unavailable, less what the employee paid for /// private use. Free fuel adds the year's fuel benefit multiplier times the /// same percentage, cut for the same days. /// /// # Panics /// Panics on a currency other than GBP, a negative amount or count, a malformed /// date, a diesel supplement on a zero-emission car, a registration after the /// tax year, too many unavailable days, or a tax year the tables do not cover. #[allow(clippy::too_many_arguments)] pub fn company_car_bik( list_price: &Money, capital_contributions: &Money, co2: i64, electric_range_miles: i64, diesel_supplement: bool, first_registered: &str, on_date: &str, unavailable_days: i64, private_use_contributions: &Money, free_fuel: bool, ) -> CompanyCarBik { check_gbp("listPrice", list_price); check_gbp("capitalContributions", capital_contributions); check_gbp("privateUseContributions", private_use_contributions); check_whole("co2", co2, "g/km"); check_whole("electricRangeMiles", electric_range_miles, "miles"); check_date("firstRegistered", first_registered); check_date("onDate", on_date); if diesel_supplement && co2 == 0 { panic!("a zero-emission car cannot take the diesel supplement"); } let year = uk_tax_year(on_date); if first_registered > year.end.as_str() { panic!( "firstRegistered ({}) is after the end of tax year {}", first_registered, year.label ); } let days_in_year = days_between(&year.start, &year.end) + 1; if unavailable_days < 0 || unavailable_days > days_in_year { panic!( "unavailableDays must be a whole number from 0 to {}, received {}", days_in_year, unavailable_days ); } let days_available = days_in_year - unavailable_days; let registration = if first_registered < WLTP_FROM { "before-2020-04-06" } else { "from-2020-04-06" }; let applies = |row_registration: &str| row_registration == "any" || row_registration == registration; let limit = match BIK_LIMITS .iter() .find(|row| applies(row.registration) && in_force(row.valid_from, row.valid_to, on_date)) { Some(limit) => limit, None => missing( "company car benefit rates", &year.label, BIK_LIMITS.iter().map(|row| row.valid_from).min(), BIK_LIMITS_HISTORY, on_date, ), }; let mut basis_points = if co2 >= 75 { // s139(3): one point per whole 5 g/km above 75, up to the year's maximum. (limit.from75_basis_points + 100 * ((co2 - 75) / 5)).min(limit.maximum_basis_points) } else { match BIK_BANDS.iter().find(|row| { applies(row.registration) && in_force(row.valid_from, row.valid_to, on_date) && row.co2_from <= co2 && co2 <= row.co2_to && row.range_from <= electric_range_miles && row.range_to.map_or(true, |to| electric_range_miles <= to) }) { Some(band) => band.basis_points, None => missing( "company car benefit band", &year.label, BIK_BANDS.iter().map(|row| row.valid_from).min(), BIK_BANDS_HISTORY, on_date, ), } }; if diesel_supplement { basis_points = (basis_points + limit.diesel_supplement_basis_points).min(limit.maximum_basis_points); } let counted = capital_contributions.minor.min(limit.capital_contribution_cap_minor); let price = money((list_price.minor - counted).max(0), "GBP"); let provisional = proportion(price.minor, basis_points, days_available, days_in_year); let car_benefit = money((provisional - private_use_contributions.minor).max(0), "GBP"); let mut fuel_benefit = money(0, "GBP"); // s149(4): electricity, and energy for a car that cannot emit CO2, is not fuel. if free_fuel && co2 > 0 { let multiplier = match FUEL_MULTIPLIERS .iter() .find(|row| in_force(row.valid_from, row.valid_to, on_date)) { Some(row) => row, None => missing( "car fuel benefit multiplier", &year.label, FUEL_MULTIPLIERS.iter().map(|row| row.valid_from).min(), FUEL_MULTIPLIERS_HISTORY, on_date, ), }; fuel_benefit = money( proportion(multiplier.pounds * 100, basis_points, days_available, days_in_year), "GBP", ); } let total = add_money(&car_benefit, &fuel_benefit); CompanyCarBik { tax_year: year.label.clone(), days_in_year, days_available, price, appropriate_basis_points: basis_points, car_benefit, fuel_benefit, total, } } pub fn company_car_bik_to_value(b: &CompanyCarBik) -> Value { Value::obj(vec![ ("taxYear", Value::str(&b.tax_year)), ("daysInYear", Value::Int(b.days_in_year)), ("daysAvailable", Value::Int(b.days_available)), ("price", money_to_value(&b.price)), ("appropriateBasisPoints", Value::Int(b.appropriate_basis_points)), ("carBenefit", money_to_value(&b.car_benefit)), ("fuelBenefit", money_to_value(&b.fuel_benefit)), ("total", money_to_value(&b.total)), ]) } fn whole(value: &Value, name: &str, unit: &str) -> i64 { match value { Value::Int(i) => *i, Value::Float(f) if f.fract() == 0.0 => *f as i64, Value::Float(f) => panic!("{} must be a whole number of {}, not negative, received {}", name, unit, f), _ => panic!("{} must be a whole number of {}, not negative, received a non-number", name, unit), } } pub fn fune_vector(args: &[Value]) -> Value { let co2 = whole(&args[2], "co2", "g/km"); let range = whole(&args[3], "electricRangeMiles", "miles"); let unavailable = match &args[7] { Value::Int(i) => *i, Value::Float(f) if f.fract() == 0.0 => *f as i64, other => panic!( "unavailableDays must be a whole number from 0 to 366, received {}", match other { Value::Float(f) => f.to_string(), _ => "a non-number".to_string(), } ), }; company_car_bik_to_value(&company_car_bik( &money_from_value(&args[0]), &money_from_value(&args[1]), co2, range, args[4].as_bool(), args[5].as_str(), args[6].as_str(), unavailable, &money_from_value(&args[8]), args[9].as_bool(), )) }