legal.statutory-interest@1.0.0
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,
};
}