From 583b5ee2f8e473dab0f2d5186df013d0dafa1d29 Mon Sep 17 00:00:00 2001 From: krisf Date: Fri, 9 Oct 2026 11:44:40 -0400 Subject: [PATCH] PS pension module: optional federal PSSA engine + UI (PSPC-accurate, EN/FR) --- .gitignore | 1 + PS-PENSION-REPORT.md | 190 +++++++++ audit/ps-geo-check.json | 14 + audit/ps-pension/geo.json | 818 ++++++++++++++++++++++++++++++++++++++ audit/ps_audit.py | 114 ++++++ audit/ps_offlan.py | 64 +++ site/app.js | 180 ++++++++- site/engine/engine.js | 42 +- site/engine/pspension.js | 200 ++++++++++ site/i18n.js | 150 ++++++- site/index.html | 4 +- site/style.css | 4 + tests/ps-pension.test.js | 192 +++++++++ tests/ui.smoke.js | 45 ++- 14 files changed, 1991 insertions(+), 27 deletions(-) create mode 100644 PS-PENSION-REPORT.md create mode 100644 audit/ps-geo-check.json create mode 100644 audit/ps-pension/geo.json create mode 100644 audit/ps_audit.py create mode 100644 audit/ps_offlan.py create mode 100644 site/engine/pspension.js create mode 100644 tests/ps-pension.test.js 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 = '

' + t(s.titleK) + '

' + (hintTxt ? '

' + hintTxt + '

' : ''); + var titleTxt = typeof s.titleK === 'function' ? s.titleK() : t(s.titleK); + var optList = typeof s.opts === 'function' ? s.opts() : s.opts; + var h = '

' + titleTxt + '

' + (hintTxt ? '

' + hintTxt + '

' : ''); if (s.type === 'grid') { - h += '
' + s.opts.map(function (o) { + h += '
' + optList.map(function (o) { return ''; }).join('') + '
'; } else if (s.type === 'opts') { - h += '
' + s.opts.map(function (o) { - return ''; + h += '
' + optList.map(function (o) { + var d = o[2] ? (typeof o[2] === 'function' ? o[2]() : t(o[2])) : ''; + return ''; }).join('') + '
'; } else if (s.type === 'chips') { var cur = s.get() || []; @@ -238,6 +336,11 @@ h += '
'; } else if (s.type === 'fields') { h += s.fields.map(function (f, i) { + if (f.key === 'pensionAnnual' && psOn()) { + var pr = psCompute(+state.a.retireAge); + return '

' + + t('q.extra.psNote', { v: pr ? money(pr.pensionAtRetirement) + t('unit.yr') : t('q.extra.psNotePending') }) + '

'; + } return '
'; }).join(''); } else if (s.type === 'slider') { @@ -319,6 +422,7 @@ var ins; if (proj.depletionAge) ins = t('i.depletion', { age: proj.depletionAge }); + else if (psCoverageInsight(p, proj)) ins = psCoverageInsight(p, proj); else if (p.cppStartAge < 65) ins = t('i.cppEarly', { age: p.cppStartAge }); else if (p.oasStartAge >= 70) ins = t('i.oasLate', { age: p.oasStartAge }); else if (p.province === 'QC') ins = t('i.qc'); @@ -392,7 +496,7 @@ $(sel).innerHTML = chartH(sel) + (inner || '') + (legendItems ? legend(legendItems) : ''); } - var COL = { bal: '#2563eb', cpp: '#2563eb', oas: '#dc2626', gis: '#f59e0b', wdr: '#10b981', cash: '#0ea5e9', need: '#dc2626' }; + var COL = { bal: '#2563eb', cpp: '#2563eb', oas: '#dc2626', gis: '#f59e0b', wdr: '#10b981', cash: '#0ea5e9', need: '#dc2626', ps: '#7c3aed' }; function g1_balance(proj) { var yrs = proj.years, ages = yrs.map(function (r) { return r.age; }); @@ -421,9 +525,12 @@ var series = [ [t('g2.cpp'), COL.cpp, yrs.map(function (r) { return r.cpp; })], [t('g2.oas'), COL.oas, yrs.map(function (r) { return Math.max(0, r.oasGross - r.oasClawback); })], - [t('g2.gis'), COL.gis, yrs.map(function (r) { return r.gis; })], - [t('g2.savings'), COL.wdr, yrs.map(function (r) { return r.rrifWithdrawal + r.nonregW + r.tfsaW; })] + [t('g2.gis'), COL.gis, yrs.map(function (r) { return r.gis; })] ]; + if (yrs.some(function (r) { return (r.pension || 0) > 0; })) { + series.push([t('g2.psPension'), COL.ps, yrs.map(function (r) { return r.pension || 0; })]); + } + series.push([t('g2.savings'), COL.wdr, yrs.map(function (r) { return r.rrifWithdrawal + r.nonregW + r.tfsaW; })]); var n = ages.length, tot = [], i, acc; for (i = 0; i < n; i++) tot[i] = series.reduce(function (s, se) { return s + se[2][i]; }, 0); var ymax = Math.max(1000, Math.max.apply(null, tot) * 1.08), Y = sy(ymax); @@ -464,7 +571,7 @@ var scen = [ [t('g4.s0'), p], [t('g4.s1'), Object.assign({}, p, { monthlyContribution: p.monthlyContribution + 200, employerMatchMonthly: p.employerMatchMonthly })], - [t('g4.s2'), Object.assign({}, p, { retireAge: Math.min(p.retireAge + 2, 75) })], + [t('g4.s2'), psAugment(Object.assign({}, p, { retireAge: Math.min(p.retireAge + 2, 75) }))], [t('g4.s3'), Object.assign({}, p, { cppStartAge: 70 })] ]; var res = scen.map(function (s) { return [s[0], E.sustainableSpending(s[1]).annual]; }); @@ -545,6 +652,60 @@ el.innerHTML = '
' + t('vision.label', { slug: slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) }) + '
' + cap + '
'; } + /* ---------- public service pension: insight + results card ---------- */ + function psCoverageInsight(p) { + if (!psOn()) return null; + var ps = psCompute(p.retireAge); + if (!ps || !(p.spendingAnnual > 0)) return null; + var pct = Math.round(100 * ps.pensionAtRetirement / p.spendingAnnual); + if (pct < 10 || pct > 250) return null; + return t('i.psCover', { pct: fmtInt(pct) }); + } + function renderPsCard(p) { + var el = $('#ps-card'); if (!el) return; + var ps = psCompute(p.retireAge); + if (!ps) { el.hidden = true; el.innerHTML = ''; return; } + el.hidden = false; + var m = fmtMoney2, S = PS.SOURCES, ampe = C.ympe.value; + var rows = [ + [t('ps.card.group', { n: ps.group }), ''], + [t('ps.card.service', { y: fmtInt(ps.serviceCapped) }), ''], + [t('ps.card.avg'), m(+state.a.psAvgSalary || 0) + t('unit.yr')], + [t('ps.card.ampe', { v: m(ampe), y: C.ympe.year || 2026 }), ''], + [t('ps.card.lifetime'), m(ps.lifetime) + t('unit.yr')] + ]; + if (ps.bridge > 0) rows.push([t('ps.card.bridge'), m(ps.bridge) + t('unit.yr')]); + rows.push([t('ps.card.reduction', { + pct: fmtPct(ps.reductionPct, 1), + f: ps.reductionPct === 0 ? t('ps.card.none') : t(ps.formula === 'F1' ? 'ps.card.fF1' : ps.formula === 'F2' ? 'ps.card.fF2' : 'ps.card.fMin') + }), '']); + rows.push(['' + t('ps.card.atRet', { age: fmtInt(ps.startAge) }) + '', '' + m(ps.pensionAtRetirement) + t('unit.yr') + ' · ' + m(ps.pensionAtRetirement / 12) + t('unit.mo') + '']); + rows.push(['' + t('ps.card.from65') + '', '' + m(ps.pensionFrom65) + t('unit.yr') + ' · ' + m(ps.pensionFrom65 / 12) + t('unit.mo') + '']); + var h = '
' + t('ps.card.title') + '
' + + rows.map(function (r) { return ''; }).join('') + '
' + r[0] + '' + r[1] + '
'; + var notes = []; + if (ps.notes.indexOf('lt2') >= 0) notes.push(t('ps.card.lt2')); + if (ps.earlyOnlyNote === 'deferredG2') notes.push(t('ps.card.deferred')); + if (ps.notes.indexOf('f1Under50') >= 0) notes.push(t('ps.card.under50')); + if (ps.notes.indexOf('f1Under55') >= 0) notes.push(t('ps.card.under55')); + if (ps.startAge < 65 && ps.bridge > 0) notes.push(t('ps.card.bridgeEnds')); + notes.push(t('ps.card.nSurvivor')); + notes.push(t('ps.card.nIndex')); + notes.push(t('ps.card.nPartTime')); + notes.push(t('ps.card.nSplit')); + h += '

