Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { BANDS, BANDS_HISTORY, BANDS_HORIZON, SURCHARGES, SURCHARGES_HISTORY, SURCHARGES_HORIZON, type StampDutyBand, type StampDutySurcharge } from "./property_stamp_duty_data.ts"; ← this capability’s own data, compiled from data/bands.json into the same file by fune build
import { type StampDutyPurchase, type StampDutyResult, type StampDutySlice } from "./property_stamp_duty_types.ts";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const TAX_NAMES: Readonly<Record<string, string>> = { "england-ni": "SDLT", scotland: "LBTT", wales: "LTT" };
// £10bn: price × the highest rate stays an exact JavaScript number.
const MAX_PRICE = 1_000_000_000_000;
// 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.
function prunedNote(history: string): string {
return history === "full" ? "" : ` (this build was installed with history=${history}; reinstall with history=full for older dates)`;
}
function inForce(row: { validFrom: string; validTo: string | null }, onDate: string): boolean {
return onDate >= row.validFrom && (row.validTo === null || onDate <= row.validTo);
}
function schedule(region: string, use: string, name: string, onDate: string): StampDutyBand[] {
const rows = BANDS.filter((b) => b.region === region && b.propertyUse === use && b.schedule === name && inForce(b, onDate));
rows.sort((a, b) => a.fromMinor - b.fromMinor);
// The bands must run from zero without gaps, or the data is wrong.
let expected: number | null = 0;
for (const row of rows) {
if (expected === null || row.fromMinor !== expected) {
throw new RangeError(`the ${name} bands for ${region} ${use} on ${onDate} do not form a ladder`);
}
expected = row.toMinor;
}
if (rows.length > 0 && expected !== null) {
throw new RangeError(`the ${name} bands for ${region} ${use} on ${onDate} do not form a ladder`);
}
return rows;
}
/**
* 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.
*/
export function stampDuty(purchase: StampDutyPurchase): StampDutyResult {
const { price, region, propertyUse: use, firstTimeBuyer, additionalProperty, nonResident, effectiveDate } = purchase;
if (!ISO_DATE.test(effectiveDate)) {
throw new RangeError(`effectiveDate must be an ISO date (YYYY-MM-DD), received "${effectiveDate}"`);
}
const taxName = TAX_NAMES[region];
if (taxName === undefined) {
throw new RangeError(`unknown region "${region}": use england-ni, scotland or wales`);
}
if (use !== "residential" && use !== "non-residential") {
throw new RangeError(`unknown use "${use}": use residential or non-residential`);
}
if (price.currency !== "GBP") {
throw new RangeError(`price must be in GBP, received ${price.currency}`);
}
if (!Number.isInteger(price.minor) || price.minor < 0 || price.minor > MAX_PRICE) {
throw new RangeError(`price must be between 0 and ${MAX_PRICE} minor units, received ${price.minor}`);
}
if (use === "non-residential" && (firstTimeBuyer || additionalProperty)) {
throw new RangeError("firstTimeBuyer and additionalProperty apply only to residential purchases");
}
if (firstTimeBuyer && additionalProperty) {
throw new RangeError("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 (const [history, horizon] of [[BANDS_HISTORY, BANDS_HORIZON], [SURCHARGES_HISTORY, SURCHARGES_HORIZON]] as const) {
if (history !== "full" && horizon !== null && effectiveDate < horizon) {
throw new RangeError(
`no ${taxName} rates for ${effectiveDate}: this build was installed with history=${history}, so it only carries rules from ${horizon}`,
);
}
}
const surcharges: StampDutySurcharge[] = [];
if (use === "residential") {
if (additionalProperty) {
const row = SURCHARGES.find((s) => s.region === region && s.kind === "additional-property" && inForce(s, effectiveDate));
if (row === undefined) {
throw new RangeError(`no higher rates rule for ${region} on ${effectiveDate}${prunedNote(SURCHARGES_HISTORY)}`);
}
if (price.minor >= row.minimumPriceMinor) surcharges.push(row);
}
if (nonResident) {
const row = SURCHARGES.find((s) => s.region === region && s.kind === "non-resident" && inForce(s, effectiveDate));
if (row !== undefined && price.minor >= row.minimumPriceMinor) surcharges.push(row);
}
}
let scheduleName = "standard";
if (surcharges.some((s) => s.basis === "higher-schedule")) {
scheduleName = "higher";
} else if (firstTimeBuyer) {
const relief = schedule(region, use, "first-time-buyer", effectiveDate);
const cap = relief.length > 0 ? relief[0].priceCapMinor : null;
if (relief.length > 0 && (cap === null || price.minor <= cap)) scheduleName = "first-time-buyer";
}
const bands = schedule(region, use, scheduleName, effectiveDate);
if (bands.length === 0) {
throw new RangeError(`no ${taxName} ${scheduleName} rates for ${region} ${use} on ${effectiveDate}${prunedNote(BANDS_HISTORY)}`);
}
let surchargePoints = 0;
let supplementPoints = 0;
for (const s of surcharges) {
if (s.basis === "each-band") surchargePoints += s.basisPoints;
else if (s.basis === "whole-price") supplementPoints += s.basisPoints;
}
// Everything below is in pence × basis points, exact until the final floor.
const slices: StampDutySlice[] = [];
let exact = 0;
for (const band of bands) {
if (price.minor <= band.fromMinor) break;
const top = band.toMinor === null ? price.minor : Math.min(price.minor, band.toMinor);
const taxable = top - band.fromMinor;
const rate = band.basisPoints + surchargePoints;
exact += taxable * rate;
slices.push({ basisPoints: rate, taxable: money(taxable, "GBP"), tax: money(Math.floor((taxable * rate) / 10000), "GBP") });
}
const toWholePounds = (value: number) => Math.floor(value / 1_000_000) * 100;
const bandTax = toWholePounds(exact);
const supplement = toWholePounds(price.minor * supplementPoints);
return {
tax: taxName,
schedule: scheduleName,
slices,
surchargeBasisPoints: surchargePoints,
bandTax: money(bandTax, "GBP"),
supplementBasisPoints: supplementPoints,
supplement: money(supplement, "GBP"),
total: money(bandTax + supplement, "GBP"),
};
}