/* 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, 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; } /* ---- 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 other0 = (p.pensionAnnual || 0) + (p.rentalAnnual || 0) + (p.otherAnnual || 0); 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.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 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: (p.pensionAnnual || 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.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.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 (both 65+): split pension+RRIF 50/50, keep the cheaper total. if (ra.phase === 'draw' && rb.phase === 'draw' && yr >= 65 && (ra.age >= 65 && rb.age >= 65)) { var pa = (me.pensionAnnual || 0) + ra.rrifWithdrawal, pb2 = (partnerInp.pensionAnnual || 0) + rb.rrifWithdrawal; 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, defaults: DEFAULTS, constants: C }; });