impl/typescript.ts
6,940 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 { daysBetween } from "./dates_days_between.ts"; ← from dates.days-between ^1.0.0 · built alongside by fune
import { ukTaxYear } from "./dates_uk_tax_year.ts"; ← from dates.uk-tax-year ^1.0.0 · built alongside by fune
import { roundDiv } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { addMoney } from "./money_add.ts"; ← from money.add ^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 { BIK_BANDS, BIK_BANDS_HISTORY, BIK_LIMITS, BIK_LIMITS_HISTORY, FUEL_MULTIPLIERS, FUEL_MULTIPLIERS_HISTORY } from "./fleet_company_car_bik_data.ts"; ← this capability’s own data, compiled from data/bik-bands.json into the same file by fune build
import { type CompanyCarBik } from "./fleet_company_car_bik_types.ts";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const MAX_SAFE = 9007199254740991;
// Cars first registered before this date keep their NEDC figures, and in
// 2021-22 their own (one point higher) column of the table.
const WLTP_FROM = "2020-04-06";
interface Dated {
validFrom: string;
validTo: string | null;
}
function inForce(row: Dated, onDate: string): boolean {
return row.validFrom <= onDate && (row.validTo === null || onDate <= row.validTo);
}
// A history=current build has dropped old rows; say so rather than answering
// an old tax year with today's figures.
function missing(what: string, label: string, rows: readonly Dated[], history: string, onDate: string): never {
if (history !== "full") {
let earliest: string | null = null;
for (const row of rows) if (earliest === null || row.validFrom < earliest) earliest = row.validFrom;
if (earliest !== null && onDate < earliest) {
throw new RangeError(
`no ${what} for tax year ${label}: this build was installed with history=${history}, ` +
`so it only carries figures from ${earliest}. Reinstall with history=full for earlier years.`
);
}
}
throw new RangeError(`no ${what} for tax year ${label}`);
}
function checkGbp(name: string, amount: Money): void {
if (amount.currency !== "GBP") {
throw new RangeError(`company car benefit is worked in GBP; ${name} is in ${amount.currency}`);
}
if (amount.minor < 0) {
throw new RangeError(`${name} must not be negative, received ${amount.minor}`);
}
}
function checkWhole(name: string, value: number, unit: string): void {
if (!Number.isInteger(value) || value < 0) {
throw new RangeError(`${name} must be a whole number of ${unit}, not negative, received ${value}`);
}
}
function checkDate(name: string, value: string): void {
if (!ISO_DATE.test(value)) {
throw new RangeError(`${name} must be an ISO date (YYYY-MM-DD), received "${value}"`);
}
}
// price x basis points x days available / (10000 x days in year), rounded down
// to the penny: one rounding, and never more benefit than the law charges.
function proportion(minor: number, basisPoints: number, daysAvailable: number, daysInYear: number): number {
const product = minor * basisPoints * daysAvailable;
if (product > MAX_SAFE) {
throw new RangeError("the benefit calculation exceeds 2^53 - 1; the list price is too large");
}
return roundDiv(product, 10000 * daysInYear, "down");
}
/**
* The taxable benefit of a company car for one UK tax year (ITEPA 2003 s121).
*
* Price less capital contributions (at most 5,000.00), times the appropriate
* percentage for the car's CO2 figure and electric range in that year, cut
* pro rata for days the car was unavailable, less what the employee paid for
* private use. Free fuel adds the year's fuel benefit multiplier times the same
* percentage, cut for the same days.
*/
export function companyCarBik(
listPrice: Money,
capitalContributions: Money,
co2: number,
electricRangeMiles: number,
dieselSupplement: boolean,
firstRegistered: string,
onDate: string,
unavailableDays: number,
privateUseContributions: Money,
freeFuel: boolean
): CompanyCarBik {
checkGbp("listPrice", listPrice);
checkGbp("capitalContributions", capitalContributions);
checkGbp("privateUseContributions", privateUseContributions);
checkWhole("co2", co2, "g/km");
checkWhole("electricRangeMiles", electricRangeMiles, "miles");
checkDate("firstRegistered", firstRegistered);
checkDate("onDate", onDate);
if (dieselSupplement && co2 === 0) {
throw new RangeError("a zero-emission car cannot take the diesel supplement");
}
const year = ukTaxYear(onDate);
if (firstRegistered > year.end) {
throw new RangeError(`firstRegistered (${firstRegistered}) is after the end of tax year ${year.label}`);
}
const daysInYear = daysBetween(year.start, year.end) + 1;
if (!Number.isInteger(unavailableDays) || unavailableDays < 0 || unavailableDays > daysInYear) {
throw new RangeError(`unavailableDays must be a whole number from 0 to ${daysInYear}, received ${unavailableDays}`);
}
const daysAvailable = daysInYear - unavailableDays;
const registration = firstRegistered < WLTP_FROM ? "before-2020-04-06" : "from-2020-04-06";
const applies = (row: { registration: string }) => row.registration === "any" || row.registration === registration;
const limit = BIK_LIMITS.find((row) => applies(row) && inForce(row, onDate));
if (limit === undefined) missing("company car benefit rates", year.label, BIK_LIMITS, BIK_LIMITS_HISTORY, onDate);
let basisPoints: number;
if (co2 >= 75) {
// s139(3): one point per whole 5 g/km above 75, up to the year's maximum.
basisPoints = Math.min(limit.from75BasisPoints + 100 * Math.floor((co2 - 75) / 5), limit.maximumBasisPoints);
} else {
const band = BIK_BANDS.find(
(row) =>
applies(row) &&
inForce(row, onDate) &&
row.co2From <= co2 &&
co2 <= row.co2To &&
row.rangeFrom <= electricRangeMiles &&
(row.rangeTo === null || electricRangeMiles <= row.rangeTo)
);
if (band === undefined) missing("company car benefit band", year.label, BIK_BANDS, BIK_BANDS_HISTORY, onDate);
basisPoints = band.basisPoints;
}
if (dieselSupplement) {
basisPoints = Math.min(basisPoints + limit.dieselSupplementBasisPoints, limit.maximumBasisPoints);
}
const counted = Math.min(capitalContributions.minor, limit.capitalContributionCapMinor);
const price = money(Math.max(listPrice.minor - counted, 0), "GBP");
const provisional = proportion(price.minor, basisPoints, daysAvailable, daysInYear);
const carBenefit = money(Math.max(provisional - privateUseContributions.minor, 0), "GBP");
let fuelBenefit = money(0, "GBP");
// s149(4): electricity, and energy for a car that cannot emit CO2, is not fuel.
if (freeFuel && co2 > 0) {
const multiplier = FUEL_MULTIPLIERS.find((row) => inForce(row, onDate));
if (multiplier === undefined) {
missing("car fuel benefit multiplier", year.label, FUEL_MULTIPLIERS, FUEL_MULTIPLIERS_HISTORY, onDate);
}
fuelBenefit = money(proportion(multiplier.pounds * 100, basisPoints, daysAvailable, daysInYear), "GBP");
}
return {
taxYear: year.label,
daysInYear,
daysAvailable,
price,
appropriateBasisPoints: basisPoints,
carBenefit,
fuelBenefit,
total: addMoney(carBenefit, fuelBenefit),
};
}