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

+86
View File
@@ -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);
+9
View File
@@ -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
View File
@@ -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);
}))))))
}))))))))))
;