Functional Weave
Code in TypeScript

property.stamp-duty@1.0.2

impl/typescript.ts

6,732 bytes · the TypeScript 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 { 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"),
  };
}