Files
retire-planner/PS-PENSION-REPORT.md
T

11 KiB
Raw Blame History

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

  1. No constants changed vs. the plan's fact base → gate PASS, implementation proceeded.
  2. 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 canonical canada.ca, linked in the results card) are what the module ships.
  3. 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 from retireAge, 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 field row.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/#panel stay 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 container retireapp: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-grid td{white-space:nowrap}, making it 716px inside its scroll wrap at 320px. Fixed with a .cards table style (table-layout:fixed, wrapping cells) in style.css; redeployed (DEPLOY-OK). Re-run: 64 shots, 0 failures → PS-AUDIT-OK (audit/ps-pension/geo.json; all PS quiz steps: #step-body sole scroller, #btn-next in 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 pensionAnnual field 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 (expect DEPLOY-OK)
  • UI audit: TMPDIR=$PWD/.tmp python3 audit/ps_audit.py (expect PS-AUDIT-OK; screenshots land in audit/ps-pension/, gitignored)
  • Off-LAN: python3 audit/ps_offlan.py (expect OFFLAN-OK)