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

238 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<!-- retire-planner v1 -->` first line in `<head>`; 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`**