Files
retire-planner/site/engine/pspension.js
T

201 lines
11 KiB
JavaScript
Raw 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.
/* ============================================================================
* 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
};
});