UNPKG

asset-loan-amortization

Version:

Amortisation maths for fixed-rate asset-backed instalment loans: level payment, full period-by-period schedule, total finance charge, and extra-payment payoff savings. Zero dependencies.

139 lines (122 loc) 4.71 kB
'use strict'; /** * asset-loan-amortization * Amortisation maths for fixed-rate instalment loans (vehicle, equipment, land, * manufactured-home and other asset-backed notes). * * All money values are handled as plain numbers in major currency units. * Rounding to the cent is applied only when a row is reported and is never * carried into the next period's balance, so the running balance stays exact * and the reported principal column sums back to the principal to within the * per-row rounding error (at most 0.005 per period). */ const MONTHS_PER_YEAR = 12; function assertFinitePositive(name, value, allowZero) { if (typeof value !== 'number' || !Number.isFinite(value)) { throw new TypeError(name + ' must be a finite number'); } if (allowZero ? value < 0 : value <= 0) { throw new RangeError(name + (allowZero ? ' must be >= 0' : ' must be > 0')); } } function round2(n) { return Math.round((n + Number.EPSILON) * 100) / 100; } /** * Level monthly payment for a fully amortising fixed-rate loan. * * pmt = P * i / (1 - (1 + i)^-n) where i = annualRate / 12 * * A zero rate degrades to P / n. * * @param {object} opts * @param {number} opts.principal amount financed, > 0 * @param {number} opts.annualRate nominal annual rate as a decimal (0.0699 = 6.99%), >= 0 * @param {number} opts.termMonths integer number of monthly payments, > 0 * @returns {number} unrounded monthly payment */ function monthlyPayment(opts) { const o = opts || {}; assertFinitePositive('principal', o.principal, false); assertFinitePositive('annualRate', o.annualRate, true); assertFinitePositive('termMonths', o.termMonths, false); if (!Number.isInteger(o.termMonths)) { throw new RangeError('termMonths must be an integer'); } const i = o.annualRate / MONTHS_PER_YEAR; if (i === 0) return o.principal / o.termMonths; return (o.principal * i) / (1 - Math.pow(1 + i, -o.termMonths)); } /** * Full period-by-period amortisation schedule. * * Supports a recurring `extraPayment` applied to principal each period. When * extra principal is paid the loan retires early and the schedule is shorter * than termMonths. The final period's payment is trimmed so the balance lands * exactly on zero rather than going negative. * * @param {object} opts principal, annualRate, termMonths, [extraPayment=0] * @returns {{payment:number, periods:Array, totalInterest:number, totalPaid:number, months:number}} */ function schedule(opts) { const o = opts || {}; const extra = o.extraPayment === undefined ? 0 : o.extraPayment; assertFinitePositive('extraPayment', extra, true); const payment = monthlyPayment(o); const i = o.annualRate / MONTHS_PER_YEAR; let balance = o.principal; let totalInterest = 0; let totalPaid = 0; const periods = []; // Bounded loop: extra principal can only shorten the term, never lengthen it. for (let period = 1; period <= o.termMonths && balance > 0; period += 1) { const interest = balance * i; let principalPart = payment + extra - interest; if (principalPart > balance) principalPart = balance; const cash = principalPart + interest; balance -= principalPart; totalInterest += interest; totalPaid += cash; periods.push({ period: period, payment: round2(cash), interest: round2(interest), principal: round2(principalPart), balance: round2(balance) }); } if (periods.length < 1) throw new Error('schedule produced no periods'); if (round2(balance) !== 0) throw new Error('schedule did not amortise to zero'); return { payment: round2(payment), periods: periods, totalInterest: round2(totalInterest), totalPaid: round2(totalPaid), months: periods.length }; } /** * Total finance charge over the life of the loan. * @param {object} opts same shape as schedule() * @returns {number} */ function totalInterest(opts) { return schedule(opts).totalInterest; } /** * Interest saved and months removed by paying `extraPayment` extra each period. * @param {object} opts same shape as schedule(), extraPayment required * @returns {{interestSaved:number, monthsSaved:number, baselineMonths:number, acceleratedMonths:number}} */ function payoffSavings(opts) { const o = opts || {}; const base = schedule({ principal: o.principal, annualRate: o.annualRate, termMonths: o.termMonths }); const fast = schedule(o); return { interestSaved: round2(base.totalInterest - fast.totalInterest), monthsSaved: base.months - fast.months, baselineMonths: base.months, acceleratedMonths: fast.months }; } module.exports = { monthlyPayment, schedule, totalInterest, payoffSavings, round2 };