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