PS pension module: optional federal PSSA engine + UI (PSPC-accurate, EN/FR)

This commit is contained in:
krisf committed 2026-10-09 11:44:40 -04:00
1 parent fe62022a32
commit 583b5ee2f8
14 files changed
+1991 -27

No files matched your search

+36 -6
View File
@@ -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
};
+200
View File
@@ -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
};
});