PS pension module: optional federal PSSA engine + UI (PSPC-accurate, EN/FR)
This commit is contained in:
1 parent
fe62022a32
commit
583b5ee2f8
14 files changed
+1991
-27
No files matched your search
@@ -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);
|
||||
Reference in new issue
Block a user