Functional Weave
Code in TypeScript

fleet.mileage-allowance@1.0.2

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