engine: GIS 65+ floor, clawback only with OAS, couple mixed-row footnote, FIRE retire age 40
This commit is contained in:
1 parent
de451f0525
commit
a210e7edfc
10 files changed
+348
-24
No files matched your search
+7
-3
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user