/* ============================================================================ * 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; } /* Auto-derive years of pensionable service (PS-AUTO-SERVICE): * yearsWorkedSoFar = currentYear − joinYear * ageAtJoin = age − yearsWorkedSoFar * serviceYears = retireAge − ageAtJoin, clamped to [0, 60] (whole years; * the pension formula applies its own 35-yr MAX_SERVICE cap). * currentYear defaults to the calendar year. Returns null when age, joinYear or * retireAge is missing/invalid — the UI then falls back to manual entry. */ function autoServiceYears(age, joinYear, retireAge, currentYear) { var y = +currentYear; if (!(y > 0)) y = new Date().getFullYear(); var a = +age, j = +joinYear, r = +retireAge; if (!(a > 0) || !(r > 0) || !(y > 0)) return null; if (!(j >= 1900 && j <= 2999)) return null; if (!isFinite(a) || !isFinite(j) || !isFinite(r) || !isFinite(y)) return null; var joinAge = a - (y - j); var s = r - joinAge; return Math.round(Math.max(0, Math.min(60, s))); } 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, autoServiceYears: autoServiceYears, itLevel: itLevel, itRange: itRange }; });