engine: GIS 65+ floor, clawback only with OAS, couple mixed-row footnote, FIRE retire age 40
This commit is contained in:
1 parent
53f12f207e
commit
0839826704
10 files changed
+348
-24
No files matched your search
+37
-10
@@ -77,11 +77,18 @@
|
||||
}
|
||||
|
||||
/* ---- OAS recovery tax (clawback) ---- */
|
||||
// s.3(1) ETA: recovery applies ONLY to years OAS is actually payable/received —
|
||||
// a pre-OAS-start pensioner has no OAS to recover. Callers guard with oasGross > 0
|
||||
// (2026-10 math fix: pre-65 withdrawals must not generate a "clawback" on OAS never paid).
|
||||
// Recovery is capped at the OAS received (Math.min at call sites) → net OAS is never negative.
|
||||
function clawback(netIncome) {
|
||||
return 0.15 * Math.max(0, (netIncome || 0) - V('oasClawbackThreshold'));
|
||||
}
|
||||
|
||||
/* ---- GIS (SIMPLIFIED — stamp: simplified) ---- */
|
||||
/* ---- GIS (SIMPLIFIED — stamp: simplified; PAYABLE ONLY AT AGE 65+) ---- */
|
||||
// Source fact: Service Canada — you must be 65 or older to receive the GIS.
|
||||
// The 65+ floor is enforced at the call sites: solve() gates gis() on a >= 65,
|
||||
// projectCouple() zeroes gisSum when the merged row age < 65 (2026-10 math fix).
|
||||
// Only below ~$20k (single) / ~$26k (couple, both 65+) income ex-OAS.
|
||||
// gis = max(0, gisMax − 0.5 × max(0, incomeExOAS − 5000)) (first $5k employment exemption approximated).
|
||||
// Returns ANNUAL $, non-taxable.
|
||||
@@ -174,6 +181,11 @@
|
||||
var o = {}, k;
|
||||
for (k in DEFAULTS) o[k] = DEFAULTS[k];
|
||||
for (k in (inp || {})) if (inp[k] !== undefined && inp[k] !== null) o[k] = inp[k];
|
||||
// FIRE-safe guard (2026-10 math fix): a stale saved retireAge below the current age
|
||||
// (52-year-old returning user with retireAge 40, what-if slider below current age)
|
||||
// would "retire" this run years in the past → clamp to next birthday. Age-30 user
|
||||
// planning to retire at 40 is untouched.
|
||||
if (+o.retireAge < +o.age + 1) o.retireAge = +o.age + 1;
|
||||
return o;
|
||||
}
|
||||
|
||||
@@ -233,8 +245,8 @@
|
||||
var taxable1 = pre + W;
|
||||
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');
|
||||
var c1 = oasGross > 0 ? Math.min(clawback(taxable1), oasGross) : 0; // clawback only in years OAS is received, capped at OAS paid
|
||||
var g = a >= 65 ? gis(taxable1 - oasGross, 'single') : 0; // GIS payable only at 65+ (Service Canada)
|
||||
var cash1 = pre + W + g - t1.total - c1;
|
||||
var n = 0, t = 0, taxable = taxable1, tt = t1, cc = c1, opts = opts1;
|
||||
var rem = need - cash1;
|
||||
@@ -244,13 +256,13 @@
|
||||
taxable = taxable1 + 0.5 * n;
|
||||
opts = { pensionIncome: opts1.pensionIncome };
|
||||
tt = tax(taxable, p.province, a, opts);
|
||||
cc = clawback(taxable);
|
||||
g = gis(taxable - oasGross, 'single');
|
||||
cc = oasGross > 0 ? Math.min(clawback(taxable), oasGross) : 0; // same rule: no OAS received → no clawback; capped at OAS paid
|
||||
g = a >= 65 ? gis(taxable - oasGross, 'single') : 0;
|
||||
rem = need - (pre + W + g + n - tt.total - cc);
|
||||
}
|
||||
if (rem > 0 && tfsa > 0) {
|
||||
t = Math.min(tfsa, rem);
|
||||
g = gis(taxable - oasGross, 'single');
|
||||
g = a >= 65 ? gis(taxable - oasGross, 'single') : 0; // 65+ floor (TFSA draw is non-taxable; guard kept uniform)
|
||||
rem = need - (pre + W + g + n + t - tt.total - cc);
|
||||
}
|
||||
var cash = pre + W + g + n + t - tt.total - cc;
|
||||
@@ -348,25 +360,39 @@
|
||||
if (Math.abs(shift) > 1) {
|
||||
var tA = tax(ra.taxable - shift, p.province, yr, { pensionIncome: pa - shift });
|
||||
var tB = tax(rb.taxable + shift, p.province, yr, { pensionIncome: pb2 + shift });
|
||||
var newTax = tA.total + tB.total, newClaw = clawback(ra.taxable - shift) + clawback(rb.taxable + shift);
|
||||
var newTax = tA.total + tB.total;
|
||||
// Splitting comparison uses the SAME clawback rule: only a partner receiving OAS
|
||||
// this year can generate recovery tax (2026-10 math fix).
|
||||
var newClaw = (ra.oasGross > 0 ? Math.min(clawback(ra.taxable - shift), ra.oasGross) : 0)
|
||||
+ (rb.oasGross > 0 ? Math.min(clawback(rb.taxable + shift), rb.oasGross) : 0);
|
||||
if (newTax + newClaw < taxSum + clawSum) { taxSum = newTax; clawSum = newClaw; }
|
||||
}
|
||||
}
|
||||
row.tax = taxSum; row.oasClawback = clawSum;
|
||||
// GIS: household rate when both 65+, else sum of singles
|
||||
// GIS: household rate when both 65+, else sum of singles. GIS is payable ONLY at
|
||||
// 65+ (Service Canada): a partner's single GIS term counts only at their own age ≥ 65.
|
||||
var incA = ra.taxable - ra.oasGross, incB = rb.taxable - rb.oasGross;
|
||||
var gisSum;
|
||||
if (ra.phase === 'draw' && rb.phase === 'draw' && ra.age >= 65 && rb.age >= 65) {
|
||||
gisSum = gis(incA + incB, 'couple');
|
||||
} else {
|
||||
gisSum = (ra.phase === 'draw' ? gis(incA, 'single') : 0) + (rb.phase === 'draw' ? gis(incB, 'single') : 0);
|
||||
gisSum = (ra.phase === 'draw' && ra.age >= 65 ? gis(incA, 'single') : 0)
|
||||
+ (rb.phase === 'draw' && rb.age >= 65 ? gis(incB, 'single') : 0);
|
||||
}
|
||||
if (yr < 65) gisSum = 0; // GIS requires age 65+ (Service Canada) — 2026-10 math fix
|
||||
row.gis = gisSum;
|
||||
var cash = ra.cash + rb.cash + (ra.gis + rb.gis ? gisSum - ra.gis - rb.gis : 0) + (taxSum ? (ra.tax + rb.tax - taxSum) : 0);
|
||||
cash = ra.cash + rb.cash - (ra.gis + rb.gis) + gisSum + (ra.tax + rb.tax) - taxSum;
|
||||
row.cash = Math.min(cash, row.need);
|
||||
// Mixed window: retirement HAS begun (row labelled 'draw' from the FIRST retirement),
|
||||
// but one partner is still accumulating at the pre-retirement rate. Honest summation,
|
||||
// and the renderer footnotes these rows (†) so a growing Total + Withdraw > 0 is explained.
|
||||
row.mixed = row.phase === 'draw' && (ra.phase === 'accum' || rb.phase === 'accum');
|
||||
row.unfunded = Math.max(0, row.need - cash);
|
||||
if (row.phase === 'draw' && row.totalBal <= 0.5 && row.unfunded > 0.5 && depletionAge === null) depletionAge = yr;
|
||||
// Depletion must not fire in a mixed window: the balance there is mostly the
|
||||
// accum partner's pre-retirement money, not the drawing partner's. Count fully-drawing
|
||||
// rows only — a missing partner row (phase 'none') counts as sole income (not accum).
|
||||
if (row.phase === 'draw' && !row.mixed && row.totalBal <= 0.5 && row.unfunded > 0.5 && depletionAge === null) depletionAge = yr;
|
||||
totalTax += taxSum; totalClawback += clawSum;
|
||||
merged.push(row);
|
||||
}
|
||||
@@ -445,6 +471,7 @@
|
||||
recommendations: recommendations,
|
||||
pensionStartAge: pensionStartAge,
|
||||
pensionBridgeAnnual: pensionBridgeAnnual,
|
||||
fillDefaults: fillDefaults,
|
||||
defaults: DEFAULTS,
|
||||
constants: C
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user