' + 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 = '' + t('t.age') + '' + t('t.rrsp') + '' + t('t.tfsa') + '' + t('t.nonreg') + '' + t('t.total') + '' + t('t.wdr') + '' + t('t.cpp') + '' + t('t.oas') + '' + t('t.gis') + '' + t('t.tax') + '' + t('t.unfunded') + ''; var n = 0; diff --git a/site/engine/engine.js b/site/engine/engine.js index 15a7f3f..b105d24 100644 --- a/site/engine/engine.js +++ b/site/engine/engine.js @@ -166,6 +166,8 @@ cppStartAge: 65, cppOverrideMonthlyAt65: 0, oasStartAge: 65, rrspWithdrawalOverrideAnnual: 0, pensionAnnual: 0, rentalAnnual: 0, otherAnnual: 0, + pensionStartAge: 0, // 0 = defaults to retireAge (public-service pension) + pensionBridgeAnnual: 0, // pre-65 bridge benefit, ends at 65 (PSPC) realReturnPre: 0.035, realReturnPost: 0.025, inflation: 0.02, lifeExp: 95 }; function fillDefaults(inp) { @@ -175,13 +177,31 @@ return o; } + /* ---- Public-service pension: start age & bridge benefit ---- + * pensionStartAge: pension income pays from max(retireAge, pensionStartAge) + * (0 = not set → starts at retirement, legacy behaviour). + * Bridge benefit is a term annuity payable from start to the 65th birthday. */ + function pensionStartAge(p) { return Math.max(p.retireAge, +p.pensionStartAge || 0); } + function pensionBridgeAnnual(p, selfAge) { + var b = +p.pensionBridgeAnnual || 0; + if (!b) return 0; + return (selfAge >= pensionStartAge(p) && selfAge < 65) ? b : 0; + } + /* ---- Core year-by-year projection (single person) ---- */ function projectSingle(p) { var at65 = p.cppOverrideMonthlyAt65 > 0 ? p.cppOverrideMonthlyAt65 : estimateCppMonthlyAt65(p.income, p.age); var rrsp = Math.max(0, p.rrsp), tfsa = Math.max(0, p.tfsa), nonreg = Math.max(0, p.nonreg); var years = [], depletionAge = null, totalTax = 0, totalClawback = 0, nestEgg = null; var end = Math.ceil(p.lifeExp); - var other0 = (p.pensionAnnual || 0) + (p.rentalAnnual || 0) + (p.otherAnnual || 0); + var rentalOther = (p.rentalAnnual || 0) + (p.otherAnnual || 0); + var psStart = pensionStartAge(p); // pension income starts here + function penAt(a) { // pension + bridge payable at age a + var v = 0; + if (a >= psStart) v += (p.pensionAnnual || 0); + v += pensionBridgeAnnual(p, a); // 0 at/after 65, 0 before psStart + return v; + } for (var a = p.age; a < end; a++) { var row = { age: a }; @@ -189,7 +209,7 @@ rrsp = rrsp * (1 + p.realReturnPre) + 12 * ((p.monthlyContribution || 0) + (p.employerMatchMonthly || 0)); tfsa = tfsa * (1 + p.realReturnPre); nonreg = nonreg * (1 + p.realReturnPre); - row.phase = 'accum'; row.cpp = 0; row.oasGross = 0; row.oasClawback = 0; row.gis = 0; row.other = 0; + row.phase = 'accum'; row.cpp = 0; row.oasGross = 0; row.oasClawback = 0; row.gis = 0; row.other = 0; row.pension = 0; row.rrifWithdrawal = 0; row.nonregW = 0; row.tfsaW = 0; row.taxable = 0; row.tax = 0; row.cash = 0; row.need = 0; row.unfunded = 0; row.rrspBal = rrsp; row.tfsaBal = tfsa; row.nonregBal = nonreg; row.totalBal = rrsp + tfsa + nonreg; @@ -203,6 +223,7 @@ var rrifMin = a >= 71 ? rrifMinPct(a) / 100 * rrspJan : 0; var cpp = cppMonthlyAt(a, p.cppStartAge, at65) * 12; var oasGross = oasMonthlyAt(a, p.oasStartAge) * 12; + var other0 = rentalOther + penAt(a); var pre = cpp + oasGross + other0; var need = p.spendingAnnual; @@ -210,7 +231,7 @@ function solve(W) { W = Math.min(Math.max(W, 0), rrspJan); var taxable1 = pre + W; - var opts1 = { pensionIncome: (p.pensionAnnual || 0) + (a >= 65 ? W : 0) }; + var opts1 = { pensionIncome: (a >= 65 && a >= psStart ? (p.pensionAnnual || 0) : 0) + (a >= 65 ? W : 0) }; var t1 = tax(taxable1, p.province, a, opts1); var c1 = clawback(taxable1); var g = gis(taxable1 - oasGross, 'single'); @@ -267,6 +288,7 @@ totalTax += r.tax.total; totalClawback += r.cw; row.phase = 'draw'; row.cpp = cpp; row.oasGross = oasGross; row.oasClawback = r.cw; row.gis = r.gis; row.other = other0; + row.pension = penAt(a); // pension + bridge only (for chart breakdown) row.rrifWithdrawal = r.W; row.nonregW = r.n; row.tfsaW = r.t; row.taxable = r.taxable; row.tax = r.tax.total; row.cash = Math.min(r.cash, need); row.need = need; row.unfunded = unfunded; @@ -310,12 +332,18 @@ rrspBal: ra.rrspBal + rb.rrspBal, tfsaBal: ra.tfsaBal + rb.tfsaBal, nonregBal: ra.nonregBal + rb.nonregBal }; row.totalBal = row.rrspBal + row.tfsaBal + row.nonregBal; + row.pension = (ra.pension || 0) + (rb.pension || 0); // chart breakdown (life pension + bridge) row.need = ra.need + rb.need; var taxSum = ra.tax + rb.tax, clawSum = ra.oasClawback + rb.oasClawback; row.taxable = ra.taxable + rb.taxable; - // Pension income splitting (both 65+): split pension+RRIF 50/50, keep the cheaper total. - if (ra.phase === 'draw' && rb.phase === 'draw' && yr >= 65 && (ra.age >= 65 && rb.age >= 65)) { - var pa = (me.pensionAnnual || 0) + ra.rrifWithdrawal, pb2 = (partnerInp.pensionAnnual || 0) + rb.rrifWithdrawal; + // Pension income splitting (CRA): a life annuity from a superannuation plan is + // splittable at ANY age once payable (pensionStartAge); RRIF withdrawals only at + // 65+. The bridge benefit (term annuity) is excluded — documented modelling + // choice. Split 50/50 the eligible amounts, keep the cheaper total. + if (ra.phase === 'draw' && rb.phase === 'draw') { + var psA = pensionStartAge(me), psB = pensionStartAge(partnerInp); + var pa = ((me.pensionAnnual > 0 && psA <= yr) ? me.pensionAnnual : 0) + (yr >= 65 ? ra.rrifWithdrawal : 0); + var pb2 = ((partnerInp.pensionAnnual > 0 && psB <= yr) ? partnerInp.pensionAnnual : 0) + (yr >= 65 ? rb.rrifWithdrawal : 0); var shift = 0.5 * (pa - pb2); if (Math.abs(shift) > 1) { var tA = tax(ra.taxable - shift, p.province, yr, { pensionIncome: pa - shift }); @@ -415,6 +443,8 @@ project: project, sustainableSpending: sustainableSpending, recommendations: recommendations, + pensionStartAge: pensionStartAge, + pensionBridgeAnnual: pensionBridgeAnnual, defaults: DEFAULTS, constants: C }; diff --git a/site/engine/pspension.js b/site/engine/pspension.js new file mode 100644 index 0000000..dad8aaa --- /dev/null +++ b/site/engine/pspension.js @@ -0,0 +1,200 @@ +/* ============================================================================ + * pspension.js — Optional federal public service pension (PSSA) module. + * + * Implements the PSPC-published formula for members with 2+ years of + * pensionable service (contributed to the pension): + * + * Lifetime annual = 1.375% × min(avg5, AMPE) × min(S, 35) + * + 2% × max(0, avg5 − AMPE) × min(S, 35) + * Bridge benefit = 0.625% × min(avg5, AMPE) × min(S, 35) (pre-65 only; + * payable from retirement, ends at the start of the month + * of the 65th birthday — coincides with CPP/D coverage) + * Total pre-65 = 2% × avg5 × min(S, 35) + * + * avg5 = average salary over the 5 consecutive years of highest paid service + * (modelled as the user-entered figure; AMPE = Average Maximum + * Pensionable Earnings — indexed to YMPE, 100% of YMPE since 2010; + * we use the current YMPE from canada.js as a real-dollar proxy). + * S = years of recognised pensionable service, capped at 35. + * + * Early-reduction (annual allowance) decision tree — Group 1 (joined on/after + * 1999-04-01 and before 2013-01-01; payable from 50): + * unreduced if (age ≥ 60 and S ≥ 2) or (age ≥ 55 and S ≥ 30); + * F1 (S < 25): 5% × (60 − age) + * F2 (S ≥ 25): max( 5% × (55 − age), 5% × (30 − S) ) + * age ≥ 55 with S ≥ 25: apply the LOWER of F1 and F2 (PSPC page); + * leaving before 50: F1 with age pinned at 50 → 50% reduction cap. + * Group 2 (joined on/after 2013-01-01; payable from 55): + * unreduced if (age ≥ 65 and S ≥ 2) or (age ≥ 60 and S ≥ 30); + * F1 (S < 25): 5% × (65 − age) + * F2 (S ≥ 25): max( 5% × (60 − age), 5% × (30 − S) ) + * age ≥ 60 with S ≥ 25: apply the LOWER of F1 and F2; + * leaving before 55: F1 pinned at 55 → 50%; the planner models the + * PSPC-default path: deferred annuity, unreduced, payable at 65. + * Reduction applies to the WHOLE pension (lifetime + bridge); ages/service + * to the nearest tenth of a year. Under 2 years of service: return of + * contributions only (no pension). + * + * Sources verified 2026-02 (constants re-checked verbatim, page dated 2025-08-15). + * UMD: window.RetirePS in the browser, module.exports in Node. + * ==========================================================================*/ +(function (root, factory) { + if (typeof module !== 'undefined' && module.exports) module.exports = factory(); + else root.RetirePS = factory(); +})(typeof self !== 'undefined' ? self : this, function () { + 'use strict'; + + var PCT_LOW = 0.01375; // 1.375% — career average up to AMPE + var PCT_HIGH = 0.02; // 2% — career average above AMPE + var BRIDGE_PCT = 0.00625; // 0.625% — bridge benefit (pre-65), up to AMPE only + var MAX_SERVICE = 35; // pensionable-service cap + var EARLY_PCT = 0.05; // 5% per year (or part-year, to the tenth) + var GROUP1_CUTOFF_YEAR = 2012; // joined on/before 2012-12-31 → Group 1 + var MAX_REDUCTION = 0.5; // 50% cap (F1 at the earliest payable age) + + /* IT (Indian and Northern Government Services) annual rates of pay, + * TBS "IT" collective agreement, Appendix A, wage-adjustment row + * effective 2024-12-22. Overridable: the UI exposes these as defaults. */ + var IT_RATES_ASOF = '2024-12-22'; + var IT_RATES_SOURCE = 'https://www.canada.ca/en/treasury-board-secretariat/topics/pay/collective-agreements/it.html'; + var IT_LEVELS = [ + { id: 'IT-01', steps: [69361, 71925, 74485, 77031, 79574, 82120, 84661, 89382] }, + { id: 'IT-02', steps: [85854, 88603, 91348, 94095, 96844, 99589, 102335, 105080] }, + { id: 'IT-03', steps: [101343, 104833, 108324, 111817, 115306, 118791, 122281, 125914] }, + { id: 'IT-04', steps: [116037, 120038, 124039, 128039, 132039, 136041, 140039, 144434] }, + { id: 'IT-05', steps: [133582, 138563, 143539, 148520, 153503, 158485, 163465, 168446, 174076] } + ]; + + var SOURCES = { + g1En: 'https://www.canada.ca/en/public-services-procurement/services/pay-pension/public-service-pension-plan/retired/entitlement-information-packages/2-more-years-pensionable-service/member-on-before-december-31-2012.html', + g1Fr: 'https://www.canada.ca/fr/services-publics-approvisionnement/services/paye-pension/regime-retraite-fonction-publique/retraite/trousse-information-presentations/2-ans-plus-service-ouvrant-droit-pension/participant-31-decembre-2012-ou-avant.html', + g2En: 'https://www.canada.ca/en/public-services-procurement/services/pay-pension/public-service-pension-plan/retired/entitlement-information-packages/2-more-years-pensionable-service/member-on-after-january-1-2013.html', + g2Fr: 'https://www.canada.ca/fr/services-publics-approvisionnement/services/paye-pension/regime-retraite-fonction-publique/retraite/trousse-information-presentations/2-ans-plus-service-ouvrant-droit-pension/participant-1er-janvier-2013-ou-apres.html', + glossaryEn: 'https://www.canada.ca/en/treasury-board-secretariat/services/pension-plan/plan-information/glossary.html', + atAGlanceEn: 'https://www.canada.ca/en/treasury-board-secretariat/services/pension-plan/plan-information/public-service-pension-glance.html', + ympeCra: 'https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/payroll/payroll-deductions-contributions/canada-pension-plan-cpp/cpp-enhancement.html', + splittingCra: 'https://www.canada.ca/en/revenue-agency/services/tax/individuals/topics/pension-income-splitting.html', + itAgreement: IT_RATES_SOURCE + }; + + function tenth(x) { return Math.round(x * 10) / 10; } + function round2(x) { return Math.round(x * 100) / 100; } + + /* 1 | 2 from the year the member joined the federal public service. */ + function psGroup(joinYear) { + var y = +joinYear; + if (!y) return null; + return y <= GROUP1_CUTOFF_YEAR ? 1 : 2; + } + + function itLevel(id) { + for (var i = 0; i < IT_LEVELS.length; i++) if (IT_LEVELS[i].id === id) return IT_LEVELS[i]; + return null; + } + function itRange(id) { + var lv = itLevel(id); + if (!lv) return null; + return { min: lv.steps[0], top: lv.steps[lv.steps.length - 1], steps: lv.steps, asof: IT_RATES_ASOF }; + } + + /* Early-reduction (annual allowance) per the PSPC decision tree. + * Returns { unreduced, pct, formula: 'F1'|'F2'|'min(F1,F2)'|null, note } + * with pct in [0, 0.5]; ages/service to the nearest tenth (PSPC). + * note keys: 'unreduced' | 'f1Under50' | 'f1Under55' (fixed-age rules). */ + function psEarlyReduction(group, retireAge, serviceYears) { + var age = tenth(+retireAge), S = tenth(+serviceYears); + var g = +group === 2 ? 2 : 1; + var R = function (pct, formula, unreduced, note) { + return { unreduced: !!unreduced, pct: pct, formula: formula, note: note || null }; + }; + if (g === 1) { + if (age >= 60 || (age >= 55 && S >= 30)) return R(0, null, true, 'unreduced'); + var f1 = EARLY_PCT * Math.max(0, 60 - age); + if (age < 50) return R(MAX_REDUCTION, 'F1', false, 'f1Under50'); // payable from 50, age pinned at 50 + if (S < 25) return R(Math.min(MAX_REDUCTION, f1), 'F1', false, null); + var f2a = Math.max(0, EARLY_PCT * (55 - age)), f2b = Math.max(0, EARLY_PCT * (30 - S)); + var f2 = Math.max(f2a, f2b); + if (age >= 55) return R(Math.min(MAX_REDUCTION, Math.min(f1, f2)), 'min(F1,F2)', false, null); + return R(Math.min(MAX_REDUCTION, f2), 'F2', false, null); + } + if (age >= 65 || (age >= 60 && S >= 30)) return R(0, null, true, 'unreduced'); + var g1 = EARLY_PCT * Math.max(0, 65 - age); + if (age < 55) return R(MAX_REDUCTION, 'F1', false, 'f1Under55'); // payable from 55, age pinned at 55 + if (S < 25) return R(Math.min(MAX_REDUCTION, g1), 'F1', false, null); + var h2a = Math.max(0, EARLY_PCT * (60 - age)), h2b = Math.max(0, EARLY_PCT * (30 - S)); + var h2 = Math.max(h2a, h2b); + if (age >= 60) return R(Math.min(MAX_REDUCTION, Math.min(g1, h2)), 'min(F1,F2)', false, null); + return R(Math.min(MAX_REDUCTION, h2), 'F2', false, null); + } + + /* Lifetime annual pension (years capped at 35 by the caller's service). */ + function psLifetimePension(avgSalary, ampe, years) { + var Sc = Math.min(tenth(+years || 0), MAX_SERVICE); + var capAvg = Math.min(Math.max(0, +avgSalary || 0), Math.max(0, +ampe || 0)); + var over = Math.max(0, (+avgSalary || 0) - (+ampe || 0)); + return round2(PCT_LOW * capAvg * Sc + PCT_HIGH * over * Sc); + } + /* Bridge benefit: 0.625% × min(avg,AMPE) × min(S,35) — caller zeroes at 65+. */ + function psBridgeBenefit(avgSalary, ampe, years) { + var Sc = Math.min(tenth(+years || 0), MAX_SERVICE); + var capAvg = Math.min(Math.max(0, +avgSalary || 0), Math.max(0, +ampe || 0)); + return round2(BRIDGE_PCT * capAvg * Sc); + } + + /* Full benefit computation. + * Input: { joinYear, retireAge, serviceYears, avgSalary, ampe } + * Output (plan contract): { group, serviceCapped, lifetime, bridge, + * reductionPct, formula, pensionAtRetirement, pensionFrom65, startAge, + * deferred, earlyOnlyNote, notes[] } — $ annual, to the cent. */ + function computePSPension(o) { + o = o || {}; + var avg = Math.max(0, +o.avgSalary || 0); + var ampe = Math.max(0, +o.ampe || 0); + var S = tenth(+o.serviceYears || 0); + var age = tenth(+o.retireAge || 0); + var group = +o.group === 1 || +o.group === 2 ? +o.group : psGroup(o.joinYear) || 1; + var Sc = Math.min(S, MAX_SERVICE); + var lifetime = psLifetimePension(avg, ampe, Sc); + var out = { + group: group, serviceCapped: Sc, startAge: age, deferred: false, + lifetime: lifetime, bridge: 0, + reductionPct: 0, formula: null, + pensionAtRetirement: lifetime, pensionFrom65: lifetime, + earlyOnlyNote: null, notes: [] + }; + if (S < 2) { // return of contributions only — no annuity (PSPC) + out.lifetime = 0; out.pensionAtRetirement = 0; out.pensionFrom65 = 0; + out.notes.push('lt2'); + return out; + } + var red = { pct: 0, formula: null }; + if (group === 2 && age < 55) { + // PSPC default (no election within 1 year): deferred annuity, unreduced, at 65. + out.deferred = true; out.startAge = 65; + red = { pct: 0, formula: null }; + out.earlyOnlyNote = 'deferredG2'; + } else { + red = psEarlyReduction(group, age, S); + out.startAge = age; + if (red.note === 'f1Under50') { out.startAge = 50; out.notes.push('f1Under50'); } + if (red.note === 'f1Under55') { out.startAge = 55; out.notes.push('f1Under55'); } + } + out.reductionPct = red.pct; + out.formula = red.formula; + out.bridge = out.startAge < 65 ? psBridgeBenefit(avg, ampe, Sc) : 0; + out.pensionAtRetirement = round2((out.lifetime + out.bridge) * (1 - out.reductionPct)); + out.pensionFrom65 = round2(out.lifetime * (1 - out.reductionPct)); + return out; + } + + return { + PCT_LOW: PCT_LOW, PCT_HIGH: PCT_HIGH, BRIDGE_PCT: BRIDGE_PCT, + MAX_SERVICE: MAX_SERVICE, GROUP1_CUTOFF_YEAR: GROUP1_CUTOFF_YEAR, + IT_LEVELS: IT_LEVELS, IT_RATES_ASOF: IT_RATES_ASOF, IT_RATES_SOURCE: IT_RATES_SOURCE, + SOURCES: SOURCES, + psGroup: psGroup, psLifetimePension: psLifetimePension, + psBridgeBenefit: psBridgeBenefit, psEarlyReduction: psEarlyReduction, + computePSPension: computePSPension, + itLevel: itLevel, itRange: itRange + }; +}); diff --git a/site/i18n.js b/site/i18n.js index 10bf7ad..0e1a972 100644 --- a/site/i18n.js +++ b/site/i18n.js @@ -56,6 +56,7 @@ qc: 'ℹ️ Québec: QPP is not modelled — add your QPP estimate as “Pension $/yr”. QPP typically replaces ~35% of income.', cliff: 'You’ll hit the RRIF-minimum tax cliff at 71. TFSA room now softens it later — check the gap cards.', couple: 'With pension splitting + household GIS, your joint taxes drop — the panel shows the joint total.', + psCover: 'ℹ️ Your public service pension covers about {pct}% of your retirement spending goal.', live: 'Live numbers — change any answer and this panel moves.' }, prov: { @@ -105,7 +106,42 @@ }, extra: { title: 'Other income', hint: 'DB pension, rental, royalties… leave 0 if none.', - pension: 'Pension $/yr', rental: 'Rental $/yr', other: 'Other $/yr' + pension: 'Pension $/yr', rental: 'Rental $/yr', other: 'Other $/yr', + psNote: 'Covered by your public service pension module: {v} — see your plan.', + psNotePending: 'Answer the pension module questions — this is computed from them.' + }, + ps: { + title: 'Do you have a federal public service pension?', + hint: 'Optional — for federal employees under the PSSA. We’ll compute it from PSPC rules and add it to your plan.', + yes: 'Yes — I am (or was) a federal public servant', yesD: 'Public Service Pension Plan (PSSA)', + no: 'No / not sure', noD: 'Skip the pension module' + }, + ps_join: { + title: 'What year did you join the federal public service?', + hint: 'This sets your group: on/before Dec 31, 2012 = Group 1 (annual allowance from 50); on/after Jan 1, 2013 = Group 2 (from 55).' + }, + ps_service: { + title: 'Years of pensionable service', + hint: 'Recognized by PSPC (the calculation caps it at 35 years).' + }, + ps_salary: { + title: 'Average salary — your 5 consecutive highest-paid years', + hint: 'The annual average of your five consecutive years of highest paid service, in today’s dollars.', + hintIt: 'Your IT table shows {lo}–{hi} a year (TBS–PSAC, effective {asof}) — pick your step next, or type your own.' + }, + ps_occ: { + title: 'Are you in the IT (Information Technology) group?', + hint: 'The pension formula is the same for every occupational group — we just show official TBS–PSAC pay tables to help with the salary.', + it: 'Yes — IT group', itD: 'IT-01 to IT-05, TBS–PSAC rates', + other: 'No — another group', otherD: 'I’ll type my average salary' + }, + ps_it: { + title: 'Which IT level are you at?', + hint: 'Official annual rates, effective {asof} — you can override with your own salary.', + stepTitle: 'Which step of {level}?', + stepHint: 'Range {lo}–{hi} · rates effective {asof}. Your step sets the 5-year average (override it on the salary step).', + stepMin: 'first step', stepTop: 'top step', + skip: 'Not my pay scale', skipD: 'I’ll type my average salary' }, assumptions: { title: 'Assumptions', hint: 'Real (above-inflation) returns. Defaults match a balanced portfolio.', @@ -131,7 +167,40 @@ h: 'Where retirement income comes from', cap: 'CPP, OAS, GIS and what you must withdraw from savings — stacked, after-tax view.', empty: 'Retirement after your plan horizon.', - cpp: 'CPP', oas: 'OAS (net)', gis: 'GIS', savings: 'From savings' + cpp: 'CPP', oas: 'OAS (net)', gis: 'GIS', savings: 'From savings', psPension: 'Public service pension' + }, + ps: { + card: { + title: 'Your federal public service pension', + group: 'Group {n} (rules from your join year)', + service: 'Pensionable service used: {y} years (max 35)', + avg: '5-year average salary (highest paid)', + ampe: 'AMPE (proxy = YMPE {v}, {y})', + lifetime: 'Lifetime annual pension', + bridge: 'Bridge benefit (until 65)', + reduction: 'Early reduction (annual allowance): {pct} — {f}', + none: 'None — you qualify for an unreduced pension', + fF1: 'Formula 1', fF2: 'Formula 2', fMin: 'lower of F1 and F2', + atRet: 'Your pension at retirement (from {age})', + from65: 'From age 65 (bridge ends)', + lt2: 'Under 2 years of service: return of contributions only — no annuity.', + deferred: 'You retire before 55 (Group 2): modelled as a deferred annuity — unreduced pension from age 65. Alternative: an annual allowance from age 55 would be permanently reduced by 50%.', + under50: 'Retiring before 50 (Group 1): the annual allowance is payable from your 50th birthday, with Formula 1 computed at age 50 — a 50% reduction.', + under55: 'Payable from 55 with Formula 1 computed at age 55 — a 50% reduction.', + bridgeEnds: 'The bridge benefit ends the month you turn 65 (earlier only with CPP/QPP disability).', + nSurvivor: 'Survivor: 50% of your pre-65 pension before any reduction, plus child allowances to 18 (25 if in school).', + nIndex: 'Indexation: the pension is increased every January 1 by CPI.', + nPartTime: 'Part-time service is pro-rated for assigned hours — not modelled; full-time assumed.', + nSplit: 'The bridge benefit is a term annuity — excluded from pension income splitting (conservative). The lifetime pension is splittable at any age.', + sources: 'Sources:', + srcPspc: 'PSPC formula & reduction rules ({d})', + srcGlance: 'TBS pension at a glance', + srcGloss: 'TBS glossary (AMPE)', + srcCra: 'CRA pension splitting', + srcYmpe: 'CRA YMPE {v} ({y})', + srcIt: 'TBS–PSAC IT agreement rates ({d})', + dataAsOf: 'benefit constants as of {d}' + } }, g3: { h: 'Spending vs. what you\'ll actually have', @@ -178,7 +247,7 @@ cpp: 'CPP', oas: 'OAS', gis: 'GIS', tax: 'Tax+claw', unfunded: 'Unfunded' }, d: { - main: '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.', + main: '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.', privacy: '🔒 100% client-side — answers never leave your browser. Your answers are saved only in this browser (localStorage).' }, res: { @@ -244,6 +313,7 @@ qc: 'ℹ️ Québec : le RRQ n\'est pas modélisé — ajoutez votre estimation du RRQ sous « Pension ($/an) ». Le RRQ remplace habituellement ~35 % du revenu.', cliff: 'Vous heurterez le palier fiscal du minimum de FERR à 71 ans. Du CELI aujourd\'hui en atténue l\'effet plus tard — voyez les cartes d\'écart.', couple: 'Avec le fractionnement du revenu de pension et la SRG du ménage, vos impôts conjoints baissent — le panneau affiche le total conjoint.', + psCover: 'ℹ️ Votre pension de la fonction publique couvre environ {pct}% de votre objectif de dépenses de retraite.', live: 'Chiffres en direct — changez une réponse et ce panneau bouge.' }, prov: { @@ -293,7 +363,42 @@ }, extra: { title: 'Autres revenus', hint: 'Pension à prestations, location, redevances… laissez 0 sinon.', - pension: 'Pension par année', rental: 'Location par année', other: 'Autre par année' + pension: 'Pension par année', rental: 'Location par année', other: 'Autre par année', + psNote: 'Couvert par votre module de pension fédérale : {v} — voir votre plan.', + psNotePending: 'Répondez aux questions du module de pension — ce montant en découle.' + }, + ps: { + title: 'Avez-vous une pension de la fonction publique fédérale?', + hint: 'Facultatif — pour les fonctionnaires fédéraux (LRFP). Nous la calculons selon les règles de la PSCC et l\'ajoutons à votre plan.', + yes: 'Oui — je suis (ou j\'étais) fonctionnaire fédéral', yesD: 'Régime de retraite de la fonction publique (LRFP)', + no: 'Non / je ne sais pas', noD: 'Passer le module de pension' + }, + ps_join: { + title: 'En quelle année avez-vous adhéré à la fonction publique fédérale?', + hint: 'Cela détermine votre groupe : avant le 31 déc. 2012 = groupe 1 (allocation annuelle dès 50 ans); à partir du 1er janv. 2013 = groupe 2 (dès 55 ans).' + }, + ps_service: { + title: 'Années de service ouvrant droit à pension', + hint: 'Reconnues par la PSCC (le calcul est plafonné à 35 années).' + }, + ps_salary: { + title: 'Salaire moyen — vos 5 années consécutives les mieux rémunérées', + hint: 'La moyenne annuelle de vos cinq années de service consécutives les mieux rémunérées, en dollars d\'aujourd\'hui.', + hintIt: 'Votre barre TI affiche {lo}–{hi} par année (SECT–PSAC, en vigueur le {asof}) — choisissez votre échelon, ou saisissez votre propre salaire.' + }, + ps_occ: { + title: 'Faites-vous partie du groupe TI (technologies de l\'information)?', + hint: 'La formule de pension est la même pour tous les groupes professionnels — les barèmes officiels SECT–PSAC aident seulement à estimer le salaire.', + it: 'Oui — groupe TI', itD: 'TI-01 à TI-05, taux SECT–PSAC', + other: 'Non — un autre groupe', otherD: 'Je saisirai mon salaire moyen' + }, + ps_it: { + title: 'Quel niveau TI occupez-vous?', + hint: 'Taux annuels officiels, en vigueur le {asof} — vous pouvez les remplacer par votre propre salaire.', + stepTitle: 'Quel échelon du niveau {level}?', + stepHint: 'Fourchette {lo}–{hi} · taux en vigueur le {asof}. Votre échelon fixe la moyenne sur 5 ans (modifiable à l\'étape du salaire).', + stepMin: 'premier échelon', stepTop: 'échelon supérieur', + skip: 'Ce n\'est pas mon barème', skipD: 'Je saisirai mon salaire moyen' }, assumptions: { title: 'Hypothèses', hint: 'Rendements réels (supérieurs à l\'inflation). Les valeurs par défaut correspondent à un portefeuille équilibré.', @@ -319,7 +424,40 @@ h: 'D\'où vient le revenu de retraite', cap: 'RPC, SV, SRG et ce que vous retirez de l\'épargne — empilé, après impôt.', empty: 'Retraite après l\'horizon du plan.', - cpp: 'RPC', oas: 'SV (net)', gis: 'SRG', savings: 'Épargne' + cpp: 'RPC', oas: 'SV (net)', gis: 'SRG', savings: 'Épargne', psPension: 'Pension de la fonction publique' + }, + ps: { + card: { + title: 'Votre pension de la fonction publique fédérale', + group: 'Groupe {n} (règles selon votre année d\'adhésion)', + service: 'Service ouvrant droit à pension retenu : {y} années (max 35)', + avg: 'Salaire moyen sur 5 ans (les mieux rémunérées)', + ampe: 'MMGP (proxy = PGMA {v}, {y})', + lifetime: 'Pension viagère annuelle', + bridge: 'Prestation de raccordement (jusqu\'à 65 ans)', + reduction: 'Réduction hâtive (allocation annuelle) : {pct} — {f}', + none: 'Aucune — vous avez droit à une pension sans réduction', + fF1: 'Formule 1', fF2: 'Formule 2', fMin: 'la plus faible de F1 et F2', + atRet: 'Votre pension à la retraite (dès {age} ans)', + from65: 'À partir de 65 ans (fin du raccordement)', + lt2: 'Moins de 2 années de service : remboursement des cotisations seulement — pas de rente.', + deferred: 'Vous prenez votre retraite avant 55 ans (groupe 2) : modélisé comme pension différée — pension sans réduction dès 65 ans. Autre option : une allocation annuelle dès 55 ans serait réduite de 50 % de façon permanente.', + under50: 'Retraite avant 50 ans (groupe 1) : l\'allocation annuelle est payable à partir de votre 50e anniversaire, calculée à 50 ans selon la formule 1 — réduction de 50 %.', + under55: 'Payable dès 55 ans, calculée à 55 ans selon la formule 1 — réduction de 50 %.', + bridgeEnds: 'La prestation de raccordement prend fin le mois de votre 65e anniversaire (avant 65 ans seulement en cas d\'invalidité RPC/RRQ).', + nSurvivor: 'Conjoint survivant : 50 % de la pension avant 65 ans calculée avant toute réduction, plus les allocations aux enfants jusqu\'à 18 ans (25 ans aux études).', + nIndex: 'Indexation : la pension est augmentée chaque 1er janvier selon l\'IPC.', + nPartTime: 'Le service à temps partiel est prorogé selon les heures de service assignées — non modélisé; temps plein présumé.', + nSplit: 'La prestation de raccordement est une rente temporaire — exclue du fractionnement du revenu de pension (choix prudent). La pension viagère est fractionnable à tout âge.', + sources: 'Sources :', + srcPspc: 'PSCC — formule et réductions ({d})', + srcGlance: 'SECT — la pension en bref', + srcGloss: 'SECT — glossaire (MMGP)', + srcCra: 'ARC — fractionnement du revenu de pension', + srcYmpe: 'ARC — PGMA {v} ({y})', + srcIt: 'Convention collective TI — SECT–PSAC ({d})', + dataAsOf: 'constantes au {d}' + } }, g3: { h: 'Dépenses vs ce que vous aurez', @@ -366,7 +504,7 @@ cpp: 'RPC', oas: 'SV', gis: 'SRG', tax: 'Impôt + récup.', unfunded: 'Non financé' }, d: { - main: 'Estimations seulement — il ne s\'agit pas de conseils financiers. Modèle simplifié : retrait progressif simplifié de 50 % pour la SRG; Québec présenté avec le dégagement fédéral de 16,5 % (RRQ et cotisations sociales québécoises non modélisés); pensions à prestations non modélisées; rendements à taux réels constants; surtaxe de l\'Ontario calculée sur l\'impôt de base.', + main: 'Estimations seulement — il ne s\'agit pas de conseils financiers. Modèle simplifié : retrait progressif simplifié de 50 % pour la SRG; Québec présenté avec le dégagement fédéral de 16,5 % (RRQ et cotisations sociales québécoises non modélisés); pension de la fonction publique fédérale (LRFP) modélisée si activée — autres pensions à prestations non modélisées; rendements à taux réels constants; surtaxe de l\'Ontario calculée sur l\'impôt de base.', privacy: '🔒 100 % côté client — vos réponses ne quittent jamais votre navigateur. Vos réponses ne sont enregistrées que dans ce navigateur (localStorage).' }, res: { diff --git a/site/index.html b/site/index.html index c820830..c5f6fe0 100644 --- a/site/index.html +++ b/site/index.html @@ -70,6 +70,7 @@
+

