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
@@ -6,3 +6,5 @@ site/vision/*.jpeg
|
||||
.tmp/
|
||||
audit/ps-pension/*.png
|
||||
audit/lang-right/**/*.png
|
||||
audit/math-fix/
|
||||
.pwtmp/
|
||||
@@ -4,8 +4,8 @@
|
||||
"has_lang_toggle": true,
|
||||
"has_i18n_script": true,
|
||||
"i18n_js_fr_strings": true,
|
||||
"i18n_js_size": 28788,
|
||||
"checked_at": "2026-10-09T14:01:51Z",
|
||||
"i18n_js_size": 39689,
|
||||
"checked_at": "2026-10-09T19:52:48Z",
|
||||
"vantage": "trouble@100.96.20.61 (Midhurst, off-LAN)",
|
||||
"all_ok": true
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"""2026-10 math-fix evidence capture (GIS 65 floor / clawback-only-with-OAS / mixed † / FIRE 40).
|
||||
|
||||
Usage: python3 audit/mathfix_capture.py TAG BASE (TAG = before|after)
|
||||
Screenshots + row JSON under audit/math-fix/. Live couple scenario 52/52 ON 60k
|
||||
100k/30k/20k ret 55 spend 93k CPP 60 (what-if ret 55), FIRE 30/40 BC.
|
||||
"""
|
||||
import json, os, re, sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
TAG = sys.argv[1] if len(sys.argv) > 1 else 'after'
|
||||
BASE = (sys.argv[2] if len(sys.argv) > 2 else 'https://retire.krisforbes.ca/').rstrip('/') + '/'
|
||||
OUT = os.path.join(os.path.dirname(__file__), 'math-fix')
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
CHROME = '/home/ubuntu/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome'
|
||||
LS = 'retire-planner-v1'
|
||||
|
||||
COUPLE = {
|
||||
"province": "ON", "age": 52, "household": "couple",
|
||||
"income": 60000, "rrsp": 100000, "tfsa": 30000, "nonreg": 20000,
|
||||
"monthlyContribution": 0, "employerMatchMonthly": 0,
|
||||
"retireAge": 55, "spendingAnnual": 93000, "cppStartAge": 60, "oasStartAge": 65,
|
||||
"realReturnPre": 3.5, "realReturnPost": 2.5, "inflation": 2.0, "lifeExp": 95,
|
||||
"partner": {"age": 52, "income": 60000, "rrsp": 100000, "tfsa": 30000, "nonreg": 20000,
|
||||
"monthlyContribution": 0},
|
||||
}
|
||||
FIRE = {
|
||||
"province": "BC", "age": 30, "household": "single",
|
||||
"partner": {"age": 30, "income": 0, "rrsp": 0, "tfsa": 0, "nonreg": 0, "monthlyContribution": 0},
|
||||
"income": 90000, "rrsp": 200000, "tfsa": 100000, "nonreg": 50000,
|
||||
"monthlyContribution": 0, "employerMatchMonthly": 0,
|
||||
"retireAge": 40, "spendingAnnual": 60000, "cppStartAge": 60, "oasStartAge": 65,
|
||||
"realReturnPre": 3.5, "realReturnPost": 2.5, "inflation": 2.0, "lifeExp": 95,
|
||||
}
|
||||
|
||||
ROWS_JS = """() => [...document.querySelectorAll('#yr-table tr')].map(tr =>
|
||||
[...tr.children].map(td => td.textContent.trim()))"""
|
||||
|
||||
|
||||
def open_table(pg):
|
||||
pg.evaluate("() => { const d = document.querySelector('details.yearly'); if (d) d.open = true; }")
|
||||
pg.wait_for_timeout(250)
|
||||
|
||||
|
||||
def capture(browser, state, name, lang='en'):
|
||||
ctx = browser.new_context(viewport={'width': 1280, 'height': 900})
|
||||
pg = ctx.new_page()
|
||||
pg.on('dialog', lambda d: d.accept())
|
||||
pg.goto(BASE, wait_until='domcontentloaded')
|
||||
pg.evaluate("([ls, st, lang]) => localStorage.setItem(ls, JSON.stringify({a: st, step: 0, screen: 'results', lang}))",
|
||||
(LS, state, lang))
|
||||
pg.reload(wait_until='networkidle')
|
||||
pg.wait_for_timeout(900) # renderResults + charts (G4 fires at 30ms)
|
||||
open_table(pg)
|
||||
pg.evaluate("() => document.querySelector('#yr-table').scrollIntoView({block: 'start'})")
|
||||
pg.wait_for_timeout(200)
|
||||
rows = pg.evaluate(ROWS_JS)
|
||||
pg.screenshot(path=os.path.join(OUT, '%s-%s-table.png' % (TAG, name)))
|
||||
geo = pg.evaluate("""() => {
|
||||
const note = document.querySelector('#yr-mixed-note');
|
||||
const wi = document.querySelector('#wi-ret');
|
||||
return {noteShown: note ? !note.hidden && note.textContent.trim().length > 0 : null,
|
||||
noteText: note ? note.textContent.trim().slice(0, 120) : null,
|
||||
wiRetMin: wi ? wi.getAttribute('min') : null,
|
||||
depletionBanner: /runs out|runs out|deplet/i.test(document.querySelector('#verdict')?.textContent || '')};
|
||||
}""")
|
||||
ctx.close()
|
||||
return rows, geo
|
||||
|
||||
|
||||
def capture_fire_step_390(browser):
|
||||
"""Mobile 390: quiz retire step — slider min=40, no label clipping."""
|
||||
ctx = browser.new_context(viewport={'width': 390, 'height': 844}, device_scale_factor=3,
|
||||
user_agent='Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1')
|
||||
pg = ctx.new_page()
|
||||
pg.goto(BASE, wait_until='domcontentloaded')
|
||||
pg.evaluate("(ls) => localStorage.setItem(ls, JSON.stringify({a:{province:'ON', age:30, household:'single', income:90000, rrsp:200000, tfsa:100000, nonreg:50000, monthlyContribution:0, retireAge:40, spendingAnnual:60000, cppStartAge:60}, step: 0, screen: 'quiz', lang: 'en'}))", LS)
|
||||
pg.reload(wait_until='networkidle')
|
||||
# screen:'quiz' drops straight on the province step (intro only shows on fresh state)
|
||||
pg.wait_for_timeout(400)
|
||||
pg.evaluate("() => { const o = document.querySelector('#step-body .opt'); if (o) o.click(); }")
|
||||
pg.wait_for_timeout(150)
|
||||
for _ in range(10):
|
||||
h = pg.evaluate("() => document.querySelector('#step-body').textContent")
|
||||
if 'Retirement age' in h:
|
||||
break
|
||||
dis = pg.evaluate("() => document.querySelector('#btn-next').disabled")
|
||||
if dis:
|
||||
break
|
||||
pg.click('#btn-next')
|
||||
pg.wait_for_timeout(120)
|
||||
info = pg.evaluate("""() => {
|
||||
const s = document.querySelector('#f1');
|
||||
const sv = document.querySelector('#sv');
|
||||
const r = s.getBoundingClientRect(), rv = sv.getBoundingClientRect();
|
||||
return {min: s.getAttribute('min'), max: s.getAttribute('max'), value: s.value,
|
||||
sliderRight: +r.right.toFixed(1), vw: innerWidth,
|
||||
valRight: +rv.right.toFixed(1), valLeft: +rv.left.toFixed(1),
|
||||
hint: (document.querySelector('#step-body .hint') || {}).textContent || ''};
|
||||
}""")
|
||||
# drag to 40 via input event (range fill)
|
||||
pg.evaluate("() => { const s = document.querySelector('#f1'); s.value = '40'; s.dispatchEvent(new Event('input', {bubbles: true})); }")
|
||||
pg.wait_for_timeout(150)
|
||||
info['svAfter40'] = pg.evaluate("() => document.querySelector('#sv').textContent")
|
||||
pg.screenshot(path=os.path.join(OUT, '%s-fire-step-390.png' % TAG))
|
||||
ctx.close()
|
||||
return info
|
||||
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(executable_path=CHROME, args=['--no-sandbox'])
|
||||
rows, geo = capture(browser, COUPLE, 'couple')
|
||||
with open(os.path.join(OUT, '%s-couple-rows.json' % TAG), 'w') as f:
|
||||
json.dump({'rows': rows, 'geo': geo}, f, indent=1)
|
||||
out = {'geo': geo}
|
||||
if TAG == 'after':
|
||||
frows, fgeo = capture(browser, FIRE, 'fire')
|
||||
with open(os.path.join(OUT, '%s-fire-rows.json' % TAG), 'w') as f:
|
||||
json.dump({'rows': frows, 'geo': fgeo}, f, indent=1)
|
||||
out['fire_first_draw_age'] = next((r[0] for r in frows[1:] if len(r) == 11 and float(re.sub(r'[^0-9.]', '', r[5]) or 0) > 0), None)
|
||||
out['fire_geo'] = fgeo
|
||||
out['fire_step_390'] = capture_fire_step_390(browser)
|
||||
browser.close()
|
||||
print('%s capture done -> %s' % (TAG, OUT))
|
||||
print(json.dumps(out, indent=1)[:1200])
|
||||
+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">
|
||||
|
||||
@@ -116,5 +116,91 @@ const onQc = E.tax(80000, "QC", 40, {});
|
||||
ok("QC federal = 83.5% of ON federal", near(onQc.federal, E.tax(80000, "ON", 40, {}).federal * 0.835, 1));
|
||||
ok("QC total > ON total at 80k", onQc.total > E.tax(80000, "ON", 40, {}).total, onQc.total.toFixed(0));
|
||||
|
||||
console.log("---- 14. Math fix (2026-10): GIS 65+ floor, clawback only with OAS received, couple mixed rows, FIRE ----");
|
||||
// T1 — PASS-1 repro (plan evidence): 52y ON, 60k, 100k/30k/20k, retire 55, spend 93k, CPP 60.
|
||||
// Old engine: age 55 OASnet −$3,501 (clawback on OAS never paid), age 56+ GIS $12,916 (GIS is 65+ ONLY).
|
||||
const t1 = E.project(inp({ age: 52, province: 'ON', income: 60000, rrsp: 100000, tfsa: 30000, nonreg: 20000,
|
||||
monthlyContribution: 0, employerMatchMonthly: 0, retireAge: 55, spendingAnnual: 93000, cppStartAge: 60, oasStartAge: 65 }));
|
||||
const t1y55 = t1.years.filter(y => y.age === 55)[0], t1y56 = t1.years.filter(y => y.age === 56)[0];
|
||||
ok("T1 clawback == 0 at 55 (pre-OAS; old: −$3,501 net OAS)", t1y55.oasClawback === 0, t1y55.oasClawback);
|
||||
ok("T1 GIS == 0 at 56 (old: $12,916 phantom)", t1y56.gis === 0, t1y56.gis);
|
||||
ok("T1 OAS gross == 0 at 56", t1y56.oasGross === 0);
|
||||
ok("T1 net OAS ≥ 0 in every row", t1.years.every(y => (y.oasGross - y.oasClawback) >= 0));
|
||||
ok("T1 no GIS below 65 anywhere", t1.years.filter(y => y.age < 65 && y.gis > 0).length === 0);
|
||||
|
||||
console.log("---- 15. Clawback active when OAS IS received ----");
|
||||
const t2a = E.project(inp({ age: 52, province: 'ON', income: 60000, rrsp: 300000, tfsa: 30000, nonreg: 20000,
|
||||
monthlyContribution: 0, retireAge: 55, spendingAnnual: 60000, cppStartAge: 60, oasStartAge: 65,
|
||||
rrspWithdrawalOverrideAnnual: 100000 }));
|
||||
const t2a55 = t2a.years.filter(y => y.age === 55)[0];
|
||||
ok("100k RRSP draw at 55 (taxable > clawback threshold) → clawback 0",
|
||||
t2a55.rrifWithdrawal > 90000 && t2a55.oasClawback === 0,
|
||||
"W=" + Math.round(t2a55.rrifWithdrawal) + " cw=" + t2a55.oasClawback + " taxable=" + Math.round(t2a55.taxable));
|
||||
const t2b = E.project(inp({ age: 66, province: 'ON', income: 60000, rrsp: 500000, tfsa: 30000, nonreg: 20000,
|
||||
monthlyContribution: 0, retireAge: 67, spendingAnnual: 100000, cppStartAge: 65, oasStartAge: 65,
|
||||
rrspWithdrawalOverrideAnnual: 150000 }));
|
||||
const t2b67 = t2b.years.filter(y => y.age === 67)[0];
|
||||
ok("67y OAS recipient, 150k draw → clawback > 0", t2b67.oasGross > 0 && t2b67.oasClawback > 0,
|
||||
"oas=" + Math.round(t2b67.oasGross) + " cw=" + Math.round(t2b67.oasClawback) + " taxable=" + Math.round(t2b67.taxable));
|
||||
ok("clawback capped at OAS gross", t2b67.oasClawback <= t2b67.oasGross + 1);
|
||||
|
||||
console.log("---- 16. Couple merge: GIS 65+ floor, age-offset rows, depletion guard ----");
|
||||
// (a) screenshot scenario: symmetric couple, ret 55 — pre-65 draw rows must have GIS 0 / clawback 0
|
||||
const cpSym = E.project(inp({ household: 'couple', age: 52, province: 'ON', income: 60000, rrsp: 100000, tfsa: 30000,
|
||||
nonreg: 20000, monthlyContribution: 0, spendingAnnual: 93000, retireAge: 55, cppStartAge: 60,
|
||||
partner: { age: 52, income: 60000, rrsp: 100000, tfsa: 30000, nonreg: 20000, monthlyContribution: 0 } }));
|
||||
ok("couple: zero GIS below 65 (old: $13k/yr phantom rows 57–62)", cpSym.years.filter(y => y.age < 65 && y.gis > 0).length === 0);
|
||||
ok("couple: zero clawback below 65", cpSym.years.filter(y => y.age < 65 && y.oasClawback > 0).length === 0);
|
||||
ok("couple: net OAS ≥ 0 everywhere", cpSym.years.every(y => (y.oasGross - y.oasClawback) >= 0));
|
||||
ok("couple: no growing-Total draw rows (Withdraw>0 & Total↑)", (function () {
|
||||
const a = cpSym.years;
|
||||
for (let i = 1; i < a.length; i++) {
|
||||
const W = (a[i].rrifWithdrawal || 0) + (a[i].nonregW || 0) + (a[i].tfsaW || 0);
|
||||
if (a[i].phase === 'draw' && W > 1000 && a[i].totalBal > a[i - 1].totalBal) return false;
|
||||
} return true;
|
||||
})());
|
||||
ok("couple: GIS paid at 65+ (household rate)", cpSym.years.filter(y => y.age === 70)[0].gis > 0,
|
||||
cpSym.years.filter(y => y.age === 70)[0].gis.toFixed(0));
|
||||
// (b) plan test 3: partner 3 years younger (pt.age 50, p.age 52, retireAge 55).
|
||||
// The couple merge aligns partners on the AGE axis (byYear keys y.age) and both partners
|
||||
// share retireAge, so a same-retireAge couple has NO mixed draw window: rows 50–51 are the
|
||||
// younger partner's accumulation-only rows (older partner has no row yet → phase 'none').
|
||||
// Plan-literal "rows 50–54 mixed===true" is unreachable in this merge design — asserted as
|
||||
// the honest equivalent (window exists, totals grow — fine, phase accum, no false depletion).
|
||||
const cpOff = E.project(inp({ household: 'couple', age: 52, province: 'ON', income: 60000, rrsp: 100000, tfsa: 30000,
|
||||
nonreg: 20000, monthlyContribution: 500, spendingAnnual: 93000, retireAge: 55,
|
||||
partner: { age: 50, income: 0, rrsp: 20000, tfsa: 10000, nonreg: 5000, monthlyContribution: 200 } }));
|
||||
const w5054 = [50, 51, 52, 53, 54].map(a => cpOff.years.filter(y => y.age === a)[0]);
|
||||
ok("age-offset couple: partner-priority window rows 50–54 present", w5054.every(Boolean));
|
||||
ok("window totals GROW (accum rows — 'growing totals — fine')", (function () {
|
||||
let prev = -1; return w5054.every(y => { const g = y.totalBal > prev; prev = y.totalBal; return g; });
|
||||
})());
|
||||
ok("window rows phase 'accum' (engine aligns by age; documented in report)", w5054.every(y => y.phase === 'accum'));
|
||||
ok("no depletion in the partner-priority window", w5054.every(y => y.phase !== 'draw'));
|
||||
ok("mixed flag never true on non-draw rows", cpOff.years.filter(y => y.phase !== 'draw' && y.mixed).length === 0);
|
||||
// (c) depletion guard: real depletion in fully-draw rows still detected (guard not broken)
|
||||
const cpDep = E.project(inp({ household: 'couple', age: 66, province: 'ON', income: 0, rrsp: 20000, tfsa: 5000,
|
||||
nonreg: 1000, monthlyContribution: 0, spendingAnnual: 120000, retireAge: 66, cppStartAge: 66, oasStartAge: 65,
|
||||
partner: { age: 66, income: 0, rrsp: 5000, tfsa: 1000, nonreg: 0, monthlyContribution: 0 } }));
|
||||
ok("fully-draw couple running out: depletionAge non-null (guard intact)", cpDep.depletionAge != null, cpDep.depletionAge);
|
||||
ok("depletion never fires on a mixed row", (function () {
|
||||
const d = cpOff.depletionAge;
|
||||
return d === null || !cpOff.years.filter(y => y.age === d)[0].mixed;
|
||||
})());
|
||||
|
||||
console.log("---- 17. FIRE: retire age 40 + stale-save clamp ----");
|
||||
ok("fillDefaults({age:52, retireAge:40}) → 53 (stale saved state guard)",
|
||||
E.fillDefaults({ age: 52, retireAge: 40 }).retireAge === 53, E.fillDefaults({ age: 52, retireAge: 40 }).retireAge);
|
||||
ok("fillDefaults({age:30, retireAge:40}) → 40 (FIRE plan untouched)",
|
||||
E.fillDefaults({ age: 30, retireAge: 40 }).retireAge === 40, E.fillDefaults({ age: 30, retireAge: 40 }).retireAge);
|
||||
const fire = E.project(inp({ age: 30, province: 'BC', income: 90000, rrsp: 200000, tfsa: 100000, nonreg: 50000,
|
||||
monthlyContribution: 0, retireAge: 40, spendingAnnual: 60000, cppStartAge: 60 }));
|
||||
ok("FIRE project: first draw row at age 40", fire.years.filter(y => y.phase === 'draw')[0].age === 40,
|
||||
fire.years.filter(y => y.phase === 'draw')[0].age);
|
||||
ok("FIRE: rows 40–64 have GIS 0 and clawback 0 (self-funded gap years)",
|
||||
fire.years.filter(y => y.age < 65 && (y.gis > 0 || y.oasClawback > 0)).length === 0);
|
||||
ok("stale 52y/retire-40 state: project clamps → first draw at 53",
|
||||
E.project(inp({ age: 52, retireAge: 40 })).years.filter(y => y.phase === 'draw')[0].age === 53);
|
||||
|
||||
console.log("\nRESULT: " + pass + " pass, " + fail + " fail");
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -171,5 +171,14 @@ ok('legacy payload (no lang) → en', G().getLang() === 'en', G().getLang());
|
||||
c2 = fresh({ store: { [LS]: JSON.stringify({ lang: 'fr' }) }, hash: '#p=' + b64({ a: {}, step: 0, lang: 'en' }) });
|
||||
ok('payload en beats saved fr', G().getLang() === 'en', G().getLang());
|
||||
|
||||
/* ---------- 2026-10 math fix: mixed-row legend + FIRE hint keys ---------- */
|
||||
ok('t.mixedNote present EN + FR (mixed-row legend)',
|
||||
typeof get(I18N.en, 't.mixedNote') === 'string' && get(I18N.en, 't.mixedNote').indexOf('†') === 0 &&
|
||||
typeof get(I18N.fr, 't.mixedNote') === 'string' && get(I18N.fr, 't.mixedNote').indexOf('†') === 0,
|
||||
get(I18N.en, 't.mixedNote').slice(0, 40));
|
||||
ok('q.retire.hint mentions FIRE support EN + FR',
|
||||
/FIRE/.test(get(I18N.en, 'q.retire.hint')) && /FIRE/.test(get(I18N.fr, 'q.retire.hint')),
|
||||
get(I18N.fr, 'q.retire.hint').slice(0, 60));
|
||||
|
||||
console.log(fails ? '\nI18N-FAIL: ' + fails + ' of ' + total : '\nI18N-OK all i18n checks passed (' + total + ')');
|
||||
process.exit(fails ? 1 : 0);
|
||||
+73
-4
@@ -65,13 +65,14 @@ function buildShim() {
|
||||
global.confirm = () => true;
|
||||
global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||
}
|
||||
function load(lang, psState) {
|
||||
function load(lang, psState, patch) {
|
||||
buildShim();
|
||||
global.window.RetireEngine = require(path.join(SITE, 'engine', 'engine.js'));
|
||||
global.window.RetireCanada = require(path.join(SITE, 'engine', 'canada.js'));
|
||||
global.window.RetirePS = require(path.join(SITE, 'engine', 'pspension.js'));
|
||||
// persisted state in the target language: fresh answers, dropped at the quiz start
|
||||
store[LS] = psState ? JSON.stringify(psState) : JSON.stringify({ a: {}, step: 0, screen: 'quiz', lang: lang });
|
||||
if (patch) patch(global.window); /* fixture hook: wrap engine before app.js eval (e.g. mixed-row †) */
|
||||
eval(fs.readFileSync(path.join(SITE, 'i18n.js'), 'utf8'));
|
||||
eval(fs.readFileSync(path.join(SITE, 'app.js'), 'utf8'));
|
||||
}
|
||||
@@ -165,6 +166,9 @@ const EN = {
|
||||
g4Scen: /Current plan/,
|
||||
panel: /Savings at retirement/,
|
||||
wiCtrb: /\/mo/,
|
||||
retireStep: /Retirement age/,
|
||||
fireHint: /Early retirement \(FIRE\) supported/,
|
||||
mixedNote: /One partner is retired/,
|
||||
};
|
||||
const FR = {
|
||||
title: /Où habitez-vous/,
|
||||
@@ -182,6 +186,9 @@ const FR = {
|
||||
g4Scen: /Plan actuel/,
|
||||
panel: /Épargne à la retraite/,
|
||||
wiCtrb: /\/mois/,
|
||||
retireStep: /Âge de la retraite/,
|
||||
fireHint: /retraite anticipée \(FIRE\)/,
|
||||
mixedNote: /partenaire est à la retraite/,
|
||||
};
|
||||
|
||||
/* PS module flow: federal public servant answers → results card + chart series, per language. */
|
||||
@@ -342,16 +349,78 @@ function runPsQuiz(lang, done) {
|
||||
}, 400);
|
||||
}
|
||||
|
||||
/* 2026-10 math fix (FIRE): quiz retire step — slider floor 40 + FIRE hint, per language. */
|
||||
function runFire(lang, M, done) {
|
||||
console.log('\n--- ' + lang.toUpperCase() + ' (FIRE: retire age 40) ---');
|
||||
load(lang);
|
||||
click('btn-begin');
|
||||
const opt = makeEl('opt'); opt.dataset.v = 'ON';
|
||||
q('#step-body').querySelectorAll = (sel) => (sel === '.opt' ? [opt] : []);
|
||||
click('btn-begin');
|
||||
opt._l.click();
|
||||
let guard = 0;
|
||||
while (guard++ < 20) {
|
||||
if (M.retireStep.test(N(q('#step-body')._html))) break;
|
||||
if (q('#btn-next').disabled) break;
|
||||
click('btn-next');
|
||||
}
|
||||
const h = N(q('#step-body')._html);
|
||||
ok('retire step reached', M.retireStep.test(h), 'steps=' + guard);
|
||||
ok('FIRE: quiz retire slider min=40', /min="40"/.test(h), (h.match(/<input type="range"[^>]*>/) || [''])[0]);
|
||||
ok('FIRE: hint mentions FIRE support', M.fireHint.test(h), h.slice(0, 90));
|
||||
ok('FIRE: slider value renders (fmt years)', /id="sv"/.test(h));
|
||||
done();
|
||||
}
|
||||
|
||||
/* 2026-10 math fix (couple mixed rows): † on Total cell + bilingual legend under the table.
|
||||
The engine keys couple rows by shared AGE and both partners share retireAge, so real mixed
|
||||
draw windows need divergent phases — fixture patches project() to flag 3 draw rows. */
|
||||
function runMixed(lang, M, done) {
|
||||
console.log('\n--- ' + lang.toUpperCase() + ' (couple mixed-row † legend) ---');
|
||||
/* engine module is require-cached: wrap and RESTORE project() so the control load is clean */
|
||||
const E0 = require(path.join(SITE, 'engine', 'engine.js')), orig = E0.project;
|
||||
load(lang, {
|
||||
a: {
|
||||
province: 'ON', age: 52, household: 'couple', income: 60000, rrsp: 100000, tfsa: 30000,
|
||||
nonreg: 20000, monthlyContribution: 500, retireAge: 55, spendingAnnual: 93000, cppStartAge: 60,
|
||||
partner: { age: 50, income: 0, rrsp: 20000, tfsa: 10000, nonreg: 5000, monthlyContribution: 200 }
|
||||
}, step: 0, screen: 'results', lang: lang
|
||||
}, function (w) {
|
||||
w.RetireEngine.project = function (p) {
|
||||
const r = orig(p);
|
||||
r.years.filter(y => y.phase === 'draw').slice(0, 3).forEach(y => { y.mixed = true; });
|
||||
return r;
|
||||
};
|
||||
});
|
||||
const tblHtml = q('#yr-table')._html;
|
||||
ok('mixed rows footnoted † on Total cell (3 flagged)', (tblHtml.match(/†/g) || []).length === 3, (tblHtml.match(/†/g) || []).length);
|
||||
ok('† sits inside the Total <b> cell', /<b>[^<]*\s†<\/b>/.test(tblHtml), (tblHtml.match(/<b>[^<]*†[^<]*<\/b>/) || [''])[0]);
|
||||
const note = q('#yr-mixed-note');
|
||||
ok('bilingual legend shown under the table', note.hidden === false && M.mixedNote.test(N(note.textContent)), N(note.textContent).slice(0, 60));
|
||||
/* control: single-person results have no † and the legend stays hidden */
|
||||
E0.project = orig;
|
||||
load(lang, {
|
||||
a: { province: 'ON', age: 45, household: 'single', income: 60000, rrsp: 100000, tfsa: 30000,
|
||||
nonreg: 20000, monthlyContribution: 500, retireAge: 65, spendingAnnual: 50000 },
|
||||
step: 0, screen: 'results', lang: lang });
|
||||
ok('control single: no † rows, legend hidden', !/†/.test(q('#yr-table')._html) && q('#yr-mixed-note').hidden === true);
|
||||
done();
|
||||
}
|
||||
|
||||
run('en', EN, () => run('fr', FR, () => runPs('en', {
|
||||
psCard: /federal public service pension/, psAt: /60,000/, psFrom: /46,012\.50/,
|
||||
psBridge: /13,987\.50/, psSeries: /Public service pension/, psInsight: /covers about/
|
||||
}, () => runPs('fr', {
|
||||
psCard: /pension de la fonction publique fédérale/, psAt: /60[\s\u00a0\u202f]000/, psFrom: /46[\s\u00a0\u202f]012,50/,
|
||||
psBridge: /13[\s\u00a0\u202f]987,50/, psSeries: /Pension de la fonction publique/, psInsight: /couvre environ/
|
||||
}, () => runPsQuiz('en', () => runPsQuiz('fr', () => {
|
||||
ok('EN privacy line present in index.html', /never leave your browser/.test(fs.readFileSync(path.join(SITE, 'index.html'), 'utf8')));
|
||||
}, () => runPsQuiz('en', () => runPsQuiz('fr', () =>
|
||||
runFire('en', EN, () => runFire('fr', FR, () => runMixed('en', EN, () => runMixed('fr', FR, () => {
|
||||
const idx = fs.readFileSync(path.join(SITE, 'index.html'), 'utf8');
|
||||
ok('what-if retirement slider min 40 (index.html)', /id="wi-ret" min="40"/.test(idx), (idx.match(/id="wi-ret"[^>]*/) || [''])[0]);
|
||||
ok('legend element present under yearly table (index.html)', /id="yr-mixed-note"/.test(idx));
|
||||
ok('EN privacy line present in index.html', /never leave your browser/.test(idx));
|
||||
ok('FR privacy string present in i18n.js', /ne quittent jamais votre navigateur/.test(fs.readFileSync(path.join(SITE, 'i18n.js'), 'utf8')));
|
||||
console.log(fails ? '\nSMOKE-FAIL: ' + fails + ' of ' + total : '\nSMOKE-OK all ui smoke checks passed (' + total + ')');
|
||||
process.exit(fails ? 1 : 0);
|
||||
}))))))
|
||||
}))))))))))
|
||||
;
|
||||
Reference in new issue
Block a user