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

248 lines
15 KiB
Markdown
Raw Permalink 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.
# 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
- [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 70/70 · engine 36/36 · i18n OK · smoke 92/92 (74 at DSH handoff + 18 added by Hermes verification, see §11)
- [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`)
## 11. Post-deploy independent verification (Hermes, 2026-10-09) — 4 real bugs found & fixed
DSH's stage B was functionally complete (engine math, decision tree, IT table, bilingual
strings, module-off default all verified correct — see below), but independent verification
by Hermes (not trusting DSH's audit) caught **4 user-facing bugs**, all fixed, tested, deployed:
**Verified correct (no change needed):**
- Engine math to the cent, 6 hand-checked cases incl. the subtlest branch — G1 age 55+ with
S≥25 applies PSPC's "compare Formula 1 and 2, apply the lowest reduction" (code correct in
`psEarlyReduction`; the §2 summary line was imprecise and is fixed). G2's comparison
threshold (60+) also correct per the live PSPC text.
- All 5 IT levels × all steps match the live TBS–PSAC agreement cell-for-cell
(IT-01 69,361…89,382 · IT-02 85,854…105,080 · IT-03 101,343…125,914 · IT-04 116,037…144,434 ·
IT-05 133,582…174,076, 9 steps; Z row 2024-12-22).
- Module-off parity: default off, base flow = 15 steps, no PS card, legacy pension input intact.
- Stage-0 gate honest: no constants changed vs. the plan's fact base.
**Bug 1 — stuck Next button on PS number steps (ed37f82).** `renderStep()` validated only at
render; typing into an initially-empty number input (PS join year / service / salary — the only
steps with no default) never re-enabled **Next**, so a real user enabling the module was STUCK
on "When did you join". Fix: `validate(s)` in the number-input `oninput` handlers. Regression
test added: full PS quiz click-through EN+FR (toggle→join→service→salary→occ→IT-03→step 3→
results, asserts Next enables after typing). Smoke 74→88.
**Bug 2 — PS card body blank (244f599).** The card's `<table class="cards">` collided with the
existing `.cards{display:grid}` (gap-cards stat grid) — grid-on-a-table collapses the rows to
zero height: the card rendered its title over an empty box. Fix: table class renamed `ps-tbl`
with its own CSS. Smoke guard added (smoke 88→90).
**Bug 3 — `.tbl-wrap::before` float pushed tables out of view (d53dd81).** At ≤560px the
fade pseudo-element was `float:right;height:340px` — a 340px in-flow float; a table may not
overlap floats, so the table was laid out 340px below the wrap's top, entirely clipped by
`overflow:auto;max-height:340px`. This broke **both** the PS card table **and the pre-existing
year-by-year table** on mobile (verified: yr-table at 320px had its first row at the wrap's
bottom edge). Fix: `position:absolute;right:0;top:0;height:100%` overlay (the wrap is already
`position:relative`); PS card also gets `max-height:none` (8 rows, show them all). Both tables
re-verified painting at 320/390px.
**Bug 4 — English month in the French as-of line (cab6e3d).** `canada.js` `asOf` was
English-only, so the FR card/stamp read "constantes au October 2026 (CPP 2026, OAS/GIS Oct-Dec
2026, tax 2026)". Fix: `asOfFr` added to canada.js **and its generator** `build_canada.js`;
both render sites pick by `getLang()`. Smoke guard added (smoke 90→92).
**Verification totals after fixes:** engine 36/36 · ps-pension 70/70 · i18n 48/48 · smoke 92/92 ·
live Playwright battery 63/63 (seeded card values EN desk/320 + FR 390, module-on walks FR 390 +
EN 320 with app-shell geometry, module-off walk, card-body visibility: table display=table,
td heights, innerText, table-laid-out-inside-wrap) · off-LAN OK · vision-checked: FR+EN PS card
(9 rows, notes, 6 canada.ca source links with as-of dates), PS toggle step, IT level picker,
IT step picker, year-by-year table @320.
**Test-coverage note:** case K (G1 55+ min(F1,F2)) added to `tests/ps-pension.test.js` (bef1f9e) —
the PSPC "lowest of F1/F2" branch was implemented correctly but untested.
- Off-LAN: `python3 audit/ps_offlan.py` (expect `OFFLAN-OK`)