Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::money_amount::{money, money_from_value, money_to_value}; ← from money.amount ^1.0.0 · built alongside by fune
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<i64> = 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<StampDutySlice> = 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])))
}