Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 183 additions & 34 deletions src/app/[lang]/(main)/tax-visualizer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
calculateCappedContribution,
calculateCpp2Contribution,
calculateDetailedTax,
calculateEnhancedContributionPortion,
calculateHealthPremium,
calculateSurtax,
formatCurrency,
Expand Down Expand Up @@ -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 `<details>` 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 (
<div className="mb-6">
<div className="flex justify-between items-baseline">
<h4 className="font-semibold text-base">
<Trans>Total Taxable Income</Trans>
</h4>
<span className="font-semibold text-base">
{formatAmount(taxableIncome)}
</span>
</div>
{hasDeduction && (
<details className="mt-2 group">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground select-none">
<Trans>
{formatAmount(income)} gross income less{" "}
{formatAmount(deductionTotal)} CPP/QPP enhanced deduction (line
22215)
</Trans>
</summary>
<table className="w-full text-left text-sm mt-2">
<tbody>
<tr>
<td className="py-1 text-muted-foreground">
<Trans>Gross employment income</Trans>
</td>
<td className="py-1 text-right font-medium w-24">
{formatAmount(income)}
</td>
</tr>
{deductionItems.map((item, index) => (
<tr key={index}>
<td className="py-1 text-muted-foreground">
<div>{item.label}</div>
{item.sublabel && (
<div className="text-xs text-muted-foreground/70">
{item.sublabel}
</div>
)}
</td>
<td className="py-1 text-right font-medium align-top w-24 text-red-600">
-{formatAmount(item.amount)}
</td>
</tr>
))}
<tr className="font-semibold border-t border-border">
<td className="py-1">
<Trans>Taxable income</Trans>
</td>
<td className="py-1 text-right w-24">
{formatAmount(taxableIncome)}
</td>
</tr>
</tbody>
</table>
</details>
)}
</div>
);
}

// 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
Expand All @@ -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;

Expand All @@ -323,11 +457,14 @@ function FederalTaxCard({
<Trans>Federal Taxes</Trans>
</h3>

{/* Income Tax Brackets */}
{/* Taxable Income (with line 22215 deduction details collapsed) */}
<TaxableIncomeSection breakdown={taxableBreakdown} />

{/* Income Tax Brackets (applied to taxable income) */}
<IncomeTaxBracketsSection
title={config.incomeTax.name}
config={config.incomeTax}
income={income}
income={taxableIncome}
/>

{/* Federal Abatement (if applicable) */}
Expand Down Expand Up @@ -436,41 +573,47 @@ 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;
const provincialPensionAdditionalAmount = config.pensionPlanAdditionalOverride
? 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 +
Expand All @@ -486,11 +629,14 @@ function ProvincialTaxCard({
<Trans>{provinceName} Taxes</Trans>
</h3>

{/* Income Tax Brackets */}
{/* Taxable Income (with line 22215 deduction details collapsed) */}
<TaxableIncomeSection breakdown={taxableBreakdown} />

{/* Income Tax Brackets (applied to taxable income) */}
<IncomeTaxBracketsSection
title={config.incomeTax.name}
config={config.incomeTax}
income={income}
income={taxableIncome}
/>

{/* Surtax (if applicable) */}
Expand Down Expand Up @@ -682,6 +828,7 @@ function TaxDetails({
setYear,
}: TaxDetailsProps) {
const provinceName = PROVINCE_NAMES[config.province] || config.province;
const taxableBreakdown = computeTaxableIncomeBreakdown(income, config);

return (
<div id="tax-details" className="mt-16 scroll-mt-8">
Expand Down Expand Up @@ -710,11 +857,13 @@ function TaxDetails({
config={config.federal}
provincialConfig={config.provincial}
income={income}
taxableBreakdown={taxableBreakdown}
/>
<ProvincialTaxCard
provinceName={provinceName}
config={config.provincial}
income={income}
taxableBreakdown={taxableBreakdown}
/>
</div>
</div>
Expand Down
58 changes: 58 additions & 0 deletions src/lib/tax/calculator.deduction.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
Loading
Loading