UNPKG

loan

Version:

Tool for making calculations on loans

452 lines (379 loc) 12.6 kB
'use strict'; var _ = require('lodash'); var moment = require('moment'); /** * @class Loan * @summary Constructor for a Loan * @param {Object} [props] * @param {Number} [props.interest_rate=0] - Nominal yearly interest rate * @param {Number} [props.principal=0] - Current debt * @param {Number} [props.instalments=0] - Instalments left * @param {Number} [props.instalment=0] - Instalments made up to today * @param {String} [props.pay_every="month"] - Period of payment: month/year/quarter * @param {String} [props.type="annuity"] - Type of payment plan: serial/annuity * @param {Number} [props.invoice_fee=0] - Invoice fee * @param {Number} [props.initial_fee=0] - Initial fee * @param {Number} [props.to_pay] - Amount due current period * @param {Number} [props.amortization=0] - Amortization size current period * @param {Number} [props.interest=0] - Interest size current period * @param {Boolean} [props.locked=false] - Locked loan * @param {Date} [props.as_of] - Data current as of * @param {Object} [props.data] - Extra data * @return {Loan} */ var Loan = function(props) { if(!_.isObject(props) || !props) props = {}; if(!(this instanceof Loan)) { return new Loan(props); } var defaults = this.getDefaults(); _.extend(this, defaults, _.pick(props, _.keys(defaults))); }; module.exports = Loan; /** * @type Utils * @static */ Loan.Utils = Loan.utils = require('./utils'); /** * @type Format * @static */ Loan.Format = Loan.format = require('./format'); /** * @summary Return default values for this instance * @return {Object} */ Loan.prototype.getDefaults = function() { return { interest_rate: 0, principal: 0, instalments: 0, instalment: 0, pay_every: 'month', type: 'annuity', invoice_fee: 0, initial_fee: 0, to_pay: 0, amortization: 0, interest: 0, locked: false, as_of: null, data: {} }; }; /** * @summary Calculate the total cost of the loan * @return {Number} */ Loan.prototype.getTotalCost = function() { if(this.instalments < 1) return 0; return _.reduce(this.getPaymentPlan(), function(total, curr) { return total + curr.invoice_fee + curr.initial_fee + curr.interest; }, 0); }; /** * @summary Calculate some meta fields and put them in the data object */ Loan.prototype.calculateMeta = function() { if(!this.data) this.data = {}; var locked = this.locked; var plan = this.getPaymentPlan(); var fplan = _.first(plan) || {}; var ndata = { interest_rate_effective: this.getEffectiveInterestRate(), should_amortize: this.shouldAmortize(), monthly_cost: (fplan.to_pay || 0) + (fplan.invoice_fee || 0), total_cost: this.getTotalCost() || 0 }; // omit existing keys in data when loan is locked ndata = locked ? _.omit(ndata, _.keys(this.data)) : ndata; this.data = _.extend(this.data, ndata); return this.data; }; /** * @summary Determine if the properties of this loan can be used to calculate an unkown property * @param {String} prop * @return {Boolean} */ Loan.prototype.canCalculateUnknown = function(prop) { var isFiniteNumber = Loan.Utils.isFiniteNumber; switch(prop) { case 'instalments': return _.every([ !this.instalments, isFiniteNumber(this.principal), isFiniteNumber(this.interest_rate), isFiniteNumber(this.data.monthly_cost) ]); } return false; }; /** * @summary Calculate an unknown property * @param {String} prop * @return {Object} */ Loan.prototype.calculateUnknown = function(prop) { if(!this.canCalculateUnknown(prop)) return {}; switch(prop) { case 'instalments': var r = this.getInterestRatePerInstalment(); var A = this.data.monthly_cost, P = this.principal; var instalments = -Math.log(1 - P * r / A) / Math.log(1 + r); return {instalments: instalments || 0}; } return {}; }; /** * @summary Return a payment plan for a loan */ Loan.prototype.getPaymentPlan = function() { var self = this; if(this.instalments < 1) return []; var range = _.range(this.instalments); var interestRate = this.getInterestRatePerInstalment(); var type = this.type; // for zero interest rate, let all loans fall back to be serial if(this.interest_rate === 0) { type = 'serial'; } switch(type) { /** * @summary A serial loan has the same amortization amount every payment event */ case 'serial': // same for every iteration var amortization = this.principal / this.instalments; return _.reduce(range, function(plan, instalment) { var isFirst = plan.length <= 0; var payment = isFirst ? self.toObject() : _.clone(_.last(plan)); payment.amortization = amortization; payment.principal = isFirst ? payment.principal : payment.principal - payment.amortization; payment.interest = payment.principal * interestRate; payment.to_pay = payment.amortization + payment.interest; payment.initial_fee = isFirst ? payment.initial_fee : 0; payment.instalments = isFirst ? payment.instalments : payment.instalments - 1; payment.instalment = isFirst ? payment.instalment : payment.instalment + 1; payment.as_of = self.getDateOfInstalment(instalment); plan.push(payment); return plan; }, []); /** * @summary An annuity loan has the same total amount every payment event */ case 'annuity': // same for every iteration var toPay = (interestRate * this.principal) / (1 - Math.pow(1 + interestRate, -self.instalments)); return _.reduce(range, function(plan, instalment) { var isFirst = plan.length <= 0; var payment = isFirst ? self.toObject() : _.clone(_.last(plan)); payment.principal = isFirst ? payment.principal : payment.principal - payment.amortization; payment.interest = payment.principal * interestRate; payment.amortization = toPay - payment.interest; payment.initial_fee = isFirst ? payment.initial_fee : 0; payment.instalments = isFirst ? payment.instalments : payment.instalments - 1; payment.instalment = isFirst ? payment.instalment : payment.instalment + 1; payment.to_pay = toPay; payment.as_of = self.getDateOfInstalment(instalment); plan.push(payment); return plan; }, []); } }; /** * @summary Get the interest rate (per instalment - considers pay_every) * @return {Number} */ Loan.prototype.getInterestRatePerInstalment = function() { switch(this.pay_every) { case 'month': return this.interest_rate / 12; case 'quarter': return this.interest_rate / 4; case 'year': return this.interest_rate; } return 0; }; /** * @summary Get the date (as_of) the instalment of a certain number in a series * @param {Number} instalment - Instalment to return * @return {Date} */ Loan.prototype.getDateOfInstalment = function(instalment) { if(!Loan.Utils.isFiniteNumber(instalment)) { return null; } return moment(this.getAsOf()).add(instalment, this.pay_every).startOf(this.pay_every).toDate(); }; /** * @summary Get the effective yearly interest rate * @param {String} [method="secant"] - Method to use * @return {Number} */ Loan.prototype.getEffectiveInterestRate = function(method) { method = method || 'secant'; if(this.instalments < 1) return 0; var invoiceFee = this.invoice_fee || 0; var initialFee = this.initial_fee || 0; var interest = this.getInterestRatePerInstalment(); var instalments = this.instalments; var principal = this.principal || 0; switch(this.type) { case 'annuity': switch(method) { /** * @summary Secant method */ case 'secant': var payment = -PMT(interest, instalments, -principal-initialFee, 0, 0); var toPay = payment + invoiceFee; var rate = RATE(instalments, toPay, -principal, 0, 0, interest * 12) * 12; var effect = EFFECT(rate, 12); return effect; /** * @summary Newton-Rhapson method * FIXME Probably does not work */ case 'newton': var P = this.principal; var m = (this.getTotalCost() + this.principal) / this.instalments; var N = this.instalments; var approx = 0.1 / 12; var papprox = 0; for(var k = 0; k < 20; k++) { papprox = approx; approx = papprox - f(papprox) / df(papprox); if(Math.abs(approx - papprox) < Math.pow(10, -10)) break; } return approx * 12; } } return 0; function f(x) { return P * x * Math.pow(1 + x, N) / (Math.pow(1 + x, N) - 1) - m; } function df(x) { return P * (Math.pow(1 + x, N) / (-1 + Math.pow(1 + x, N)) - N * x * Math.pow(1 + x, -1 + 2 * N) / Math.pow(-1 + Math.pow(1 + x, N), 2) + N * x * Math.pow(1 + x, -1 + N) / (-1 + Math.pow(1 + x, N))); } function EFFECT(rate, npery) { var effect_value = (Math.pow(1 + (rate/npery), npery)) - 1; return Loan.Utils.round(effect_value, 4); } function RATE(nper, pmt, pv, fv, type, guess) { var f = 0, y = 0, i = 0, x0 = 0, x1 = 0, y0 = 0, y1 = 0; rate = guess; if (Math.abs(rate) < 0.0000001) { y = pv * (1 + nper * rate) + pmt * (1 + rate * type) * nper + fv; } else { f = Math.exp(nper * Math.log(1 + rate)); y = pv * f + pmt * (1 / rate + type) * (f - 1) + fv; } y0 = pv + pmt * nper + fv; y1 = pv * f + pmt * (1 / rate + type) * (f - 1) + fv; // find root by secant method x1 = rate; while ((Math.abs(y0 - y1) > 0.0000001) && (i < 20 )) { rate = Math.abs((y1 * x0 - y0 * x1) / (y1 - y0)); x0 = x1; x1 = rate; if (Math.abs(rate) < 0.0000001) { y = pv * (1 + nper * rate) + pmt * (1 + rate * type) * nper + fv; } else { f = Math.exp(nper * Math.log(1 + rate)); y = pv * f + pmt * (1 / rate + type) * (f - 1) + fv; } y0 = y1; y1 = y; ++i; } return rate; } function PMT(ir, np, pv, fv){ return ir * -(fv-Math.pow((1+ir),np)*pv) / (-1+Math.pow((1+ir),np)); } }; /** * @summary Determine if this loan should be automatically amortized * @param {Date} [compare] - Date to compare with * @return {Boolean} */ Loan.prototype.shouldAmortize = function(compare) { compare = _.isDate(compare) ? moment(compare) : moment(); // can never amortize a loan without instalments or principal left if(this.instalments <= 0 || this.principal <= 0) { return false; } var asOf = moment(this.getAsOf()); var payEvery = this.pay_every; return asOf.startOf(payEvery).toDate() < compare.startOf(payEvery).toDate(); }; /** * @summary Get date that this loan info is current as of * @return {Date} */ Loan.prototype.getAsOf = function() { if(_.isDate(this.as_of)) return this.as_of; return moment().startOf('second').toDate(); }; /** * @summary Return an amortized copy of the loan * @param {Date} [toDate] - The loan has been will be amortized up until this date * @return {Loan} */ Loan.prototype.amortize = function(toDate) { toDate = _.isDate(toDate) ? moment(toDate) : moment(); var asOf = moment(this.getAsOf()); var plan = this.getPaymentPlan(); var diff = Math.ceil(toDate.diff(asOf, this.pay_every)); // out of bounds - loan has been fully amortized if(diff > this.instalments) { var last = _.last(plan); return new Loan(_.extend(last, { principal: 0, instalment: last.instalment + 1, instalments: 0 })); } var amortized = plan[diff] || {}; var loan = new Loan(amortized); loan.calculateMeta(); return new Loan(amortized); }; /** * @summary Return a clone of this loan * @return {Loan} */ Loan.prototype.toObject = function() { var props = [ 'interest_rate', 'principal', 'instalments', 'instalment', 'pay_every', 'type', 'invoice_fee', 'initial_fee', 'to_pay', 'amortization', 'interest', 'locked', 'as_of' ]; var data = _.extend({}, this.data); var clone = _.extend({}, _.pick(this, props), {data: data}); return clone; }; /** * @summary Create a copy of this loan fit for serialization * @return {Object} */ Loan.prototype.toJSON = function() { var minimal = Loan.format.minimal(this); return minimal.toObject(); }; /** * @summary Return a copy of this loan passed through Loan.rounded * @param {Number} [precision] * @return {Object} */ Loan.prototype.round = function(precision) { return Loan.Format.round(this, precision); };