Functional Weave
Code in Python

legal.statutory-interest@1.0.1

impl/typescript.ts

4,947 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 { addDays, daysBetween } from "./dates_add_days.ts";  ← from dates.add-days ^1.0.0 · built alongside by fune
import { latePaymentInterest } from "./finance_late_payment_interest.ts";  ← from finance.late-payment-interest ^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 { type Money, money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { COUNTY_COURT_THRESHOLDS, COUNTY_COURT_THRESHOLDS_HISTORY, JUDGMENT_RATES, JUDGMENT_RATES_HISTORY } from "./legal_statutory_interest_data.ts";  ← this capability’s own data, compiled from data/judgment-rates.json into the same file by fune build
import { type InterestBasis, type StatutoryInterest } from "./legal_statutory_interest_types.ts";

const YEAR = 10000 * 365;
const MAX_EXACT = BigInt(Number.MAX_SAFE_INTEGER);

function horizon(history: string, froms: readonly string[], onDate: string, what: string): void {
  // A history=current build keeps only rows still in force; answering an older
  // date with today's rule is the failure dated data exists to prevent.
  if (history === "full" || froms.length === 0) return;
  const earliest = froms.reduce((a, b) => (b < a ? b : a));
  if (onDate < earliest) {
    throw new RangeError(
      `no ${what} on ${onDate}: this build was installed with history=${history}, so it only carries rules from ${earliest}. Reinstall with history=full for older judgments.`,
    );
  }
}

function inForce(onDate: string, validFrom: string, validTo: string | null): boolean {
  return onDate >= validFrom && (validTo === null || onDate <= validTo);
}

function rateOn(onDate: string): number {
  for (const row of JUDGMENT_RATES) {
    if (inForce(onDate, row.validFrom, row.validTo)) return row.basisPoints;
  }
  horizon(JUDGMENT_RATES_HISTORY, JUDGMENT_RATES.map((r) => r.validFrom), onDate, "judgment interest rate");
  throw new RangeError(`no judgment interest rate in force on ${onDate}`);
}

function checked(a: number, b: number, c: number): number {
  if (BigInt(a) * BigInt(b) * BigInt(c) > MAX_EXACT) {
    throw new RangeError("the interest calculation exceeds the exact integer range");
  }
  return a * b * c;
}

/** Statutory interest on a judgment debt or a late commercial debt. */
export function statutoryInterest(amount: Money, fromDate: string, toDate: string, basis: InterestBasis): StatutoryInterest {
  if (amount.currency !== "GBP") {
    throw new RangeError(`statutory interest is defined for GBP debts, received ${amount.currency}`);
  }
  if (amount.minor <= 0) {
    throw new RangeError("amount must be greater than zero");
  }
  const zero = money(0, "GBP");
  if (basis === "late-payment") {
    const claim = latePaymentInterest(amount, fromDate, toDate);
    const rate = claim.interestRateBasisPoints ?? 0;
    return {
      days: claim.daysLate,
      rateBasisPoints: rate,
      dailyInterest: money(roundDiv(amount.minor * rate, YEAR, "half-up"), "GBP"),
      interest: claim.interest,
      compensation: claim.compensation,
      total: claim.total,
    };
  }
  if (basis !== "judgment-high-court" && basis !== "judgment-county-court") {
    throw new RangeError(`unknown interest basis "${basis}": expected judgment-high-court, judgment-county-court or late-payment`);
  }
  const days = daysBetween(fromDate, toDate);
  if (days < 0) {
    throw new RangeError(`toDate ${toDate} is before fromDate ${fromDate}`);
  }
  if (basis === "judgment-county-court") {
    const threshold = COUNTY_COURT_THRESHOLDS.find((r) => inForce(fromDate, r.validFrom, r.validTo));
    if (threshold === undefined) {
      horizon(COUNTY_COURT_THRESHOLDS_HISTORY, COUNTY_COURT_THRESHOLDS.map((r) => r.validFrom), fromDate, "County Court judgment interest rule");
      throw new RangeError(`no County Court judgment interest rule in force on ${fromDate}`);
    }
    if (amount.minor < threshold.minJudgmentMinor) {
      // Not a "relevant judgment": no statutory interest at all.
      return { days, rateBasisPoints: 0, dailyInterest: zero, interest: zero, compensation: zero, total: zero };
    }
  }
  // Each day from the day after judgment to the payment date at the rate in force on it.
  let numerator = 0;
  let covered = 0;
  const first = addDays(fromDate, 1);
  for (const row of JUDGMENT_RATES) {
    if (days === 0) break;
    const start = first > row.validFrom ? first : row.validFrom;
    const end = row.validTo === null || toDate < row.validTo ? toDate : row.validTo;
    if (start > end) continue;
    const segment = daysBetween(start, end) + 1;
    covered += segment;
    numerator += checked(amount.minor, row.basisPoints, segment);
  }
  if (covered !== days) {
    rateOn(first);
    throw new RangeError(`no judgment interest rate for every day from ${first} to ${toDate}`);
  }
  if (BigInt(numerator) > MAX_EXACT) {
    throw new RangeError("the interest calculation exceeds the exact integer range");
  }
  const rate = rateOn(toDate);
  const interest = money(roundDiv(numerator, YEAR, "half-up"), "GBP");
  return {
    days,
    rateBasisPoints: rate,
    dailyInterest: money(roundDiv(amount.minor * rate, YEAR, "half-up"), "GBP"),
    interest,
    compensation: zero,
    total: interest,
  };
}