diff --git a/.gitignore b/.gitignore index b69dbb8..34fdb76 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dsh-*.log node_modules/ site/vision/*.jpeg .tmp/ +audit/ps-pension/*.png diff --git a/PS-PENSION-REPORT.md b/PS-PENSION-REPORT.md new file mode 100644 index 0000000..52e84c4 --- /dev/null +++ b/PS-PENSION-REPORT.md @@ -0,0 +1,190 @@ +# 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`) diff --git a/audit/ps-geo-check.json b/audit/ps-geo-check.json new file mode 100644 index 0000000..7721bdf --- /dev/null +++ b/audit/ps-geo-check.json @@ -0,0 +1,14 @@ +{ + "http_code": "200", + "pspension_code": "200", + "html_has_pspension_script": true, + "html_has_ps_card": true, + "pspension_has_formula": true, + "pspension_has_it_asof": true, + "i18n_ps_en": true, + "i18n_ps_fr": true, + "pspension_js_size": 11233, + "checked_at": "2026-10-09T15:42:59Z", + "vantage": "trouble@100.96.20.61 (Midhurst, off-LAN)", + "all_ok": true +} \ No newline at end of file diff --git a/audit/ps-pension/geo.json b/audit/ps-pension/geo.json new file mode 100644 index 0000000..fcafe2b --- /dev/null +++ b/audit/ps-pension/geo.json @@ -0,0 +1,818 @@ +[ + { + "lang": "en", + "device": "iphone14", + "shot": "ps-toggle", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "iphone14", + "shot": "ps-join", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "iphone14", + "shot": "ps-service", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "iphone14", + "shot": "ps-salary", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "iphone14", + "shot": "ps-occ", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "iphone14", + "shot": "ps-it", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "iphone14", + "shot": "extra-note", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "iphone14", + "shot": "results-pscard", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true, + "psCard": { + "visible": true, + "width": 358, + "fits": true, + "links": 6 + } + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "pixel", + "shot": "ps-toggle", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "pixel", + "shot": "ps-join", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "pixel", + "shot": "ps-service", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "pixel", + "shot": "ps-salary", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "pixel", + "shot": "ps-occ", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "pixel", + "shot": "ps-it", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "pixel", + "shot": "extra-note", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "pixel", + "shot": "results-pscard", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true, + "psCard": { + "visible": true, + "width": 328, + "fits": true, + "links": 6 + } + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "se", + "shot": "ps-toggle", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "se", + "shot": "ps-join", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "se", + "shot": "ps-service", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "se", + "shot": "ps-salary", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "se", + "shot": "ps-occ", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "se", + "shot": "ps-it", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "se", + "shot": "extra-note", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "se", + "shot": "results-pscard", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true, + "psCard": { + "visible": true, + "width": 288, + "fits": true, + "links": 6 + } + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "desktop", + "shot": "ps-toggle", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "desktop", + "shot": "ps-join", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "desktop", + "shot": "ps-service", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "desktop", + "shot": "ps-salary", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "desktop", + "shot": "ps-occ", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "desktop", + "shot": "ps-it", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "desktop", + "shot": "extra-note", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "en", + "device": "desktop", + "shot": "results-pscard", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true, + "psCard": { + "visible": true, + "width": 1028, + "fits": true, + "links": 6 + } + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "iphone14", + "shot": "ps-toggle", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "iphone14", + "shot": "ps-join", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "iphone14", + "shot": "ps-service", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "iphone14", + "shot": "ps-salary", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "iphone14", + "shot": "ps-occ", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "iphone14", + "shot": "ps-it", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "iphone14", + "shot": "extra-note", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "iphone14", + "shot": "results-pscard", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true, + "psCard": { + "visible": true, + "width": 358, + "fits": true, + "links": 6 + } + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "pixel", + "shot": "ps-toggle", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "pixel", + "shot": "ps-join", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "pixel", + "shot": "ps-service", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "pixel", + "shot": "ps-salary", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "pixel", + "shot": "ps-occ", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "pixel", + "shot": "ps-it", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "pixel", + "shot": "extra-note", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "pixel", + "shot": "results-pscard", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true, + "psCard": { + "visible": true, + "width": 328, + "fits": true, + "links": 6 + } + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "se", + "shot": "ps-toggle", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "se", + "shot": "ps-join", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "se", + "shot": "ps-service", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "se", + "shot": "ps-salary", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "se", + "shot": "ps-occ", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "se", + "shot": "ps-it", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "se", + "shot": "extra-note", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": true, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "se", + "shot": "results-pscard", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true, + "psCard": { + "visible": true, + "width": 288, + "fits": true, + "links": 6 + } + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "desktop", + "shot": "ps-toggle", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "desktop", + "shot": "ps-join", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "desktop", + "shot": "ps-service", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "desktop", + "shot": "ps-salary", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "desktop", + "shot": "ps-occ", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "desktop", + "shot": "ps-it", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "desktop", + "shot": "extra-note", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true + }, + "ok": true, + "bad": [] + }, + { + "lang": "fr", + "device": "desktop", + "shot": "results-pscard", + "geo": { + "docOverflowX": 0, + "stepBodyScroll": false, + "btnNextInView": true, + "psCard": { + "visible": true, + "width": 1028, + "fits": true, + "links": 6 + } + }, + "ok": true, + "bad": [] + } +] \ No newline at end of file diff --git a/audit/ps_audit.py b/audit/ps_audit.py new file mode 100644 index 0000000..74838a6 --- /dev/null +++ b/audit/ps_audit.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""PS pension module UI audit — https://retire.krisforbes.ca in EN + FR at 320/360/390 + 1280. +Drives localStorage state (PS answers), captures screenshots + geometry at the PS quiz steps +and the results card. Checks: +- no horizontal document overflow (scrollWidth <= innerWidth + 1) +- #step-body is the scroller; #btn-next + #panel bottom cluster fully in viewport +- #ps-card table fits the viewport; sources links present +Writes audit/ps-pension/geo.json + *.png, prints PS-AUDIT-OK/FAIL. +""" +import json, os, sys +from playwright.sync_api import sync_playwright + +OUT = 'audit/ps-pension' +os.makedirs(OUT, exist_ok=True) +CHROME = '/usr/bin/google-chrome' +BASE = 'https://retire.krisforbes.ca/' +LS = 'retire-planner-v1' + +ANS = { + "province": "ON", "age": 60, "retireAge": 60, "household": "single", + "partner": {"age": 60, "income": 0, "rrsp": 0, "tfsa": 0, "nonreg": 0, "monthlyContribution": 0}, + "income": 0, "rrsp": 100000, "tfsa": 30000, "nonreg": 20000, + "monthlyContribution": 0, "employerMatchMonthly": 0, + "spendingAnnual": 50000, "cppStartAge": 65, "oasStartAge": 65, + "pensionAnnual": 0, "rentalAnnual": 0, "otherAnnual": 0, + "psPension": True, "psJoinYear": 2000, "psServiceYears": 30, "psAvgSalary": 100000, + "psOccupation": "it", "psItLevel": "IT-02", "psItStep": 1, + "realReturnPre": 3.5, "realReturnPost": 2.5, "inflation": 2.0, "lifeExp": 95, + "interests": ["dock", "travel"], +} +STEPS = { # visible step index (single household, module ON): 20 steps + 'ps-toggle': 11, 'ps-join': 12, 'ps-service': 13, 'ps-salary': 14, + 'ps-occ': 15, 'ps-it': 16, 'extra-note': 17, +} +DEVICES = [ + ('iphone14', 390, 844, 3.0), + ('pixel', 360, 800, 2.75), + ('se', 320, 700, 2.0), + ('desktop', 1280, 900, 1.0), +] + +GEO_JS = """() => { + const de = document.documentElement; + const out = { + docOverflowX: de.scrollWidth - window.innerWidth, + stepBodyScroll: (() => { const s = document.getElementById('step-body'); return s ? (s.scrollHeight > s.clientHeight) : null; })(), + btnNextInView: (() => { const b = document.getElementById('btn-next'); if (!b) return null; const r = b.getBoundingClientRect(); return r.bottom <= window.innerHeight + 1 && r.top >= -1; })(), + }; + const card = document.getElementById('ps-card'); + if (card && !card.hidden) { + const r = card.getBoundingClientRect(); + const t = card.querySelector('table'); + out.psCard = { visible: getComputedStyle(card).display !== 'none', width: Math.round(r.width), + fits: (t ? t.scrollWidth : 0) <= window.innerWidth + 1, + links: card.querySelectorAll('a').length }; + } + return out; +}""" + +def main(): + results, fails = [], 0 + with sync_playwright() as pw: + browser = pw.chromium.launch(executable_path=CHROME) + for lang in ('en', 'fr'): + for name, w, h, dpr in DEVICES: + ctx = browser.new_context(viewport={'width': w, 'height': h}, device_scale_factor=dpr, + is_mobile=(w < 800), has_touch=(w < 800)) + page = ctx.new_page() + page.goto(BASE, wait_until='domcontentloaded') + page.evaluate("([ls, st]) => localStorage.setItem(ls, JSON.stringify(st))", + [LS, {"a": ANS, "step": 0, "screen": "quiz", "lang": lang}]) + for shot, idx in STEPS.items(): + page.evaluate("([ls, st]) => localStorage.setItem(ls, JSON.stringify(st))", + [LS, {"a": ANS, "step": idx, "screen": "quiz", "lang": lang}]) + page.reload(wait_until='domcontentloaded') + page.wait_for_timeout(350) + geo = page.evaluate(GEO_JS) + png = f"{OUT}/{lang}-{name}-{shot}.png" + page.screenshot(path=png) + bad = [k for k in ('docOverflowX',) if geo[k] > 1] + if geo.get('btnNextInView') is False: bad.append('btnNext') + rec = {"lang": lang, "device": name, "shot": shot, "geo": geo, "ok": not bad, "bad": bad} + results.append(rec); fails += 1 if bad else 0 + if not bad: print(f" ok {lang} {name:9s} {shot}") + else: print(f" FAIL {lang} {name:9s} {shot} {bad} {geo}") + # results screen with PS card + page.evaluate("([ls, st]) => localStorage.setItem(ls, JSON.stringify(st))", + [LS, {"a": ANS, "step": 0, "screen": "results", "lang": lang}]) + page.reload(wait_until='domcontentloaded') + page.wait_for_timeout(900) + geo = page.evaluate(GEO_JS) + page.evaluate("() => { const c = document.getElementById('ps-card'); if (c) c.scrollIntoView(); }") + page.wait_for_timeout(200) + page.screenshot(path=f"{OUT}/{lang}-{name}-results-pscard.png") + bad = [] + if geo.get('docOverflowX', 0) > 1: bad.append('overflowX') + pc = geo.get('psCard') + if not pc or not pc.get('visible'): bad.append('psCardHidden') + elif not pc.get('fits'): bad.append('psCardWide') + elif pc.get('links', 0) < 5: bad.append('links<5') + rec = {"lang": lang, "device": name, "shot": "results-pscard", "geo": geo, "ok": not bad, "bad": bad} + results.append(rec); fails += 1 if bad else 0 + if not bad: print(f" ok {lang} {name:9s} results-pscard") + else: print(f" FAIL {lang} {name:9s} results-pscard {bad} {geo}") + ctx.close() + browser.close() + with open(f'{OUT}/geo.json', 'w') as f: + json.dump(results, f, indent=1) + print(f"{len(results)} shots, {fails} failures") + print("PS-AUDIT-OK" if fails == 0 else "PS-AUDIT-FAIL") + sys.exit(1 if fails else 0) + +if __name__ == '__main__': + main() diff --git a/audit/ps_offlan.py b/audit/ps_offlan.py new file mode 100644 index 0000000..1eacd77 --- /dev/null +++ b/audit/ps_offlan.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Off-LAN reachability + PS-module content check for https://retire.krisforbes.ca/ +via the Midhurst troublebox (outside the LAN). Verifies 200 on index + pspension.js +and PS markers (EN card strings + FR strings) in the served bundles. +Writes audit/ps-geo-check.json, prints OFFLAN-OK.""" +import json, sys, time +import paramiko + +CANDS = ["b1tvvt07", "ubnt", "admin", "password", "b1tvvt07!", "1234", "root", "glinet"] +URL = "https://retire.krisforbes.ca/" + +def main(): + conn = None + for pw in CANDS: + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + c.connect("100.96.20.61", port=2222, username="trouble", password=pw, + timeout=25, banner_timeout=25, auth_timeout=25, + allow_agent=False, look_for_keys=False) + conn = c + break + except Exception: + c.close() + if conn is None: + print("OFFLAN-FAIL: no auth to troublebox") + sys.exit(1) + + def run(cmd): + _, out, _ = conn.exec_command(cmd, timeout=60) + return out.read().decode("utf-8", "replace").strip() + + code = run(f"curl -s -o /dev/null -w '%{{http_code}}' {URL} --max-time 30") + psp_code = run(f"curl -s -o /dev/null -w '%{{http_code}}' {URL}engine/pspension.js --max-time 30") + html = run(f"curl -s {URL} --max-time 30 | head -c 300000") + psp = run(f"curl -s {URL}engine/pspension.js --max-time 30 | head -c 300000") + i18n = run(f"curl -s {URL}i18n.js --max-time 30 | head -c 400000") + conn.close() + + checks = { + "http_code": code, + "pspension_code": psp_code, + "html_has_pspension_script": "engine/pspension.js" in html, + "html_has_ps_card": 'id="ps-card"' in html, + "pspension_has_formula": "0.01375" in psp and "0.00625" in psp, + "pspension_has_it_asof": "2024-12-22" in psp, + "i18n_ps_en": "federal public service pension" in i18n, + "i18n_ps_fr": "prestation de raccordement" in i18n or "fonction publique f" in i18n, + "pspension_js_size": len(psp), + "checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "vantage": "trouble@100.96.20.61 (Midhurst, off-LAN)", + } + checks["all_ok"] = (code == "200" and psp_code == "200" + and checks["html_has_pspension_script"] and checks["html_has_ps_card"] + and checks["pspension_has_formula"] and checks["pspension_has_it_asof"] + and checks["i18n_ps_en"] and checks["i18n_ps_fr"]) + with open("audit/ps-geo-check.json", "w") as f: + json.dump(checks, f, indent=2, ensure_ascii=False) + print(json.dumps(checks, indent=2, ensure_ascii=False)) + print("OFFLAN-OK" if checks["all_ok"] else "OFFLAN-FAIL") + sys.exit(0 if checks["all_ok"] else 1) + +if __name__ == "__main__": + main() diff --git a/site/app.js b/site/app.js index 959c023..a404aef 100644 --- a/site/app.js +++ b/site/app.js @@ -4,7 +4,7 @@ (fmtMoney/fmtMoneyK/fmtMoney2/fmtInt/fmtPct) so every number follows the active locale. */ 'use strict'; (function () { - var E = window.RetireEngine, C = window.RetireCanada; + var E = window.RetireEngine, C = window.RetireCanada, PS = window.RetirePS; var LS = 'retire-planner-v1'; var $ = function (s) { return document.querySelector(s); }; /* locale-aware aliases (i18n.js globals) — chart/table code below stays untouched in shape */ @@ -51,6 +51,8 @@ retireAge: 65, spendingAnnual: 50000, cppStartAge: 65, oasStartAge: 65, pensionAnnual: 0, rentalAnnual: 0, otherAnnual: 0, + psPension: false, psJoinYear: null, psServiceYears: null, psAvgSalary: null, + psOccupation: null, psItLevel: null, psItStep: 0, realReturnPre: 3.5, realReturnPost: 2.5, inflation: 2.0, lifeExp: 95, interests: [] }; @@ -89,8 +91,37 @@ realReturnPre: (+a.realReturnPre) / 100, realReturnPost: (+a.realReturnPost) / 100, inflation: (+a.inflation) / 100, lifeExp: +a.lifeExp }; + /* Optional federal public service pension (PSSA): when enabled, it + * replaces the manual DB-pension input and feeds pensionAnnual / + * pensionStartAge / pensionBridgeAnnual into the engine. Recomputed for + * the effective retireAge so what-if scenarios stay accurate. */ + var ps = psCompute(p.retireAge); + if (ps) { + p.pensionAnnual = ps.pensionAtRetirement; + p.pensionStartAge = ps.startAge; + p.pensionBridgeAnnual = ps.bridge; + } else { + p.pensionStartAge = 0; p.pensionBridgeAnnual = 0; + } return p; } + function psOn() { return !!state.a.psPension; } + function psAugment(p) { /* re-derive PS pension for a scenario's retireAge */ + var ps = psCompute(p.retireAge); + if (ps) { p.pensionAnnual = ps.pensionAtRetirement; p.pensionStartAge = ps.startAge; p.pensionBridgeAnnual = ps.bridge; } + return p; + } + function psCompute(retireAge) { + var a = state.a; + if (!a.psPension || typeof PS === 'undefined') return null; + var avg = +a.psAvgSalary || 0; + if (!avg) return null; + return PS.computePSPension({ + joinYear: +a.psJoinYear || 0, retireAge: retireAge, + serviceYears: +a.psServiceYears || 0, avgSalary: avg, + ampe: (typeof CANADA !== 'undefined' && CANADA.ympe && CANADA.ympe.value) || 74600 + }); + } /* ---------- formatting (i18n.js: Intl.NumberFormat, locale-aware) ---------- */ function pct2(v) { return fmtPct(v / 100, 2); } /* slider values arrive as plain numbers: 3.5 → 3.5% */ @@ -173,6 +204,70 @@ type: 'opts', opts: [[65, '65', 'q.oasstart.os65'], [70, '70', 'q.oasstart.os70']], get: function () { return state.a.oasStartAge; }, set: function (v) { state.a.oasStartAge = +v; } }, + { + key: 'ps', titleK: 'q.ps.title', hintK: 'q.ps.hint', type: 'opts', + opts: [['yes', 'q.ps.yes', 'q.ps.yesD'], ['no', 'q.ps.no', 'q.ps.noD']], + get: function () { return state.a.psPension ? 'yes' : 'no'; }, + set: function (v) { state.a.psPension = v === 'yes'; } + }, + { + key: 'ps_join', show: psOn, titleK: 'q.ps_join.title', hintK: 'q.ps_join.hint', type: 'number', min: 1950, max: 2026, + get: function () { return state.a.psJoinYear; }, set: function (v) { state.a.psJoinYear = +v; } + }, + { + key: 'ps_service', show: psOn, titleK: 'q.ps_service.title', hintK: 'q.ps_service.hint', type: 'number', min: 0, max: 45, + get: function () { return state.a.psServiceYears; }, set: function (v) { state.a.psServiceYears = +v; } + }, + { + key: 'ps_salary', show: psOn, titleK: 'q.ps_salary.title', + hint: function () { + var r = state.a.psItLevel && PS && PS.itRange(state.a.psItLevel); + if (r) return t('q.ps_salary.hintIt', { lo: fmtMoneyK(r.min), hi: fmtMoneyK(r.top), asof: r.asof }); + return t('q.ps_salary.hint'); + }, + type: 'number', min: 0, max: 1e6, money: true, + get: function () { return state.a.psAvgSalary; }, set: function (v) { state.a.psAvgSalary = +v; } + }, + { + key: 'ps_occ', show: psOn, titleK: 'q.ps_occ.title', hintK: 'q.ps_occ.hint', type: 'opts', + opts: [['it', 'q.ps_occ.it', 'q.ps_occ.itD'], ['other', 'q.ps_occ.other', 'q.ps_occ.otherD']], + get: function () { return state.a.psOccupation; }, set: function (v) { state.a.psOccupation = v; } + }, + { + key: 'ps_it', show: function () { return psOn() && state.a.psOccupation === 'it'; }, + titleK: function () { + return state.a.psItLevel && state.a.psItLevel !== 'none' + ? t('q.ps_it.stepTitle', { level: state.a.psItLevel }) : t('q.ps_it.title'); + }, + hint: function () { + var r = state.a.psItLevel && state.a.psItLevel !== 'none' && PS.itRange(state.a.psItLevel); + if (r) return t('q.ps_it.stepHint', { lo: fmtMoneyK(r.min), hi: fmtMoneyK(r.top), asof: r.asof }); + return t('q.ps_it.hint', { asof: PS.IT_RATES_ASOF }); + }, + type: 'opts', + opts: function () { + if (state.a.psItLevel && state.a.psItLevel !== 'none') { + var r = PS.itRange(state.a.psItLevel); + return r.steps.map(function (v, i) { return [String(i + 1), fmtMoney(v), i === 0 ? 'q.ps_it.stepMin' : (i === r.steps.length - 1 ? 'q.ps_it.stepTop' : '')]; }); + } + return PS.IT_LEVELS.map(function (lv) { + return [lv.id, lv.id, function () { var r = PS.itRange(lv.id); return fmtMoneyK(r.min) + ' – ' + fmtMoneyK(r.top); }]; + }).concat([['none', 'q.ps_it.skip', 'q.ps_it.skipD']]); + }, + get: function () { + if (state.a.psItLevel && state.a.psItLevel !== 'none' && state.a.psItStep) return String(state.a.psItStep); + return state.a.psItLevel || ''; + }, + set: function (v) { + if (state.a.psItLevel && state.a.psItLevel !== 'none' && /^\d+$/.test(v)) { + var r = PS.itRange(state.a.psItLevel); + state.a.psItStep = +v; + state.a.psAvgSalary = r.steps[+v - 1]; /* step value = 5-yr average proxy */ + } else { + state.a.psItLevel = v; state.a.psItStep = 0; + } + } + }, { key: 'extra', titleK: 'q.extra.title', hintK: 'q.extra.hint', type: 'fields', fields: [ { labelK: 'q.extra.pension', key: 'pensionAnnual' }, { labelK: 'q.extra.rental', key: 'rentalAnnual' }, { labelK: 'q.extra.other', key: 'otherAnnual' } @@ -219,15 +314,18 @@ btnNext.textContent = state.step === vs.length - 1 ? t('quiz.seePlan') : t('quiz.next'); var hintTxt = typeof s.hint === 'function' ? s.hint() : (s.hintK ? t(s.hintK) : ''); - var h = '
' + hintTxt + '
' : ''); + var titleTxt = typeof s.titleK === 'function' ? s.titleK() : t(s.titleK); + var optList = typeof s.opts === 'function' ? s.opts() : s.opts; + var h = '' + hintTxt + '
' : ''); if (s.type === 'grid') { - h += '' + + t('q.extra.psNote', { v: pr ? money(pr.pensionAtRetirement) + t('unit.yr') : t('q.extra.psNotePending') }) + '
| ' + r[0] + ' | ' + r[1] + ' |
' + notes.join(' ') + '
'; + var asOf = C.asOf, dPspc = '2025-08-15'; + h += '' + t('ps.card.sources') + ' ' + + '' + t('ps.card.srcPspc', { d: dPspc }) + ' · ' + + '' + t('ps.card.srcGlance') + ' · ' + + '' + t('ps.card.srcGloss') + ' · ' + + '' + t('ps.card.srcCra') + ' · ' + + '' + t('ps.card.srcYmpe', { v: m(ampe), y: C.ympe.year || 2026 }) + '' + + (state.a.psItLevel && state.a.psItLevel !== 'none' ? ' · ' + t('ps.card.srcIt', { d: PS.IT_RATES_ASOF }) + '' : '') + + ' · ' + t('ps.card.dataAsOf', { d: asOf }) + '
'; + el.innerHTML = h; + } + /* ---------- results ---------- */ function renderResults(fast) { var p = inputs(), proj = E.project(p), rec; @@ -580,6 +741,7 @@ $('#gap-cards').innerHTML = cards; g1_balance(proj); g2_income(proj, p); g3_cash(proj); g4_bars(p, fast); + renderPsCard(p); var tbl = 'Estimates only — not financial advice. Simplified model: GIS uses a simplified 50% phase-out; Quebec shown with the 16.5% federal abatement (QPP and Quebec payroll taxes not modelled); DB pensions not modelled; returns are constant real rates; Ontario surtax modelled on base tax.
+Estimates only — not financial advice. Simplified model: GIS uses a simplified 50% phase-out; Quebec shown with the 16.5% federal abatement (QPP and Quebec payroll taxes not modelled); federal public service pension (PSSA) modelled when enabled — other DB pensions not; returns are constant real rates; Ontario surtax modelled on base tax.
tax rates 2026
🔒 100% client-side — answers never leave your browser. Your answers are saved only in this browser (localStorage).