124 lines
7.3 KiB
JavaScript
124 lines
7.3 KiB
JavaScript
// 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(/ /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(/ /g, " ").replace(/&#\d+;/g, " ")
|
||
.replace(/&/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]));
|