# 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 60+ w/ S≥25, min(F1,F2) applies; 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, 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` | **63 pass, 0 fail** | | `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–J verified to the cent** (A/B/C/D/E/F/G/H/I/J per plan §tests), 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 - [x] PS module live behind an explicit opt-in (default off, legacy untouched — 36/36) - [x] Formula + reductions match PSPC/TBS published rules; cases A–J tested to the cent - [x] Bridge stops at 65 (engine + chart + tax) - [x] IT levels sourced from TBS agreement, user-overridable - [x] Results card with full breakdown + dated sources; insight; chart series - [x] Pension flows through income/tax/PIA/GIS/splitting incl. pre-65 life-annuity splitting - [x] Full EN + FR (parity 48/48, FR smoke green) - [x] Tests: ps-pension 63/63 · engine 36/36 · i18n OK · smoke 74/74 - [x] Audits EN+FR @320/360/390 + desktop + off-LAN (evidence in `audit/`) - [x] Deployed (DEPLOY-OK) and reachable off-LAN (OFFLAN-OK) - [x] 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`)