finance.late-payment-interest@1.0.0
impl/typescript.ts
3,961 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 } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { daysBetween } from "./dates_days_between.ts"; ← from dates.days-between ^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 { FIXED_SUMS, FIXED_SUMS_HISTORY } from "./finance_late_payment_interest_data.ts"; ← this capability’s own data, compiled from data/fixed-sums.json into the same file by fune build
import { ukBankRate } from "./finance_uk_bank_rate.ts"; ← from finance.uk-bank-rate ^1.0.0 · built alongside by fune
import { type LatePaymentClaim } from "./finance_late_payment_interest_types.ts";
const STATUTORY_MARGIN = 800; // 8% over the reference rate, SI 2002/1675 art. 4
const MAX_EXACT = BigInt(Number.MAX_SAFE_INTEGER);
function inForce(onDate: string, validFrom: string, validTo: string | null): boolean {
return onDate >= validFrom && (validTo === null || onDate <= validTo);
}
function prunedHorizon(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 figure 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 debts.`,
);
}
}
function fixedSumFor(debtMinor: number, onDate: string): number {
let best: { minDebtMinor: number; compensationMinor: number } | null = null;
for (const row of FIXED_SUMS) {
if (!inForce(onDate, row.validFrom, row.validTo) || debtMinor < row.minDebtMinor) continue;
if (best === null || row.minDebtMinor > best.minDebtMinor) best = row;
}
if (best === null) {
prunedHorizon(FIXED_SUMS_HISTORY, FIXED_SUMS.map((r) => r.validFrom), onDate, "Late Payment Act fixed sum");
throw new RangeError(`no Late Payment Act fixed sum in force on ${onDate}`);
}
return best.compensationMinor;
}
/** The 30 June or 31 December immediately before the day interest starts. */
function referenceDateFor(startDate: string): string {
const year = Number(startDate.slice(0, 4));
const month = Number(startDate.slice(5, 7));
return month <= 6 ? `${String(year - 1).padStart(4, "0")}-12-31` : `${startDate.slice(0, 4)}-06-30`;
}
/**
* Statutory interest and the fixed sum on a late commercial debt (UK).
*
* The reference rate is fixed when interest starts, not when it is paid, and
* interest is simple at 1/365 of the yearly rate per day, rounded once.
*/
export function latePaymentInterest(debt: Money, dueDate: string, paymentDate: string): LatePaymentClaim {
if (debt.currency !== "GBP") {
throw new RangeError(`late payment interest is defined for GBP debts, received ${debt.currency}`);
}
if (debt.minor <= 0) {
throw new RangeError("debt must be greater than zero");
}
const late = daysBetween(dueDate, paymentDate);
const zero = money(0, "GBP");
if (late <= 0) {
return {
daysLate: 0,
referenceDate: null,
referenceRateBasisPoints: null,
interestRateBasisPoints: null,
interest: zero,
compensation: zero,
total: zero,
};
}
const startDate = addDays(dueDate, 1);
const compensation = money(fixedSumFor(debt.minor, startDate), "GBP");
const referenceDate = referenceDateFor(startDate);
const referenceRate = ukBankRate(referenceDate).basisPoints;
const rate = STATUTORY_MARGIN + referenceRate;
if (BigInt(debt.minor) * BigInt(rate) * BigInt(late) > MAX_EXACT) {
throw new RangeError("the interest calculation exceeds the exact integer range");
}
const interest = money(roundDiv(debt.minor * rate * late, 10000 * 365, "half-up"), "GBP");
return {
daysLate: late,
referenceDate,
referenceRateBasisPoints: referenceRate,
interestRateBasisPoints: rate,
interest,
compensation,
total: addMoney(interest, compensation),
};
}