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
};