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:
commit
e25dd4e0a1
29 files changed
+3143
No files matched your search
@@ -0,0 +1,527 @@
|
||||
/* AUTO-GENERATED by scripts/build_canada.js from live sources — see PLAN.md Stage 1.
|
||||
* Evidence JSON kept at scripts/constants-evidence.json */
|
||||
(function (root) { root.RetireCanada = {
|
||||
"generated": "2026-10-08",
|
||||
"year": "2026 (live-fetched; plan fallbacks were 2025)",
|
||||
"asOf": "October 2026 (CPP 2026, OAS/GIS Oct-Dec 2026, tax 2026)",
|
||||
"cppMax65Monthly": {
|
||||
"value": 1507.65,
|
||||
"source": "https://www.canada.ca/en/employment-social-development/programs/pensions/pension/statistics/2026-quarterly-april-june.html Table 1, Jan 2026 (incl. CPP enhancement)",
|
||||
"asOf": "2026-04-01"
|
||||
},
|
||||
"ympe": {
|
||||
"value": 74600,
|
||||
"source": "https://www.canada.ca/en/employment-social-development/programs/pensions/pension/statistics/2026-quarterly-april-june.html Table 2 YMPE 2026 (YBE $3,500)",
|
||||
"asOf": "2026-04-01"
|
||||
},
|
||||
"oasMax6574Monthly": {
|
||||
"value": 762.5,
|
||||
"source": "https://www.canada.ca/en/services/benefits/publicpensions/old-age-security/payments.html Oct-Dec 2026",
|
||||
"asOf": "2026-09-29"
|
||||
},
|
||||
"oas75Boost": {
|
||||
"value": 1.1,
|
||||
"source": "https://www.canada.ca/en/services/benefits/publicpensions/old-age-security/payments.html (+10% at 75, permanent since Jul 2022)",
|
||||
"asOf": "2026-09-29"
|
||||
},
|
||||
"oasClawbackThreshold": {
|
||||
"value": 95323,
|
||||
"source": "https://www.canada.ca/en/services/benefits/publicpensions/old-age-security/recovery-tax.html (2026 income year; est. published 2026-09-29)",
|
||||
"asOf": "2026-09-29"
|
||||
},
|
||||
"oasFullRecovery6574": {
|
||||
"value": 155320,
|
||||
"source": "https://www.canada.ca/en/services/benefits/publicpensions/old-age-security/recovery-tax.html",
|
||||
"asOf": "2026-09-29"
|
||||
},
|
||||
"gisMaxSingleMonthly": {
|
||||
"value": 1138.9,
|
||||
"source": "https://www.canada.ca/en/services/benefits/publicpensions/old-age-security/payments.html Oct-Dec 2026",
|
||||
"asOf": "2026-09-29"
|
||||
},
|
||||
"gisSingleThr": {
|
||||
"value": 23112,
|
||||
"source": "https://www.canada.ca/en/services/benefits/publicpensions/old-age-security/payments.html (income ex-OAS threshold, single)",
|
||||
"asOf": "2026-09-29"
|
||||
},
|
||||
"gisMaxCoupleMonthly": {
|
||||
"value": 1371.12,
|
||||
"source": "https://www.canada.ca/en/services/benefits/publicpensions/old-age-security/payments.html (685.56 x2, spouse receives full OAS)",
|
||||
"asOf": "2026-09-29"
|
||||
},
|
||||
"gisCoupleThr": {
|
||||
"value": 30528,
|
||||
"source": "https://www.canada.ca/en/services/benefits/publicpensions/old-age-security/payments.html (combined income ex-OAS, both on OAS)",
|
||||
"asOf": "2026-09-29"
|
||||
},
|
||||
"fedBrackets": {
|
||||
"value": [
|
||||
{
|
||||
"up": 58523,
|
||||
"rate": 0.14
|
||||
},
|
||||
{
|
||||
"up": 117045,
|
||||
"rate": 0.205
|
||||
},
|
||||
{
|
||||
"up": 181440,
|
||||
"rate": 0.26
|
||||
},
|
||||
{
|
||||
"up": 258482,
|
||||
"rate": 0.29
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.33
|
||||
}
|
||||
],
|
||||
"source": "https://www.canada.ca/en/revenue-agency/services/tax/individuals/tax-rates-brackets/current-year.html",
|
||||
"asOf": "2026-06-25"
|
||||
},
|
||||
"fedBPA": {
|
||||
"value": 16452,
|
||||
"source": "taxtips FAQ: federal BPA 2026 max $16,452 (min $14,829)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"fedAgeAmount": {
|
||||
"value": 8790,
|
||||
"source": "2025 value (plan-stamped; web search unavailable at build time)",
|
||||
"asOf": "2025-01-01"
|
||||
},
|
||||
"fedAgeThr": {
|
||||
"value": 44017,
|
||||
"source": "2025 value (plan-stamped)",
|
||||
"asOf": "2025-01-01"
|
||||
},
|
||||
"piaAmount": {
|
||||
"value": 2000,
|
||||
"source": "plan-stamped 2025 simplification (indexed ~$2.2k in 2026)",
|
||||
"asOf": "2025-01-01"
|
||||
},
|
||||
"cppEarlyAdjMonthly": {
|
||||
"value": 0.006,
|
||||
"source": "plan-stamped / canada.ca CPP (0.6% per month before 65)",
|
||||
"asOf": "2025-01-01"
|
||||
},
|
||||
"cppLateAdjMonthly": {
|
||||
"value": 0.007,
|
||||
"source": "plan-stamped (0.7% per month after 65)",
|
||||
"asOf": "2025-01-01"
|
||||
},
|
||||
"oasDeferralMonthly": {
|
||||
"value": 0.006,
|
||||
"source": "https://www.canada.ca/en/employment-social-development/programs/pensions/pension/statistics/2026-quarterly-april-june.html Table 5 footnote 8 (+0.6%/mo, max 36%)",
|
||||
"asOf": "2026-04-01"
|
||||
},
|
||||
"rrifMinPct": {
|
||||
"value": {
|
||||
"71": 5.28,
|
||||
"72": 5.4,
|
||||
"73": 5.53,
|
||||
"74": 5.67,
|
||||
"75": 5.82,
|
||||
"76": 5.98,
|
||||
"77": 6.15,
|
||||
"78": 6.33,
|
||||
"79": 6.53,
|
||||
"80": 6.74,
|
||||
"81": 6.97,
|
||||
"82": 7.22,
|
||||
"83": 7.48,
|
||||
"84": 7.77,
|
||||
"85": 8.08,
|
||||
"86": 8.41,
|
||||
"87": 8.77,
|
||||
"88": 9.15,
|
||||
"89": 9.56,
|
||||
"90": 10,
|
||||
"91": 10.48,
|
||||
"92": 11,
|
||||
"93": 11.56,
|
||||
"94": 12.18,
|
||||
"95": 20
|
||||
},
|
||||
"source": "CRA RRIF minimum factor table (Reg. 8301(1)) — CRA RRIF page 404 at fetch time, table stamped (plan fallback: 71→5.28 ✓)",
|
||||
"asOf": "2026"
|
||||
},
|
||||
"provBrackets": {
|
||||
"ON": {
|
||||
"name": "Ontario",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 53891,
|
||||
"rate": 0.050499999999999996
|
||||
},
|
||||
{
|
||||
"up": 107785,
|
||||
"rate": 0.0915
|
||||
},
|
||||
{
|
||||
"up": 150000,
|
||||
"rate": 0.1116
|
||||
},
|
||||
{
|
||||
"up": 220000,
|
||||
"rate": 0.1216
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.1316
|
||||
}
|
||||
],
|
||||
"bpa": 12989,
|
||||
"source": "taxtips.ca/taxrates/on.htm (2026 table)",
|
||||
"asOf": "2026-02-22",
|
||||
"surtax": [
|
||||
{
|
||||
"from": 6474,
|
||||
"rate": 0.2
|
||||
},
|
||||
{
|
||||
"from": 8102,
|
||||
"rate": 0.36
|
||||
}
|
||||
]
|
||||
},
|
||||
"BC": {
|
||||
"name": "British Columbia",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 50363,
|
||||
"rate": 0.055999999999999994
|
||||
},
|
||||
{
|
||||
"up": 100728,
|
||||
"rate": 0.077
|
||||
},
|
||||
{
|
||||
"up": 115648,
|
||||
"rate": 0.105
|
||||
},
|
||||
{
|
||||
"up": 140430,
|
||||
"rate": 0.1229
|
||||
},
|
||||
{
|
||||
"up": 190405,
|
||||
"rate": 0.147
|
||||
},
|
||||
{
|
||||
"up": 265545,
|
||||
"rate": 0.168
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.205
|
||||
}
|
||||
],
|
||||
"bpa": 13216,
|
||||
"source": "taxtips.ca/taxrates/bc.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"AB": {
|
||||
"name": "Alberta",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 61200,
|
||||
"rate": 0.08
|
||||
},
|
||||
{
|
||||
"up": 154259,
|
||||
"rate": 0.1
|
||||
},
|
||||
{
|
||||
"up": 185111,
|
||||
"rate": 0.12
|
||||
},
|
||||
{
|
||||
"up": 246813,
|
||||
"rate": 0.13
|
||||
},
|
||||
{
|
||||
"up": 370220,
|
||||
"rate": 0.14
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.15
|
||||
}
|
||||
],
|
||||
"bpa": 22769,
|
||||
"source": "taxtips.ca/taxrates/ab.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"MB": {
|
||||
"name": "Manitoba",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 47000,
|
||||
"rate": 0.10800000000000001
|
||||
},
|
||||
{
|
||||
"up": 100000,
|
||||
"rate": 0.1275
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.174
|
||||
}
|
||||
],
|
||||
"bpa": 15780,
|
||||
"source": "taxtips.ca/taxrates/mb.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"SK": {
|
||||
"name": "Saskatchewan",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 54532,
|
||||
"rate": 0.105
|
||||
},
|
||||
{
|
||||
"up": 155805,
|
||||
"rate": 0.125
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.145
|
||||
}
|
||||
],
|
||||
"bpa": 20381,
|
||||
"source": "taxtips.ca/taxrates/sk.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"NS": {
|
||||
"name": "Nova Scotia",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 30995,
|
||||
"rate": 0.08789999999999999
|
||||
},
|
||||
{
|
||||
"up": 61991,
|
||||
"rate": 0.1495
|
||||
},
|
||||
{
|
||||
"up": 97417,
|
||||
"rate": 0.16670000000000001
|
||||
},
|
||||
{
|
||||
"up": 157124,
|
||||
"rate": 0.175
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.21
|
||||
}
|
||||
],
|
||||
"bpa": 11932,
|
||||
"source": "taxtips.ca/taxrates/ns.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"NB": {
|
||||
"name": "New Brunswick",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 52333,
|
||||
"rate": 0.094
|
||||
},
|
||||
{
|
||||
"up": 104666,
|
||||
"rate": 0.14
|
||||
},
|
||||
{
|
||||
"up": 193861,
|
||||
"rate": 0.16
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.195
|
||||
}
|
||||
],
|
||||
"bpa": 13664,
|
||||
"source": "taxtips.ca/taxrates/nb.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"NL": {
|
||||
"name": "Newfoundland & Labrador",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 44678,
|
||||
"rate": 0.087
|
||||
},
|
||||
{
|
||||
"up": 89354,
|
||||
"rate": 0.145
|
||||
},
|
||||
{
|
||||
"up": 159528,
|
||||
"rate": 0.158
|
||||
},
|
||||
{
|
||||
"up": 223340,
|
||||
"rate": 0.17800000000000002
|
||||
},
|
||||
{
|
||||
"up": 285319,
|
||||
"rate": 0.198
|
||||
},
|
||||
{
|
||||
"up": 570638,
|
||||
"rate": 0.20800000000000002
|
||||
},
|
||||
{
|
||||
"up": 1141275,
|
||||
"rate": 0.213
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.218
|
||||
}
|
||||
],
|
||||
"bpa": 13094,
|
||||
"source": "taxtips.ca/taxrates/nl.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"PE": {
|
||||
"name": "Prince Edward Island",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 33928,
|
||||
"rate": 0.095
|
||||
},
|
||||
{
|
||||
"up": 65820,
|
||||
"rate": 0.13470000000000001
|
||||
},
|
||||
{
|
||||
"up": 106890,
|
||||
"rate": 0.166
|
||||
},
|
||||
{
|
||||
"up": 142250,
|
||||
"rate": 0.17620000000000002
|
||||
},
|
||||
{
|
||||
"up": 200000,
|
||||
"rate": 0.19
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.2
|
||||
}
|
||||
],
|
||||
"bpa": 15000,
|
||||
"source": "taxtips.ca/taxrates/pe.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"YT": {
|
||||
"name": "Yukon",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 58523,
|
||||
"rate": 0.064
|
||||
},
|
||||
{
|
||||
"up": 117045,
|
||||
"rate": 0.09
|
||||
},
|
||||
{
|
||||
"up": 181440,
|
||||
"rate": 0.109
|
||||
},
|
||||
{
|
||||
"up": 258482,
|
||||
"rate": 0.1293
|
||||
},
|
||||
{
|
||||
"up": 500000,
|
||||
"rate": 0.128
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.15
|
||||
}
|
||||
],
|
||||
"bpa": 16452,
|
||||
"source": "taxtips.ca/taxrates/yt.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"NT": {
|
||||
"name": "Northwest Territories",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 53003,
|
||||
"rate": 0.059000000000000004
|
||||
},
|
||||
{
|
||||
"up": 106009,
|
||||
"rate": 0.086
|
||||
},
|
||||
{
|
||||
"up": 172346,
|
||||
"rate": 0.122
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.1405
|
||||
}
|
||||
],
|
||||
"bpa": 18198,
|
||||
"source": "taxtips.ca/taxrates/nt.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"NU": {
|
||||
"name": "Nunavut",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 55801,
|
||||
"rate": 0.04
|
||||
},
|
||||
{
|
||||
"up": 111602,
|
||||
"rate": 0.07
|
||||
},
|
||||
{
|
||||
"up": 181439,
|
||||
"rate": 0.09
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.115
|
||||
}
|
||||
],
|
||||
"bpa": 19659,
|
||||
"source": "taxtips.ca/taxrates/nu.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
},
|
||||
"QC": {
|
||||
"name": "Quebec",
|
||||
"brackets": [
|
||||
{
|
||||
"up": 54345,
|
||||
"rate": 0.14
|
||||
},
|
||||
{
|
||||
"up": 108680,
|
||||
"rate": 0.19
|
||||
},
|
||||
{
|
||||
"up": 132245,
|
||||
"rate": 0.24
|
||||
},
|
||||
{
|
||||
"up": null,
|
||||
"rate": 0.2575
|
||||
}
|
||||
],
|
||||
"bpa": 18952,
|
||||
"source": "taxtips.ca/taxrates/qc.htm (2026 table)",
|
||||
"asOf": "2026-02-22"
|
||||
}
|
||||
}
|
||||
};
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = root.RetireCanada;
|
||||
})(typeof self !== 'undefined' ? self : this);
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
Reference in new issue
Block a user