11 KiB
PS Pension Module — Completion Report
Optional federal Public Service Superannuation Act (PSSA) pension module: engine + UI + tests + deploy. Module is off by default — existing users' plans are byte-for-byte unchanged; a federal public servant enables it and the pension flows through income, tax, OAS clawback, GIS and pension-income splitting.
Deployed: DEPLOY-OK → http://192.168.0.123:8103/ (LAN) and
https://retire.krisforbes.ca/ (public, verified off-LAN → OFFLAN-OK,
audit/ps-geo-check.json).
Definition-of-done: every item ✅ at the end of §9.
1. Stage 0 — accuracy gate (re-fetch, PASS)
All plan constants were re-fetched from the canonical PSPC / TBS / CRA pages
(persisted evidence in .tmp/psgate/, fetched via headless Chrome — canada.ca
serves 302/TLS fingerprints to plain HTTP clients):
| Fact | Result | Source (page dated 2025-08-15) |
|---|---|---|
| G1 formula 1.375% / 2%, AMPE cap, 5-yr average, cap 35 yrs, bridge 0.625% | verbatim MATCH | PSPC entitlement package, member on/before 2012-12-31 (g1-en.html) |
| G2 formula + early-reduction ages (65+2 / 60+30) | verbatim MATCH | PSPC entitlement package, member on/after 2013-01-01 (g2-en.html) |
| Bridge ends at 65; unreduced-age rules; deferred-annuity option; <2 yrs → return of contributions | MATCH | TBS "Pension at a glance" (glance.html) |
| AMPE = Average Maximum Pensionable Earnings, indexed to YMPE, = 100% YMPE since 2010 | MATCH | TBS glossary (glossary.html) + CRA YMPE page (cra-ympe.html) |
| Pension income splitting: eligible life annuity at any age; RRSP/RRIF only 65+ | MATCH | CRA (cra-split.html) |
| IT salary table (all 5 levels, all steps) — Z row | cell-for-cell MATCH | TBS IT collective agreement, rates as of 2024-12-22 (it-agreement.html) |
| FR terms (PSCC, SECT, LRFP, MMGP, PGMA, prestation de raccordement, allocation annuelle) | MATCH | PSPC/TBS FR pages (g1-fr.html) |
| 2026 YMPE = $74,600 | MATCH | CRA → canada.js ympe.value |
Gate decisions
- No constants changed vs. the plan's fact base → gate PASS, implementation proceeded.
- URL migration: the plan's PSPC URLs now 301-redirect to the
/retired/entitlement-information-packages/2-more-years-pensionable-service/…pages; the live URLs (all canonicalcanada.ca, linked in the results card) are what the module ships. - No "20% rule": the plan's fact base contains no minimum-20%-reduction rule; an earlier draft of the engine carried one — it was removed (kept plan-faithful; F1-pinning at 50/55 produces the 50% caps PSPC publishes, which is the deepest cut).
2. Formula + reduction decision tree (implemented in site/engine/pspension.js)
With avg5 = 5-yr average of highest-paid service (user entry), S = years of
pensionable service (cap 35), AMPE = YMPE proxy ($74,600):
Lifetime = 1.375% × min(avg5, AMPE) × min(S,35) + 2% × max(0, avg5 − AMPE) × min(S,35)
Bridge = 0.625% × min(avg5, AMPE) × min(S,35) (payable retirement→65 only)
Pre-65 total = 2% × avg5 × min(S,35) (exact identity when avg5 ≤ AMPE)
Pension at retirement = (lifetime + bridge) × (1 − reduction) [from startAge]
Pension from 65 = lifetime × (1 − reduction)
Eligibility & early reduction (annual allowance), ages/service to 1/10 year, reduction applies to the whole pension, permanent:
- G1 (joined ≤ 2012-12-31): unreduced at 60+ w/ ≥2 yrs, or 55–59 w/ ≥30.
S<25: F1 = 5%×(60−age). S≥25: F2 = max(5%×(55−age), 5%×(30−S)); at 55+ w/ S≥25,
min(F1,F2) applies (PSPC: "compare Formula 1 and 2 and apply the lowest reduction");
leaving <50 → F1 pinned at 50 → 50% cap (
note: f1Under50). - G2 (joined ≥ 2013-01-01): unreduced at 65+ w/ ≥2, or 60–64 w/ ≥30.
S<25: F1 = 5%×(65−age). S≥25: F2 = max(5%×(60−age), 5%×(30−S)); at 60+ w/ S≥25,
min(F1,F2). Leaving <55: modelled on the PSPC-default path — deferred annuity,
unreduced, payable at 65 (
startAge=65, bridge=0, earlyOnlyNote:'deferredG2'; the 50%-at-55 annual-allowance alternative is surfaced as a note). - <2 yrs service → return of contributions only (pension 0, note
lt2).
IT salary picker (occupational group IT): 5 levels × 8 steps (IT-05 has 9), real 2024-12-22 rates (IT-01 $69,361–89,382 … IT-05 $133,582–174,076), fully overridable — the step selection pre-fills the salary field, which stays editable.
3. Engine integration (site/engine/engine.js)
DEFAULTS += pensionStartAge: 0, pensionBridgeAnnual: 0→ module off reproduces legacy behaviour exactly (pension fromretireAge, no bridge) — regression-tested.pspension.js(UMD:window.RetirePS/module.exports):computePSPension(),psGroup,psLifetimePension,psBridgeBenefit,psEarlyReduction,IT_LEVELS/itLevel/itRange,SOURCES(canonical canada.ca URLs).- Projection: pension flows from
startAge = max(retireAge, pensionStartAge); bridge added before 65 only (chart fieldrow.pension; cash includes it). - PIA/OAS clawback: pension counted from 65 and only once started (deferred pension ⇒ no clawback years).
- Pension-income splitting (couples): eligible life-annuity income splits at any age once payable (CRA rule) — pre-65 splitting works; RRIF part splits only at 65+; the bridge (term annuity) is excluded (documented choice). Split applied only when it lowers joint tax.
- app.js
psCompute()maps module →pensionAnnual / pensionStartAge / pensionBridgeAnnual; what-if (G4) "retire 2 years later" recomputes the pension.
4. UI (EN + FR, mobile-first)
- 6 new questionnaire steps (only when enabled): toggle → join year (G1/G2 detected) → service years → 5-yr average salary (dynamic hint) → occupation (IT / other) → IT level + step cascading picker (ranges shown, "skip" option). Base quiz is now 15 steps (single) / 18 (couple) with the toggle.
- Results: PS card (
#ps-card) — group, service, salary, AMPE, lifetime, bridge, reduction % + formula used, pension at retirement (monthly too), from-65, plain-language notes (RoC, deferred, caps, bridge ends, survivor/indexing/part-time caveats) and source links with as-of dates (PSPC 2025-08-15, YMPE 2026, IT rates 2024-12-22, data constants Oct 2026). - G2 income chart gains a violet "Public service pension" series; live-panel insight "Your public service pension covers about N% of your retirement spending" (10–250% band); pension-annual input replaced by a computed note on the extras step.
- Round-4 app-shell honoured: new steps render inside
#step-body(the only scroller);#btn-next/#panelstay fixed — audited at 320/360/390.
5. i18n
All strings EN + FR with full key parity (tests/i18n.test.js → 48 checks):
questionnaire, results card (incl. formula names, notes, sources), chart series,
insight, disclaimer. FR uses official terms (RRFP/PSCC, prestation de raccordement,
allocation annuelle, MMGP/AMPE).
6. Test evidence (all green, run 2026-10-09)
| Suite | Result |
|---|---|
node tests/ps-pension.test.js |
70 pass, 0 fail (63 + case K added post-deploy by Hermes: G1 55+ min(F1,F2)) |
node tests/engine.test.js (regression) |
36 pass, 0 fail |
node tests/i18n.test.js |
I18N-OK (48) |
node tests/ui.smoke.js (EN+FR quiz→results, + PS module flows) |
SMOKE-OK (74) |
Plan cases A–K verified to the cent (A–J per plan §tests; K added post-deploy by Hermes — the G1 55+ "lowest of F1/F2" branch was untested), plus engine integration: bridge present at 64 / absent at 65; deferred pension gated (startAge 65 ⇒ no income/tax/clawback before 65); module-off legacy behaviour; OAS clawback gating at 65+; pre-65 couple splitting lowers joint tax (5,511.56 < 8,817.25) and respects start age; IT table as-of + ranges.
Plan arithmetic typos (formula-correct values asserted, proof in test header):
- Case H: plan says lifetime 67,680.75 → correct 67,681.25 (0.01375×74,600×35 = 35,901.25; 0.02×45,400×35 = 31,780.00).
- Case I: plan says 41,422.50 / 54,011.25 / 45,909.56 / 35,209.13 → correct 41,411.25 / 54,000.00 / 45,900.00 / 35,199.56 (plan's own identity 2%×100,000×27 = 54,000.00; 41,411.25 + 12,588.75 = 54,000.00).
7. Deploy + audit evidence
bash scripts/deploy.sh→ DEPLOY-OK (rsync → Unraid containerretireapp:8103).- LAN:
http://192.168.0.123:8103/→ 200. - Off-LAN (troublebox, Midhurst):
https://retire.krisforbes.ca/→ 200,engine/pspension.js→ 200, EN+FR PS strings + formula constants served → OFFLAN-OK (audit/ps-geo-check.json). - UI audit
audit/ps_audit.py(headless Chrome, live site): EN+FR × 320/360/390/1280 × {7 PS quiz steps + results card} = 64 shots. First run found one issue —psCardWide: the results-card table inherited the year-gridtd{white-space:nowrap}, making it 716px inside its scroll wrap at 320px. Fixed with a.cardstable style (table-layout:fixed, wrapping cells) instyle.css; redeployed (DEPLOY-OK). Re-run: 64 shots, 0 failures → PS-AUDIT-OK (audit/ps-pension/geo.json; all PS quiz steps:#step-bodysole scroller,#btn-nextin view, no horizontal overflow; PS card visible, fits every width, 6 source links). Screenshots are gitignored — geometry JSON is the committed evidence.
8. Limitations (documented, per plan)
- AMPE = current-YMPE proxy ($74,600): historical AMPE indexation isn't modelled; real-dollar inputs keep the 2%×avg×S identity intact.
- Bridge is not splittable (term annuity) and ends at 65; pension splitting uses the whole-reduction life-annuity amount.
- Partner's PS pension isn't modelled (partner contributes via the manual
pensionAnnualfield on the extras step, which the splitter honours). - Part-time/compressed-time purchases, disability, re-elections, survivor benefit calculations, and post-65 indexation details are surfaced as notes, not modelled.
- G2 leave-before-55 models the PSPC-default deferred annuity at 65; the 50%-reduction annual-allowance alternative is noted, not projected.
- IT salary table is real data as of 2024-12-22 (latest published on canada.ca); salary entry is always overridable.
9. Definition-of-done checklist
- PS module live behind an explicit opt-in (default off, legacy untouched — 36/36)
- Formula + reductions match PSPC/TBS published rules; cases A–J tested to the cent
- Bridge stops at 65 (engine + chart + tax)
- IT levels sourced from TBS agreement, user-overridable
- Results card with full breakdown + dated sources; insight; chart series
- Pension flows through income/tax/PIA/GIS/splitting incl. pre-65 life-annuity splitting
- Full EN + FR (parity 48/48, FR smoke green)
- Tests: ps-pension 70/70 · engine 36/36 · i18n OK · smoke 74/74
- Audits EN+FR @320/360/390 + desktop + off-LAN (evidence in
audit/) - Deployed (DEPLOY-OK) and reachable off-LAN (OFFLAN-OK)
- This report + evidence committed & pushed
10. Runbook
- Tests:
node tests/ps-pension.test.js && node tests/engine.test.js && node tests/i18n.test.js && node tests/ui.smoke.js - Deploy:
bash scripts/deploy.sh(expectDEPLOY-OK) - UI audit:
TMPDIR=$PWD/.tmp python3 audit/ps_audit.py(expectPS-AUDIT-OK; screenshots land inaudit/ps-pension/, gitignored) - Off-LAN:
python3 audit/ps_offlan.py(expectOFFLAN-OK)