Functional Weave
Code in Rust

invest.capital-gains-tax@1.0.1

impl/typescript.ts

6,180 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 { ukTaxYear } from "./dates_uk_tax_year.ts";  ← from dates.uk-tax-year ^1.0.0 · built alongside by fune
import { type Money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { applyRate } from "./money_apply_rate.ts";  ← from money.apply-rate ^1.0.0 · built alongside by fune
import { CGT_RATES, CGT_RATES_HISTORY, CGT_RATES_HORIZON, CGT_YEARS, CGT_YEARS_HISTORY, CGT_YEARS_HORIZON } from "./invest_capital_gains_tax_data.ts";  ← this capability’s own data, compiled from data/cgt-years.json into the same file by fune build
import { type CgtDisposal, type CgtLine, type CgtResult } from "./invest_capital_gains_tax_types.ts";

/** Gains that share a pair of rates; they are interchangeable for every step below. */
interface Bucket {
  lower: number;
  higher: number;
  amount: number;
}

function gbp(minor: number): Money {
  return { minor, currency: "GBP" };
}

function checkMoney(value: Money, name: string): void {
  if (value.currency !== "GBP") throw new RangeError(`${name} must be in GBP, received ${value.currency}`);
  if (!Number.isInteger(value.minor)) throw new RangeError(`${name} must be a whole number of pence, received ${value.minor}`);
}

function pruned(history: string, horizon: string | null, date: string): string {
  return horizon !== null && date < horizon
    ? `: this build was installed with history=${history}, so it only carries rules from ${horizon}. Reinstall with history=full for earlier years.`
    : "";
}

function rateFor(disposal: CgtDisposal): Bucket {
  for (const rule of CGT_RATES) {
    if (rule.kind !== disposal.kind || disposal.date < rule.validFrom) continue;
    if (rule.validTo !== null && disposal.date > rule.validTo) continue;
    return { lower: rule.lowerBasisPoints, higher: rule.higherBasisPoints, amount: 0 };
  }
  throw new RangeError(
    `no CGT rate for ${disposal.kind} gains on ${disposal.date}` + pruned(CGT_RATES_HISTORY, CGT_RATES_HORIZON, disposal.date)
  );
}

/**
 * Capital Gains Tax for an individual for one UK tax year.
 *
 * The year's losses come off its gains in full, even when that wastes the
 * annual exempt amount; losses brought forward only bring the net gains down
 * to the annual exempt amount. Losses and the exempt amount go against the
 * gains charged at the highest rate first, and what taxable income leaves of
 * the basic rate band goes to the gains whose two rates are furthest apart:
 * that is the "most beneficial" order TCGA 1992 s1F and s1K allow.
 */
export function capitalGainsTax(
  taxYearDate: string,
  disposals: readonly CgtDisposal[],
  taxableIncome: Money,
  lossesBroughtForward: Money
): CgtResult {
  const year = ukTaxYear(taxYearDate);
  const rules = CGT_YEARS.find((row) => row.validFrom <= year.start && (row.validTo === null || year.start <= row.validTo));
  if (rules === undefined) {
    throw new RangeError(`no CGT rules for the ${year.label} tax year` + pruned(CGT_YEARS_HISTORY, CGT_YEARS_HORIZON, year.start));
  }
  checkMoney(taxableIncome, "taxableIncome");
  checkMoney(lossesBroughtForward, "lossesBroughtForward");
  if (taxableIncome.minor < 0) throw new RangeError(`taxableIncome must not be negative, received ${taxableIncome.minor}`);
  if (lossesBroughtForward.minor < 0) {
    throw new RangeError(`lossesBroughtForward must not be negative, received ${lossesBroughtForward.minor}`);
  }

  const buckets: Bucket[] = [];
  let gains = 0;
  let losses = 0;
  for (const disposal of disposals) {
    checkMoney(disposal.gain, "gain");
    if (disposal.kind !== "residential" && disposal.kind !== "other") {
      throw new RangeError(`kind must be residential or other, received "${disposal.kind}"`);
    }
    if (disposal.date < year.start || disposal.date > year.end) {
      throw new RangeError(`the disposal on ${disposal.date} is outside the ${year.label} tax year`);
    }
    const rate = rateFor(disposal);
    if (disposal.gain.minor <= 0) {
      losses -= disposal.gain.minor;
      continue;
    }
    gains += disposal.gain.minor;
    const bucket = buckets.find((b) => b.lower === rate.lower && b.higher === rate.higher);
    if (bucket === undefined) buckets.push({ ...rate, amount: disposal.gain.minor });
    else bucket.amount += disposal.gain.minor;
  }

  const net = gains - losses;
  const annualExemptAmount = rules.annualExemptAmount;
  let broughtForwardUsed = 0;
  let exemptUsed = 0;
  let taxable = 0;
  let carried = lossesBroughtForward.minor;
  if (net > 0) {
    broughtForwardUsed = Math.min(lossesBroughtForward.minor, Math.max(0, net - annualExemptAmount));
    exemptUsed = Math.min(annualExemptAmount, net - broughtForwardUsed);
    taxable = net - broughtForwardUsed - exemptUsed;
    carried -= broughtForwardUsed;
  } else {
    carried += -net;
  }

  // Deductions against the gains taxed hardest first.
  let deductions = gains - taxable;
  const byHigher = [...buckets].sort((a, b) => b.higher - a.higher || b.lower - a.lower);
  for (const bucket of byHigher) {
    const used = Math.min(bucket.amount, deductions);
    bucket.amount -= used;
    deductions -= used;
  }

  // The basic rate band where it saves the most: the widest gap between the two rates.
  const bandAvailable = Math.max(0, rules.basicRateBand - taxableIncome.minor);
  let band = bandAvailable;
  const byGap = [...buckets].sort((a, b) => b.higher - b.lower - (a.higher - a.lower) || b.higher - a.higher);
  const charged = new Map<number, number>();
  const charge = (rate: number, amount: number) => {
    if (amount > 0) charged.set(rate, (charged.get(rate) ?? 0) + amount);
  };
  for (const bucket of byGap) {
    const lower = Math.min(bucket.amount, band);
    band -= lower;
    charge(bucket.lower, lower);
    charge(bucket.higher, bucket.amount - lower);
  }

  const lines: CgtLine[] = [...charged.entries()]
    .sort((a, b) => a[0] - b[0])
    .map(([rate, amount]) => ({ rateBasisPoints: rate, gains: gbp(amount), tax: applyRate(gbp(amount), rate, "down") }));
  const tax = lines.reduce((sum, line) => sum + line.tax.minor, 0);

  return {
    taxYear: year.label,
    gains: gbp(gains),
    losses: gbp(losses),
    lossesBroughtForwardUsed: gbp(broughtForwardUsed),
    annualExemptAmount: gbp(annualExemptAmount),
    annualExemptAmountUsed: gbp(exemptUsed),
    taxableGains: gbp(taxable),
    basicRateBandAvailable: gbp(bandAvailable),
    lines,
    tax: gbp(tax),
    lossesCarriedForward: gbp(carried),
  };
}