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

+171 -9
View File
@@ -4,7 +4,7 @@
(fmtMoney/fmtMoneyK/fmtMoney2/fmtInt/fmtPct) so every number follows the active locale. */
'use strict';
(function () {
var E = window.RetireEngine, C = window.RetireCanada;
var E = window.RetireEngine, C = window.RetireCanada, PS = window.RetirePS;
var LS = 'retire-planner-v1';
var $ = function (s) { return document.querySelector(s); };
/* locale-aware aliases (i18n.js globals) — chart/table code below stays untouched in shape */
@@ -51,6 +51,8 @@
retireAge: 65, spendingAnnual: 50000,
cppStartAge: 65, oasStartAge: 65,
pensionAnnual: 0, rentalAnnual: 0, otherAnnual: 0,
psPension: false, psJoinYear: null, psServiceYears: null, psAvgSalary: null,
psOccupation: null, psItLevel: null, psItStep: 0,
realReturnPre: 3.5, realReturnPost: 2.5, inflation: 2.0, lifeExp: 95,
interests: []
};
@@ -89,8 +91,37 @@
realReturnPre: (+a.realReturnPre) / 100, realReturnPost: (+a.realReturnPost) / 100,
inflation: (+a.inflation) / 100, lifeExp: +a.lifeExp
};
/* Optional federal public service pension (PSSA): when enabled, it
* replaces the manual DB-pension input and feeds pensionAnnual /
* pensionStartAge / pensionBridgeAnnual into the engine. Recomputed for
* the effective retireAge so what-if scenarios stay accurate. */
var ps = psCompute(p.retireAge);
if (ps) {
p.pensionAnnual = ps.pensionAtRetirement;
p.pensionStartAge = ps.startAge;
p.pensionBridgeAnnual = ps.bridge;
} else {
p.pensionStartAge = 0; p.pensionBridgeAnnual = 0;
}
return p;
}
function psOn() { return !!state.a.psPension; }
function psAugment(p) { /* re-derive PS pension for a scenario's retireAge */
var ps = psCompute(p.retireAge);
if (ps) { p.pensionAnnual = ps.pensionAtRetirement; p.pensionStartAge = ps.startAge; p.pensionBridgeAnnual = ps.bridge; }
return p;
}
function psCompute(retireAge) {
var a = state.a;
if (!a.psPension || typeof PS === 'undefined') return null;
var avg = +a.psAvgSalary || 0;
if (!avg) return null;
return PS.computePSPension({
joinYear: +a.psJoinYear || 0, retireAge: retireAge,
serviceYears: +a.psServiceYears || 0, avgSalary: avg,
ampe: (typeof CANADA !== 'undefined' && CANADA.ympe && CANADA.ympe.value) || 74600
});
}
/* ---------- formatting (i18n.js: Intl.NumberFormat, locale-aware) ---------- */
function pct2(v) { return fmtPct(v / 100, 2); } /* slider values arrive as plain numbers: 3.5 → 3.5% */
@@ -173,6 +204,70 @@
type: 'opts', opts: [[65, '65', 'q.oasstart.os65'], [70, '70', 'q.oasstart.os70']],
get: function () { return state.a.oasStartAge; }, set: function (v) { state.a.oasStartAge = +v; }
},
{
key: 'ps', titleK: 'q.ps.title', hintK: 'q.ps.hint', type: 'opts',
opts: [['yes', 'q.ps.yes', 'q.ps.yesD'], ['no', 'q.ps.no', 'q.ps.noD']],
get: function () { return state.a.psPension ? 'yes' : 'no'; },
set: function (v) { state.a.psPension = v === 'yes'; }
},
{
key: 'ps_join', show: psOn, titleK: 'q.ps_join.title', hintK: 'q.ps_join.hint', type: 'number', min: 1950, max: 2026,
get: function () { return state.a.psJoinYear; }, set: function (v) { state.a.psJoinYear = +v; }
},
{
key: 'ps_service', show: psOn, titleK: 'q.ps_service.title', hintK: 'q.ps_service.hint', type: 'number', min: 0, max: 45,
get: function () { return state.a.psServiceYears; }, set: function (v) { state.a.psServiceYears = +v; }
},
{
key: 'ps_salary', show: psOn, titleK: 'q.ps_salary.title',
hint: function () {
var r = state.a.psItLevel && PS && PS.itRange(state.a.psItLevel);
if (r) return t('q.ps_salary.hintIt', { lo: fmtMoneyK(r.min), hi: fmtMoneyK(r.top), asof: r.asof });
return t('q.ps_salary.hint');
},
type: 'number', min: 0, max: 1e6, money: true,
get: function () { return state.a.psAvgSalary; }, set: function (v) { state.a.psAvgSalary = +v; }
},
{
key: 'ps_occ', show: psOn, titleK: 'q.ps_occ.title', hintK: 'q.ps_occ.hint', type: 'opts',
opts: [['it', 'q.ps_occ.it', 'q.ps_occ.itD'], ['other', 'q.ps_occ.other', 'q.ps_occ.otherD']],
get: function () { return state.a.psOccupation; }, set: function (v) { state.a.psOccupation = v; }
},
{
key: 'ps_it', show: function () { return psOn() && state.a.psOccupation === 'it'; },
titleK: function () {
return state.a.psItLevel && state.a.psItLevel !== 'none'
? t('q.ps_it.stepTitle', { level: state.a.psItLevel }) : t('q.ps_it.title');
},
hint: function () {
var r = state.a.psItLevel && state.a.psItLevel !== 'none' && PS.itRange(state.a.psItLevel);
if (r) return t('q.ps_it.stepHint', { lo: fmtMoneyK(r.min), hi: fmtMoneyK(r.top), asof: r.asof });
return t('q.ps_it.hint', { asof: PS.IT_RATES_ASOF });
},
type: 'opts',
opts: function () {
if (state.a.psItLevel && state.a.psItLevel !== 'none') {
var r = PS.itRange(state.a.psItLevel);
return r.steps.map(function (v, i) { return [String(i + 1), fmtMoney(v), i === 0 ? 'q.ps_it.stepMin' : (i === r.steps.length - 1 ? 'q.ps_it.stepTop' : '')]; });
}
return PS.IT_LEVELS.map(function (lv) {
return [lv.id, lv.id, function () { var r = PS.itRange(lv.id); return fmtMoneyK(r.min) + ' – ' + fmtMoneyK(r.top); }];
}).concat([['none', 'q.ps_it.skip', 'q.ps_it.skipD']]);
},
get: function () {
if (state.a.psItLevel && state.a.psItLevel !== 'none' && state.a.psItStep) return String(state.a.psItStep);
return state.a.psItLevel || '';
},
set: function (v) {
if (state.a.psItLevel && state.a.psItLevel !== 'none' && /^\d+$/.test(v)) {
var r = PS.itRange(state.a.psItLevel);
state.a.psItStep = +v;
state.a.psAvgSalary = r.steps[+v - 1]; /* step value = 5-yr average proxy */
} else {
state.a.psItLevel = v; state.a.psItStep = 0;
}
}
},
{
key: 'extra', titleK: 'q.extra.title', hintK: 'q.extra.hint', type: 'fields', fields: [
{ labelK: 'q.extra.pension', key: 'pensionAnnual' }, { labelK: 'q.extra.rental', key: 'rentalAnnual' }, { labelK: 'q.extra.other', key: 'otherAnnual' }
@@ -219,15 +314,18 @@
btnNext.textContent = state.step === vs.length - 1 ? t('quiz.seePlan') : t('quiz.next');
var hintTxt = typeof s.hint === 'function' ? s.hint() : (s.hintK ? t(s.hintK) : '');
var h = '<h2>' + t(s.titleK) + '</h2>' + (hintTxt ? '<p class="hint">' + hintTxt + '</p>' : '');
var titleTxt = typeof s.titleK === 'function' ? s.titleK() : t(s.titleK);
var optList = typeof s.opts === 'function' ? s.opts() : s.opts;
var h = '<h2>' + titleTxt + '</h2>' + (hintTxt ? '<p class="hint">' + hintTxt + '</p>' : '');
if (s.type === 'grid') {
h += '<div class="opts">' + s.opts.map(function (o) {
h += '<div class="opts">' + optList.map(function (o) {
return '<button class="opt' + (s.get() === o[0] ? ' sel' : '') + '" data-v="' + o[0] + '">' + t(o[1]) + '</button>';
}).join('') + '</div>';
} else if (s.type === 'opts') {
h += '<div class="opts">' + s.opts.map(function (o) {
return '<button class="opt' + (String(s.get()) === String(o[0]) ? ' sel' : '') + '" data-v="' + o[0] + '">' + t(o[1]) + (o[2] ? '<small>' + t(o[2]) + '</small>' : '') + '</button>';
h += '<div class="opts">' + optList.map(function (o) {
var d = o[2] ? (typeof o[2] === 'function' ? o[2]() : t(o[2])) : '';
return '<button class="opt' + (String(s.get()) === String(o[0]) ? ' sel' : '') + '" data-v="' + o[0] + '">' + t(o[1]) + (d ? '<small>' + d + '</small>' : '') + '</button>';
}).join('') + '</div>';
} else if (s.type === 'chips') {
var cur = s.get() || [];
@@ -238,6 +336,11 @@
h += '<div class="fld"><input type="number" id="f1" value="' + (s.get() == null ? '' : s.get()) + '" min="' + (s.min != null ? s.min : 0) + '" max="' + (s.max != null ? s.max : 1e9) + '" inputmode="numeric" ' + (s.money ? 'placeholder="0"' : 'placeholder="' + fmtInt(s.max) + '"') + '></div>';
} else if (s.type === 'fields') {
h += s.fields.map(function (f, i) {
if (f.key === 'pensionAnnual' && psOn()) {
var pr = psCompute(+state.a.retireAge);
return '<div class="fld"><label>' + t(f.labelK) + '</label><p class="hint" style="margin:4px 0 0">' +
t('q.extra.psNote', { v: pr ? money(pr.pensionAtRetirement) + t('unit.yr') : t('q.extra.psNotePending') }) + '</p></div>';
}
return '<div class="fld"><label>' + t(f.labelK) + ' <span class="unit">' + t('fields.unit') + '</span></label><input type="number" id="f' + (i + 1) + '" data-k="' + f.key + '" value="' + (s.get(f.key) == null ? '' : s.get(f.key)) + '" min="0" inputmode="numeric" placeholder="0"></div>';
}).join('');
} else if (s.type === 'slider') {
@@ -319,6 +422,7 @@
var ins;
if (proj.depletionAge) ins = t('i.depletion', { age: proj.depletionAge });
else if (psCoverageInsight(p, proj)) ins = psCoverageInsight(p, proj);
else if (p.cppStartAge < 65) ins = t('i.cppEarly', { age: p.cppStartAge });
else if (p.oasStartAge >= 70) ins = t('i.oasLate', { age: p.oasStartAge });
else if (p.province === 'QC') ins = t('i.qc');
@@ -392,7 +496,7 @@
$(sel).innerHTML = chartH(sel) + (inner || '') + (legendItems ? legend(legendItems) : '');
}
var COL = { bal: '#2563eb', cpp: '#2563eb', oas: '#dc2626', gis: '#f59e0b', wdr: '#10b981', cash: '#0ea5e9', need: '#dc2626' };
var COL = { bal: '#2563eb', cpp: '#2563eb', oas: '#dc2626', gis: '#f59e0b', wdr: '#10b981', cash: '#0ea5e9', need: '#dc2626', ps: '#7c3aed' };
function g1_balance(proj) {
var yrs = proj.years, ages = yrs.map(function (r) { return r.age; });
@@ -421,9 +525,12 @@
var series = [
[t('g2.cpp'), COL.cpp, yrs.map(function (r) { return r.cpp; })],
[t('g2.oas'), COL.oas, yrs.map(function (r) { return Math.max(0, r.oasGross - r.oasClawback); })],
[t('g2.gis'), COL.gis, yrs.map(function (r) { return r.gis; })],
[t('g2.savings'), COL.wdr, yrs.map(function (r) { return r.rrifWithdrawal + r.nonregW + r.tfsaW; })]
[t('g2.gis'), COL.gis, yrs.map(function (r) { return r.gis; })]
];
if (yrs.some(function (r) { return (r.pension || 0) > 0; })) {
series.push([t('g2.psPension'), COL.ps, yrs.map(function (r) { return r.pension || 0; })]);
}
series.push([t('g2.savings'), COL.wdr, yrs.map(function (r) { return r.rrifWithdrawal + r.nonregW + r.tfsaW; })]);
var n = ages.length, tot = [], i, acc;
for (i = 0; i < n; i++) tot[i] = series.reduce(function (s, se) { return s + se[2][i]; }, 0);
var ymax = Math.max(1000, Math.max.apply(null, tot) * 1.08), Y = sy(ymax);
@@ -464,7 +571,7 @@
var scen = [
[t('g4.s0'), p],
[t('g4.s1'), Object.assign({}, p, { monthlyContribution: p.monthlyContribution + 200, employerMatchMonthly: p.employerMatchMonthly })],
[t('g4.s2'), Object.assign({}, p, { retireAge: Math.min(p.retireAge + 2, 75) })],
[t('g4.s2'), psAugment(Object.assign({}, p, { retireAge: Math.min(p.retireAge + 2, 75) }))],
[t('g4.s3'), Object.assign({}, p, { cppStartAge: 70 })]
];
var res = scen.map(function (s) { return [s[0], E.sustainableSpending(s[1]).annual]; });
@@ -545,6 +652,60 @@
el.innerHTML = '<div class="vcap"><b>' + t('vision.label', { slug: slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) }) + '</b><br>' + cap + '</div>';
}
/* ---------- public service pension: insight + results card ---------- */
function psCoverageInsight(p) {
if (!psOn()) return null;
var ps = psCompute(p.retireAge);
if (!ps || !(p.spendingAnnual > 0)) return null;
var pct = Math.round(100 * ps.pensionAtRetirement / p.spendingAnnual);
if (pct < 10 || pct > 250) return null;
return t('i.psCover', { pct: fmtInt(pct) });
}
function renderPsCard(p) {
var el = $('#ps-card'); if (!el) return;
var ps = psCompute(p.retireAge);
if (!ps) { el.hidden = true; el.innerHTML = ''; return; }
el.hidden = false;
var m = fmtMoney2, S = PS.SOURCES, ampe = C.ympe.value;
var rows = [
[t('ps.card.group', { n: ps.group }), ''],
[t('ps.card.service', { y: fmtInt(ps.serviceCapped) }), ''],
[t('ps.card.avg'), m(+state.a.psAvgSalary || 0) + t('unit.yr')],
[t('ps.card.ampe', { v: m(ampe), y: C.ympe.year || 2026 }), ''],
[t('ps.card.lifetime'), m(ps.lifetime) + t('unit.yr')]
];
if (ps.bridge > 0) rows.push([t('ps.card.bridge'), m(ps.bridge) + t('unit.yr')]);
rows.push([t('ps.card.reduction', {
pct: fmtPct(ps.reductionPct, 1),
f: ps.reductionPct === 0 ? t('ps.card.none') : t(ps.formula === 'F1' ? 'ps.card.fF1' : ps.formula === 'F2' ? 'ps.card.fF2' : 'ps.card.fMin')
}), '']);
rows.push(['<b>' + t('ps.card.atRet', { age: fmtInt(ps.startAge) }) + '</b>', '<b>' + m(ps.pensionAtRetirement) + t('unit.yr') + ' · ' + m(ps.pensionAtRetirement / 12) + t('unit.mo') + '</b>']);
rows.push(['<b>' + t('ps.card.from65') + '</b>', '<b>' + m(ps.pensionFrom65) + t('unit.yr') + ' · ' + m(ps.pensionFrom65 / 12) + t('unit.mo') + '</b>']);
var h = '<div class="chart-h"><b>' + t('ps.card.title') + '</b></div><div class="tbl-wrap"><table class="cards">' +
rows.map(function (r) { return '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td></tr>'; }).join('') + '</table></div>';
var notes = [];
if (ps.notes.indexOf('lt2') >= 0) notes.push(t('ps.card.lt2'));
if (ps.earlyOnlyNote === 'deferredG2') notes.push(t('ps.card.deferred'));
if (ps.notes.indexOf('f1Under50') >= 0) notes.push(t('ps.card.under50'));
if (ps.notes.indexOf('f1Under55') >= 0) notes.push(t('ps.card.under55'));
if (ps.startAge < 65 && ps.bridge > 0) notes.push(t('ps.card.bridgeEnds'));
notes.push(t('ps.card.nSurvivor'));
notes.push(t('ps.card.nIndex'));
notes.push(t('ps.card.nPartTime'));
notes.push(t('ps.card.nSplit'));
h += '<p class="cap">' + notes.join(' ') + '</p>';
var asOf = C.asOf, dPspc = '2025-08-15';
h += '<p class="cap">' + t('ps.card.sources') + ' ' +
'<a href="' + (ps.group === 1 ? S.g1En : S.g2En) + '" rel="noopener" target="_blank">' + t('ps.card.srcPspc', { d: dPspc }) + '</a> · ' +
'<a href="' + S.atAGlanceEn + '" rel="noopener" target="_blank">' + t('ps.card.srcGlance') + '</a> · ' +
'<a href="' + S.glossaryEn + '" rel="noopener" target="_blank">' + t('ps.card.srcGloss') + '</a> · ' +
'<a href="' + S.splittingCra + '" rel="noopener" target="_blank">' + t('ps.card.srcCra') + '</a> · ' +
'<a href="' + S.ympeCra + '" rel="noopener" target="_blank">' + t('ps.card.srcYmpe', { v: m(ampe), y: C.ympe.year || 2026 }) + '</a>' +
(state.a.psItLevel && state.a.psItLevel !== 'none' ? ' · <a href="' + S.itAgreement + '" rel="noopener" target="_blank">' + t('ps.card.srcIt', { d: PS.IT_RATES_ASOF }) + '</a>' : '') +
' · <span>' + t('ps.card.dataAsOf', { d: asOf }) + '</span></p>';
el.innerHTML = h;
}
/* ---------- results ---------- */
function renderResults(fast) {
var p = inputs(), proj = E.project(p), rec;
@@ -580,6 +741,7 @@
$('#gap-cards').innerHTML = cards;
g1_balance(proj); g2_income(proj, p); g3_cash(proj); g4_bars(p, fast);
renderPsCard(p);
var tbl = '<tr><th>' + t('t.age') + '</th><th>' + t('t.rrsp') + '</th><th>' + t('t.tfsa') + '</th><th>' + t('t.nonreg') + '</th><th>' + t('t.total') + '</th><th>' + t('t.wdr') + '</th><th>' + t('t.cpp') + '</th><th>' + t('t.oas') + '</th><th>' + t('t.gis') + '</th><th>' + t('t.tax') + '</th><th>' + t('t.unfunded') + '</th></tr>';
var n = 0;