(function() {
var environment;
if(typeof define === 'function') {
define('Payoff', Payoff);
} else {
environment = (typeof global === 'object') ? global : window;
environment.Payoff = Payoff;
}
return Payoff;
/**
* @class Payoff
* @classdesc Creates instances of payoffs, pre-calculating all details for the passed loan terms.
* @constructor
* @param {object} setup - A hash of available setup parameters.
* @see {@link Payoff#update} for the available properties passed through to the initial loan setup.
*/
function Payoff(setup) {
const payments = [ ]; // a list of extra payments
let terms = { }; // contains the terms of the loan
let index = 0; // an incremented index, to be assigned to additional payments
// private function
const reconcile = () => {
const {
startMonth, // the month the loan starts; NOTE: (month + 1) for first payment date, -1 for JS zero-indexing
startYear, // the year the loan starts
duration, // total number of months in the loan terms
amount, // total amount borrowed
rate // the vig (apr)
} = terms;
const startDay = 1; // hard-code start-day to 1
const monthlyRate = rate / 12;
const totalRate = monthlyRate + 1;
const overallRate = Math.pow(totalRate, duration);
let monthlyPayment = amount * ((monthlyRate * overallRate) / (overallRate - 1));
monthlyPayment = roundNumber(monthlyPayment, 2);
let [total, totalInterest, totalPrincipal] = [0, 0, 0];
let principal = amount;
let table = [ ];
for(let i = 0; i <= duration; i++) {
const date = new Date(startYear, startMonth + i, startDay);
const year = date.getFullYear();
const yearLabel = year.toString();
const month = date.getMonth()+1;
const monthLabel = padZeroes(month, 2);
const day = date.getDate();
const dayLabel = padZeroes(day, 2);
let extraPayment = 0;
for(let j = 0; j < payments.length; j++) {
const payment = payments[j];
const paymentYear = payment.year;
const paymentMonth = payment.month;
const paymentEndYear = payment.endYear;
const paymentEndMonth = payment.endMonth;
const recurring = payment.recurring;
let matches = false;
if(paymentYear === year && paymentMonth === month) { // exact match
matches = true;
} else if(recurring) { // monthly range of extra payments
if(year > paymentYear || (year === paymentYear && month >= paymentMonth)) { // range has started
if(typeof paymentEndMonth === `number` && typeof paymentEndYear === `number`) { // end date was passed
if(year < paymentEndYear || (year === paymentEndYear && month <= paymentEndMonth)) { // range hasn't ended
matches = true;
}
} else { // no recurring end date set
matches = true;
}
}
}
if(matches) {
extraPayment += payment.amount;
}
}
const interestPaid = roundNumber(principal * monthlyRate, 2);
let principalPaid = roundNumber(monthlyPayment - interestPaid + extraPayment, 2);
principal = roundNumber(principal - principalPaid, 2);
if(principal < 0) {
principalPaid += principal;
principal = 0;
}
const paid = interestPaid + principalPaid;
table.push({
paid,
year,
month,
principal,
interestPaid,
principalPaid,
date: `${yearLabel}-${monthLabel}-${dayLabel}`,
});
total += paid;
totalInterest += interestPaid;
totalPrincipal += principalPaid;
if(principal <= 0) {
break;
}
}
let lastPayment = table[table.length - 1];
/**
* The initial loan amount.
* @member payment#amount
* @type {number}
**/
this.amount = roundNumber(amount, 2);
/**
* The total interest paid over the course of the loan.
* @type {number}
**/
this.interestPaid = roundNumber(totalInterest, 2);
/**
* The minimum monthly payment in order to pay off the loan on time.
* @type {number}
**/
this.monthlyPayment = roundNumber(monthlyPayment, 2);
/**
* The total amount paid over the course of the loan.
* @type {number}
**/
this.paid = roundNumber(total, 2);
/**
* The date of the final payment.
* @type {string}
**/
this.payoffDate = lastPayment.date;
/**
* The month of the final payment.
* @type {number}
**/
this.payoffMonth = lastPayment.month;
/**
* The year of the final payment.
* @type {number}
**/
this.payoffYear = lastPayment.year;
/**
* The total principal paid over the course of the loan.
* @type {number}
**/
this.principalPaid = roundNumber(totalPrincipal, 2);
/**
* The start year of the loan.
* @type {number}
**/
this.startYear = startYear;
/**
* The start month of the loan.
* @type {number}
**/
this.startMonth = startMonth;
/**
* The amortization table of each payment.
* @type {array}
**/
this.table = table;
};
Object.defineProperties(this, {
/**
* Recalculate the payoff properties and amortization table.
* @method Payoff#update
* @param {object} terms - A hash of loan parameters. Used to define/update the basic terms of a loan.
* @param {number} terms.amount - The total amount being borrowed.
* @param {integer} terms.duration - The number of months the loan is spread over.
* @param {number} terms.rate - The annual interest rate of the loan (ex. 0.05 for 5% APR).
* @param {integer} terms.startMonth - The month the loan begins (indexed 1-12).
* @param {integer} terms.startYear - The year the loan begins.
*/
update: {
value: (newTerms) => {
if(typeof terms !== `object`) {
throw new Error(`setup object required`);
}
terms = newTerms;
return reconcile();
}
},
/**
* Add a supplemental payment to the loan.
* @method Payoff#addPayment
* @param {object} payment - A hash of payment parameters.
* @param {number} payment.amount - The amount of extra principal being paid
* @param {number} payment.month - The month of the extra payment
* @param {number} payment.year - The year of the extra payment
* @param {boolean} [payment.recurring=false] - Whether it's a single-time or recurring payment
* @param {number} [payment.endMonth] - The last month of the recurring payment
* @param {number} [payment.endYear] - The last year of the recurring payment
*/
addPayment: {
value: (payment) => {
if(typeof payment.amount !== `number`) {
throw new Error(`Payoff.addPayment(): No amount specified`);
}
if(typeof payment.month !== `number`) {
throw new Error(`Payoff.addPayment(): No month specified`);
}
if(typeof payment.year !== `number`) {
throw new Error(`Payoff.addPayment(): No year specified`);
}
payment.id = index++;
if(payment.recurring !== true) {
payment.recurring = false;
}
payments.push(payment);
return reconcile();
}
},
/**
* Remove a supplemental payment from the loan.
* @method Payoff#removePayment
* @param {object} options - A hash of option parameters
* @param {number} options.id - The id of the payment to remove
*/
removePayment: {
value: (options) => {
if(typeof options.id !== `number`) {
throw new Error(`Payoff.removePayment(): No id specified`);
}
for(let i = 0; i < payments.length; i++) {
const payment = payments[i];
if(options.id === payment.id) {
payments.splice(i, 1);
break;
}
}
return reconcile();
}
},
/**
* The list of supplemental payments that have been added to the loan.
* @readonly
* @member {array} Payoff.payments
*/
payments: {
get: () => {
const stringified = JSON.stringify(payments);
return JSON.parse(stringified); // clone the array
}
}
});
return this.update(setup);
}
/**
* @function padZeroes
* @static
* @param {number} num - The number being padded.
* @param {integer} minLength - How minimum length of the final number.
* @return {string} The passed number, prepended with any extra zeroes needed to reach minLength.
* @example padZeroes(1, 2); // "01"
* @example padZeroes(15, 2); // "15"
* @example padZeroes(12, 3); // "012"
*/
function padZeroes(num, minLength) {
const string = Math.floor(num).toString();
const zeroesNeeded = minLength - string.length;
let prefix = '';
for(let i = 0; i < zeroesNeeded; i++) {
prefix += `0`;
}
return `${prefix}${string}`;
}
/**
* @function roundNumber
* @static
* @param {number} num - The number being padded.
* @param {integer} minLength - How minimum length of the final number.
* @return {number} The passed number, rounded to the passed number of places.
* @example roundNumber(1.1426, 2); // 1.14
* @example roundNumber(1.1426, 3); // 1.143
*/
function roundNumber(num, places) {
const factor = Math.pow(10, places);
const bigVersion = num * factor;
return Math.round(bigVersion) / factor;
}
})(this);