Functional Weave
Code in TypeScript

property.stamp-duty@1.0.2

impl/rust.rs

9,325 bytes · the Rust implementation · view raw

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])))
}