diff --git a/src/app/[lang]/(main)/tax-visualizer/page.tsx b/src/app/[lang]/(main)/tax-visualizer/page.tsx index a60d5b5a..e2c092ae 100644 --- a/src/app/[lang]/(main)/tax-visualizer/page.tsx +++ b/src/app/[lang]/(main)/tax-visualizer/page.tsx @@ -270,31 +270,154 @@ function IncomeTaxBracketsSection({ ); } +interface DeductionItem { + label: string; + sublabel?: string; + amount: number; +} + +interface TaxableIncomeBreakdown { + taxableIncome: number; + deductionTotal: number; + deductionItems: DeductionItem[]; +} + +// Compute the line 22215 CPP/QPP enhanced-contribution deduction for the +// active province. The deduction reduces taxable income for both federal +// and provincial brackets, so it's computed once and shared by both cards. +function computeTaxableIncomeBreakdown( + income: number, + config: TaxYearProvinceConfig, +): TaxableIncomeBreakdown { + const pensionConfig = + config.provincial.pensionPlanOverride ?? config.federal.cpp; + const pensionAdditionalConfig = + config.provincial.pensionPlanAdditionalOverride ?? config.federal.cpp2; + const pensionAmount = calculateCappedContribution(income, pensionConfig); + const pensionAdditionalAmount = calculateCpp2Contribution( + income, + pensionAdditionalConfig, + ); + const enhancedRate = + pensionConfig.baseRate !== undefined + ? Math.max(0, pensionConfig.rate - pensionConfig.baseRate) + : 0; + const enhancedPortion = + pensionConfig.rate > 0 + ? pensionAmount * (enhancedRate / pensionConfig.rate) + : 0; + const deductionTotal = enhancedPortion + pensionAdditionalAmount; + const taxableIncome = Math.max(0, income - deductionTotal); + + const deductionItems: DeductionItem[] = []; + if (enhancedPortion > 0) { + deductionItems.push({ + label: `${pensionConfig.shortName} enhanced portion (line 22215)`, + sublabel: `${formatPercent(enhancedRate)} of pensionable earnings`, + amount: enhancedPortion, + }); + } + if (pensionAdditionalAmount > 0) { + deductionItems.push({ + label: `${pensionAdditionalConfig.shortName} (line 22215, fully deductible)`, + amount: pensionAdditionalAmount, + }); + } + + return { taxableIncome, deductionTotal, deductionItems }; +} + +// Taxable income section: shows gross income reduced by the line 22215 +// CPP/QPP enhanced contribution deduction. The deduction detail is hidden +// behind an expandable `
` so it doesn't read as a reduction in +// total tax owing. +function TaxableIncomeSection({ + income, + taxableIncome, + deductionItems, + deductionTotal, +}: { + income: number; + taxableIncome: number; + deductionItems: DeductionItem[]; + deductionTotal: number; +}) { + const hasDeduction = deductionTotal > 0; + + return ( +
+
+

+ Total Taxable Income +

+ + {formatAmount(taxableIncome)} + +
+ {hasDeduction && ( +
+ + + {formatAmount(income)} gross income less{" "} + {formatAmount(deductionTotal)} CPP/QPP enhanced deduction (line + 22215) + + + + + + + + + {deductionItems.map((item, index) => ( + + + + + ))} + + + + + +
+ Gross employment income + + {formatAmount(income)} +
+
{item.label}
+ {item.sublabel && ( +
+ {item.sublabel} +
+ )} +
+ -{formatAmount(item.amount)} +
+ Taxable income + + {formatAmount(taxableIncome)} +
+
+ )} +
+ ); +} + // Federal Tax Card - all federal taxes in one card interface FederalTaxCardProps { config: TaxYearProvinceConfig["federal"]; provincialConfig: TaxYearProvinceConfig["provincial"]; income: number; + taxableBreakdown: TaxableIncomeBreakdown; } function FederalTaxCard({ config, provincialConfig, income, + taxableBreakdown, }: FederalTaxCardProps) { - // Calculate income tax - const breakdown = getBracketTaxBreakdown(income, config.incomeTax.brackets); - const lowestRate = config.incomeTax.brackets[0]?.rate ?? 0; - const bpaCredit = config.incomeTax.basicPersonalAmount * lowestRate; - const incomeTaxBeforeCredit = breakdown.reduce( - (sum, b) => sum + b.taxAmount, - 0, - ); - const incomeTaxAmount = Math.max(0, incomeTaxBeforeCredit - bpaCredit); - - // Resolve EI override (Quebec residents pay a reduced rate). The pension - // plan, when overridden, is administered provincially (Retraite Québec) - // and is rendered in the Provincial card instead. const eiConfig = provincialConfig.eiOverride ?? config.ei; const hasProvincialPension = !!provincialConfig.pensionPlanOverride; const cppAmount = hasProvincialPension @@ -306,13 +429,26 @@ function FederalTaxCard({ const eiAmount = calculateCappedContribution(income, eiConfig); const payrollTotal = cppAmount + cpp2Amount + eiAmount; - // Calculate federal abatement (Quebec Abatement) + // Federal income tax is computed on taxable income (after the line 22215 + // CPP/QPP enhanced deduction). + const { taxableIncome } = taxableBreakdown; + const breakdown = getBracketTaxBreakdown( + taxableIncome, + config.incomeTax.brackets, + ); + const lowestRate = config.incomeTax.brackets[0]?.rate ?? 0; + const bpaCredit = config.incomeTax.basicPersonalAmount * lowestRate; + const incomeTaxBeforeCredit = breakdown.reduce( + (sum, b) => sum + b.taxAmount, + 0, + ); + const incomeTaxAmount = Math.max(0, incomeTaxBeforeCredit - bpaCredit); + const federalAbatementConfig = provincialConfig.federalAbatement; const federalAbatementAmount = federalAbatementConfig ? incomeTaxAmount * federalAbatementConfig.rate : 0; - // Overall total (minus abatement) const totalFederalTax = incomeTaxAmount + payrollTotal - federalAbatementAmount; @@ -323,11 +459,19 @@ function FederalTaxCard({ Federal Taxes - {/* Income Tax Brackets */} + {/* Taxable Income (with line 22215 deduction details collapsed) */} + + + {/* Income Tax Brackets (applied to taxable income) */} {/* Federal Abatement (if applicable) */} @@ -436,33 +580,21 @@ interface ProvincialTaxCardProps { provinceName: string; config: TaxYearProvinceConfig["provincial"]; income: number; + taxableBreakdown: TaxableIncomeBreakdown; } function ProvincialTaxCard({ provinceName, config, income, + taxableBreakdown, }: ProvincialTaxCardProps) { - // Calculate provincial income tax for surtax calculation - const breakdown = getBracketTaxBreakdown(income, config.incomeTax.brackets); - const lowestRate = config.incomeTax.brackets[0]?.rate ?? 0; - const bpaCredit = config.incomeTax.basicPersonalAmount * lowestRate; - const provincialTaxBeforeCredit = breakdown.reduce( - (sum, b) => sum + b.taxAmount, - 0, - ); - const provincialTax = Math.max(0, provincialTaxBeforeCredit - bpaCredit); - - const surtaxAmount = config.surtax - ? calculateSurtax(provincialTax, config.surtax) - : 0; const healthPremiumAmount = config.healthPremium ? calculateHealthPremium(income, config.healthPremium) : 0; const parentalInsuranceAmount = config.parentalInsurance ? calculateCappedContribution(income, config.parentalInsurance) : 0; - // Province-administered pension plan (e.g., Quebec QPP / QPP2) const provincialPensionAmount = config.pensionPlanOverride ? calculateCappedContribution(income, config.pensionPlanOverride) : 0; @@ -470,7 +602,25 @@ function ProvincialTaxCard({ ? calculateCpp2Contribution(income, config.pensionPlanAdditionalOverride) : 0; - // Overall total + // Provincial income tax is computed on taxable income (after the line + // 22215 CPP/QPP enhanced deduction). + const { taxableIncome } = taxableBreakdown; + const breakdown = getBracketTaxBreakdown( + taxableIncome, + config.incomeTax.brackets, + ); + const lowestRate = config.incomeTax.brackets[0]?.rate ?? 0; + const bpaCredit = config.incomeTax.basicPersonalAmount * lowestRate; + const provincialTaxBeforeCredit = breakdown.reduce( + (sum, b) => sum + b.taxAmount, + 0, + ); + const provincialTax = Math.max(0, provincialTaxBeforeCredit - bpaCredit); + + const surtaxAmount = config.surtax + ? calculateSurtax(provincialTax, config.surtax) + : 0; + const totalProvincialTax = provincialTax + surtaxAmount + @@ -486,11 +636,19 @@ function ProvincialTaxCard({ {provinceName} Taxes - {/* Income Tax Brackets */} + {/* Taxable Income (with line 22215 deduction details collapsed) */} + + + {/* Income Tax Brackets (applied to taxable income) */} {/* Surtax (if applicable) */} @@ -682,6 +840,7 @@ function TaxDetails({ setYear, }: TaxDetailsProps) { const provinceName = PROVINCE_NAMES[config.province] || config.province; + const taxableBreakdown = computeTaxableIncomeBreakdown(income, config); return (
@@ -710,11 +869,13 @@ function TaxDetails({ config={config.federal} provincialConfig={config.provincial} income={income} + taxableBreakdown={taxableBreakdown} />
diff --git a/src/lib/tax/calculator.deduction.test.ts b/src/lib/tax/calculator.deduction.test.ts new file mode 100644 index 00000000..e907e07c --- /dev/null +++ b/src/lib/tax/calculator.deduction.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { calculateDetailedTax } from "./calculator"; + +// CRA line 22215: deduction for the "enhanced" portion of CPP/QPP +// contributions on employment income. +// - For CPP: the rate above the pre-2019 base of 4.95% (currently 1.0%) +// on first-tier pensionable earnings is deductible, plus 100% of CPP2. +// - For QPP: the rate above the pre-2019 base of 5.40% on first-tier +// pensionable earnings is deductible, plus 100% of QPP2. + +describe("CPP/QPP enhanced contribution deduction (line 22215)", () => { + it("computes CPP enhanced deduction for Ontario 2024 above YMPE", () => { + // 2024 CPP: rate 5.95%, base 4.95%, exemption $3,500, YMPE $68,500. + // CPP1 = ($68,500 − $3,500) × 5.95% = $3,867.50 + // Enhanced portion = $3,867.50 × (1.0% / 5.95%) = $650.00 + // CPP2 = ($73,200 − $68,500) × 4% = $188.00 (fully deductible) + // Total deduction = $650.00 + $188.00 = $838.00 + const detailed = calculateDetailedTax(80000, "ontario", "2024"); + expect(detailed.cppQppEnhancedDeduction).toBeCloseTo(838, 2); + }); + + it("computes QPP enhanced deduction for Quebec 2024 above YMPE", () => { + // 2024 QPP: rate 6.40%, base 5.40%, exemption $3,500, YMPE $68,500. + // QPP1 = ($68,500 − $3,500) × 6.40% = $4,160.00 + // Enhanced portion = $4,160.00 × (1.0% / 6.40%) = $650.00 + // QPP2 = ($73,200 − $68,500) × 4% = $188.00 (fully deductible) + // Total deduction = $650.00 + $188.00 = $838.00 + const detailed = calculateDetailedTax(80000, "quebec", "2024"); + expect(detailed.cppQppEnhancedDeduction).toBeCloseTo(838, 2); + }); + + it("scales the deduction proportionally for low income", () => { + // 2024 Ontario at $30,000: + // CPP1 = ($30,000 − $3,500) × 5.95% = $1,576.75 + // Enhanced portion = $1,576.75 × (1.0% / 5.95%) ≈ $264.99 + // CPP2 = 0 (income below YMPE) + const detailed = calculateDetailedTax(30000, "ontario", "2024"); + expect(detailed.cppQppEnhancedDeduction).toBeCloseTo(264.99, 1); + }); + + it("reduces federal income tax by deducting enhanced contributions", () => { + // Without the deduction, federal income tax at $80k Ontario 2024 would be + // higher. With the $838 deduction, tax is lowered by ~ $838 × 20.5% + // (the marginal bracket the deduction comes off of). + const detailedWithLine22215 = calculateDetailedTax( + 80000, + "ontario", + "2024", + ); + + // Verify the deduction at least reduces taxable income (federal tax is + // computed on income − deduction). The exact federal tax is checked via + // the bracket calculator in other tests; here we just confirm the + // deduction takes effect. + expect(detailedWithLine22215.cppQppEnhancedDeduction).toBeGreaterThan(0); + expect(detailedWithLine22215.federalIncomeTax).toBeGreaterThan(0); + }); + + it("reports the deduction as a negative line item at the federal level for non-Quebec", () => { + const detailed = calculateDetailedTax(80000, "ontario", "2024"); + const line = detailed.lineItems.find( + (l) => l.id === "cpp-qpp-enhanced-deduction", + ); + expect(line).toBeDefined(); + expect(line!.amount).toBeLessThan(0); + expect(line!.level).toBe("federal"); + }); + + it("reports the deduction at the provincial level for Quebec", () => { + const detailed = calculateDetailedTax(80000, "quebec", "2024"); + const line = detailed.lineItems.find( + (l) => l.id === "cpp-qpp-enhanced-deduction", + ); + expect(line).toBeDefined(); + expect(line!.level).toBe("provincial"); + }); +}); diff --git a/src/lib/tax/calculator.ts b/src/lib/tax/calculator.ts index b626aa73..edbd4dd4 100644 --- a/src/lib/tax/calculator.ts +++ b/src/lib/tax/calculator.ts @@ -58,20 +58,6 @@ function calculateWithConfig( ? "provincial" : "federal"; - // Federal income tax - const federalIncomeTax = calculateBracketTax( - income, - config.federal.incomeTax, - ); - lineItems.push({ - id: "federal-income-tax", - name: "Federal Income Tax", - level: "federal", - amount: federalIncomeTax, - effectiveRate: income > 0 ? (federalIncomeTax / income) * 100 : 0, - category: "incomeTax", - }); - // EI contribution (Quebec residents pay a reduced rate; see eiConfig) const eiContribution = calculateCappedContribution(income, eiConfig); lineItems.push({ @@ -130,9 +116,49 @@ function calculateWithConfig( } } - // Provincial income tax + // CRA line 22215: deduction for the "enhanced" portion of CPP/QPP + // contributions on employment income. The first-additional enhancement + // (rate above pre-2019 baseRate) and the entire second-additional + // contribution (CPP2/QPP2) are deductible from taxable income. + const cppEnhancedRate = + pensionConfig.baseRate !== undefined + ? Math.max(0, pensionConfig.rate - pensionConfig.baseRate) + : 0; + const cppEnhancedPortion = + pensionConfig.rate > 0 + ? cppContribution * (cppEnhancedRate / pensionConfig.rate) + : 0; + const cppQppEnhancedDeduction = cppEnhancedPortion + cpp2Contribution; + const taxableIncome = Math.max(0, income - cppQppEnhancedDeduction); + if (cppQppEnhancedDeduction > 0) { + lineItems.push({ + id: "cpp-qpp-enhanced-deduction", + name: "Deduction for CPP/QPP Enhanced Contributions", + level: pensionLevel, + amount: -cppQppEnhancedDeduction, + effectiveRate: income > 0 ? (-cppQppEnhancedDeduction / income) * 100 : 0, + category: "cppQppEnhancedDeduction", + }); + } + + // Federal income tax (computed on taxable income after the line 22215 + // deduction). + const federalIncomeTax = calculateBracketTax( + taxableIncome, + config.federal.incomeTax, + ); + lineItems.push({ + id: "federal-income-tax", + name: "Federal Income Tax", + level: "federal", + amount: federalIncomeTax, + effectiveRate: income > 0 ? (federalIncomeTax / income) * 100 : 0, + category: "incomeTax", + }); + + // Provincial income tax (also on taxable income after the deduction). const provincialIncomeTax = calculateBracketTax( - income, + taxableIncome, config.provincial.incomeTax, ); const provinceName = @@ -241,6 +267,7 @@ function calculateWithConfig( surtax, healthPremium, federalAbatement, + cppQppEnhancedDeduction, // Metadata year: config.year, diff --git a/src/lib/tax/configs/2023/federal.ts b/src/lib/tax/configs/2023/federal.ts index 5e2354c1..0d70ebbd 100644 --- a/src/lib/tax/configs/2023/federal.ts +++ b/src/lib/tax/configs/2023/federal.ts @@ -27,6 +27,7 @@ export const FEDERAL_TAX_CONFIG: FederalTaxConfig = { name: "Canada Pension Plan", shortName: "CPP", rate: 0.0595, + baseRate: 0.0495, exemption: 3500, maxEarnings: 66600, maxContribution: 3754.45, diff --git a/src/lib/tax/configs/2023/quebec.ts b/src/lib/tax/configs/2023/quebec.ts index 6ad1f1aa..c5c9e52c 100644 --- a/src/lib/tax/configs/2023/quebec.ts +++ b/src/lib/tax/configs/2023/quebec.ts @@ -37,6 +37,7 @@ export const QUEBEC_TAX_CONFIG: ProvincialTaxConfig = { name: "Québec Pension Plan", shortName: "QPP", rate: 0.064, + baseRate: 0.054, exemption: 3500, maxEarnings: 66600, maxContribution: 4038.4, diff --git a/src/lib/tax/configs/2024/federal.ts b/src/lib/tax/configs/2024/federal.ts index 8a449043..6c934808 100644 --- a/src/lib/tax/configs/2024/federal.ts +++ b/src/lib/tax/configs/2024/federal.ts @@ -27,6 +27,7 @@ export const FEDERAL_TAX_CONFIG: FederalTaxConfig = { name: "Canada Pension Plan", shortName: "CPP", rate: 0.0595, + baseRate: 0.0495, exemption: 3500, maxEarnings: 68500, maxContribution: 3867.5, diff --git a/src/lib/tax/configs/2024/quebec.ts b/src/lib/tax/configs/2024/quebec.ts index 1d82e783..07066aec 100644 --- a/src/lib/tax/configs/2024/quebec.ts +++ b/src/lib/tax/configs/2024/quebec.ts @@ -39,6 +39,7 @@ export const QUEBEC_TAX_CONFIG: ProvincialTaxConfig = { name: "Québec Pension Plan", shortName: "QPP", rate: 0.064, + baseRate: 0.054, exemption: 3500, maxEarnings: 68500, maxContribution: 4160, diff --git a/src/lib/tax/configs/2025/federal.ts b/src/lib/tax/configs/2025/federal.ts index f7832d88..fc861737 100644 --- a/src/lib/tax/configs/2025/federal.ts +++ b/src/lib/tax/configs/2025/federal.ts @@ -27,6 +27,7 @@ export const FEDERAL_TAX_CONFIG: FederalTaxConfig = { name: "Canada Pension Plan", shortName: "CPP", rate: 0.0595, + baseRate: 0.0495, exemption: 3500, maxEarnings: 71300, maxContribution: 4034.1, diff --git a/src/lib/tax/configs/2025/quebec.ts b/src/lib/tax/configs/2025/quebec.ts index 21e79101..2a8ee09f 100644 --- a/src/lib/tax/configs/2025/quebec.ts +++ b/src/lib/tax/configs/2025/quebec.ts @@ -38,6 +38,7 @@ export const QUEBEC_TAX_CONFIG: ProvincialTaxConfig = { name: "Québec Pension Plan", shortName: "QPP", rate: 0.064, + baseRate: 0.054, exemption: 3500, maxEarnings: 71300, maxContribution: 4339.2, diff --git a/src/lib/tax/configs/2026/federal.ts b/src/lib/tax/configs/2026/federal.ts index a2b33568..44b636f2 100644 --- a/src/lib/tax/configs/2026/federal.ts +++ b/src/lib/tax/configs/2026/federal.ts @@ -27,6 +27,7 @@ export const FEDERAL_TAX_CONFIG: FederalTaxConfig = { name: "Canada Pension Plan", shortName: "CPP", rate: 0.0595, + baseRate: 0.0495, exemption: 3500, maxEarnings: 74600, maxContribution: 4230.45, diff --git a/src/lib/tax/configs/2026/quebec.ts b/src/lib/tax/configs/2026/quebec.ts index 97348d75..dfe32b79 100644 --- a/src/lib/tax/configs/2026/quebec.ts +++ b/src/lib/tax/configs/2026/quebec.ts @@ -45,6 +45,7 @@ export const QUEBEC_TAX_CONFIG: ProvincialTaxConfig = { name: "Québec Pension Plan", shortName: "QPP", rate: 0.063, + baseRate: 0.054, exemption: 3500, maxEarnings: 74600, maxContribution: 4479.3, diff --git a/src/lib/tax/types.ts b/src/lib/tax/types.ts index 2f81684d..c281ee23 100644 --- a/src/lib/tax/types.ts +++ b/src/lib/tax/types.ts @@ -22,6 +22,11 @@ export interface CappedContributionConfig { exemption: number; maxEarnings: number; maxContribution: number; + // For CPP/QPP: the historical "base" rate (pre-2019 enhancement). The + // portion of the contribution attributable to (rate - baseRate) is the + // "enhanced" CPP/QPP contribution and is deductible from taxable income + // on CRA line 22215. If unset, no enhanced deduction is computed. + baseRate?: number; } // Surtax config (Ontario surtax - tiers applied to base tax) @@ -135,7 +140,8 @@ export interface TaxLineItem { | "healthPremium" | "incomeTaxProvincial" | "federalAbatement" - | "parentalInsurance"; + | "parentalInsurance" + | "cppQppEnhancedDeduction"; } // Detailed calculation result @@ -161,6 +167,7 @@ export interface DetailedTaxCalculation { surtax: number; healthPremium: number; federalAbatement: number; + cppQppEnhancedDeduction: number; // Metadata year: string;