diff --git a/src/app/[lang]/(main)/tax-visualizer/page.tsx b/src/app/[lang]/(main)/tax-visualizer/page.tsx index a60d5b5a..927198dd 100644 --- a/src/app/[lang]/(main)/tax-visualizer/page.tsx +++ b/src/app/[lang]/(main)/tax-visualizer/page.tsx @@ -13,6 +13,7 @@ import { calculateCappedContribution, calculateCpp2Contribution, calculateDetailedTax, + calculateEnhancedContributionPortion, calculateHealthPremium, calculateSurtax, formatCurrency, @@ -270,31 +271,151 @@ function IncomeTaxBracketsSection({ ); } +interface DeductionItem { + label: string; + sublabel?: string; + amount: number; +} + +interface TaxableIncomeBreakdown { + grossIncome: number; + 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 enhancedPortion = calculateEnhancedContributionPortion( + pensionAmount, + pensionConfig, + ); + const deductionTotal = enhancedPortion + pensionAdditionalAmount; + const taxableIncome = Math.max(0, income - deductionTotal); + + const deductionItems: DeductionItem[] = []; + if (enhancedPortion > 0 && pensionConfig.baseRate !== undefined) { + deductionItems.push({ + label: `${pensionConfig.shortName} enhanced portion (line 22215)`, + sublabel: `${formatPercent(pensionConfig.rate - pensionConfig.baseRate)} of pensionable earnings`, + amount: enhancedPortion, + }); + } + if (pensionAdditionalAmount > 0) { + deductionItems.push({ + label: `${pensionAdditionalConfig.shortName} (line 22215, fully deductible)`, + amount: pensionAdditionalAmount, + }); + } + + return { grossIncome: income, 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({ + breakdown, +}: { + breakdown: TaxableIncomeBreakdown; +}) { + const { + grossIncome: income, + taxableIncome, + deductionItems, + deductionTotal, + } = breakdown; + 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 +427,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 +457,14 @@ 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 +573,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 +595,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 +629,14 @@ 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 +828,7 @@ function TaxDetails({ setYear, }: TaxDetailsProps) { const provinceName = PROVINCE_NAMES[config.province] || config.province; + const taxableBreakdown = computeTaxableIncomeBreakdown(income, config); return (
@@ -710,11 +857,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..5dd5f2de --- /dev/null +++ b/src/lib/tax/calculator.deduction.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { calculateDetailedTax } from "./calculator"; +import { calculateBracketTax } from "./calculators"; +import { getTaxConfig } from "./configs"; + +// 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("applies the deduction to taxable income for both federal and provincial brackets", () => { + // Ontario 2024 at $80,000 the deduction is $838 (see above), so income + // tax at both levels must equal the bracket tax on $79,162. + const config = getTaxConfig("2024", "ontario")!; + const detailed = calculateDetailedTax(80000, "ontario", "2024"); + expect(detailed.federalIncomeTax).toBeCloseTo( + calculateBracketTax(80000 - 838, config.federal.incomeTax), + 2, + ); + expect(detailed.provincialIncomeTax).toBeCloseTo( + calculateBracketTax(80000 - 838, config.provincial.incomeTax), + 2, + ); + }); +}); diff --git a/src/lib/tax/calculator.ts b/src/lib/tax/calculator.ts index b626aa73..a3cf2ec4 100644 --- a/src/lib/tax/calculator.ts +++ b/src/lib/tax/calculator.ts @@ -2,6 +2,7 @@ import { calculateBracketTax, calculateCappedContribution, calculateCpp2Contribution, + calculateEnhancedContributionPortion, calculateFederalAbatement, calculateHealthPremium, calculateSurtax, @@ -58,20 +59,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 +117,33 @@ 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 cppQppEnhancedDeduction = + calculateEnhancedContributionPortion(cppContribution, pensionConfig) + + cpp2Contribution; + const taxableIncome = Math.max(0, income - 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 +252,7 @@ function calculateWithConfig( surtax, healthPremium, federalAbatement, + cppQppEnhancedDeduction, // Metadata year: config.year, diff --git a/src/lib/tax/calculators/cappedCalculator.ts b/src/lib/tax/calculators/cappedCalculator.ts index 1d2515f6..9ca63dc8 100644 --- a/src/lib/tax/calculators/cappedCalculator.ts +++ b/src/lib/tax/calculators/cappedCalculator.ts @@ -23,3 +23,20 @@ export function calculateCappedContribution( return Math.min(contribution, config.maxContribution); } + +/** + * Portion of a CPP/QPP contribution attributable to the post-2019 "enhanced" + * rate (rate - baseRate). This portion is deductible from taxable income on + * CRA line 22215. Returns 0 when the config has no baseRate. + */ +export function calculateEnhancedContributionPortion( + contribution: number, + config: CappedContributionConfig, +): number { + if (config.baseRate === undefined || config.rate <= 0) { + return 0; + } + + const enhancedRate = Math.max(0, config.rate - config.baseRate); + return contribution * (enhancedRate / config.rate); +} diff --git a/src/lib/tax/calculators/index.ts b/src/lib/tax/calculators/index.ts index 34f9a039..b6ed8606 100644 --- a/src/lib/tax/calculators/index.ts +++ b/src/lib/tax/calculators/index.ts @@ -4,7 +4,10 @@ export { getBracketTaxBreakdown, } from "./bracketCalculator"; export type { BracketTaxBreakdown } from "./bracketCalculator"; -export { calculateCappedContribution } from "./cappedCalculator"; +export { + calculateCappedContribution, + calculateEnhancedContributionPortion, +} from "./cappedCalculator"; export { calculateCpp2Contribution } from "./cpp2Calculator"; export { calculateFederalAbatement } from "./federalAbatementCalculator"; export { calculateHealthPremium } from "./healthPremiumCalculator"; 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/index.ts b/src/lib/tax/index.ts index 960bc6c6..46e0faae 100644 --- a/src/lib/tax/index.ts +++ b/src/lib/tax/index.ts @@ -45,6 +45,7 @@ export { calculateBracketTax, calculateCappedContribution, calculateCpp2Contribution, + calculateEnhancedContributionPortion, calculateHealthPremium, calculateSurtax, calculateTaxFromBrackets, diff --git a/src/lib/tax/types.ts b/src/lib/tax/types.ts index 2f81684d..582c0092 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) @@ -161,6 +166,7 @@ export interface DetailedTaxCalculation { surtax: number; healthPremium: number; federalAbatement: number; + cppQppEnhancedDeduction: number; // Metadata year: string;