lending.overpayment-effect@1.0.1
impl/typescript.ts
3,591 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 RoundingMode } 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 { paymentCount } from "./lending_loan_payment.ts"; ← from lending.loan-payment ^1.0.0 · built alongside by fune
import { amortisationSchedule, periodInterest } from "./lending_amortisation_schedule.ts"; ← from lending.amortisation-schedule ^1.0.0 · built alongside by fune
import { type OverpaymentEffect } from "./lending_overpayment_effect_types.ts";
interface PayOff {
payments: number;
finalPayment: number;
interest: number;
}
/**
* Run a balance down at a fixed payment, with the schedule's rounding, until
* it is clear or the term ends; as in lending.amortisation-schedule, the last
* payment of the term is whatever clears the balance.
*/
function payOff(balance: number, annualRateBasisPoints: number, paymentsPerYear: number, payment: number, term: number): PayOff {
let owed = balance;
let payments = 0;
let interest = 0;
let finalPayment = 0;
while (owed > 0) {
const accrued = periodInterest(owed, annualRateBasisPoints, paymentsPerYear);
if (payment <= accrued) {
throw new RangeError(`the payment of ${payment} does not cover the interest of ${accrued}`);
}
const due = payments + 1 === term || owed + accrued <= payment ? owed + accrued : payment;
owed -= due - accrued;
interest += accrued;
payments += 1;
finalPayment = due;
}
return { payments, finalPayment, interest };
}
/**
* The two things a lender offers after a lump-sum overpayment, side by side:
* keep paying the same amount and finish sooner, or keep the same end date
* and pay less each period. Both are built period by period with the same
* rounding as lending.amortisation-schedule, and compared with carrying on
* as if nothing had been overpaid.
*/
export function overpaymentEffect(
balance: Money,
annualRateBasisPoints: number,
remainingTermMonths: number,
paymentsPerYear: number,
payment: Money,
overpayment: Money,
mode: RoundingMode,
): OverpaymentEffect {
const term = paymentCount(annualRateBasisPoints, remainingTermMonths, paymentsPerYear);
const currency = balance.currency;
for (const other of [payment, overpayment]) {
if (other.currency !== currency) {
throw new RangeError(`currency mismatch: ${currency} and ${other.currency}`);
}
}
if (!Number.isInteger(payment.minor) || payment.minor <= 0) {
throw new RangeError(`payment must be greater than zero, received ${payment.minor}`);
}
if (!Number.isInteger(overpayment.minor) || overpayment.minor <= 0) {
throw new RangeError(`overpayment must be greater than zero, received ${overpayment.minor}`);
}
if (overpayment.minor >= balance.minor) {
throw new RangeError("overpayment must be less than the balance; paying it all off is an early settlement");
}
const newBalance = balance.minor - overpayment.minor;
const baseline = payOff(balance.minor, annualRateBasisPoints, paymentsPerYear, payment.minor, term);
const shorter = payOff(newBalance, annualRateBasisPoints, paymentsPerYear, payment.minor, term);
const lower = amortisationSchedule(money(newBalance, currency), annualRateBasisPoints, remainingTermMonths, paymentsPerYear, mode);
return {
newBalance: money(newBalance, currency),
baselinePayments: baseline.payments,
baselineInterest: money(baseline.interest, currency),
reducedTermPayments: shorter.payments,
reducedTermFinalPayment: money(shorter.finalPayment, currency),
reducedTermInterest: money(shorter.interest, currency),
paymentsSaved: baseline.payments - shorter.payments,
reducedPayment: lower.payment,
reducedPaymentInterest: lower.totalInterest,
};
}