Functional Weave
Code in TypeScript

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,
  };
}