Files
retire-planner/scripts/build_canada.js
T

124 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Build site/engine/canada.js from fetched 2025/2026 sources (PLAN.md Stage 1).
// Sources: CRA current-year (2026) tax-rates page (fetched 2026 via harness), ESDC quarterly
// report Apr-Jun 2026, OAS payments page (Oct-Dec 2026 block), OAS recovery-tax page,
// taxtips.ca province pages (brackets 2026+2025, BPA, surtax FAQ, QC brackets).
const fs = require("fs");
const path = require("path");
const FETCH = path.join(__dirname, "..", ".fetch");
const OUT = path.join(__dirname, "..", "site", "engine", "canada.js");
const ASOF_CRA = "2026-06-25"; // CRA current-year (2026) page publish
const ASOF_ESDC = "2026-04-01"; // quarterly report Apr-Jun 2026 (CPP 2026 figures)
const ASOF_OAS = "2026-09-29"; // payments page Oct-Dec 2026 block
const ASOF_TT = "2026-02-22"; // taxtips dateModified (fetched today)
function num(s) { return Number(String(s).replace(/[$,\s]/g, "")); }
// ---------- province tables (taxtips) ----------
function provinceTable(p) {
const html = fs.readFileSync(path.join(FETCH, "tt_" + p + ".htm"), "utf8");
const first = html.match(/<table[\s\S]*?<\/table>/i); // province table is first on page
if (!first) return [];
const rows = [...first[0].matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)];
const out = [];
for (const tr of rows) {
const cells = [...tr[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)]
.map(c => c[1].replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim());
if (cells.length < 2) continue;
// data row: [income26, rate26, income25, rate25, ...]
const income26 = cells[0], rate26 = cells[1];
let m;
if ((m = income26.match(/^first \$([\d,]+)$/i))) out.push({ up: num(m[1]), rate: rate26 });
else if ((m = income26.match(/^over \$[\d,]+ up to \$([\d,]+)$/i))) out.push({ up: num(m[1]), rate: rate26 });
else if ((m = income26.match(/^over \$([\d,]+)$/i))) out.push({ up: null, rate: rate26 });
}
return out.map(r => ({ up: r.up, rate: parseFloat(r.rate) / 100 }));
}
function faqBPA(p) {
const t = fs.readFileSync(path.join(FETCH, "tt_" + p + ".htm"), "utf8")
.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&#\d+;/g, " ")
.replace(/&amp;/g, "&").replace(/\s+/g, " ");
const m = t.match(/basic personal amount for 2026 is \$([\d,]+)/i) ||
t.match(/Basic Personal Amount 2026[^$]{0,200}\$([\d,]+)/i);
return m ? num(m[1]) : null;
}
const fed2026 = [
{ up: 58523, rate: 0.14 }, { up: 117045, rate: 0.205 }, { up: 181440, rate: 0.26 },
{ up: 258482, rate: 0.29 }, { up: null, rate: 0.33 }
];
const provIds = {
on: "Ontario", bc: "British Columbia", ab: "Alberta", mb: "Manitoba", sk: "Saskatchewan",
ns: "Nova Scotia", nb: "New Brunswick", nl: "Newfoundland & Labrador", pe: "Prince Edward Island",
yt: "Yukon", nt: "Northwest Territories", nu: "Nunavut", qc: "Quebec"
};
const prov = {};
for (const [id, name] of Object.entries(provIds)) {
const brackets = provinceTable(id);
if (!brackets.length) throw new Error("no brackets parsed for " + id);
const e = {
name,
brackets,
bpa: faqBPA(id),
source: "taxtips.ca/taxrates/" + id + ".htm (2026 table)",
asOf: ASOF_TT
};
if (id === "on") e.surtax = [{ from: 6474, rate: 0.20 }, { from: 8102, rate: 0.36 }];
if (id === "yt") e.bpa = 16452; // YT BPA tracks federal 2026 max (min/max table on page)
prov[id.toUpperCase()] = e;
}
// ---------- canada.ca fetched values (harness web_fetch, Oct 2026) ----------
const E = "https://www.canada.ca/en/employment-social-development/programs/pensions/pension/statistics/2026-quarterly-april-june.html";
const O = "https://www.canada.ca/en/services/benefits/publicpensions/old-age-security/payments.html";
const R = "https://www.canada.ca/en/services/benefits/publicpensions/old-age-security/recovery-tax.html";
const CRAU = "https://www.canada.ca/en/revenue-agency/services/tax/individuals/tax-rates-brackets/current-year.html";
// CRA statutory RRIF factor table (Reg. 8301(1), unchanged 2016–2026): 71→5.28 … 94→12.18, 95+→20.
const rrifMin = {};
{ const f = {71:5.28,72:5.40,73:5.53,74:5.67,75:5.82,76:5.98,77:6.15,78:6.33,79:6.53,80:6.74,
81:6.97,82:7.22,83:7.48,84:7.77,85:8.08,86:8.41,87:8.77,88:9.15,89:9.56,90:10.00,
91:10.48,92:11.00,93:11.56,94:12.18};
for (const [a,v] of Object.entries(f)) rrifMin[a] = v;
rrifMin[95] = 20.0; }
const doc = {
generated: "2026-10-08",
year: "2026 (live-fetched; plan fallbacks were 2025)",
asOf: "October 2026 (CPP 2026, OAS/GIS Oct-Dec 2026, tax 2026)",
asOfFr: "octobre 2026 (RPC 2026, SV/SRG oct.-déc. 2026, impôts 2026)",
cppMax65Monthly: { value: 1507.65, source: E + " Table 1, Jan 2026 (incl. CPP enhancement)", asOf: ASOF_ESDC },
ympe: { value: 74600, source: E + " Table 2 YMPE 2026 (YBE $3,500)", asOf: ASOF_ESDC },
oasMax6574Monthly: { value: 762.50, source: O + " Oct-Dec 2026", asOf: ASOF_OAS },
oas75Boost: { value: 1.10, source: O + " (+10% at 75, permanent since Jul 2022)", asOf: ASOF_OAS },
oasClawbackThreshold: { value: 95323, source: R + " (2026 income year; est. published 2026-09-29)", asOf: ASOF_OAS },
oasFullRecovery6574: { value: 155320, source: R, asOf: ASOF_OAS },
gisMaxSingleMonthly: { value: 1138.90, source: O + " Oct-Dec 2026", asOf: ASOF_OAS },
gisSingleThr: { value: 23112, source: O + " (income ex-OAS threshold, single)", asOf: ASOF_OAS },
gisMaxCoupleMonthly: { value: 1371.12, source: O + " (685.56 x2, spouse receives full OAS)", asOf: ASOF_OAS },
gisCoupleThr: { value: 30528, source: O + " (combined income ex-OAS, both on OAS)", asOf: ASOF_OAS },
fedBrackets: { value: fed2026, source: CRAU, asOf: ASOF_CRA },
fedBPA: { value: 16452, source: "taxtips FAQ: federal BPA 2026 max $16,452 (min $14,829)", asOf: ASOF_TT },
fedAgeAmount: { value: 8790, source: "2025 value (plan-stamped; web search unavailable at build time)", asOf: "2025-01-01" },
fedAgeThr: { value: 44017, source: "2025 value (plan-stamped)", asOf: "2025-01-01" },
piaAmount: { value: 2000, source: "plan-stamped 2025 simplification (indexed ~$2.2k in 2026)", asOf: "2025-01-01" },
cppEarlyAdjMonthly: { value: 0.006, source: "plan-stamped / canada.ca CPP (0.6% per month before 65)", asOf: "2025-01-01" },
cppLateAdjMonthly: { value: 0.007, source: "plan-stamped (0.7% per month after 65)", asOf: "2025-01-01" },
oasDeferralMonthly: { value: 0.006, source: E + " Table 5 footnote 8 (+0.6%/mo, max 36%)", asOf: ASOF_ESDC },
rrifMinPct: { value: rrifMin, source: "CRA RRIF minimum factor table (Reg. 8301(1)) — CRA RRIF page 404 at fetch time, table stamped (plan fallback: 71→5.28 ✓)", asOf: "2026" },
provBrackets: prov
};
const body = "/* AUTO-GENERATED by scripts/build_canada.js from live sources — see PLAN.md Stage 1.\n" +
" * Evidence JSON kept at scripts/constants-evidence.json */\n" +
"(function (root) { root.RetireCanada = " + JSON.stringify(doc, null, 2) + ";\n" +
" if (typeof module !== 'undefined' && module.exports) module.exports = root.RetireCanada;\n" +
"})(typeof self !== 'undefined' ? self : this);\n";
fs.writeFileSync(OUT, body);
fs.writeFileSync(path.join(__dirname, "constants-evidence.json"), JSON.stringify(doc, null, 2));
// sanity
console.log("fed brackets:", JSON.stringify(fed2026));
for (const [k, v] of Object.entries(prov)) console.log(k, "bpa", v.bpa, "n", v.brackets.length, "first", JSON.stringify(v.brackets[0]), "top", JSON.stringify(v.brackets[v.brackets.length - 1]));