engine: GIS 65+ floor, clawback only with OAS, couple mixed-row footnote, FIRE retire age 40

This commit is contained in:
krisf committed 2026-10-09 15:52:53 -04:00
1 parent de451f0525
commit a210e7edfc
10 files changed
+348 -24

No files matched your search

+7 -3
View File
@@ -208,7 +208,7 @@
},
{
key: 'retire', titleK: 'q.retire.title', hintK: 'q.retire.hint', type: 'slider',
min: 55, max: 72, step: 1, fmt: function (v) { return t('q.retire.years', { v: v }); },
min: 40, max: 72, step: 1, fmt: function (v) { return t('q.retire.years', { v: v }); }, /* FIRE: floor 40 (fillDefaults clamps below current age) */
get: function () { return state.a.retireAge; }, set: function (v) { state.a.retireAge = +v; }
},
{
@@ -804,15 +804,19 @@
renderPsCard(p);
var tbl = '<tr><th>' + t('t.age') + '</th><th>' + t('t.rrsp') + '</th><th>' + t('t.tfsa') + '</th><th>' + t('t.nonreg') + '</th><th>' + t('t.total') + '</th><th>' + t('t.wdr') + '</th><th>' + t('t.cpp') + '</th><th>' + t('t.oas') + '</th><th>' + t('t.gis') + '</th><th>' + t('t.tax') + '</th><th>' + t('t.unfunded') + '</th></tr>';
var n = 0;
var n = 0, mixedAny = false;
proj.years.forEach(function (r) {
if (r.phase !== 'draw' && r.age % 5 !== 0) return; n++;
if (r.mixed) mixedAny = true; /* 2026-10 math fix: one partner retired, one still accumulating */
tbl += '<tr class="' + (r.unfunded > 1 ? 'dep' : '') + '"><td>' + r.age + '</td><td>' + k(r.rrspBal) + '</td><td>' + k(r.tfsaBal) +
'</td><td>' + k(r.nonregBal) + '</td><td><b>' + k(r.totalBal) + '</b></td><td>' + k(r.rrifWithdrawal + r.nonregW + r.tfsaW) +
'</td><td>' + k(r.nonregBal) + '</td><td><b>' + k(r.totalBal) + (r.mixed ? ' †' : '') + '</b></td><td>' + k(r.rrifWithdrawal + r.nonregW + r.tfsaW) +
'</td><td>' + k(r.cpp) + '</td><td>' + k(r.oasGross - r.oasClawback) + '</td><td>' + k(r.gis) + '</td><td>' + k(r.tax + r.oasClawback) +
'</td><td>' + (r.unfunded > 1 ? k(r.unfunded) : '—') + '</td></tr>';
});
$('#yr-table').innerHTML = tbl; $('#yr-summary').innerHTML = t('r.yearlySummary', { n: n });
/* Legend for † mixed-phase rows (couples: growing Total + Withdraw > 0 rows explained). */
var mn = $('#yr-mixed-note');
if (mn) { mn.textContent = mixedAny ? t('t.mixedNote') : ''; mn.hidden = !mixedAny; }
var m2 = fmtMoney2;
$('#stamp').textContent = t('r.stamp', {
+37 -10
View File
@@ -77,11 +77,18 @@
}
/* ---- 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) ---- */
/* ---- 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.
@@ -174,6 +181,11 @@
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;
}
@@ -233,8 +245,8 @@
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 = clawback(taxable1);
var g = gis(taxable1 - oasGross, 'single');
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;
@@ -244,13 +256,13 @@
taxable = taxable1 + 0.5 * n;
opts = { pensionIncome: opts1.pensionIncome };
tt = tax(taxable, p.province, a, opts);
cc = clawback(taxable);
g = gis(taxable - oasGross, 'single');
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 = gis(taxable - oasGross, 'single');
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;
@@ -348,25 +360,39 @@
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);
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: 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' ? gis(incA, 'single') : 0) + (rb.phase === 'draw' ? gis(incB, 'single') : 0);
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);
if (row.phase === 'draw' && row.totalBal <= 0.5 && row.unfunded > 0.5 && depletionAge === null) depletionAge = yr;
// 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);
}
@@ -445,6 +471,7 @@
recommendations: recommendations,
pensionStartAge: pensionStartAge,
pensionBridgeAnnual: pensionBridgeAnnual,
fillDefaults: fillDefaults,
defaults: DEFAULTS,
constants: C
};
+6 -4
View File
@@ -93,7 +93,7 @@
title: 'Monthly savings into RRSP', hint: 'Your contribution + any employer match.',
you: 'You contribute $/mo', match: 'Employer match $/mo'
},
retire: { title: 'Retirement age', hint: 'When you stop earning employment income.', years: '{v} years' },
retire: { title: 'Retirement age', hint: 'When you stop earning employment income. Early retirement (FIRE) supported — plan must self-fund the CPP/OAS gap years.', years: '{v} years' },
spend: { title: 'Planned annual spending in retirement', hint: 'Today’s dollars, after tax. Be honest — this is the number that decides everything.' },
cppstart: {
title: 'Start CPP at…', hint: 'Age {age}: {adj}',
@@ -247,7 +247,8 @@
},
t: {
age: 'Age', rrsp: 'RRSP', tfsa: 'TFSA', nonreg: 'Non-reg', total: 'Total', wdr: 'Withdraw',
cpp: 'CPP', oas: 'OAS', gis: 'GIS', tax: 'Tax+claw', unfunded: 'Unfunded'
cpp: 'CPP', oas: 'OAS', gis: 'GIS', tax: 'Tax+claw', unfunded: 'Unfunded',
mixedNote: '† One partner is retired, the other is still working: their savings grow at the pre-retirement rate — this row is not in full retirement mode.'
},
d: {
main: '<b>Estimates only — not financial advice.</b> Simplified model: GIS uses a simplified 50% phase-out; Quebec shown with the 16.5% federal abatement (QPP and Quebec payroll taxes not modelled); federal public service pension (PSSA) modelled when enabled — other DB pensions not; returns are constant real rates; Ontario surtax modelled on base tax.',
@@ -353,7 +354,7 @@
title: 'Épargne mensuelle au REER', hint: 'Votre cotisation + la contrepartie de l\'employeur.',
you: 'Vous cotisez', match: 'Contrepartie de l\'employeur'
},
retire: { title: 'Âge de la retraite', hint: 'Quand vous cessez de gagner un revenu d\'emploi.', years: '{v} ans' },
retire: { title: 'Âge de la retraite', hint: 'Quand vous cessez de gagner un revenu d\'emploi. La retraite anticipée (FIRE) est prise en charge — le plan doit autofinancer les années sans RPC/SV.', years: '{v} ans' },
spend: { title: 'Dépenses annuelles prévues à la retraite', hint: 'En dollars d\'aujourd\'hui, après impôt. Soyez honnête — c\'est le chiffre qui décide de tout.' },
cppstart: {
title: 'Débuter la RPC à…', hint: 'À {age} ans : {adj}',
@@ -507,7 +508,8 @@
},
t: {
age: 'Âge', rrsp: 'REER', tfsa: 'CELI', nonreg: 'Non enreg.', total: 'Total', wdr: 'Retrait',
cpp: 'RPC', oas: 'SV', gis: 'SRG', tax: 'Impôt + récup.', unfunded: 'Non financé'
cpp: 'RPC', oas: 'SV', gis: 'SRG', tax: 'Impôt + récup.', unfunded: 'Non financé',
mixedNote: '† Un partenaire est à la retraite, l\'autre travaille encore : son épargne croît au taux pré-retraite — cette ligne n\'est pas en mode retraite complet.'
},
d: {
main: '<b>Estimations seulement — il ne s\'agit pas de conseils financiers.</b> Modèle simplifié : retrait progressif simplifié de 50 % pour la SRG; Québec présenté avec le dégagement fédéral de 16,5 % (RRQ et cotisations sociales québécoises non modélisés); pension de la fonction publique fédérale (LRFP) modélisée si activée — autres pensions à prestations non modélisées; rendements à taux réels constants; surtaxe de l\'Ontario calculée sur l\'impôt de base.',
+2 -1
View File
@@ -83,7 +83,7 @@
<label><span data-i18n="r.wiCtrb">Monthly savings</span> <output id="wi-ctrb-o"></output>
<input type="range" id="wi-ctrb" min="0" max="5000" step="50"></label>
<label><span data-i18n="r.wiRet">Retirement age</span> <output id="wi-ret-o"></output>
<input type="range" id="wi-ret" min="55" max="72" step="1"></label>
<input type="range" id="wi-ret" min="40" max="72" step="1"></label>
<label><span data-i18n="r.wiCpp">CPP starts at</span> <output id="wi-cpp-o"></output>
<input type="range" id="wi-cpp" min="60" max="70" step="1"></label>
</div>
@@ -92,6 +92,7 @@
<details class="yearly">
<summary id="yr-summary">Year-by-year detail (<span id="yr-n">0</span> years)</summary>
<div class="tbl-wrap"><table id="yr-table"></table></div>
<p class="cap" id="yr-mixed-note" hidden></p>
</details>
<div class="disclaimer">