charity.higher-rate-relief@1.0.0
impl/typescript.ts
4,270 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 { giftAid } from "./charity_gift_aid.ts"; ← from charity.gift-aid ^1.0.0 · built alongside by fune
import { type Money, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { type HigherRateRelief, type TaxRegion } from "./charity_higher_rate_relief_types.ts";
import { type IncomeTaxBand, INCOME_TAX_BANDS, INCOME_TAX_BANDS_HISTORY } from "./payroll_income_tax_data.ts"; ← payroll.income-tax’s rule data (^1.0.0) · built alongside by fune
const REGIONS = ["rest-of-uk", "scotland", "wales"];
function bandsFor(region: string, onDate: string): IncomeTaxBand[] {
const rows = INCOME_TAX_BANDS.filter(
(b) => b.region === region && b.validFrom <= onDate && (b.validTo === null || onDate <= b.validTo)
);
if (rows.length === 0) {
// A history=current build only carries this year's bands; an older year
// would otherwise be answered with the wrong bands.
const froms = INCOME_TAX_BANDS.filter((b) => b.region === region).map((b) => b.validFrom);
if (INCOME_TAX_BANDS_HISTORY !== "full" && froms.length > 0) {
const earliest = froms.reduce((a, b) => (b < a ? b : a));
if (onDate < earliest) {
throw new RangeError(
`no income tax bands for ${region} on ${onDate}: this build was installed with history=${INCOME_TAX_BANDS_HISTORY}, so it only carries rules from ${earliest}. Reinstall with history=full for earlier tax years.`
);
}
}
throw new RangeError(`no income tax bands for ${region} on ${onDate}`);
}
return [...rows].sort((a, b) => a.band - b.band);
}
function basicBandIndex(bands: IncomeTaxBand[], region: string, onDate: string): number {
const index = bands.findIndex((b) => b.basicRate);
if (index < 0) throw new RangeError(`no basic rate band for ${region} on ${onDate}`);
return index;
}
/** Tax on income in pence, times 10000 (pence x basis points), unrounded. */
function scaledTax(income: number, bands: IncomeTaxBand[], extendFrom: number, extension: number): number {
let total = 0;
let lower = 0;
for (let i = 0; i < bands.length && income > lower; i++) {
const band = bands[i];
const upper = band.upTo === null ? null : band.upTo * 100 + (i >= extendFrom ? extension : 0);
const top = upper === null || income < upper ? income : upper;
if (top > lower) total += (top - lower) * band.basisPoints;
if (upper === null) break;
lower = upper;
}
return total;
}
/**
* The extra relief a Gift Aid donor gets through their own tax: ITA 2007
* s414(2)(b) raises the basic rate limit and the higher rate limit (for a
* Scottish taxpayer, the Scottish basic rate limit and every limit above it,
* but not the starter rate limit) by the grossed up donations. Income moved
* from a higher band into a lower one is the relief.
*/
export function higherRateRelief(taxableIncome: Money, donations: Money, region: TaxRegion, onDate: string): HigherRateRelief {
if (taxableIncome.currency !== "GBP") {
throw new RangeError(`taxableIncome must be in GBP, received ${taxableIncome.currency}`);
}
if (taxableIncome.minor < 0) {
throw new RangeError(`taxableIncome must not be negative, received ${taxableIncome.minor}`);
}
if (!REGIONS.includes(region)) {
throw new RangeError(`unknown tax region "${region}": expected rest-of-uk, scotland or wales`);
}
const grossed = giftAid(donations, onDate);
const bands = bandsFor(region, onDate);
const basic = basicBandIndex(bands, region, onDate);
const ukBands = bandsFor("rest-of-uk", onDate);
const ukBasic = ukBands[basicBandIndex(ukBands, "rest-of-uk", onDate)];
if (bands[basic].basisPoints !== ukBasic.basisPoints) {
// s414A then adjusts the donor's tax by the rate difference; not modelled.
throw new RangeError(
`the ${region} basic rate differs from the UK basic rate on ${onDate}; the section 414A adjustment is not supported`
);
}
const before = scaledTax(taxableIncome.minor, bands, basic, 0);
const after = scaledTax(taxableIncome.minor, bands, basic, grossed.gross.minor);
const taxBefore = Math.floor(before / 10000);
const extra = Math.floor((before - after) / 10000);
return {
grossDonations: grossed.gross,
basicRateRelief: grossed.taxReclaimable,
taxBefore: money(taxBefore, "GBP"),
taxAfter: money(taxBefore - extra, "GBP"),
extraRelief: money(extra, "GBP"),
netCost: money(donations.minor - extra, "GBP"),
};
}