impl/typescript.ts
4,654 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 { subtractMoney } 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 { AMAP_RATES, AMAP_RATES_HISTORY } from "./fleet_mileage_allowance_data.ts"; ← this capability’s own data, compiled from data/amap-rates.json into the same file by fune build
import { type MileageAllowance, type MileageVehicle } from "./fleet_mileage_allowance_types.ts";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
interface Rate {
firstRatePence: number;
thresholdMiles: number | null;
secondRatePence: number;
validFrom: string;
}
function rateOn(vehicle: string, onDate: string): Rate {
let best: Rate | null = null;
let earliest: string | null = null;
for (const rule of AMAP_RATES) {
if (rule.vehicle !== vehicle) continue;
if (earliest === null || rule.validFrom < earliest) earliest = rule.validFrom;
if (onDate < rule.validFrom) continue;
if (rule.validTo !== null && onDate > rule.validTo) continue;
if (best === null || rule.validFrom > best.validFrom) best = rule;
}
if (best !== null) return best;
// A history=current build has dropped the old rows; refuse rather than pay an
// old claim at today's rate.
if (AMAP_RATES_HISTORY !== "full" && earliest !== null && onDate < earliest) {
throw new RangeError(
`no approved mileage rate for ${vehicle} on ${onDate}: this build was installed with history=${AMAP_RATES_HISTORY}, ` +
`so it only carries rates from ${earliest}. Reinstall with history=full for older claims.`
);
}
throw new RangeError(`no approved mileage rate for ${vehicle} on ${onDate}`);
}
function wholeMiles(name: string, value: number): void {
if (!Number.isInteger(value) || value < 0) {
throw new RangeError(`${name} must be a whole number of miles, not negative, received ${value}`);
}
}
function gbp(name: string, value: Money): void {
if (value.currency !== "GBP") {
throw new RangeError(`${name} must be in GBP, received ${value.currency}`);
}
if (value.minor < 0) {
throw new RangeError(`${name} must not be negative, received ${value.minor}`);
}
}
/** The part of a - b above zero, and the part below it, as two amounts. */
function excessAndShortfall(paid: Money, approved: Money): [Money, Money] {
const difference = subtractMoney(paid, approved);
if (difference.minor > 0) return [difference, money(0, "GBP")];
return [money(0, "GBP"), money(-difference.minor, "GBP")];
}
/**
* HMRC approved mileage allowance payments for business miles in an
* employee's own vehicle.
*
* The 10,000-mile threshold is per tax year, so a claim that crosses it is
* split: `previousMiles` says where in the year this claim starts. Paying a
* flat 45p (or 55p) on every mile past 10,000 is the usual mistake; the
* excess over 25p is taxable.
*/
export function mileageAllowance(
vehicle: MileageVehicle,
previousMiles: number,
miles: number,
passengerMiles: number,
paid: Money,
passengerPaid: Money,
onDate: string
): MileageAllowance {
if (vehicle !== "car-van" && vehicle !== "motorcycle" && vehicle !== "bicycle") {
throw new RangeError(`unknown vehicle "${vehicle}": use car-van, motorcycle or bicycle`);
}
wholeMiles("previousMiles", previousMiles);
wholeMiles("miles", miles);
wholeMiles("passengerMiles", passengerMiles);
gbp("paid", paid);
gbp("passengerPaid", passengerPaid);
if (!ISO_DATE.test(onDate)) {
throw new RangeError(`onDate must be an ISO date (YYYY-MM-DD), received "${onDate}"`);
}
if (vehicle !== "car-van" && (passengerMiles > 0 || passengerPaid.minor > 0)) {
throw new RangeError("passenger payments apply only to cars and vans");
}
const rate = rateOn(vehicle, onDate);
let milesAtFirstRate = miles;
let milesAtSecondRate = 0;
if (rate.thresholdMiles !== null) {
const firstLeft = Math.max(0, rate.thresholdMiles - previousMiles);
milesAtFirstRate = Math.min(miles, firstLeft);
milesAtSecondRate = miles - milesAtFirstRate;
}
const approved = money(milesAtFirstRate * rate.firstRatePence + milesAtSecondRate * rate.secondRatePence, "GBP");
const [taxableExcess, relief] = excessAndShortfall(paid, approved);
let passengerApproved = money(0, "GBP");
if (vehicle === "car-van") {
passengerApproved = money(passengerMiles * rateOn("passenger", onDate).firstRatePence, "GBP");
}
// There is no relief for passenger payments below 5p, only tax above it.
const [passengerTaxableExcess] = excessAndShortfall(passengerPaid, passengerApproved);
return {
approved,
paid,
taxableExcess,
relief,
milesAtFirstRate,
milesAtSecondRate,
firstRatePence: rate.firstRatePence,
secondRatePence: rate.secondRatePence,
passengerApproved,
passengerTaxableExcess,
};
}