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),
};
}