use super::funejson::Value; use super::money_amount::{money, money_from_value, money_to_value}; use super::property_stamp_duty_data::{ StampDutyBand, StampDutySurcharge, BANDS, BANDS_HISTORY, BANDS_HORIZON, SURCHARGES, SURCHARGES_HISTORY, SURCHARGES_HORIZON, }; // £10bn: price × the highest rate stays an exact JavaScript number; the same limit here. const MAX_PRICE: i64 = 1_000_000_000_000; fn is_iso_date(value: &str) -> bool { let bytes = value.as_bytes(); bytes.len() == 10 && bytes.iter().enumerate().all(|(i, b)| if i == 4 || i == 7 { *b == b'-' } else { b.is_ascii_digit() }) } // A build installed with history=current keeps only rules still in force, so // an older date can find nothing even after the horizon check; say why. fn pruned_note(history: &str) -> String { if history == "full" { String::new() } else { format!(" (this build was installed with history={}; reinstall with history=full for older dates)", history) } } 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) } fn schedule(region: &str, use_: &str, name: &str, on_date: &str) -> Vec<&'static StampDutyBand> { let mut rows: Vec<&'static StampDutyBand> = BANDS .iter() .filter(|b| b.region == region && b.property_use == use_ && b.schedule == name && in_force(b.valid_from, b.valid_to, on_date)) .collect(); rows.sort_by_key(|b| b.from_minor); // The bands must run from zero without gaps, or the data is wrong. let mut expected: Option = Some(0); for row in &rows { if expected != Some(row.from_minor) { panic!("the {} bands for {} {} on {} do not form a ladder", name, region, use_, on_date); } expected = row.to_minor; } if !rows.is_empty() && expected.is_some() { panic!("the {} bands for {} {} on {} do not form a ladder", name, region, use_, on_date); } rows } fn find_surcharge(region: &str, kind: &str, on_date: &str) -> Option<&'static StampDutySurcharge> { SURCHARGES .iter() .find(|s| s.region == region && s.kind == kind && in_force(s.valid_from, s.valid_to, on_date)) } /// Stamp duty on one purchase: SDLT in England and Northern Ireland, LBTT in /// Scotland, LTT in Wales. The price is sliced into the bands in force on the /// effective date; England's higher rates and non-resident surcharge add /// points to every band, Wales's higher rates are a schedule of their own, and /// Scotland's ADS is a separate charge on the whole price. Each tax is rounded /// down to the whole pound once, as the returns require. /// /// # Panics /// Panics on a malformed date, an unknown region or use, a price not in GBP /// or out of range, contradictory flags, or no rates for the date. pub fn stamp_duty(purchase: &StampDutyPurchase) -> StampDutyResult { let price = &purchase.price; let region = purchase.region.as_str(); let use_ = purchase.property_use.as_str(); let on_date = purchase.effective_date.as_str(); if !is_iso_date(on_date) { panic!("effectiveDate must be an ISO date (YYYY-MM-DD), received \"{}\"", on_date); } let tax_name = match region { "england-ni" => "SDLT", "scotland" => "LBTT", "wales" => "LTT", other => panic!("unknown region \"{}\": use england-ni, scotland or wales", other), }; if use_ != "residential" && use_ != "non-residential" { panic!("unknown use \"{}\": use residential or non-residential", use_); } if price.currency != "GBP" { panic!("price must be in GBP, received {}", price.currency); } if price.minor < 0 || price.minor > MAX_PRICE { panic!("price must be between 0 and {} minor units, received {}", MAX_PRICE, price.minor); } if use_ == "non-residential" && (purchase.first_time_buyer || purchase.additional_property) { panic!("firstTimeBuyer and additionalProperty apply only to residential purchases"); } if purchase.first_time_buyer && purchase.additional_property { panic!("a first-time buyer cannot be buying an additional property"); } // A pruned build must refuse a date it no longer has the rules for. for (history, horizon) in [(BANDS_HISTORY, BANDS_HORIZON), (SURCHARGES_HISTORY, SURCHARGES_HORIZON)] { if let Some(h) = horizon { if history != "full" && on_date < h { panic!( "no {} rates for {}: this build was installed with history={}, so it only carries rules from {}", tax_name, on_date, history, h ); } } } let mut surcharges: Vec<&'static StampDutySurcharge> = Vec::new(); if use_ == "residential" { if purchase.additional_property { match find_surcharge(region, "additional-property", on_date) { None => panic!("no higher rates rule for {} on {}{}", region, on_date, pruned_note(SURCHARGES_HISTORY)), Some(row) => { if price.minor >= row.minimum_price_minor { surcharges.push(row); } } } } if purchase.non_resident { if let Some(row) = find_surcharge(region, "non-resident", on_date) { if price.minor >= row.minimum_price_minor { surcharges.push(row); } } } } let mut schedule_name = "standard"; if surcharges.iter().any(|s| s.basis == "higher-schedule") { schedule_name = "higher"; } else if purchase.first_time_buyer { let relief = schedule(region, use_, "first-time-buyer", on_date); if let Some(first) = relief.first() { if first.price_cap_minor.map_or(true, |cap| price.minor <= cap) { schedule_name = "first-time-buyer"; } } } let bands = schedule(region, use_, schedule_name, on_date); if bands.is_empty() { panic!( "no {} {} rates for {} {} on {}{}", tax_name, schedule_name, region, use_, on_date, pruned_note(BANDS_HISTORY) ); } let surcharge_points: i64 = surcharges.iter().filter(|s| s.basis == "each-band").map(|s| s.basis_points).sum(); let supplement_points: i64 = surcharges.iter().filter(|s| s.basis == "whole-price").map(|s| s.basis_points).sum(); // Everything below is in pence × basis points, exact until the final floor. let mut slices: Vec = Vec::new(); let mut exact: i64 = 0; for band in bands { if price.minor <= band.from_minor { break; } let top = band.to_minor.map_or(price.minor, |to| price.minor.min(to)); let taxable = top - band.from_minor; let rate = band.basis_points + surcharge_points; exact += taxable * rate; slices.push(StampDutySlice { basis_points: rate, taxable: money(taxable, "GBP"), tax: money(taxable * rate / 10000, "GBP"), }); } let band_tax = exact / 1_000_000 * 100; let supplement = price.minor * supplement_points / 1_000_000 * 100; StampDutyResult { tax: tax_name.to_string(), schedule: schedule_name.to_string(), slices, surcharge_basis_points: surcharge_points, band_tax: money(band_tax, "GBP"), supplement_basis_points: supplement_points, supplement: money(supplement, "GBP"), total: money(band_tax + supplement, "GBP"), } } pub fn stamp_duty_purchase_from_value(v: &Value) -> StampDutyPurchase { StampDutyPurchase { price: money_from_value(v.get("price")), region: v.get("region").as_str().to_string(), property_use: v.get("propertyUse").as_str().to_string(), first_time_buyer: v.get("firstTimeBuyer").as_bool(), additional_property: v.get("additionalProperty").as_bool(), non_resident: v.get("nonResident").as_bool(), effective_date: v.get("effectiveDate").as_str().to_string(), } } pub fn stamp_duty_result_to_value(r: &StampDutyResult) -> Value { Value::obj(vec![ ("tax", Value::str(&r.tax)), ("schedule", Value::str(&r.schedule)), ( "slices", Value::Arr( r.slices .iter() .map(|s| { Value::obj(vec![ ("basisPoints", Value::Int(s.basis_points)), ("taxable", money_to_value(&s.taxable)), ("tax", money_to_value(&s.tax)), ]) }) .collect(), ), ), ("surchargeBasisPoints", Value::Int(r.surcharge_basis_points)), ("bandTax", money_to_value(&r.band_tax)), ("supplementBasisPoints", Value::Int(r.supplement_basis_points)), ("supplement", money_to_value(&r.supplement)), ("total", money_to_value(&r.total)), ]) } pub fn fune_vector(args: &[Value]) -> Value { stamp_duty_result_to_value(&stamp_duty(&stamp_duty_purchase_from_value(&args[0]))) }