248 lines
15 KiB
Markdown
248 lines
15 KiB
Markdown
# 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`)
|