@@ -94,7 +95,7 @@
-

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).

@@ -108,6 +109,7 @@ + diff --git a/site/style.css b/site/style.css index 415ea80..ea22e10 100644 --- a/site/style.css +++ b/site/style.css @@ -155,6 +155,10 @@ input[type=range]{width:100%;accent-color:var(--blue)} .tbl-wrap{overflow:auto;max-height:340px;border:1px solid var(--line);border-radius:8px;margin-top:8px} table{border-collapse:collapse;font-size:12.5px;width:100%} th,td{padding:5px 8px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap} +/* Card-style 2-col breakdown tables (PS pension card): wrap text, fixed layout → fits 320px. */ +table.cards{table-layout:fixed} +table.cards td{white-space:normal;word-wrap:break-word;text-align:left;vertical-align:top} +table.cards td:last-child{text-align:right} th{position:sticky;top:0;background:var(--bg);text-align:right} td:first-child,th:first-child{position:sticky;left:0;background:var(--bg)} tr.dep td{color:var(--red)} diff --git a/tests/ps-pension.test.js b/tests/ps-pension.test.js new file mode 100644 index 0000000..97440b2 --- /dev/null +++ b/tests/ps-pension.test.js @@ -0,0 +1,192 @@ +// Retire-planner PS pension tests — run: node tests/ps-pension.test.js (PS-PENSION-PLAN.md Stage 3) +// Hand-computed cases A–J from the plan (AMPE proxy = 2026 YMPE = 74,600), verified to the cent, +// plus engine integration checks (bridge window, start-age gating, PIA gating, pre-65 splitting). +// +// NOTE (documented in PS-PENSION-REPORT.md): two plan cases contain arithmetic typos. +// The module implements the PSSA formula exactly; expected values here are the +// formula-correct results of the plan's OWN inputs: +// H: plan says lifetime 67,680.75 → correct is 67,681.25 (= 0.01375×74,600×35 + 0.02×45,400×35 +// = 35,901.25 + 31,780.00). +// I: plan says lifetime 41,422.50 / total 54,011.25 / from65 35,209.125 → correct is +// 41,411.25 / 54,000.00 / 35,199.56. Proof: the plan's own identity +// lifetime+bridge (avg≤AMPE case) = 2%×100,000×27 = 54,000, and 41,411.25+12,588.75 = 54,000. +'use strict'; +const E = require("../site/engine/engine.js"); +const P = require("../site/engine/pspension.js"); + +let pass = 0, fail = 0; +function ok(name, cond, detail) { + if (cond) { pass++; console.log(" PASS " + name + (detail !== undefined ? " [" + detail + "]" : "")); } + else { fail++; console.log(" FAIL " + name + (detail !== undefined ? " [" + detail + "]" : "")); } +} +function cent(a, b) { return Math.abs(a - b) < 0.005; } // to the cent + +const AMPE = 74600; +const G1 = 2010, G2 = 2015; +const calc = function (joinYear, retireAge, serviceYears, avgSalary) { + return P.computePSPension({ joinYear: joinYear, retireAge: retireAge, serviceYears: serviceYears, avgSalary: avgSalary, ampe: AMPE }); +}; + +console.log("PS pension tests (PSSA formula; AMPE proxy = " + AMPE + ")"); + +console.log("---- 0. groups + constants ----"); +ok("PCT_LOW = 1.375%", P.PCT_LOW === 0.01375); +ok("PCT_HIGH = 2%", P.PCT_HIGH === 0.02); +ok("BRIDGE_PCT = 0.625%", P.BRIDGE_PCT === 0.00625); +ok("MAX_SERVICE = 35", P.MAX_SERVICE === 35); +ok("group 1 = joined ≤ 2012", P.psGroup(2012) === 1 && P.psGroup(1995) === 1); +ok("group 2 = joined ≥ 2013", P.psGroup(2013) === 2 && P.psGroup(2024) === 2); +ok("5 IT levels, as-of 2024-12-22", P.IT_LEVELS.length === 5 && P.IT_RATES_ASOF === "2024-12-22"); +ok("IT-01 range 69,361–89,382 (TBS–PSAC, 2024-12-22)", P.itRange("IT-01").min === 69361 && P.itRange("IT-01").top === 89382); +ok("IT-02 range 85,854–105,080 (TBS–PSAC, 2024-12-22)", P.itRange("IT-02").min === 85854 && P.itRange("IT-02").top === 105080); + +console.log("---- A. G2, 100k, 30 yrs, retire 65: unreduced, no bridge ----"); +{ + const r = calc(G2, 65, 30, 100000); + ok("A lifetime 46,012.50", cent(r.lifetime, 46012.50), r.lifetime); + ok("A bridge 0 (retire ≥65)", r.bridge === 0); + ok("A reduction 0 (65+2yr rule)", r.reductionPct === 0); + ok("A atRetirement = from65 = 46,012.50", cent(r.pensionAtRetirement, 46012.50) && cent(r.pensionFrom65, 46012.50), r.pensionAtRetirement); + ok("A startAge 65", r.startAge === 65); +} + +console.log("---- B. G2, 100k, 30 yrs, retire 60: unreduced (60+30), identity 2%·avg·S ----"); +{ + const r = calc(G2, 60, 30, 100000); + ok("B lifetime 46,012.50", cent(r.lifetime, 46012.50), r.lifetime); + ok("B bridge 13,987.50", cent(r.bridge, 13987.50), r.bridge); + ok("B reduction 0 (age60 & S30)", r.reductionPct === 0 && r.formula === null); + ok("B atRetirement 60,000.00", cent(r.pensionAtRetirement, 60000.00), r.pensionAtRetirement); + ok("B from65 46,012.50", cent(r.pensionFrom65, 46012.50), r.pensionFrom65); +} + +console.log("---- C. G2, 100k, 20 yrs, retire 60: F1 25% ----"); +{ + const r = calc(G2, 60, 20, 100000); + ok("C pct 25% (F1 = 5%×(65−60))", cent(r.reductionPct, 0.25) && r.formula === "F1", r.reductionPct + " " + r.formula); + ok("C lifetime 30,675.00", cent(r.lifetime, 30675.00), r.lifetime); + ok("C bridge 9,325.00", cent(r.bridge, 9325.00), r.bridge); + ok("C atRetirement 30,000.00", cent(r.pensionAtRetirement, 30000.00), r.pensionAtRetirement); + ok("C from65 23,006.25", cent(r.pensionFrom65, 23006.25), r.pensionFrom65); +} + +console.log("---- D. G2, 100k, 28 yrs, retire 62: min(F1 15%, F2 10%) = 10% ----"); +{ + const r = calc(G2, 62, 28, 100000); + ok("D pct 10% min(F1,F2)", cent(r.reductionPct, 0.10) && r.formula === "min(F1,F2)", r.reductionPct + " " + r.formula); + ok("D lifetime 42,945.00", cent(r.lifetime, 42945.00), r.lifetime); + ok("D bridge 13,055.00", cent(r.bridge, 13055.00), r.bridge); + ok("D atRetirement 50,400.00", cent(r.pensionAtRetirement, 50400.00), r.pensionAtRetirement); + ok("D from65 38,650.50", cent(r.pensionFrom65, 38650.50), r.pensionFrom65); +} + +console.log("---- E. G1 (join 2010), 90k, 20 yrs, retire 58: F1 10% ----"); +{ + const r = calc(G1, 58, 20, 90000); + ok("E group 1", r.group === 1); + ok("E pct 10% (F1 = 5%×(60−58))", cent(r.reductionPct, 0.10) && r.formula === "F1", r.reductionPct + " " + r.formula); + ok("E lifetime 26,675.00", cent(r.lifetime, 26675.00), r.lifetime); + ok("E bridge 9,325.00", cent(r.bridge, 9325.00), r.bridge); + ok("E atRetirement 32,400.00", cent(r.pensionAtRetirement, 32400.00), r.pensionAtRetirement); + ok("E from65 24,007.50", cent(r.pensionFrom65, 24007.50), r.pensionFrom65); +} + +console.log("---- F. G1, 90k, 30 yrs, retire 55: unreduced (55+30) ----"); +{ + const r = calc(G1, 55, 30, 90000); + ok("F reduction 0 (55+30 G1)", r.reductionPct === 0); + ok("F lifetime 40,012.50", cent(r.lifetime, 40012.50), r.lifetime); + ok("F bridge 13,987.50", cent(r.bridge, 13987.50), r.bridge); + ok("F atRetirement 54,000.00", cent(r.pensionAtRetirement, 54000.00), r.pensionAtRetirement); + ok("F from65 40,012.50", cent(r.pensionFrom65, 40012.50), r.pensionFrom65); +} + +console.log("---- G. G2, 80k, 10 yrs, retire 52: deferred to 65 (no reduction, no bridge) ----"); +{ + const r = calc(G2, 52, 10, 80000); + ok("G startAge 65 deferred", r.startAge === 65 && r.deferred === true); + ok("G reduction 0 + bridge 0", r.reductionPct === 0 && r.bridge === 0); + ok("G lifetime 11,337.50", cent(r.lifetime, 11337.50), r.lifetime); + ok("G earlyOnlyNote deferredG2", r.earlyOnlyNote === "deferredG2", r.earlyOnlyNote); + const red55 = P.psEarlyReduction(2, 55, 10); + ok("G alternative AA@55 = 50% (F1 at 55)", cent(red55.pct, 0.5) && red55.formula === "F1", red55.pct); +} + +console.log("---- H. G2, 120k, 40 yrs, retire 65: service capped 35 ----"); +{ + const r = calc(G2, 65, 40, 120000); + ok("H serviceCapped 35", r.serviceCapped === 35); + ok("H lifetime 67,681.25 (plan 67,680.75 = arithmetic typo; header note)", cent(r.lifetime, 67681.25), r.lifetime); +} + +console.log("---- I. G2, 100k, 27 yrs, retire 59: F2 = max(5%,15%) = 15% ----"); +{ + const r = calc(G2, 59, 27, 100000); + ok("I pct 15% F2", cent(r.reductionPct, 0.15) && r.formula === "F2", r.reductionPct + " " + r.formula); + ok("I lifetime 41,411.25 (plan 41,422.50 = typo)", cent(r.lifetime, 41411.25), r.lifetime); + ok("I bridge 12,588.75", cent(r.bridge, 12588.75), r.bridge); + ok("I atRetirement 45,900.00 (54,000 × 0.85; plan typo)", cent(r.pensionAtRetirement, 45900.00), r.pensionAtRetirement); + ok("I from65 35,199.56 (41,411.25 × 0.85; plan typo)", cent(r.pensionFrom65, 35199.56), r.pensionFrom65); +} + +console.log("---- J. service 1.5 yrs: return of contributions only ----"); +{ + const r = calc(G2, 65, 1.5, 100000); + ok("J all zeros", r.pensionAtRetirement === 0 && r.pensionFrom65 === 0 && r.lifetime === 0 && r.bridge === 0); + ok("J notes lt2", r.notes.indexOf("lt2") >= 0, JSON.stringify(r.notes)); +} + +console.log("---- Engine integration ----"); +const base = Object.assign({}, E.defaults, { + age: 55, retireAge: 60, household: "single", province: "ON", + income: 60000, rrsp: 300000, tfsa: 50000, nonreg: 50000, monthlyContribution: 0, + spendingAnnual: 40000, pensionAnnual: 24000, pensionStartAge: 60, pensionBridgeAnnual: 9000 +}); +{ + const rows = E.project(base).years; + ok("bridge present at 64 (24,000+9,000)", cent(rows.find(function (y) { return y.age === 64; }).pension, 33000)); + ok("bridge absent at 65 (bridge ends at 65)", cent(rows.find(function (y) { return y.age === 65; }).pension, 24000)); +} +{ + const def = Object.assign({}, base, { pensionStartAge: 65, pensionBridgeAnnual: 0 }); + const rows = E.project(def).years; + ok("deferred: zero pension income at 62", rows.find(function (y) { return y.age === 62; }).pension === 0); + ok("deferred: full pension at 65", cent(rows.find(function (y) { return y.age === 65; }).pension, 24000)); +} +{ + const leg = Object.assign({}, base, { pensionStartAge: 0, pensionBridgeAnnual: 0 }); + ok("module-off legacy: pension from retireAge (byte-for-byte old behaviour)", + cent(E.project(leg).years.find(function (y) { return y.age === 60; }).pension, 24000)); +} +{ + // PIA / OAS clawback gating: pension counted from 65 only when started + const rich = Object.assign({}, base, { rrsp: 500000, pensionAnnual: 90000, pensionStartAge: 65, pensionBridgeAnnual: 0 }); + const rows = E.project(rich).years; + const cb = function (a) { return rows.find(function (y) { return y.age === a; }).oasClawback; }; + ok("PIA: pension at 65+ triggers OAS clawback", cb(67) > 0, cb(67).toFixed(0)); + const late = Object.assign({}, rich, { pensionStartAge: 70 }); + const cb2 = E.project(late).years.find(function (y) { return y.age === 67; }).oasClawback; + ok("PIA: deferred pension → no clawback at 67", cb2 < cb(67), cb2.toFixed(0) + " < " + cb(67).toFixed(0)); +} +{ + // pre-65 splitting: employer pension is an eligible life annuity at any age (CRA) + const cpl = Object.assign({}, E.defaults, { + age: 60, retireAge: 60, household: "couple", province: "ON", + income: 0, rrsp: 0, tfsa: 0, nonreg: 0, monthlyContribution: 0, + partner: { age: 60, income: 30000, rrsp: 0, tfsa: 0, nonreg: 0, monthlyContribution: 0 }, + spendingAnnual: 60000, pensionAnnual: 30000, pensionStartAge: 60 + }); + const t62 = E.project(cpl).years.find(function (y) { return y.age === 62; }); + const nosplit = E.tax(60000, "ON", 62, { pensionIncome: 30000 }).total; + ok("pre-65 splitting: joint tax with split < tax without split", t62.tax < nosplit, + t62.tax.toFixed(2) + " < " + nosplit.toFixed(2)); + const defC = Object.assign({}, cpl, { pensionStartAge: 65 }); + const t62d = E.project(defC).years.find(function (y) { return y.age === 62; }); + const none = Object.assign({}, cpl, { pensionAnnual: 0 }); + const t62n = E.project(none).years.find(function (y) { return y.age === 62; }); + ok("splitting respects start age: deferred pension adds no income/tax at 62", + cent(t62d.pension, 0) && cent(t62d.tax, t62n.tax), t62d.tax.toFixed(2) + " == " + t62n.tax.toFixed(2)); +} + +console.log("\nRESULT: " + pass + " pass, " + fail + " fail"); +process.exit(fail ? 1 : 0); diff --git a/tests/ui.smoke.js b/tests/ui.smoke.js index fb918b9..62b23cb 100644 --- a/tests/ui.smoke.js +++ b/tests/ui.smoke.js @@ -55,12 +55,13 @@ function buildShim() { global.confirm = () => true; global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); } -function load(lang) { +function load(lang, psState) { buildShim(); global.window.RetireEngine = require(path.join(SITE, 'engine', 'engine.js')); global.window.RetireCanada = require(path.join(SITE, 'engine', 'canada.js')); + global.window.RetirePS = require(path.join(SITE, 'engine', 'pspension.js')); // persisted state in the target language: fresh answers, dropped at the quiz start - store[LS] = JSON.stringify({ a: {}, step: 0, screen: 'quiz', lang: lang }); + store[LS] = psState ? JSON.stringify(psState) : JSON.stringify({ a: {}, step: 0, screen: 'quiz', lang: lang }); eval(fs.readFileSync(path.join(SITE, 'i18n.js'), 'utf8')); eval(fs.readFileSync(path.join(SITE, 'app.js'), 'utf8')); } @@ -81,7 +82,7 @@ function run(lang, M, done) { ok('intro wiring: begin button has handler', !!q('#btn-begin')._l.click); click('btn-begin'); ok('step 1 title', M.title.test(q('#step-body')._html), N(q('#step-body')._html).slice(0, 60)); - ok('step count', /1 \/ 14/.test(q('#step-count').textContent), q('#step-count').textContent); + ok('step count', /1 \/ 15/.test(q('#step-count').textContent), q('#step-count').textContent); ok('province step requires a pick (Next disabled)', q('#btn-next').disabled === true); ok('validation message', createdEls.some(e => M.needProvince.test(N(e.textContent)))); @@ -173,9 +174,43 @@ const FR = { wiCtrb: /\/mois/, }; -run('en', EN, () => run('fr', FR, () => { +/* PS module flow: federal public servant answers → results card + chart series, per language. */ +function runPs(lang, M, done) { + console.log('\n--- ' + lang.toUpperCase() + ' (PS module) ---'); + load(lang, { + a: { + province: 'ON', age: 60, retireAge: 60, household: 'single', + income: 0, rrsp: 100000, tfsa: 30000, nonreg: 20000, monthlyContribution: 0, + spendingAnnual: 50000, + psPension: true, psJoinYear: 2000, psServiceYears: 30, psAvgSalary: 100000, + psOccupation: 'it', psItLevel: 'IT-02', psItStep: 1 + }, step: 0, screen: 'results', lang: lang }); + setTimeout(() => { + const card = q('#ps-card'); + ok('PS results card visible', card.hidden === false); + const h = N(card._html); + ok('PS card title localized', M.psCard.test(h), h.slice(0, 70)); + ok('PS card pension at 60 (2%×100k×30 = 60,000)', M.psAt.test(h), (h.match(M.psAt) || [''])[0]); + ok('PS card from-65 (lifetime 46,012.50)', M.psFrom.test(h), (h.match(M.psFrom) || [''])[0]); + ok('PS card bridge line (13,987.50)', M.psBridge.test(h), (h.match(M.psBridge) || [''])[0]); + ok('PS card sources link PSPC canada.ca', /href="https:\/\/www\.canada\.ca/.test(h)); + ok('PS card IT agreement source (2024-12-22)', /2024-12-22/.test(h)); + ok('PS series in G2 chart', M.psSeries.test(N(q('#g2')._html)), N(q('#g2')._html).slice(0, 40)); + ok('PS insight in live panel', M.psInsight.test(N(q('#panel-insight')._html)), N(q('#panel-insight')._html).slice(0, 70)); + done(); + }, 400); +} + +run('en', EN, () => run('fr', FR, () => runPs('en', { + psCard: /federal public service pension/, psAt: /60,000/, psFrom: /46,012\.50/, + psBridge: /13,987\.50/, psSeries: /Public service pension/, psInsight: /covers about/ +}, () => runPs('fr', { + psCard: /pension de la fonction publique fédérale/, psAt: /60[\s\u00a0\u202f]000/, psFrom: /46[\s\u00a0\u202f]012,50/, + psBridge: /13[\s\u00a0\u202f]987,50/, psSeries: /Pension de la fonction publique/, psInsight: /couvre environ/ +}, () => { ok('EN privacy line present in index.html', /never leave your browser/.test(fs.readFileSync(path.join(SITE, 'index.html'), 'utf8'))); ok('FR privacy string present in i18n.js', /ne quittent jamais votre navigateur/.test(fs.readFileSync(path.join(SITE, 'i18n.js'), 'utf8'))); console.log(fails ? '\nSMOKE-FAIL: ' + fails + ' of ' + total : '\nSMOKE-OK all ui smoke checks passed (' + total + ')'); process.exit(fails ? 1 : 0); -})); +})))) +;