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"),
)