Functional Weave
Code in TypeScript

property.stamp-duty@1.0.1

impl/python.py

6,774 bytes · the Python 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.

import re
from typing import List

from .money_amount import money  ← from money.amount ^1.0.0 · built alongside by fune
from .property_stamp_duty_data import BANDS, BANDS_HISTORY, BANDS_HORIZON, SURCHARGES, SURCHARGES_HISTORY, SURCHARGES_HORIZON, StampDutyBand, StampDutySurcharge  ← this capability’s own data, compiled from data/bands.json into the same file by fune build
from .property_stamp_duty_types import StampDutyPurchase, StampDutyResult, StampDutySlice

_ISO_DATE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")
_TAX_NAMES = {"england-ni": "SDLT", "scotland": "LBTT", "wales": "LTT"}
# £10bn: price x the highest rate stays an exact JavaScript number; the same limit here.
_MAX_PRICE = 1_000_000_000_000


def _pruned_note(history: str) -> str:
    # 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.
    if history == "full":
        return ""
    return " (this build was installed with history=%s; reinstall with history=full for older dates)" % (history,)


def _in_force(row, on_date: str) -> bool:
    return on_date >= row.valid_from and (row.valid_to is None or on_date <= row.valid_to)


def _schedule(region: str, use: str, name: str, on_date: str) -> List[StampDutyBand]:
    rows = sorted(
        (b for b in BANDS if b.region == region and b.property_use == use and b.schedule == name and _in_force(b, on_date)),
        key=lambda b: b.from_minor,
    )
    # The bands must run from zero without gaps, or the data is wrong.
    expected = 0
    for row in rows:
        if expected is None or row.from_minor != expected:
            raise ValueError("the %s bands for %s %s on %s do not form a ladder" % (name, region, use, on_date))
        expected = row.to_minor
    if rows and expected is not None:
        raise ValueError("the %s bands for %s %s on %s do not form a ladder" % (name, region, use, on_date))
    return rows


def stamp_duty(purchase: StampDutyPurchase) -> StampDutyResult:
    """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."""
    price = purchase.price
    region = purchase.region
    use = purchase.property_use
    on_date = purchase.effective_date
    if not isinstance(on_date, str) or not _ISO_DATE.fullmatch(on_date):
        raise ValueError('effectiveDate must be an ISO date (YYYY-MM-DD), received "%s"' % (on_date,))
    tax_name = _TAX_NAMES.get(region)
    if tax_name is None:
        raise ValueError('unknown region "%s": use england-ni, scotland or wales' % (region,))
    if use not in ("residential", "non-residential"):
        raise ValueError('unknown use "%s": use residential or non-residential' % (use,))
    if price.currency != "GBP":
        raise ValueError("price must be in GBP, received %s" % (price.currency,))
    if price.minor < 0 or price.minor > _MAX_PRICE:
        raise ValueError("price must be between 0 and %d minor units, received %r" % (_MAX_PRICE, price.minor))
    if use == "non-residential" and (purchase.first_time_buyer or purchase.additional_property):
        raise ValueError("firstTimeBuyer and additionalProperty apply only to residential purchases")
    if purchase.first_time_buyer and purchase.additional_property:
        raise ValueError("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 history != "full" and horizon is not None and on_date < horizon:
            raise ValueError(
                "no %s rates for %s: this build was installed with history=%s, so it only carries rules from %s"
                % (tax_name, on_date, history, horizon)
            )

    surcharges: List[StampDutySurcharge] = []
    if use == "residential":
        if purchase.additional_property:
            row = next(
                (s for s in SURCHARGES if s.region == region and s.kind == "additional-property" and _in_force(s, on_date)),
                None,
            )
            if row is None:
                raise ValueError("no higher rates rule for %s on %s%s" % (region, on_date, _pruned_note(SURCHARGES_HISTORY)))
            if price.minor >= row.minimum_price_minor:
                surcharges.append(row)
        if purchase.non_resident:
            row = next(
                (s for s in SURCHARGES if s.region == region and s.kind == "non-resident" and _in_force(s, on_date)),
                None,
            )
            if row is not None and price.minor >= row.minimum_price_minor:
                surcharges.append(row)

    schedule_name = "standard"
    if any(s.basis == "higher-schedule" for s in surcharges):
        schedule_name = "higher"
    elif purchase.first_time_buyer:
        relief = _schedule(region, use, "first-time-buyer", on_date)
        cap = relief[0].price_cap_minor if relief else None
        if relief and (cap is None or price.minor <= cap):
            schedule_name = "first-time-buyer"
    bands = _schedule(region, use, schedule_name, on_date)
    if not bands:
        raise ValueError(
            "no %s %s rates for %s %s on %s%s"
            % (tax_name, schedule_name, region, use, on_date, _pruned_note(BANDS_HISTORY))
        )

    surcharge_points = sum(s.basis_points for s in surcharges if s.basis == "each-band")
    supplement_points = sum(s.basis_points for s in surcharges if s.basis == "whole-price")

    # Everything below is in pence x basis points, exact until the final floor.
    slices: List[StampDutySlice] = []
    exact = 0
    for band in bands:
        if price.minor <= band.from_minor:
            break
        top = price.minor if band.to_minor is None else min(price.minor, band.to_minor)
        taxable = top - band.from_minor
        rate = band.basis_points + surcharge_points
        exact += taxable * rate
        slices.append(
            StampDutySlice(basis_points=rate, taxable=money(taxable, "GBP"), tax=money(taxable * rate // 10000, "GBP"))
        )
    band_tax = exact // 1_000_000 * 100
    supplement = price.minor * supplement_points // 1_000_000 * 100
    return StampDutyResult(
        tax=tax_name,
        schedule=schedule_name,
        slices=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"),
    )