Retire Planner v1: engine (live 2026 CPP/OAS/GIS/RRIF/tax), 17-step UI, 4 SVG charts, vision gallery, tests

This commit is contained in:
krisf committed 2026-10-08 23:00:18 -04:00
commit e25dd4e0a1
29 files changed
+3143

No files matched your search

+421
View File
@@ -0,0 +1,421 @@
/* 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
};
});