// Retire-planner PS pension tests — run: node tests/ps-pension.test.js (PS-PENSION-PLAN.md Stage 3) // Hand-computed cases A–K from the plan (AMPE proxy = 2026 YMPE = 74,600), verified to the cent, // plus K added post-deploy (Hermes, 2026-10-09): G1 55+ with S≥25 must apply PSPC's // "compare Formula 1 and 2 and apply the lowest reduction" — the subtlest branch, was untested. // 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("---- K. G1, 70k, 25 yrs, retire 59: PSPC 55+ rule — lowest of F1 5% / F2 25% = 5% ----"); { const r = calc(G1, 59, 25, 70000); ok("K pct 5% min(F1,F2) at 55+ (PSPC: compare F1 and 2, apply the lowest)", cent(r.reductionPct, 0.05) && r.formula === "min(F1,F2)", r.reductionPct + " " + r.formula); ok("K lifetime 24,062.50", cent(r.lifetime, 24062.50), r.lifetime); ok("K bridge 10,937.50", cent(r.bridge, 10937.50), r.bridge); ok("K atRetirement 33,250.00 (35,000 × 0.95)", cent(r.pensionAtRetirement, 33250.00), r.pensionAtRetirement); ok("K from65 22,859.38 (24,062.50 × 0.95)", cent(r.pensionFrom65, 22859.38), r.pensionFrom65); const r2 = calc(G1, 56, 25, 70000); ok("K2 pct 20% min(F1 20% / F2 25%) at 56", cent(r2.reductionPct, 0.20) && r2.formula === "min(F1,F2)", r2.reductionPct + " " + r2.formula); ok("K2 atRetirement 28,000.00 (35,000 × 0.80)", cent(r2.pensionAtRetirement, 28000.00), r2.pensionAtRetirement); } 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);