insurance.instalment-plan@1.0.0
impl/typescript.ts
2,930 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 { apr } from "./lending_apr.ts"; ← from lending.apr ^1.0.0 · built alongside by fune
import { type CreditFlow } from "./lending_apr_types.ts";
import { type Money, assertSameCurrency, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { applyRate } from "./money_apply_rate.ts"; ← from money.apply-rate ^1.0.0 · built alongside by fune
import { type InstalmentPlan } from "./insurance_instalment_plan_types.ts";
/**
* A premium paid by monthly instalments.
*
* The credit charge is a flat percentage of the amount financed (premium less
* deposit), the way UK insurers quote it. The instalments are the amount
* financed plus the charge, split so every penny is accounted for: equal
* instalments rounded down, with the odd pennies on the first. The APR is
* then worked from those exact cash flows by lending.apr, because a flat
* 10% repaid over ten months costs far more than 10% a year.
*/
export function instalmentPlan(
premium: Money,
deposit: Money,
instalmentCount: number,
creditChargeBasisPoints: number,
firstInstalmentMonth: number
): InstalmentPlan {
assertSameCurrency(premium, deposit);
if (!Number.isInteger(instalmentCount) || instalmentCount < 1) {
throw new RangeError(`instalmentCount must be a whole number of 1 or more, received ${instalmentCount}`);
}
if (!Number.isInteger(creditChargeBasisPoints) || creditChargeBasisPoints < 0) {
throw new RangeError(`creditChargeBasisPoints must be a whole number of 0 or more, received ${creditChargeBasisPoints}`);
}
if (!Number.isInteger(firstInstalmentMonth) || firstInstalmentMonth < 0) {
throw new RangeError(`firstInstalmentMonth must be a whole number of 0 or more, received ${firstInstalmentMonth}`);
}
if (deposit.minor < 0) {
throw new RangeError(`deposit must not be negative, received ${deposit.minor}`);
}
if (deposit.minor >= premium.minor) {
throw new RangeError("the deposit leaves nothing to pay by instalments");
}
if (instalmentCount === 1 && firstInstalmentMonth === 0) {
throw new RangeError("a single instalment at inception is payment in full, not credit");
}
const c = premium.currency;
const financed = money(premium.minor - deposit.minor, c);
const charge = applyRate(financed, creditChargeBasisPoints, "half-up");
const repayable = financed.minor + charge.minor;
const each = Math.floor(repayable / instalmentCount);
if (each < 1) {
throw new RangeError("each instalment must be at least one minor unit");
}
const first = repayable - each * (instalmentCount - 1);
const instalments: Money[] = [];
const repayments: CreditFlow[] = [];
for (let i = 0; i < instalmentCount; i++) {
const amount = money(i === 0 ? first : each, c);
instalments.push(amount);
repayments.push({ period: firstInstalmentMonth + i, amount });
}
return {
deposit,
amountFinanced: financed,
creditCharge: charge,
instalments,
totalPayable: money(deposit.minor + repayable, c),
apr: apr([{ period: 0, amount: financed }], repayments, 12),
};
}