lending.early-settlement@1.0.1
impl/typescript.ts
4,612 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 { type Money, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { addDays } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { addMonths } from "./dates_add_months.ts"; ← from dates.add-months ^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 { FIXED_SCALE, powFixed, rootFixed } from "./math_fractional_power.ts"; ← from math.fractional-power ^1.0.0 · built alongside by fune
import { type DatedAmount, type EarlySettlement, type SettlementDeferral } from "./lending_early_settlement_types.ts";
/**
* The settlement date: 28 days after the creditor receives the notice
* (regulation 5(1)(a)), or a later date the notice names, then deferred by a
* month or 30 days where the creditor elects and regulation 6 allows it
* (credit repaid over more than a year from the first drawdown).
*/
function settlementDate(
advances: readonly DatedAmount[],
repayments: readonly DatedAmount[],
noticeReceivedIso: string,
requestedSettlementIso: string | null,
deferral: SettlementDeferral,
): string {
let date = addDays(noticeReceivedIso, 28);
if (requestedSettlementIso !== null) {
daysBetween(requestedSettlementIso, requestedSettlementIso);
if (requestedSettlementIso > date) date = requestedSettlementIso;
}
if (deferral === "none") return date;
if (deferral !== "one-month" && deferral !== "thirty-days") {
throw new RangeError(`unknown deferral "${deferral}": expected none, one-month or thirty-days`);
}
const first = advances.reduce((min, a) => (a.date < min ? a.date : min), advances[0].date);
const last = repayments.reduce((max, r) => (r.date > max ? r.date : max), repayments[0].date);
if (last <= addMonths(first, 12)) {
throw new RangeError("deferral is only allowed when the credit is repaid over more than a year (regulation 6)");
}
return deferral === "one-month" ? addMonths(date, 1) : addDays(date, 30);
}
/**
* The amount a debtor pays to settle a regulated consumer credit agreement
* early, and the rebate, per the Consumer Credit (Early Settlement)
* Regulations 2004, regulation 4(1): the rebate is the scheduled repayments
* falling due after the settlement date less
*
* Σ A_i (1 + r)^a_i − Σ B_j (1 + r)^b_j
*
* over the advances and the repayments made before the settlement date,
* each carried forward to the settlement date at the APR. That difference is
* the settlement figure. Times are days / 365 of a year.
*/
export function earlySettlement(
advances: readonly DatedAmount[],
repayments: readonly DatedAmount[],
aprBasisPoints: number,
noticeReceivedIso: string,
requestedSettlementIso: string | null,
deferral: SettlementDeferral,
): EarlySettlement {
if (advances.length === 0) throw new RangeError("advances must not be empty");
if (repayments.length === 0) throw new RangeError("repayments must not be empty");
if (!Number.isInteger(aprBasisPoints) || aprBasisPoints < 0 || aprBasisPoints > 1000000) {
throw new RangeError(`aprBasisPoints must be between 0 and 1000000, received ${aprBasisPoints}`);
}
const currency = advances[0].amount.currency;
for (const flow of [...advances, ...repayments]) {
daysBetween(flow.date, flow.date);
if (flow.amount.currency !== currency) {
throw new RangeError(`currency mismatch: ${currency} and ${flow.amount.currency}`);
}
if (!Number.isInteger(flow.amount.minor) || flow.amount.minor <= 0) {
throw new RangeError(`every amount must be greater than zero, received ${flow.amount.minor}`);
}
}
const date = settlementDate(advances, repayments, noticeReceivedIso, requestedSettlementIso, deferral);
// (1 + APR)^(1/365), once; each flow is then a whole power of it.
const daily = rootFixed((FIXED_SCALE * BigInt(10000 + aprBasisPoints)) / 10000n, 365);
const carried = (flow: DatedAmount) =>
BigInt(flow.amount.minor) * powFixed(daily, daysBetween(flow.date, date));
let owed = 0n;
let drawn = false;
for (const advance of advances) {
if (advance.date < date) {
owed += carried(advance);
drawn = true;
}
}
if (!drawn) throw new RangeError(`no credit was advanced before the settlement date ${date}`);
let remaining = 0;
for (const repayment of repayments) {
if (repayment.date < date) owed -= carried(repayment);
else remaining += repayment.amount.minor;
}
// Rounded down: the rebate must be at least the one the formula gives.
const formula = owed > 0n ? Number(owed / FIXED_SCALE) : 0;
const settlement = Math.min(formula, remaining);
return {
settlementDate: date,
settlementAmount: money(settlement, currency),
remainingRepayments: money(remaining, currency),
rebate: money(remaining - settlement, currency),
};
}