Files
retire-planner/tests/ps-pension.test.js
T

208 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);