PS pension module: optional federal PSSA engine + UI (PSPC-accurate, EN/FR)

This commit is contained in:
krisf committed 2026-10-09 11:44:40 -04:00
1 parent fe62022a32
commit 583b5ee2f8
14 files changed
+1991 -27

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
// Retire-planner PS pension tests — run: node tests/ps-pension.test.js (PS-PENSION-PLAN.md Stage 3)
// Hand-computed cases A–J from the plan (AMPE proxy = 2026 YMPE = 74,600), verified to the cent,
// plus engine integration checks (bridge window, start-age gating, PIA gating, pre-65 splitting).
//
// NOTE (documented in PS-PENSION-REPORT.md): two plan cases contain arithmetic typos.
// The module implements the PSSA formula exactly; expected values here are the
// formula-correct results of the plan's OWN inputs:
// H: plan says lifetime 67,680.75 → correct is 67,681.25 (= 0.01375×74,600×35 + 0.02×45,400×35
// = 35,901.25 + 31,780.00).
// I: plan says lifetime 41,422.50 / total 54,011.25 / from65 35,209.125 → correct is
// 41,411.25 / 54,000.00 / 35,199.56. Proof: the plan's own identity
// lifetime+bridge (avg≤AMPE case) = 2%×100,000×27 = 54,000, and 41,411.25+12,588.75 = 54,000.
'use strict';
const E = require("../site/engine/engine.js");
const P = require("../site/engine/pspension.js");
let pass = 0, fail = 0;
function ok(name, cond, detail) {
if (cond) { pass++; console.log(" PASS " + name + (detail !== undefined ? " [" + detail + "]" : "")); }
else { fail++; console.log(" FAIL " + name + (detail !== undefined ? " [" + detail + "]" : "")); }
}
function cent(a, b) { return Math.abs(a - b) < 0.005; } // to the cent
const AMPE = 74600;
const G1 = 2010, G2 = 2015;
const calc = function (joinYear, retireAge, serviceYears, avgSalary) {
return P.computePSPension({ joinYear: joinYear, retireAge: retireAge, serviceYears: serviceYears, avgSalary: avgSalary, ampe: AMPE });
};
console.log("PS pension tests (PSSA formula; AMPE proxy = " + AMPE + ")");
console.log("---- 0. groups + constants ----");
ok("PCT_LOW = 1.375%", P.PCT_LOW === 0.01375);
ok("PCT_HIGH = 2%", P.PCT_HIGH === 0.02);
ok("BRIDGE_PCT = 0.625%", P.BRIDGE_PCT === 0.00625);
ok("MAX_SERVICE = 35", P.MAX_SERVICE === 35);
ok("group 1 = joined ≤ 2012", P.psGroup(2012) === 1 && P.psGroup(1995) === 1);
ok("group 2 = joined ≥ 2013", P.psGroup(2013) === 2 && P.psGroup(2024) === 2);
ok("5 IT levels, as-of 2024-12-22", P.IT_LEVELS.length === 5 && P.IT_RATES_ASOF === "2024-12-22");
ok("IT-01 range 69,361–89,382 (TBS–PSAC, 2024-12-22)", P.itRange("IT-01").min === 69361 && P.itRange("IT-01").top === 89382);
ok("IT-02 range 85,854–105,080 (TBS–PSAC, 2024-12-22)", P.itRange("IT-02").min === 85854 && P.itRange("IT-02").top === 105080);
console.log("---- A. G2, 100k, 30 yrs, retire 65: unreduced, no bridge ----");
{
const r = calc(G2, 65, 30, 100000);
ok("A lifetime 46,012.50", cent(r.lifetime, 46012.50), r.lifetime);
ok("A bridge 0 (retire ≥65)", r.bridge === 0);
ok("A reduction 0 (65+2yr rule)", r.reductionPct === 0);
ok("A atRetirement = from65 = 46,012.50", cent(r.pensionAtRetirement, 46012.50) && cent(r.pensionFrom65, 46012.50), r.pensionAtRetirement);
ok("A startAge 65", r.startAge === 65);
}
console.log("---- B. G2, 100k, 30 yrs, retire 60: unreduced (60+30), identity 2%·avg·S ----");
{
const r = calc(G2, 60, 30, 100000);
ok("B lifetime 46,012.50", cent(r.lifetime, 46012.50), r.lifetime);
ok("B bridge 13,987.50", cent(r.bridge, 13987.50), r.bridge);
ok("B reduction 0 (age60 & S30)", r.reductionPct === 0 && r.formula === null);
ok("B atRetirement 60,000.00", cent(r.pensionAtRetirement, 60000.00), r.pensionAtRetirement);
ok("B from65 46,012.50", cent(r.pensionFrom65, 46012.50), r.pensionFrom65);
}
console.log("---- C. G2, 100k, 20 yrs, retire 60: F1 25% ----");
{
const r = calc(G2, 60, 20, 100000);
ok("C pct 25% (F1 = 5%×(65−60))", cent(r.reductionPct, 0.25) && r.formula === "F1", r.reductionPct + " " + r.formula);
ok("C lifetime 30,675.00", cent(r.lifetime, 30675.00), r.lifetime);
ok("C bridge 9,325.00", cent(r.bridge, 9325.00), r.bridge);
ok("C atRetirement 30,000.00", cent(r.pensionAtRetirement, 30000.00), r.pensionAtRetirement);
ok("C from65 23,006.25", cent(r.pensionFrom65, 23006.25), r.pensionFrom65);
}
console.log("---- D. G2, 100k, 28 yrs, retire 62: min(F1 15%, F2 10%) = 10% ----");
{
const r = calc(G2, 62, 28, 100000);
ok("D pct 10% min(F1,F2)", cent(r.reductionPct, 0.10) && r.formula === "min(F1,F2)", r.reductionPct + " " + r.formula);
ok("D lifetime 42,945.00", cent(r.lifetime, 42945.00), r.lifetime);
ok("D bridge 13,055.00", cent(r.bridge, 13055.00), r.bridge);
ok("D atRetirement 50,400.00", cent(r.pensionAtRetirement, 50400.00), r.pensionAtRetirement);
ok("D from65 38,650.50", cent(r.pensionFrom65, 38650.50), r.pensionFrom65);
}
console.log("---- E. G1 (join 2010), 90k, 20 yrs, retire 58: F1 10% ----");
{
const r = calc(G1, 58, 20, 90000);
ok("E group 1", r.group === 1);
ok("E pct 10% (F1 = 5%×(60−58))", cent(r.reductionPct, 0.10) && r.formula === "F1", r.reductionPct + " " + r.formula);
ok("E lifetime 26,675.00", cent(r.lifetime, 26675.00), r.lifetime);
ok("E bridge 9,325.00", cent(r.bridge, 9325.00), r.bridge);
ok("E atRetirement 32,400.00", cent(r.pensionAtRetirement, 32400.00), r.pensionAtRetirement);
ok("E from65 24,007.50", cent(r.pensionFrom65, 24007.50), r.pensionFrom65);
}
console.log("---- F. G1, 90k, 30 yrs, retire 55: unreduced (55+30) ----");
{
const r = calc(G1, 55, 30, 90000);
ok("F reduction 0 (55+30 G1)", r.reductionPct === 0);
ok("F lifetime 40,012.50", cent(r.lifetime, 40012.50), r.lifetime);
ok("F bridge 13,987.50", cent(r.bridge, 13987.50), r.bridge);
ok("F atRetirement 54,000.00", cent(r.pensionAtRetirement, 54000.00), r.pensionAtRetirement);
ok("F from65 40,012.50", cent(r.pensionFrom65, 40012.50), r.pensionFrom65);
}
console.log("---- G. G2, 80k, 10 yrs, retire 52: deferred to 65 (no reduction, no bridge) ----");
{
const r = calc(G2, 52, 10, 80000);
ok("G startAge 65 deferred", r.startAge === 65 && r.deferred === true);
ok("G reduction 0 + bridge 0", r.reductionPct === 0 && r.bridge === 0);
ok("G lifetime 11,337.50", cent(r.lifetime, 11337.50), r.lifetime);
ok("G earlyOnlyNote deferredG2", r.earlyOnlyNote === "deferredG2", r.earlyOnlyNote);
const red55 = P.psEarlyReduction(2, 55, 10);
ok("G alternative AA@55 = 50% (F1 at 55)", cent(red55.pct, 0.5) && red55.formula === "F1", red55.pct);
}
console.log("---- H. G2, 120k, 40 yrs, retire 65: service capped 35 ----");
{
const r = calc(G2, 65, 40, 120000);
ok("H serviceCapped 35", r.serviceCapped === 35);
ok("H lifetime 67,681.25 (plan 67,680.75 = arithmetic typo; header note)", cent(r.lifetime, 67681.25), r.lifetime);
}
console.log("---- I. G2, 100k, 27 yrs, retire 59: F2 = max(5%,15%) = 15% ----");
{
const r = calc(G2, 59, 27, 100000);
ok("I pct 15% F2", cent(r.reductionPct, 0.15) && r.formula === "F2", r.reductionPct + " " + r.formula);
ok("I lifetime 41,411.25 (plan 41,422.50 = typo)", cent(r.lifetime, 41411.25), r.lifetime);
ok("I bridge 12,588.75", cent(r.bridge, 12588.75), r.bridge);
ok("I atRetirement 45,900.00 (54,000 × 0.85; plan typo)", cent(r.pensionAtRetirement, 45900.00), r.pensionAtRetirement);
ok("I from65 35,199.56 (41,411.25 × 0.85; plan typo)", cent(r.pensionFrom65, 35199.56), r.pensionFrom65);
}
console.log("---- J. service 1.5 yrs: return of contributions only ----");
{
const r = calc(G2, 65, 1.5, 100000);
ok("J all zeros", r.pensionAtRetirement === 0 && r.pensionFrom65 === 0 && r.lifetime === 0 && r.bridge === 0);
ok("J notes lt2", r.notes.indexOf("lt2") >= 0, JSON.stringify(r.notes));
}
console.log("---- Engine integration ----");
const base = Object.assign({}, E.defaults, {
age: 55, retireAge: 60, household: "single", province: "ON",
income: 60000, rrsp: 300000, tfsa: 50000, nonreg: 50000, monthlyContribution: 0,
spendingAnnual: 40000, pensionAnnual: 24000, pensionStartAge: 60, pensionBridgeAnnual: 9000
});
{
const rows = E.project(base).years;
ok("bridge present at 64 (24,000+9,000)", cent(rows.find(function (y) { return y.age === 64; }).pension, 33000));
ok("bridge absent at 65 (bridge ends at 65)", cent(rows.find(function (y) { return y.age === 65; }).pension, 24000));
}
{
const def = Object.assign({}, base, { pensionStartAge: 65, pensionBridgeAnnual: 0 });
const rows = E.project(def).years;
ok("deferred: zero pension income at 62", rows.find(function (y) { return y.age === 62; }).pension === 0);
ok("deferred: full pension at 65", cent(rows.find(function (y) { return y.age === 65; }).pension, 24000));
}
{
const leg = Object.assign({}, base, { pensionStartAge: 0, pensionBridgeAnnual: 0 });
ok("module-off legacy: pension from retireAge (byte-for-byte old behaviour)",
cent(E.project(leg).years.find(function (y) { return y.age === 60; }).pension, 24000));
}
{
// PIA / OAS clawback gating: pension counted from 65 only when started
const rich = Object.assign({}, base, { rrsp: 500000, pensionAnnual: 90000, pensionStartAge: 65, pensionBridgeAnnual: 0 });
const rows = E.project(rich).years;
const cb = function (a) { return rows.find(function (y) { return y.age === a; }).oasClawback; };
ok("PIA: pension at 65+ triggers OAS clawback", cb(67) > 0, cb(67).toFixed(0));
const late = Object.assign({}, rich, { pensionStartAge: 70 });
const cb2 = E.project(late).years.find(function (y) { return y.age === 67; }).oasClawback;
ok("PIA: deferred pension → no clawback at 67", cb2 < cb(67), cb2.toFixed(0) + " < " + cb(67).toFixed(0));
}
{
// pre-65 splitting: employer pension is an eligible life annuity at any age (CRA)
const cpl = Object.assign({}, E.defaults, {
age: 60, retireAge: 60, household: "couple", province: "ON",
income: 0, rrsp: 0, tfsa: 0, nonreg: 0, monthlyContribution: 0,
partner: { age: 60, income: 30000, rrsp: 0, tfsa: 0, nonreg: 0, monthlyContribution: 0 },
spendingAnnual: 60000, pensionAnnual: 30000, pensionStartAge: 60
});
const t62 = E.project(cpl).years.find(function (y) { return y.age === 62; });
const nosplit = E.tax(60000, "ON", 62, { pensionIncome: 30000 }).total;
ok("pre-65 splitting: joint tax with split < tax without split", t62.tax < nosplit,
t62.tax.toFixed(2) + " < " + nosplit.toFixed(2));
const defC = Object.assign({}, cpl, { pensionStartAge: 65 });
const t62d = E.project(defC).years.find(function (y) { return y.age === 62; });
const none = Object.assign({}, cpl, { pensionAnnual: 0 });
const t62n = E.project(none).years.find(function (y) { return y.age === 62; });
ok("splitting respects start age: deferred pension adds no income/tax at 62",
cent(t62d.pension, 0) && cent(t62d.tax, t62n.tax), t62d.tax.toFixed(2) + " == " + t62n.tax.toFixed(2));
}
console.log("\nRESULT: " + pass + " pass, " + fail + " fail");
process.exit(fail ? 1 : 0);
+40 -5
View File
@@ -55,12 +55,13 @@ function buildShim() {
global.confirm = () => true;
global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
function load(lang) {
function load(lang, psState) {
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] = JSON.stringify({ a: {}, step: 0, screen: 'quiz', lang: lang });
store[LS] = psState ? JSON.stringify(psState) : JSON.stringify({ a: {}, step: 0, screen: 'quiz', lang: lang });
eval(fs.readFileSync(path.join(SITE, 'i18n.js'), 'utf8'));
eval(fs.readFileSync(path.join(SITE, 'app.js'), 'utf8'));
}
@@ -81,7 +82,7 @@ function run(lang, M, done) {
ok('intro wiring: begin button has handler', !!q('#btn-begin')._l.click);
click('btn-begin');
ok('step 1 title', M.title.test(q('#step-body')._html), N(q('#step-body')._html).slice(0, 60));
ok('step count', /1 \/ 14/.test(q('#step-count').textContent), q('#step-count').textContent);
ok('step count', /1 \/ 15/.test(q('#step-count').textContent), q('#step-count').textContent);
ok('province step requires a pick (Next disabled)', q('#btn-next').disabled === true);
ok('validation message', createdEls.some(e => M.needProvince.test(N(e.textContent))));
@@ -173,9 +174,43 @@ const FR = {
wiCtrb: /\/mois/,
};
run('en', EN, () => run('fr', FR, () => {
/* PS module flow: federal public servant answers → results card + chart series, per language. */
function runPs(lang, M, done) {
console.log('\n--- ' + lang.toUpperCase() + ' (PS module) ---');
load(lang, {
a: {
province: 'ON', age: 60, retireAge: 60, household: 'single',
income: 0, rrsp: 100000, tfsa: 30000, nonreg: 20000, monthlyContribution: 0,
spendingAnnual: 50000,
psPension: true, psJoinYear: 2000, psServiceYears: 30, psAvgSalary: 100000,
psOccupation: 'it', psItLevel: 'IT-02', psItStep: 1
}, step: 0, screen: 'results', lang: lang });
setTimeout(() => {
const card = q('#ps-card');
ok('PS results card visible', card.hidden === false);
const h = N(card._html);
ok('PS card title localized', M.psCard.test(h), h.slice(0, 70));
ok('PS card pension at 60 (2%×100k×30 = 60,000)', M.psAt.test(h), (h.match(M.psAt) || [''])[0]);
ok('PS card from-65 (lifetime 46,012.50)', M.psFrom.test(h), (h.match(M.psFrom) || [''])[0]);
ok('PS card bridge line (13,987.50)', M.psBridge.test(h), (h.match(M.psBridge) || [''])[0]);
ok('PS card sources link PSPC canada.ca', /href="https:\/\/www\.canada\.ca/.test(h));
ok('PS card IT agreement source (2024-12-22)', /2024-12-22/.test(h));
ok('PS series in G2 chart', M.psSeries.test(N(q('#g2')._html)), N(q('#g2')._html).slice(0, 40));
ok('PS insight in live panel', M.psInsight.test(N(q('#panel-insight')._html)), N(q('#panel-insight')._html).slice(0, 70));
done();
}, 400);
}
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/
}, () => {
ok('EN privacy line present in index.html', /never leave your browser/.test(fs.readFileSync(path.join(SITE, 'index.html'), 'utf8')));
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);
}));
}))))
;