452 lines
21 KiB
JavaScript
452 lines
21 KiB
JavaScript
/* retire-planner engine — pure functions, no DOM. UMD so node can unit-test.
|
||
* All money is in REAL (today's) dollars. Annual units unless noted.
|
||
* Sources/stamps for every constant live in canada.js (window.RetireCanada). */
|
||
(function (root, factory) {
|
||
if (typeof module !== 'undefined' && module.exports) {
|
||
var C; try { C = require('./canada.js'); } catch (e) { C = {}; }
|
||
module.exports = factory(C);
|
||
} else {
|
||
root.RetireEngine = factory(root.RetireCanada || {});
|
||
}
|
||
})(typeof self !== 'undefined' ? self : this, function (C) {
|
||
'use strict';
|
||
|
||
// In-engine fallbacks mirror PLAN.md Stage 1 fallback table (stamped FALLBACK in canada.js).
|
||
var FALLBACK = {
|
||
cppMax65Monthly: 1433.00, ympe: 69700, oasMax6574Monthly: 713.34, oas75Boost: 1.10,
|
||
oasClawbackThreshold: 93454, gisMaxSingleMonthly: 1086.88, gisMaxCoupleMonthly: 1436.68,
|
||
gisSingleThr: 20000, gisCoupleThr: 26000
|
||
};
|
||
function V(key) {
|
||
var e = C && C[key];
|
||
if (!e) return FALLBACK[key];
|
||
if (typeof e === 'number') return e;
|
||
return (e && typeof e.value === 'number') ? e.value : FALLBACK[key];
|
||
}
|
||
function fedBrackets() { return (C.fedBrackets && C.fedBrackets.value) || (C.fedBrackets && C.fedBrackets.brackets) || [
|
||
{ up: 57375, rate: 0.15 }, { up: 114750, rate: 0.205 }, { up: 177882, rate: 0.26 },
|
||
{ up: 253414, rate: 0.29 }, { up: null, rate: 0.33 }]; }
|
||
function fedBPA() { return (C.fedBPA && (C.fedBPA.value !== undefined ? C.fedBPA.value : C.fedBPA)) || 15705; }
|
||
function fedAgeAmount() { return (C.fedAgeAmount && (C.fedAgeAmount.value !== undefined ? C.fedAgeAmount.value : C.fedAgeAmount)) || 8790; }
|
||
function provTable(p) { return (C.provBrackets && (C.provBrackets.value || C.provBrackets))[p] || null; }
|
||
function rrifTable() { return (C.rrifMinPct && (C.rrifMinPct.value || C.rrifMinPct)) || null; }
|
||
|
||
var AGE_CLAW_START = 44017; // federal age amount clawback threshold (2025)
|
||
var BPA_CLAW_FED = 165000; // simplified net-income clawback starts (plan)
|
||
var BPA_CLAW_PROV = 173000;
|
||
var PIA = 2000; // pension income amount (fed + prov)
|
||
|
||
function bracketTax(income, brackets) {
|
||
var t = 0, prev = 0;
|
||
for (var i = 0; i < brackets.length; i++) {
|
||
var b = brackets[i], up = (b.up === null || b.up === undefined) ? Infinity : b.up;
|
||
if (income <= prev) break;
|
||
var seg = Math.min(income, up) - prev;
|
||
if (seg > 0) t += seg * b.rate;
|
||
prev = up;
|
||
}
|
||
return Math.max(0, t);
|
||
}
|
||
|
||
/* ---- CPP ---- */
|
||
// 0.25 × min(avgEarnings, YMPE) × min(1, contribYears/40) / 12, capped at max.
|
||
// contributory period 18→65 (47 yrs), general drop-out ≈17% → /40 effective. ESTIMATE ONLY.
|
||
function estimateCppMonthlyAt65(income, age) {
|
||
var contribYears = Math.min(47, Math.max(0, age - 22));
|
||
var eff = Math.min(1, contribYears / 40);
|
||
var m = 0.25 * Math.min(income || 0, V('ympe')) * eff / 12;
|
||
return Math.min(m, V('cppMax65Monthly'));
|
||
}
|
||
// Pension STARTS at startAge with actuarial adj (−0.6%/mo before 65, +0.7%/mo after), stays flat (real).
|
||
function cppMonthlyAt(age, startAge, at65) {
|
||
if (age < startAge) return 0;
|
||
var adj;
|
||
if (startAge < 65) adj = 1 - 0.006 * (65 - startAge) * 12; // 60 → 0.64
|
||
else if (startAge > 65) adj = 1 + 0.007 * (startAge - 65) * 12; // 70 → 1.42
|
||
else adj = 1;
|
||
return at65 * adj;
|
||
}
|
||
|
||
/* ---- OAS ---- */
|
||
function oasMonthlyAt(age, startAge) {
|
||
if (age < startAge) return 0;
|
||
var m = V('oasMax6574Monthly');
|
||
if (startAge > 65) m *= 1 + 0.006 * (startAge - 65) * 12; // +36% at 70
|
||
if (age >= 75) m *= V('oas75Boost'); // +10% at 75
|
||
return m;
|
||
}
|
||
|
||
/* ---- OAS recovery tax (clawback) ---- */
|
||
function clawback(netIncome) {
|
||
return 0.15 * Math.max(0, (netIncome || 0) - V('oasClawbackThreshold'));
|
||
}
|
||
|
||
/* ---- GIS (SIMPLIFIED — stamp: simplified) ---- */
|
||
// Only below ~$20k (single) / ~$26k (couple, both 65+) income ex-OAS.
|
||
// gis = max(0, gisMax − 0.5 × max(0, incomeExOAS − 5000)) (first $5k employment exemption approximated).
|
||
// Returns ANNUAL $, non-taxable.
|
||
function gis(incomeExOas, household) {
|
||
var single = household !== 'couple';
|
||
var thr = single ? V('gisSingleThr') : V('gisCoupleThr');
|
||
var maxMo = single ? V('gisMaxSingleMonthly') : V('gisMaxCoupleMonthly');
|
||
var i = incomeExOas || 0;
|
||
if (i >= thr) return 0;
|
||
// All-annual units: gisMax annual = maxMo*12; reduction 0.5 × (income − 5000).
|
||
return Math.max(0, maxMo * 12 - 0.5 * Math.max(0, i - 5000));
|
||
}
|
||
|
||
/* ---- Tax (2025 brackets; simplified credits) ---- */
|
||
function taxProvincial(taxable, prov, age, opts) {
|
||
var pb = provTable(prov);
|
||
if (!pb) return Math.max(0, taxable) * 0.05; // flat 5% surrogate — stamped in canada.js
|
||
var gross = bracketTax(taxable, pb.brackets);
|
||
var base = pb.brackets[0].rate;
|
||
var credit = 0;
|
||
var bpa = Math.max(0, (pb.bpa || 0) - 0.15 * Math.max(0, taxable - BPA_CLAW_PROV));
|
||
credit += bpa * base;
|
||
if (age >= 65 && pb.ageAmt) {
|
||
credit += Math.max(0, pb.ageAmt - 0.25 * Math.max(0, taxable - (pb.ageThr || 35000))) * base;
|
||
}
|
||
if (age >= 65 && opts && opts.pensionIncome > 0) credit += Math.min(opts.pensionIncome, PIA) * base;
|
||
var t = Math.max(0, gross - credit);
|
||
if (pb.surtax) {
|
||
for (var i = 0; i < pb.surtax.length; i++) {
|
||
var s = pb.surtax[i];
|
||
t += Math.max(0, gross - s.from) * s.rate;
|
||
}
|
||
}
|
||
return t;
|
||
}
|
||
// taxableIncome = T1 line-15000-style taxable income. age drives senior credits.
|
||
// opts: {pensionIncome: eligible pension/RRIF $ for PIA}
|
||
function tax(taxableIncome, province, age, opts) {
|
||
opts = opts || {};
|
||
taxableIncome = Math.max(0, taxableIncome || 0);
|
||
var ni = taxableIncome;
|
||
var fgross = bracketTax(taxableIncome, fedBrackets());
|
||
var fcr = 0;
|
||
var fbase = fedBrackets()[0].rate; // lowest federal rate (15% 2025, 14% 2026+)
|
||
var bpa = Math.max(0, fedBPA() - 0.15 * Math.max(0, ni - BPA_CLAW_FED));
|
||
fcr += bpa * fbase;
|
||
if (age >= 65) {
|
||
var ageAmt = Math.max(0, fedAgeAmount() - 0.15 * Math.max(0, ni - AGE_CLAW_START));
|
||
fcr += ageAmt * fbase;
|
||
if (opts.pensionIncome > 0) fcr += Math.min(opts.pensionIncome, PIA) * fbase;
|
||
}
|
||
var federal = Math.max(0, fgross - fcr);
|
||
if (province === 'QC') federal *= (1 - 0.165); // Quebec abatement (simplified: no QPP/YAP/RDAP)
|
||
var provincial = taxProvincial(taxableIncome, province, age, opts);
|
||
var r2 = function (x) { return Math.round(x * 100) / 100; };
|
||
return { federal: r2(federal), provincial: r2(provincial), total: r2(federal + provincial) };
|
||
}
|
||
|
||
function combinedTax(taxable, prov, age, opts) { // tax + clawback
|
||
return tax(taxable, prov, age, opts).total + clawback(taxable);
|
||
}
|
||
function marginalRate(taxable, prov, age, opts) {
|
||
var step = 2000;
|
||
return (combinedTax(taxable + step, prov, age, opts) - combinedTax(taxable, prov, age, opts)) / step;
|
||
}
|
||
|
||
/* ---- RRIF minimums: (115−age)/(120−age), 20% at 95+ ---- */
|
||
function rrifMinPct(age) {
|
||
if (age < 71) return 0;
|
||
if (age >= 95) return 20.0;
|
||
var t = rrifTable();
|
||
if (t && t[age] !== undefined) return t[age];
|
||
return Math.round((115 - age) / (120 - age) * 10000) / 100;
|
||
}
|
||
|
||
/* ---- Inputs defaults (stamped in UI) ---- */
|
||
var DEFAULTS = {
|
||
age: 45, province: 'ON', household: 'single', partner: null,
|
||
income: 60000, rrsp: 100000, tfsa: 30000, nonreg: 20000,
|
||
monthlyContribution: 500, employerMatchMonthly: 0,
|
||
retireAge: 65, spendingAnnual: 50000,
|
||
cppStartAge: 65, cppOverrideMonthlyAt65: 0, oasStartAge: 65,
|
||
rrspWithdrawalOverrideAnnual: 0,
|
||
pensionAnnual: 0, rentalAnnual: 0, otherAnnual: 0,
|
||
pensionStartAge: 0, // 0 = defaults to retireAge (public-service pension)
|
||
pensionBridgeAnnual: 0, // pre-65 bridge benefit, ends at 65 (PSPC)
|
||
realReturnPre: 0.035, realReturnPost: 0.025, inflation: 0.02, lifeExp: 95
|
||
};
|
||
function fillDefaults(inp) {
|
||
var o = {}, k;
|
||
for (k in DEFAULTS) o[k] = DEFAULTS[k];
|
||
for (k in (inp || {})) if (inp[k] !== undefined && inp[k] !== null) o[k] = inp[k];
|
||
return o;
|
||
}
|
||
|
||
/* ---- Public-service pension: start age & bridge benefit ----
|
||
* pensionStartAge: pension income pays from max(retireAge, pensionStartAge)
|
||
* (0 = not set → starts at retirement, legacy behaviour).
|
||
* Bridge benefit is a term annuity payable from start to the 65th birthday. */
|
||
function pensionStartAge(p) { return Math.max(p.retireAge, +p.pensionStartAge || 0); }
|
||
function pensionBridgeAnnual(p, selfAge) {
|
||
var b = +p.pensionBridgeAnnual || 0;
|
||
if (!b) return 0;
|
||
return (selfAge >= pensionStartAge(p) && selfAge < 65) ? b : 0;
|
||
}
|
||
|
||
/* ---- Core year-by-year projection (single person) ---- */
|
||
function projectSingle(p) {
|
||
var at65 = p.cppOverrideMonthlyAt65 > 0 ? p.cppOverrideMonthlyAt65 : estimateCppMonthlyAt65(p.income, p.age);
|
||
var rrsp = Math.max(0, p.rrsp), tfsa = Math.max(0, p.tfsa), nonreg = Math.max(0, p.nonreg);
|
||
var years = [], depletionAge = null, totalTax = 0, totalClawback = 0, nestEgg = null;
|
||
var end = Math.ceil(p.lifeExp);
|
||
var rentalOther = (p.rentalAnnual || 0) + (p.otherAnnual || 0);
|
||
var psStart = pensionStartAge(p); // pension income starts here
|
||
function penAt(a) { // pension + bridge payable at age a
|
||
var v = 0;
|
||
if (a >= psStart) v += (p.pensionAnnual || 0);
|
||
v += pensionBridgeAnnual(p, a); // 0 at/after 65, 0 before psStart
|
||
return v;
|
||
}
|
||
|
||
for (var a = p.age; a < end; a++) {
|
||
var row = { age: a };
|
||
if (a < p.retireAge) {
|
||
rrsp = rrsp * (1 + p.realReturnPre) + 12 * ((p.monthlyContribution || 0) + (p.employerMatchMonthly || 0));
|
||
tfsa = tfsa * (1 + p.realReturnPre);
|
||
nonreg = nonreg * (1 + p.realReturnPre);
|
||
row.phase = 'accum'; row.cpp = 0; row.oasGross = 0; row.oasClawback = 0; row.gis = 0; row.other = 0; row.pension = 0;
|
||
row.rrifWithdrawal = 0; row.nonregW = 0; row.tfsaW = 0; row.taxable = 0; row.tax = 0;
|
||
row.cash = 0; row.need = 0; row.unfunded = 0;
|
||
row.rrspBal = rrsp; row.tfsaBal = tfsa; row.nonregBal = nonreg; row.totalBal = rrsp + tfsa + nonreg;
|
||
years.push(row);
|
||
continue;
|
||
}
|
||
// post-retirement real growth on opening balances, then withdrawals
|
||
rrsp *= (1 + p.realReturnPost); tfsa *= (1 + p.realReturnPost); nonreg *= (1 + p.realReturnPost);
|
||
if (nestEgg === null && a === Math.floor(p.retireAge)) nestEgg = rrsp + tfsa + nonreg;
|
||
var rrspJan = rrsp;
|
||
var rrifMin = a >= 71 ? rrifMinPct(a) / 100 * rrspJan : 0;
|
||
var cpp = cppMonthlyAt(a, p.cppStartAge, at65) * 12;
|
||
var oasGross = oasMonthlyAt(a, p.oasStartAge) * 12;
|
||
var other0 = rentalOther + penAt(a);
|
||
var pre = cpp + oasGross + other0;
|
||
var need = p.spendingAnnual;
|
||
|
||
// Given RRSP/RRIF withdrawal W: waterfall RRSP → non-reg → TFSA, taxes & clawback, GIS.
|
||
function solve(W) {
|
||
W = Math.min(Math.max(W, 0), rrspJan);
|
||
var taxable1 = pre + W;
|
||
var opts1 = { pensionIncome: (a >= 65 && a >= psStart ? (p.pensionAnnual || 0) : 0) + (a >= 65 ? W : 0) };
|
||
var t1 = tax(taxable1, p.province, a, opts1);
|
||
var c1 = clawback(taxable1);
|
||
var g = gis(taxable1 - oasGross, 'single');
|
||
var cash1 = pre + W + g - t1.total - c1;
|
||
var n = 0, t = 0, taxable = taxable1, tt = t1, cc = c1, opts = opts1;
|
||
var rem = need - cash1;
|
||
if (rem > 0 && nonreg > 0) {
|
||
var m = marginalRate(taxable1, p.province, a, { pensionIncome: 0 });
|
||
n = Math.min(nonreg, Math.max(0, rem / Math.max(0.2, 1 - 0.5 * m)));
|
||
taxable = taxable1 + 0.5 * n;
|
||
opts = { pensionIncome: opts1.pensionIncome };
|
||
tt = tax(taxable, p.province, a, opts);
|
||
cc = clawback(taxable);
|
||
g = gis(taxable - oasGross, 'single');
|
||
rem = need - (pre + W + g + n - tt.total - cc);
|
||
}
|
||
if (rem > 0 && tfsa > 0) {
|
||
t = Math.min(tfsa, rem);
|
||
g = gis(taxable - oasGross, 'single');
|
||
rem = need - (pre + W + g + n + t - tt.total - cc);
|
||
}
|
||
var cash = pre + W + g + n + t - tt.total - cc;
|
||
return { W: W, n: n, t: t, taxable: taxable, tax: tt, cw: cc, gis: g, cash: cash, rem: rem };
|
||
}
|
||
|
||
var r;
|
||
if (p.rrspWithdrawalOverrideAnnual > 0) {
|
||
r = solve(Math.min(Math.max(p.rrspWithdrawalOverrideAnnual, rrifMin), rrspJan));
|
||
} else {
|
||
var lo = Math.min(rrifMin, rrspJan), hi = Math.max(rrspJan, lo);
|
||
var flo = solve(lo), fhi = solve(hi);
|
||
if (fhi.cash <= need + 0.01) {
|
||
r = fhi; // everything spent; shortfall covered below
|
||
} else if (flo.cash >= need - 0.01) {
|
||
r = flo; // minimum withdrawal already covers need
|
||
} else {
|
||
for (var it = 0; it < 25; it++) {
|
||
var mid = (lo + hi) / 2;
|
||
var fm = solve(mid);
|
||
if (fm.cash < need) lo = mid; else hi = mid;
|
||
}
|
||
r = solve((lo + hi) / 2);
|
||
}
|
||
}
|
||
|
||
var surplus = Math.max(0, r.cash - need);
|
||
var unfunded = Math.max(0, need - r.cash);
|
||
rrsp = Math.max(0, rrspJan - r.W);
|
||
nonreg = Math.max(0, nonreg - r.n);
|
||
tfsa = Math.max(0, tfsa - r.t) + surplus; // surplus saved tax-free (TFSA proxy)
|
||
var totalBal = rrsp + tfsa + nonreg;
|
||
if (totalBal <= 0.5 && unfunded > 0.5 && depletionAge === null) depletionAge = a;
|
||
|
||
totalTax += r.tax.total; totalClawback += r.cw;
|
||
row.phase = 'draw';
|
||
row.cpp = cpp; row.oasGross = oasGross; row.oasClawback = r.cw; row.gis = r.gis; row.other = other0;
|
||
row.pension = penAt(a); // pension + bridge only (for chart breakdown)
|
||
row.rrifWithdrawal = r.W; row.nonregW = r.n; row.tfsaW = r.t;
|
||
row.taxable = r.taxable; row.tax = r.tax.total; row.cash = Math.min(r.cash, need);
|
||
row.need = need; row.unfunded = unfunded;
|
||
row.rrspBal = rrsp; row.tfsaBal = tfsa; row.nonregBal = nonreg; row.totalBal = totalBal;
|
||
row.taxFederal = r.tax.federal; row.taxProvincial = r.tax.provincial;
|
||
years.push(row);
|
||
}
|
||
var last = years.length ? years[years.length - 1] : null;
|
||
return {
|
||
years: years, nestEgg: nestEgg === null ? (last ? last.totalBal : 0) : nestEgg,
|
||
depletionAge: depletionAge, totalTax: totalTax, totalClawback: totalClawback,
|
||
finalBal: last ? last.totalBal : 0
|
||
};
|
||
}
|
||
|
||
/* ---- Couple: per-person runs, household spending split 50/50, merge ---- */
|
||
function projectCouple(p) {
|
||
var me = fillDefaults(Object.assign({}, p, { household: 'single', partner: null, spendingAnnual: p.spendingAnnual * 0.5 }));
|
||
var pt = p.partner || {};
|
||
var partnerInp = fillDefaults(Object.assign({}, p, {
|
||
household: 'single', partner: null,
|
||
age: pt.age !== undefined ? pt.age : p.age,
|
||
income: pt.income || 0, rrsp: pt.rrsp || 0, tfsa: pt.tfsa || 0, nonreg: pt.nonreg || 0,
|
||
monthlyContribution: pt.monthlyContribution || 0, employerMatchMonthly: 0,
|
||
spendingAnnual: p.spendingAnnual * 0.5
|
||
}));
|
||
var A = projectSingle(me), B = projectSingle(partnerInp);
|
||
function byYear(res) { var m = {}; res.years.forEach(function (y) { m[y.age] = y; }); return m; }
|
||
var ya = byYear(A), yb = byYear(B);
|
||
var start = Math.min(p.age, partnerInp.age), end = Math.ceil(p.lifeExp);
|
||
var merged = [], totalTax = 0, totalClawback = 0, depletionAge = null;
|
||
for (var yr = start; yr < end; yr++) {
|
||
var ra = ya[yr], rb = yb[yr];
|
||
if (!ra && !rb) continue;
|
||
ra = ra || { cpp: 0, oasGross: 0, gis: 0, other: 0, rrifWithdrawal: 0, nonregW: 0, tfsaW: 0, taxable: 0, tax: 0, oasClawback: 0, cash: 0, need: 0, unfunded: 0, rrspBal: 0, tfsaBal: 0, nonregBal: 0, totalBal: 0, phase: 'none' };
|
||
rb = rb || { cpp: 0, oasGross: 0, gis: 0, other: 0, rrifWithdrawal: 0, nonregW: 0, tfsaW: 0, taxable: 0, tax: 0, oasClawback: 0, cash: 0, need: 0, unfunded: 0, rrspBal: 0, tfsaBal: 0, nonregBal: 0, totalBal: 0, phase: 'none' };
|
||
var row = {
|
||
age: yr, phase: (ra.phase === 'draw' || rb.phase === 'draw') ? 'draw' : 'accum',
|
||
cpp: ra.cpp + rb.cpp, oasGross: ra.oasGross + rb.oasGross, other: ra.other + rb.other,
|
||
rrifWithdrawal: ra.rrifWithdrawal + rb.rrifWithdrawal, nonregW: ra.nonregW + rb.nonregW, tfsaW: ra.tfsaW + rb.tfsaW,
|
||
rrspBal: ra.rrspBal + rb.rrspBal, tfsaBal: ra.tfsaBal + rb.tfsaBal, nonregBal: ra.nonregBal + rb.nonregBal
|
||
};
|
||
row.totalBal = row.rrspBal + row.tfsaBal + row.nonregBal;
|
||
row.pension = (ra.pension || 0) + (rb.pension || 0); // chart breakdown (life pension + bridge)
|
||
row.need = ra.need + rb.need;
|
||
var taxSum = ra.tax + rb.tax, clawSum = ra.oasClawback + rb.oasClawback;
|
||
row.taxable = ra.taxable + rb.taxable;
|
||
// Pension income splitting (CRA): a life annuity from a superannuation plan is
|
||
// splittable at ANY age once payable (pensionStartAge); RRIF withdrawals only at
|
||
// 65+. The bridge benefit (term annuity) is excluded — documented modelling
|
||
// choice. Split 50/50 the eligible amounts, keep the cheaper total.
|
||
if (ra.phase === 'draw' && rb.phase === 'draw') {
|
||
var psA = pensionStartAge(me), psB = pensionStartAge(partnerInp);
|
||
var pa = ((me.pensionAnnual > 0 && psA <= yr) ? me.pensionAnnual : 0) + (yr >= 65 ? ra.rrifWithdrawal : 0);
|
||
var pb2 = ((partnerInp.pensionAnnual > 0 && psB <= yr) ? partnerInp.pensionAnnual : 0) + (yr >= 65 ? rb.rrifWithdrawal : 0);
|
||
var shift = 0.5 * (pa - pb2);
|
||
if (Math.abs(shift) > 1) {
|
||
var tA = tax(ra.taxable - shift, p.province, yr, { pensionIncome: pa - shift });
|
||
var tB = tax(rb.taxable + shift, p.province, yr, { pensionIncome: pb2 + shift });
|
||
var newTax = tA.total + tB.total, newClaw = clawback(ra.taxable - shift) + clawback(rb.taxable + shift);
|
||
if (newTax + newClaw < taxSum + clawSum) { taxSum = newTax; clawSum = newClaw; }
|
||
}
|
||
}
|
||
row.tax = taxSum; row.oasClawback = clawSum;
|
||
// GIS: household rate when both 65+, else sum of singles
|
||
var incA = ra.taxable - ra.oasGross, incB = rb.taxable - rb.oasGross;
|
||
var gisSum;
|
||
if (ra.phase === 'draw' && rb.phase === 'draw' && ra.age >= 65 && rb.age >= 65) {
|
||
gisSum = gis(incA + incB, 'couple');
|
||
} else {
|
||
gisSum = (ra.phase === 'draw' ? gis(incA, 'single') : 0) + (rb.phase === 'draw' ? gis(incB, 'single') : 0);
|
||
}
|
||
row.gis = gisSum;
|
||
var cash = ra.cash + rb.cash + (ra.gis + rb.gis ? gisSum - ra.gis - rb.gis : 0) + (taxSum ? (ra.tax + rb.tax - taxSum) : 0);
|
||
cash = ra.cash + rb.cash - (ra.gis + rb.gis) + gisSum + (ra.tax + rb.tax) - taxSum;
|
||
row.cash = Math.min(cash, row.need);
|
||
row.unfunded = Math.max(0, row.need - cash);
|
||
if (row.phase === 'draw' && row.totalBal <= 0.5 && row.unfunded > 0.5 && depletionAge === null) depletionAge = yr;
|
||
totalTax += taxSum; totalClawback += clawSum;
|
||
merged.push(row);
|
||
}
|
||
return {
|
||
years: merged, nestEgg: A.nestEgg + B.nestEgg, depletionAge: depletionAge,
|
||
totalTax: totalTax, totalClawback: totalClawback,
|
||
finalBal: merged.length ? merged[merged.length - 1].totalBal : 0
|
||
};
|
||
}
|
||
|
||
function project(inputs) {
|
||
var p = fillDefaults(inputs);
|
||
if (p.household === 'couple' && p.partner) return projectCouple(p);
|
||
return projectSingle(p);
|
||
}
|
||
|
||
/* ---- Sustainable spending: bisection on spending that survives to lifeExp ---- */
|
||
function sustainableSpending(inputs) {
|
||
var lo = 4000, hi = 400000;
|
||
var survives = function (s) {
|
||
return project(Object.assign({}, inputs, { spendingAnnual: s })).depletionAge === null;
|
||
};
|
||
if (!survives(lo)) return { annual: 0, monthly: 0 };
|
||
if (survives(hi)) return { annual: hi, monthly: hi / 12 };
|
||
for (var i = 0; i < 30; i++) {
|
||
var mid = (lo + hi) / 2;
|
||
if (survives(mid)) lo = mid; else hi = mid;
|
||
}
|
||
lo = Math.floor(lo / 100) * 100;
|
||
return { annual: lo, monthly: lo / 12 };
|
||
}
|
||
|
||
/* ---- Three gap closers ---- */
|
||
function recommendations(inputs, projection) {
|
||
var p = fillDefaults(inputs);
|
||
var proj = projection || project(p);
|
||
var s = sustainableSpending(p).annual;
|
||
var gap = p.spendingAnnual - s;
|
||
var out = { extraMonthly: 0, retireDelayYears: 0, spendingReduction: 0, gapAnnual: Math.max(0, Math.round(gap)) };
|
||
if (gap <= 100) { out.gapAnnual = 0; return out; }
|
||
out.spendingReduction = Math.round(gap / 100) * 100;
|
||
// (a) extra monthly contribution that closes the gap
|
||
var lo = 0, hi = 30000;
|
||
var closes = function (c) {
|
||
return sustainableSpending(Object.assign({}, p, {
|
||
monthlyContribution: (p.monthlyContribution || 0) + c
|
||
})).annual >= p.spendingAnnual - 100;
|
||
};
|
||
if (closes(hi)) {
|
||
for (var i = 0; i < 20; i++) {
|
||
var mid = (lo + hi) / 2;
|
||
if (closes(mid)) hi = mid; else lo = mid;
|
||
}
|
||
out.extraMonthly = Math.ceil(hi / 10) * 10;
|
||
} else out.extraMonthly = -1; // not closable by saving alone below $30k/mo
|
||
// (b) retirement delay
|
||
for (var d = 1; d <= 10; d++) {
|
||
if (sustainableSpending(Object.assign({}, p, { retireAge: Math.min(p.retireAge + d, 75) })).annual >= p.spendingAnnual - 100) {
|
||
out.retireDelayYears = d; break;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
return {
|
||
estimateCppMonthlyAt65: estimateCppMonthlyAt65,
|
||
cppMonthlyAt: cppMonthlyAt,
|
||
oasMonthlyAt: oasMonthlyAt,
|
||
tax: tax,
|
||
clawback: clawback,
|
||
gis: gis,
|
||
rrifMinPct: rrifMinPct,
|
||
marginalRate: marginalRate,
|
||
project: project,
|
||
sustainableSpending: sustainableSpending,
|
||
recommendations: recommendations,
|
||
pensionStartAge: pensionStartAge,
|
||
pensionBridgeAnnual: pensionBridgeAnnual,
|
||
defaults: DEFAULTS,
|
||
constants: C
|
||
};
|
||
});
|