engine: GIS 65+ floor, clawback only with OAS, couple mixed-row footnote, FIRE retire age 40
This commit is contained in:
1 parent
de451f0525
commit
a210e7edfc
10 files changed
+348
-24
No files matched your search
@@ -116,5 +116,91 @@ const onQc = E.tax(80000, "QC", 40, {});
|
||||
ok("QC federal = 83.5% of ON federal", near(onQc.federal, E.tax(80000, "ON", 40, {}).federal * 0.835, 1));
|
||||
ok("QC total > ON total at 80k", onQc.total > E.tax(80000, "ON", 40, {}).total, onQc.total.toFixed(0));
|
||||
|
||||
console.log("---- 14. Math fix (2026-10): GIS 65+ floor, clawback only with OAS received, couple mixed rows, FIRE ----");
|
||||
// T1 — PASS-1 repro (plan evidence): 52y ON, 60k, 100k/30k/20k, retire 55, spend 93k, CPP 60.
|
||||
// Old engine: age 55 OASnet −$3,501 (clawback on OAS never paid), age 56+ GIS $12,916 (GIS is 65+ ONLY).
|
||||
const t1 = E.project(inp({ age: 52, province: 'ON', income: 60000, rrsp: 100000, tfsa: 30000, nonreg: 20000,
|
||||
monthlyContribution: 0, employerMatchMonthly: 0, retireAge: 55, spendingAnnual: 93000, cppStartAge: 60, oasStartAge: 65 }));
|
||||
const t1y55 = t1.years.filter(y => y.age === 55)[0], t1y56 = t1.years.filter(y => y.age === 56)[0];
|
||||
ok("T1 clawback == 0 at 55 (pre-OAS; old: −$3,501 net OAS)", t1y55.oasClawback === 0, t1y55.oasClawback);
|
||||
ok("T1 GIS == 0 at 56 (old: $12,916 phantom)", t1y56.gis === 0, t1y56.gis);
|
||||
ok("T1 OAS gross == 0 at 56", t1y56.oasGross === 0);
|
||||
ok("T1 net OAS ≥ 0 in every row", t1.years.every(y => (y.oasGross - y.oasClawback) >= 0));
|
||||
ok("T1 no GIS below 65 anywhere", t1.years.filter(y => y.age < 65 && y.gis > 0).length === 0);
|
||||
|
||||
console.log("---- 15. Clawback active when OAS IS received ----");
|
||||
const t2a = E.project(inp({ age: 52, province: 'ON', income: 60000, rrsp: 300000, tfsa: 30000, nonreg: 20000,
|
||||
monthlyContribution: 0, retireAge: 55, spendingAnnual: 60000, cppStartAge: 60, oasStartAge: 65,
|
||||
rrspWithdrawalOverrideAnnual: 100000 }));
|
||||
const t2a55 = t2a.years.filter(y => y.age === 55)[0];
|
||||
ok("100k RRSP draw at 55 (taxable > clawback threshold) → clawback 0",
|
||||
t2a55.rrifWithdrawal > 90000 && t2a55.oasClawback === 0,
|
||||
"W=" + Math.round(t2a55.rrifWithdrawal) + " cw=" + t2a55.oasClawback + " taxable=" + Math.round(t2a55.taxable));
|
||||
const t2b = E.project(inp({ age: 66, province: 'ON', income: 60000, rrsp: 500000, tfsa: 30000, nonreg: 20000,
|
||||
monthlyContribution: 0, retireAge: 67, spendingAnnual: 100000, cppStartAge: 65, oasStartAge: 65,
|
||||
rrspWithdrawalOverrideAnnual: 150000 }));
|
||||
const t2b67 = t2b.years.filter(y => y.age === 67)[0];
|
||||
ok("67y OAS recipient, 150k draw → clawback > 0", t2b67.oasGross > 0 && t2b67.oasClawback > 0,
|
||||
"oas=" + Math.round(t2b67.oasGross) + " cw=" + Math.round(t2b67.oasClawback) + " taxable=" + Math.round(t2b67.taxable));
|
||||
ok("clawback capped at OAS gross", t2b67.oasClawback <= t2b67.oasGross + 1);
|
||||
|
||||
console.log("---- 16. Couple merge: GIS 65+ floor, age-offset rows, depletion guard ----");
|
||||
// (a) screenshot scenario: symmetric couple, ret 55 — pre-65 draw rows must have GIS 0 / clawback 0
|
||||
const cpSym = E.project(inp({ household: 'couple', age: 52, province: 'ON', income: 60000, rrsp: 100000, tfsa: 30000,
|
||||
nonreg: 20000, monthlyContribution: 0, spendingAnnual: 93000, retireAge: 55, cppStartAge: 60,
|
||||
partner: { age: 52, income: 60000, rrsp: 100000, tfsa: 30000, nonreg: 20000, monthlyContribution: 0 } }));
|
||||
ok("couple: zero GIS below 65 (old: $13k/yr phantom rows 57–62)", cpSym.years.filter(y => y.age < 65 && y.gis > 0).length === 0);
|
||||
ok("couple: zero clawback below 65", cpSym.years.filter(y => y.age < 65 && y.oasClawback > 0).length === 0);
|
||||
ok("couple: net OAS ≥ 0 everywhere", cpSym.years.every(y => (y.oasGross - y.oasClawback) >= 0));
|
||||
ok("couple: no growing-Total draw rows (Withdraw>0 & Total↑)", (function () {
|
||||
const a = cpSym.years;
|
||||
for (let i = 1; i < a.length; i++) {
|
||||
const W = (a[i].rrifWithdrawal || 0) + (a[i].nonregW || 0) + (a[i].tfsaW || 0);
|
||||
if (a[i].phase === 'draw' && W > 1000 && a[i].totalBal > a[i - 1].totalBal) return false;
|
||||
} return true;
|
||||
})());
|
||||
ok("couple: GIS paid at 65+ (household rate)", cpSym.years.filter(y => y.age === 70)[0].gis > 0,
|
||||
cpSym.years.filter(y => y.age === 70)[0].gis.toFixed(0));
|
||||
// (b) plan test 3: partner 3 years younger (pt.age 50, p.age 52, retireAge 55).
|
||||
// The couple merge aligns partners on the AGE axis (byYear keys y.age) and both partners
|
||||
// share retireAge, so a same-retireAge couple has NO mixed draw window: rows 50–51 are the
|
||||
// younger partner's accumulation-only rows (older partner has no row yet → phase 'none').
|
||||
// Plan-literal "rows 50–54 mixed===true" is unreachable in this merge design — asserted as
|
||||
// the honest equivalent (window exists, totals grow — fine, phase accum, no false depletion).
|
||||
const cpOff = E.project(inp({ household: 'couple', age: 52, province: 'ON', income: 60000, rrsp: 100000, tfsa: 30000,
|
||||
nonreg: 20000, monthlyContribution: 500, spendingAnnual: 93000, retireAge: 55,
|
||||
partner: { age: 50, income: 0, rrsp: 20000, tfsa: 10000, nonreg: 5000, monthlyContribution: 200 } }));
|
||||
const w5054 = [50, 51, 52, 53, 54].map(a => cpOff.years.filter(y => y.age === a)[0]);
|
||||
ok("age-offset couple: partner-priority window rows 50–54 present", w5054.every(Boolean));
|
||||
ok("window totals GROW (accum rows — 'growing totals — fine')", (function () {
|
||||
let prev = -1; return w5054.every(y => { const g = y.totalBal > prev; prev = y.totalBal; return g; });
|
||||
})());
|
||||
ok("window rows phase 'accum' (engine aligns by age; documented in report)", w5054.every(y => y.phase === 'accum'));
|
||||
ok("no depletion in the partner-priority window", w5054.every(y => y.phase !== 'draw'));
|
||||
ok("mixed flag never true on non-draw rows", cpOff.years.filter(y => y.phase !== 'draw' && y.mixed).length === 0);
|
||||
// (c) depletion guard: real depletion in fully-draw rows still detected (guard not broken)
|
||||
const cpDep = E.project(inp({ household: 'couple', age: 66, province: 'ON', income: 0, rrsp: 20000, tfsa: 5000,
|
||||
nonreg: 1000, monthlyContribution: 0, spendingAnnual: 120000, retireAge: 66, cppStartAge: 66, oasStartAge: 65,
|
||||
partner: { age: 66, income: 0, rrsp: 5000, tfsa: 1000, nonreg: 0, monthlyContribution: 0 } }));
|
||||
ok("fully-draw couple running out: depletionAge non-null (guard intact)", cpDep.depletionAge != null, cpDep.depletionAge);
|
||||
ok("depletion never fires on a mixed row", (function () {
|
||||
const d = cpOff.depletionAge;
|
||||
return d === null || !cpOff.years.filter(y => y.age === d)[0].mixed;
|
||||
})());
|
||||
|
||||
console.log("---- 17. FIRE: retire age 40 + stale-save clamp ----");
|
||||
ok("fillDefaults({age:52, retireAge:40}) → 53 (stale saved state guard)",
|
||||
E.fillDefaults({ age: 52, retireAge: 40 }).retireAge === 53, E.fillDefaults({ age: 52, retireAge: 40 }).retireAge);
|
||||
ok("fillDefaults({age:30, retireAge:40}) → 40 (FIRE plan untouched)",
|
||||
E.fillDefaults({ age: 30, retireAge: 40 }).retireAge === 40, E.fillDefaults({ age: 30, retireAge: 40 }).retireAge);
|
||||
const fire = E.project(inp({ age: 30, province: 'BC', income: 90000, rrsp: 200000, tfsa: 100000, nonreg: 50000,
|
||||
monthlyContribution: 0, retireAge: 40, spendingAnnual: 60000, cppStartAge: 60 }));
|
||||
ok("FIRE project: first draw row at age 40", fire.years.filter(y => y.phase === 'draw')[0].age === 40,
|
||||
fire.years.filter(y => y.phase === 'draw')[0].age);
|
||||
ok("FIRE: rows 40–64 have GIS 0 and clawback 0 (self-funded gap years)",
|
||||
fire.years.filter(y => y.age < 65 && (y.gis > 0 || y.oasClawback > 0)).length === 0);
|
||||
ok("stale 52y/retire-40 state: project clamps → first draw at 53",
|
||||
E.project(inp({ age: 52, retireAge: 40 })).years.filter(y => y.phase === 'draw')[0].age === 53);
|
||||
|
||||
console.log("\nRESULT: " + pass + " pass, " + fail + " fail");
|
||||
process.exit(fail ? 1 : 0);
|
||||
Reference in new issue
Block a user