Files
retire-planner/site/engine/engine.js
T

479 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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) ---- */
// s.3(1) ETA: recovery applies ONLY to years OAS is actually payable/received —
// a pre-OAS-start pensioner has no OAS to recover. Callers guard with oasGross > 0
// (2026-10 math fix: pre-65 withdrawals must not generate a "clawback" on OAS never paid).
// Recovery is capped at the OAS received (Math.min at call sites) → net OAS is never negative.
function clawback(netIncome) {
return 0.15 * Math.max(0, (netIncome || 0) - V('oasClawbackThreshold'));
}
/* ---- GIS (SIMPLIFIED — stamp: simplified; PAYABLE ONLY AT AGE 65+) ---- */
// Source fact: Service Canada — you must be 65 or older to receive the GIS.
// The 65+ floor is enforced at the call sites: solve() gates gis() on a >= 65,
// projectCouple() zeroes gisSum when the merged row age < 65 (2026-10 math fix).
// 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];
// FIRE-safe guard (2026-10 math fix): a stale saved retireAge below the current age
// (52-year-old returning user with retireAge 40, what-if slider below current age)
// would "retire" this run years in the past → clamp to next birthday. Age-30 user
// planning to retire at 40 is untouched.
if (+o.retireAge < +o.age + 1) o.retireAge = +o.age + 1;
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 = oasGross > 0 ? Math.min(clawback(taxable1), oasGross) : 0; // clawback only in years OAS is received, capped at OAS paid
var g = a >= 65 ? gis(taxable1 - oasGross, 'single') : 0; // GIS payable only at 65+ (Service Canada)
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 = oasGross > 0 ? Math.min(clawback(taxable), oasGross) : 0; // same rule: no OAS received → no clawback; capped at OAS paid
g = a >= 65 ? gis(taxable - oasGross, 'single') : 0;
rem = need - (pre + W + g + n - tt.total - cc);
}
if (rem > 0 && tfsa > 0) {
t = Math.min(tfsa, rem);
g = a >= 65 ? gis(taxable - oasGross, 'single') : 0; // 65+ floor (TFSA draw is non-taxable; guard kept uniform)
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;
// Splitting comparison uses the SAME clawback rule: only a partner receiving OAS
// this year can generate recovery tax (2026-10 math fix).
var newClaw = (ra.oasGross > 0 ? Math.min(clawback(ra.taxable - shift), ra.oasGross) : 0)
+ (rb.oasGross > 0 ? Math.min(clawback(rb.taxable + shift), rb.oasGross) : 0);
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. GIS is payable ONLY at
// 65+ (Service Canada): a partner's single GIS term counts only at their own age ≥ 65.
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' && ra.age >= 65 ? gis(incA, 'single') : 0)
+ (rb.phase === 'draw' && rb.age >= 65 ? gis(incB, 'single') : 0);
}
if (yr < 65) gisSum = 0; // GIS requires age 65+ (Service Canada) — 2026-10 math fix
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);
// Mixed window: retirement HAS begun (row labelled 'draw' from the FIRST retirement),
// but one partner is still accumulating at the pre-retirement rate. Honest summation,
// and the renderer footnotes these rows (†) so a growing Total + Withdraw > 0 is explained.
row.mixed = row.phase === 'draw' && (ra.phase === 'accum' || rb.phase === 'accum');
row.unfunded = Math.max(0, row.need - cash);
// Depletion must not fire in a mixed window: the balance there is mostly the
// accum partner's pre-retirement money, not the drawing partner's. Count fully-drawing
// rows only — a missing partner row (phase 'none') counts as sole income (not accum).
if (row.phase === 'draw' && !row.mixed && 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,
fillDefaults: fillDefaults,
defaults: DEFAULTS,
constants: C
};
});