diff --git a/PS-PENSION-PLAN.md b/PS-PENSION-PLAN.md new file mode 100644 index 0000000..1275736 --- /dev/null +++ b/PS-PENSION-PLAN.md @@ -0,0 +1,237 @@ +# FEATURE: OPTIONAL FEDERAL PUBLIC SERVICE PENSION MODULE — retire.krisforbes.ca + +**Plan v1 — written 2026-10-09 by Hermes (GLM). Execution: DSH (local 27B), all stages, zero user interaction.** +User request (verbatim): *"plan and have DSH implement our first large feature addition: Add features for canadian public servants pension calculations - specifcally ensure data for IT classification pension is accurate and look at early retirement penalties. I also want this application to be available in english and french fully bilingual. This app shouldnt only be for public servants, just add optional; helpful public servant pension calculations. Check online for resources on this, it should all be very accurate."* + +**Prerequisite: BILINGUAL-PLAN.md (stage A) must be complete and verified first.** This module is +born bilingual (all new strings go through `t()` in both languages from day one). + +## Goal + +Add an **optional** module that computes a federal public service pension (PSSA) accurately — +including the **early retirement (annual allowance) reductions** — and feeds it into the existing +planner engine. The app stays for everyone; the module activates only when the user says they have +a federal public service pension. IT-classification salary data must be accurate and sourced. + +## Verified research fact base (Hermes, 2026-10-09 — re-verify in Stage 0; if any source changed, STOP and report) + +### Pension formula — PSPC (Public Service Pension Centre), pages dated 2025-08-15 +Sources: +- https://www.tpsgc-pwgsc.gc.ca/remuneration-compensation/services-pension-services/pension/info/retr/avant-before-dec2012-eng.html (members on/before Dec 31, 2012 — "Group 1") +- https://www.tpsgc-pwgsc.gc.ca/remuneration-compensation/services-pension-services/pension/info/retr/apres-after-jan2013-eng.html (members on/after Jan 1, 2013 — "Group 2") +- French mirror for terminology: …/apres-after-jan2013-fra.html + +**The formula is the same for both groups:** +- **Lifetime pension** = 1.375% × min(avgSalary, AMPE) × min(years, 35) + 2% × max(0, avgSalary − AMPE) × min(years, 35) +- **Bridge benefit** (only if retiring before 65) = 0.625% × min(avgSalary, AMPE) × min(years, 35), + payable until the first day of the month after the 65th birthday (or CPP/QPP disability). +- Total pension before 65 = 2% × avgSalary × years (max 35). At 65+ only the lifetime pension continues. +- **avgSalary** = average salary for the **5 consecutive years of highest paid service**. +- **AMPE** = Average Maximum Pensionable Earnings = average of the YMPE (C/QPP ceilings) over the years + of pensionable service (TBS glossary: https://www.canada.ca/en/treasury-board-secretariat/services/pension-plan/plan-information/glossary.html). + **Modeling decision (document in UI + limitations):** the engine works in today's (real) dollars, so use the + **current YMPE as the real-terms AMPE proxy**: `$74,600` (YMPE 2026 — already in `canada.js` as `ympe.value`, + CRA-verified: https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/payroll/payroll-deductions-contributions/canada-pension-plan-cpp/cpp-enhancement.html + "YMPE is $71,300 in 2025 and $74,600 in 2026"). +- Max pensionable service: **35 years**. Part-time service is adjusted for assigned hours (limitation note). + +### Unreduced pension (immediate annuity) — PSPC +- **Group 1** (member on/before 2012-12-31): age **60+** with ≥2 years service; or age **55–59 with ≥30 years**; or disability (any age). +- **Group 2** (member on/after 2013-01-01): age **65+** with ≥2 years service; or age **60–64 with ≥30 years**; or disability. + +### Early retirement — annual allowance (permanently reduced) — PSPC (this is the "early retirement penalty") +- **Group 1**, retiring at age 50–59 with ≥2 years service (payable as early as 50): + - **Formula 1** (service < 25 years): reduction = 5% × (60 − age), age to the nearest tenth of a year. + - **Formula 2** (service ≥ 25 years): reduction = **greater of** (a) 5% × (55 − age), (b) 5% × (30 − serviceYears). + - Under age 50: Formula 1 only, using age 50 (max reduction 50%). +- **Group 2**, retiring at age 55–64 with ≥2 years service (payable as early as 55): + - **Formula 1** (service < 25 years): reduction = 5% × (65 − age). + - **Formula 2** (service ≥ 25 years): reduction = **greater of** (a) 5% × (60 − age), (b) 5% × (30 − serviceYears). + - **Age 60+ with ≥25 years: compare Formula 1 and Formula 2, apply the LOWEST reduction.** + - Under age 55: Formula 1 only, using age 55 (max 50%). +- The reduction applies to the whole pension (lifetime + bridge), permanently. +- **Deferred annuity**: unreduced, payable at 60 (Group 1) / 65 (Group 2). If the member takes no + election within a year of leaving, the deferred annuity is the default. <2 years service → return of contributions (no pension). +- Disability retirement → immediate annuity any age (out of scope, limitation note). + +### Survivor + indexing — TBS "Public service pension at a glance" +- https://www.canada.ca/en/treasury-board-secretariat/services/pension-plan/plan-information/public-service-pension-glance.html +- Survivor benefit: monthly allowance = **half (50%) of the pension you would have received before age 65, + calculated BEFORE any applicable reduction**, payable immediately to the eligible survivor. + Child allowance: 1/5 of the survivor benefit (2/5 if no eligible survivor), until 18 (25 if a student), max 4 children. +- Indexing: once retired, the pension is increased each January 1 by CPI (in real terms → flat; the engine already works in real dollars — no change needed). +- At 65 the bridge ends; if CPP is taken at 65 unreduced, total income (lifetime pension + CPP) ≈ what it was + before 65 (lifetime + bridge). If CPP is taken early, total income at 65 DROPS (bridge stops, CPP stays reduced). + The engine already models CPP early/late actuarial adjustment and the bridge stops at 65 regardless — consistent. + +### IT classification salary data — PRIMARY SOURCE (verified cell-by-cell by Hermes) +TBS–PSAC **Information Technology (IT) collective agreement, Appendix A "Rates of Pay"**, +effective **December 22, 2024** (wage adjustment = current rates; the 2021–2025 agreement expired +Dec 21, 2025 and rates remain in force pending a new agreement as of Oct 2026). +Source: https://www.canada.ca/en/treasury-board-secretariat/topics/pay/collective-agreements/it.html + +| Level | Steps (annual $, Dec 22 2024) | Range | +|---|---|---| +| IT-01 (8 steps) | 69,361 / 71,925 / 74,485 / 77,031 / 79,574 / 82,120 / 84,661 / 89,382 | $69,361–$89,382 | +| IT-02 (8) | 85,854 / 88,603 / 91,348 / 94,095 / 96,844 / 99,589 / 102,335 / 105,080 | $85,854–$105,080 | +| IT-03 (8) | 101,343 / 104,833 / 108,324 / 111,817 / 115,306 / 118,791 / 122,281 / 125,914 | $101,343–$125,914 | +| IT-04 (8) | 116,037 / 120,038 / 124,039 / 128,039 / 132,039 / 136,041 / 140,039 / 144,434 | $116,037–$144,434 | +| IT-05 (9) | 133,582 / 138,563 / 143,539 / 148,520 / 153,503 / 158,485 / 163,465 / 168,446 / 174,076 | $133,582–$174,076 | + +Cross-checked against fedpay.ca (matches). The pension formula is identical for all occupational +groups — the IT-specific data is the pay rates used to estimate the best-5-years average salary. +UI must show: the level's range, the as-of date (Dec 22, 2024), and the source URL, and let the user override. + +### CRA — pension income splitting + pension income amount +- https://www.canada.ca/en/revenue-agency/services/tax/individuals/topics/pension-income-splitting.html +- Under 65, **eligible pension income** for splitting = **life annuity payments from a superannuation or + pension plan** (+ death-related amounts). A public service **lifetime pension is a life annuity → splittable + at ANY age**. RRIF/RRSP-annuity amounts are eligible only at 65+ (or death-related). The **bridge benefit is a + term (temporary) annuity → conservative modeling choice: EXCLUDE it from pre-65 splitting eligibility** (documented). +- Pension income amount ($2,000 non-refundable credit base, age 65+): the engine ALREADY models this + (`PIA = 2000`, gated at 65+) — no change needed. + +### Engine state (verified in code) +- `pensionAnnual` is flat income from `retireAge` onward (added to `pre` in `projectSingle`). +- Couple-mode splitting (engine.js ~line 316) currently gated on **both spouses 65+** — must be fixed per CRA above. +- `PIA` credit gated at 65+ ✓. GIS/clawback consume `taxable` which includes pension ✓. +- `canada.js` `ympe.value` = 74600 (2026) ✓. + +## Module spec + +### New file `site/engine/pspension.js` (pure, testable; loads after canada.js) +Constants: `PS = { MAX_SERVICE: 35, GROUP1_CUTOFF_YEAR: 2012, RATES_IT: { 'IT-01': [ …8 steps… ], … }, RATES_IT_ASOF: '2024-12-22', RATES_IT_SOURCE: '…canada.ca…/it.html', EMPLOYEE_PENSION_PCT_LOW: 0.01375, EMPLOYEE_PENSION_PCT_HIGH: 0.02, BRIDGE_PCT: 0.00625, EARLY_RED_PCT_PER_YEAR: 0.05 }`. +Functions (all pure): +- `psGroup(joinYear)` → 1 if joinYear ≤ 2012 else 2. +- `psLifetimePension(avgSalary, ampe, years)` → formula above (years capped at 35). +- `psBridgeBenefit(avgSalary, ampe, years)` → 0.625% formula (0 if retiring ≥ 65 — caller decides). +- `psEarlyReduction(group, retireAge, serviceYears)` → `{ unreduced: bool, pct: 0..0.5, formula: 'F1'|'F2'|'min(F1,F2)'|null, note: string }` implementing the PSPC decision tree verbatim: + - G1: unreduced if (age ≥ 60) or (age ≥ 55 && S ≥ 30). Else if age < 50 → F1 with age 50. Else F1 if S < 25 (5%×(60−age)) else F2 = max(5%×(55−age), 5%×(30−S)). + - G2: unreduced if (age ≥ 65) or (age ≥ 60 && S ≥ 30). Else if age < 55 → **deferred path** (see below). Else F1 if S < 25 (5%×(65−age)); else (S ≥ 25): F2 = max(5%×(60−age), 5%×(30−S)), and if age ≥ 60 → pct = min(F1, F2). + - Round ages/service to the nearest tenth before applying (PSPC: "to the nearest tenth of a year"). +- `computePSPension({ joinYear, retireAge, serviceYears, avgSalary, ampe })` → + `{ group, serviceCapped, lifetime, bridge, reductionPct, pensionAtRetirement, pensionFrom65, startAge, deferred, earlyOnlyNote }`: + - lifetime = psLifetimePension; bridge = (retireAge < 65 && !deferred) ? psBridgeBenefit : 0. + - reduction per above; `pensionAtRetirement = (lifetime + bridge) × (1 − pct)`; `pensionFrom65 = lifetime × (1 − pct)`. + - `startAge` = retireAge normally; **deferred path**: G2 && retireAge < 55 → `startAge = 65`, `pct = 0`, no bridge (deferred annuity, unreduced at 65); card also shows the alternative: annual allowance from 55 with F1(age 55) = 50% reduction (`earlyOnlyNote`). + - serviceYears < 2 → pension 0 + note (return of contributions). +- Expose on a global (e.g. `root.RetirePS`) like the other engine files. + +### Engine integration (`site/engine/engine.js` — the ONLY engine file changed) +New optional inputs (default off / zero — **backward compatible**: absent ⇒ today's behavior): +- `pensionStartAge` (default = `retireAge`): `pensionAnnual` is paid only for ages ≥ `pensionStartAge`. +- `pensionBridgeAnnual` (default 0): added to retirement income for `pensionStartAge ≤ age < 65` (stops at 65 regardless of CPP start age — PSPC rule). +- Gate the PIA-eligible pension income on the pension having started (currently `p.pensionAnnual` is passed unconditionally). +- **Splitting fix (CRA)**: in `projectCouple`, replace the both-65+ gate: splitting is allowed whenever both + spouses are in draw phase; eligible income per spouse = employer pension (life annuity — **any age**, but only + once that spouse's pension has started) + RRIF withdrawals **only if that spouse is 65+**. Bridge benefit is + excluded from pre-65 splitting (term annuity — documented choice). Keep the existing "keep the cheaper total" logic. +- No change to canada.js constants. Existing 36 engine tests must stay green; if a couple test encodes the OLD + (incorrect) both-65+ splitting gate with pension income pre-65, update that expectation and cite the CRA rule in a comment. + +### UI — questionnaire (conditional steps, inserted between the OAS step and the pension/rental/other step) +All new strings bilingual via `t()` (glossary in BILINGUAL-PLAN.md; official FR terms from the PSPC FR page: +pension viagère, prestation de raccordement, allocation annuelle, pension différée, service ouvrant droit à pension, +salaire moyen des cinq années consécutives les mieux payées, groupe 1 / groupe 2, MMGP). + +**Step-count impact (update tests — do not miss this):** the PS toggle step is ALWAYS present (default No), +so base counts become **15 single / 18 couple** (was 14/17). Module on: +3 (join year, service years, salary) ++1 (group) and +1 more if IT (IT level). `tests/ui.smoke.js` line ~84 asserts `/1 \/ 14/` — update it to +`/1 \/ 15/` and re-check every hardcoded step count in the test files. (Hermes's own scratch walk scripts +also assume 14/17 — not DSH's concern.) + +1. **"Do you have a federal public service pension?"** (optional; yes/no; default no). Sub-text: optional, for federal + public servants (PSSA); everyone else: No. If no → module skipped, behavior exactly as today. +2. If yes → **"What year did you join the public service?"** (number, e.g. 2008; valid 1950–2026; determines Group 1/2 — hint explains: joined before 2013 = Group 1, 2013 or later = Group 2). +3. → **"How many years of pensionable service will you have at retirement?"** (number, 1 decimal allowed, 0.1–45; hint: total years counting toward your pension, including service in other federal groups). +4. → **"Your average salary for your best 5 consecutive years"** (number, today's $; hint explains the PSSA uses the + average of your 5 consecutive highest-paid years, in today's dollars). +5. → **"Your occupational group / classification"** (choice: `IT (Information Technology)` / `Other or not sure`). + If IT → **"IT classification level"** (choice IT-01…IT-05) and the salary input's hint shows the level's current + range + as-of date + source, with the input **prefilled to the level's top step** (guidance: if you expect to be near + the top of your level's range at retirement, the top step is a reasonable estimate; promotions can push your best-5 + average above your current step — override freely). +6. The existing **pension/rental/other** step: when the PS module is active, the manual pension input is replaced by a + read-only note: "Your public service pension is calculated below" (the computed lifetime pension flows into the engine + automatically); rental/other inputs unchanged. If the module is off, the manual pension input shows as today. + (Edge: a user with a PS pension AND other employer pension income → limitation note; they can add the extra via "other".) + +### UI — results: new card "Your public service pension" (only when module active) +Placed after the retirement-income chart. Rows (all locale-formatted): +- Membership: Group 1 (joined {year}, before 2013) / Group 2 (joined {year}, 2013 or later) +- Pensionable service: {years} years (capped at 35 in the formula) +- Average salary (best 5 consecutive years): {money} (today's $) +- Lifetime pension: {money}/yr +- Bridge benefit (until age 65): {money}/yr — or "—" if retiring ≥ 65 +- Early retirement reduction: {pct}% (Formula 1 / Formula 2 / lower of the two) — or "None — you qualify for an unreduced pension" +- **Your pension at retirement: {money}/yr** (lifetime + bridge, after reduction) +- **From age 65: {money}/yr** (bridge ends) +- If deferred path: "You retire before 55 (Group 2): modeled as a deferred annuity — unreduced pension from age 65. Alternative: an annual allowance from age 55 would be permanently reduced by 50%." +- Notes (small print): survivor benefit = 50% of the pre-reduction pension (before 65) + child allowance to 18/25; + pension is indexed to CPI each January once retired; part-time service adjusts benefits; <2 years service = return of contributions. +- Sources with as-of dates: PSPC formula pages (2025-08-15), TBS at-a-glance, TBS glossary (AMPE), CRA (splitting), + TBS–PSAC IT collective agreement Appendix A (rates effective 2024-12-22), CRA YMPE 2026 ($74,600). +- Optional insight (when active): "Your public service pension covers about {pct}% of your retirement spending goal." + +### What-if scenarios +The what-if scenarios modify inputs (e.g. "Retire at 63"). The PS pension must be **recomputed per scenario** +(app.js computes `computePSPension` with the scenario's retireAge/service inputs and passes the resulting +`pensionAnnual`/`pensionBridgeAnnual`/`pensionStartAge` into `project()`). The main results use the same code path. + +### Limitations (document in the card's small print + REPORT) +- AMPE proxied by current YMPE ($74,600, 2026) in real terms (true AMPE = average YMPE over pensionable service; future YMPEs unknown). +- Primary member only — a partner's public service pension is not modeled (approximate via the partner's manual pension input). +- Part-time service, disability retirement, transfer values, and pension transfers (CA/RCMP) are not modeled. +- Bridge benefit excluded from pre-65 pension-income splitting (term annuity; conservative). +- IT rates as of 2024-12-22 (agreement expired 2025-12-21; in force pending new agreement) — re-check at each deploy. +- Provincial pension income credits beyond the federal $2,000 are not modeled (consistent with existing limitations). + +## Tests (new `tests/ps-pension.test.js`; engine tests stay 36/36) + +Hand-computed cases (AMPE = 74,600; verify to the cent): +- **A** G2, salary 100,000, 30 yrs, retire 65 → lifetime 46,012.50; no bridge; no reduction; atRetirement = from65 = 46,012.50. +- **B** G2, 100,000, 30 yrs, retire 60 → unreduced (60+30); lifetime 46,012.50; bridge 13,987.50; atRetirement 60,000.00 (= 2%×100,000×30); from65 46,012.50. +- **C** G2, 100,000, 20 yrs, retire 60 → F1 25%; lifetime 30,675; bridge 9,325; total 40,000 × 0.75 = 30,000; from65 23,006.25. +- **D** G2, 100,000, 28 yrs, retire 62 → min(F1 15%, F2 10%) = 10%; lifetime 42,945; bridge 13,055; total 56,000 × 0.90 = 50,400; from65 38,650.50. +- **E** G1 (join 2010), 90,000, 20 yrs, retire 58 → F1 10%; lifetime 26,675; bridge 9,325; total 36,000 × 0.90 = 32,400; from65 24,007.50. +- **F** G1, 90,000, 30 yrs, retire 55 → unreduced (55+30); lifetime 40,012.50; bridge 13,987.50; atRetirement 54,000; from65 40,012.50. +- **G** G2, 80,000, 10 yrs, retire 52 → deferred: startAge 65, unreduced, no bridge; lifetime 11,337.50; earlyOnlyNote = 50% (F1 at 55). +- **H** G2, 120,000, 40 yrs, retire 65 → service capped 35; lifetime 67,680.75. +- **I** G2, 100,000, 27 yrs, retire 59 → F2 = max(5%, 15%) = 15%; lifetime 41,422.50; bridge 12,588.75; total 54,011.25 × 0.85 = 45,909.5625; from65 35,209.125. +- **J** service 1.5 yrs → pension 0 + return-of-contributions note. +Engine integration tests: bridge present at age 64 and absent at 65; pensionStartAge gating (deferred case: zero pension income before 65); pre-65 couple splitting with an employer pension (tax with optimal split < tax without, other things equal); existing 36 green. +i18n: key parity for all new keys (reuse `tests/i18n.test.js` — it must pass with the new keys present). + +## Stages (execute in order; zero questions; every stage pre-approved) + +0. **Preflight + accuracy gate**: working tree clean on top of the verified bilingual commit. Re-fetch the PSPC + formula pages (EN+FR), the TBS at-a-glance, the TBS glossary (AMPE), the CRA splitting page, the CRA YMPE page, + and the TBS IT collective agreement page. Confirm the constants in this plan still match the live sources + (formula percentages, unreduced ages, reduction formulas, AMPE definition, YMPE 2026 = $74,600, IT rate tables). + **If anything changed: STOP, do not deploy, report the diff.** Run all existing tests (36/36 + SMOKE-OK + i18n). +1. **Engine module**: create `site/engine/pspension.js`; add the script tag in index.html; engine.js integration + (pensionStartAge, pensionBridgeAnnual, PIA gating, splitting fix). Unit-test with the cases above before UI work. +2. **UI**: conditional questionnaire steps (inserted before the pension/rental/other step), IT level picker + sourced + guidance, pension-step read-only note when active, results card, optional insight, all strings via `t()` (en+fr). +3. **Tests**: `tests/ps-pension.test.js` green; engine 36/36 green; `tests/i18n.test.js` green with new keys; smoke green in both languages (extend smoke to also walk one PS-module flow in FR: enable the module, fill the PS steps, assert the results card renders French strings). +4. **Deploy + verify**: `bash scripts/deploy.sh` → `DEPLOY-OK`; origin marker = 1. EN + FR mobile audits (320/360/390) with the PS module enabled (localStorage prefill incl. ps fields) — reuse/adapt the audit scripts; programmatic checks (sheet/CTA/charts/table) + screenshots → `audit/ps-pension/`. Desktop EN+FR screenshots. Off-LAN paramiko check: site 200 + marker. +5. **Report + commit + push**: `PS-PENSION-REPORT.md` (research sources + as-of dates, formula, decision tree, IT data + provenance, engine changes, test evidence, limitations, screenshots) + `audit/ps-pension/` evidence; commit + push. + +## Hard constraints +- Accuracy first: every constant cited in the UI must carry its source + as-of date; the Stage-0 gate is mandatory. +- Optional: module off ⇒ byte-for-byte today's behavior (engine defaults preserve it; tests prove it). +- Bilingual from birth: no untranslated new strings. +- Keep `` first line in ``; privacy statement intact in both languages. +- No frameworks/build/CDN. Mobile layout rules from rounds 1–4 must keep working in EN and FR: + the round-4 app-shell quiz layout (#step-body is the ONLY scroll container; #btn-next + #panel are the + fixed bottom cluster) — new conditional steps must render correctly inside it at 320/360/390. +- If a stage fails twice, stop, keep the last good deploy live, report the blocker. + +## Definition of done +- Optional PS module live; formula + early-retirement reductions match PSPC (unit-tested to the cent); bridge stops at 65; + IT salary data sourced (TBS–PSAC Appendix A, 2024-12-22) and overridable; card shows full breakdown + sources. +- Engine: PS pension flows into income/tax/GIS/splitting correctly (incl. pre-65 life-annuity splitting per CRA). +- Bilingual EN/FR for every new string; tests green (ps-pension + 36/36 engine + i18n parity + dual-lang smoke). +- EN+FR mobile verified at 320/360/390; desktop verified; off-LAN 200; deployed; committed + pushed with report + evidence. +- **Your FINAL output line must be exactly: `PS_PENSION_DONE`**