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
JavaScript
;
/**
* 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 };