fire-planner/tests/test_tax_uk.py
2026-05-07 17:06:19 +00:00

147 lines
5.3 KiB
Python

"""UK tax regime — bands, allowances, tapers."""
from decimal import Decimal
from hypothesis import given
from hypothesis import strategies as st
from fire_planner.tax.base import TaxInputs
from fire_planner.tax.uk import (
PA_TAPER_CEILING,
PERSONAL_ALLOWANCE,
UkTaxRegime,
taper_personal_allowance,
)
def test_pa_no_taper_below_100k() -> None:
assert taper_personal_allowance(Decimal("80000")) == PERSONAL_ALLOWANCE
def test_pa_full_taper_at_ceiling() -> None:
assert taper_personal_allowance(PA_TAPER_CEILING) == Decimal("0")
def test_pa_partial_taper_at_110k() -> None:
# £10k above floor → £5k reduction off PA
assert taper_personal_allowance(Decimal("110000")) == PERSONAL_ALLOWANCE - Decimal("5000")
def test_zero_income_zero_tax() -> None:
b = UkTaxRegime().compute_tax(TaxInputs())
assert b.total == Decimal("0")
def test_isa_only_zero_tax() -> None:
b = UkTaxRegime().compute_tax(TaxInputs(isa_withdrawals=Decimal("100000")))
assert b.total == Decimal("0")
def test_below_pa_zero_tax() -> None:
b = UkTaxRegime().compute_tax(TaxInputs(earned_income=Decimal("12000")))
# NI primary threshold matches PA so NI is zero too.
assert b.total == Decimal("0")
def test_basic_rate_paye_smoke() -> None:
# £30k earned: £17,430 taxable @ 20% = £3,486 income tax
# NI: £17,430 @ 8% = £1,394.40
b = UkTaxRegime().compute_tax(TaxInputs(earned_income=Decimal("30000")))
assert b.income_tax == Decimal("3486.00")
assert b.national_insurance == Decimal("1394.40")
def test_higher_rate_paye_100k() -> None:
# £100k earned, PA still full (taper starts strictly above £100k):
# taxable = £87,430
# £37,700 @ 20% = £7,540
# £49,730 @ 40% = £19,892
# total income tax = £27,432
b = UkTaxRegime().compute_tax(TaxInputs(earned_income=Decimal("100000")))
assert b.income_tax == Decimal("27432.00")
def test_pa_taper_at_125k() -> None:
# £125,000: PA = 12,570 - (25,000/2) = 12,570 - 12,500 = 70
# taxable = 125,000 - 70 = 124,930
# £37,700 @ 20% = £7,540
# £87,230 @ 40% = £34,892
# total = £42,432
b = UkTaxRegime().compute_tax(TaxInputs(earned_income=Decimal("125000")))
assert b.income_tax == Decimal("42432.00")
def test_additional_rate_above_125k() -> None:
# £200k earned: PA fully tapered.
# taxable income = £200,000
# £37,700 @ 20% = £7,540
# £87,440 @ 40% = £34,976
# £74,860 @ 45% = £33,687
# total = £76,203
b = UkTaxRegime().compute_tax(TaxInputs(earned_income=Decimal("200000")))
assert b.income_tax == Decimal("76203.00")
def test_cgt_basic_rate_only() -> None:
# No earned income, £20k gains:
# exempt £3k → £17k taxable @ 18% (basic band has plenty of room)
# = £3,060
b = UkTaxRegime().compute_tax(TaxInputs(capital_gains=Decimal("20000")))
assert b.capital_gains_tax == Decimal("3060.00")
def test_cgt_spans_into_higher_band() -> None:
# £30k earned (taxable income £17,430 — well under £37,700 band top)
# £40k gains:
# exempt £3k → £37k taxable
# basic band remaining = 37,700 - 17,430 = 20,270 → @ 18% = £3,648.60
# higher = 37,000 - 20,270 = 16,730 → @ 24% = £4,015.20
# total CGT = £7,663.80
b = UkTaxRegime().compute_tax(
TaxInputs(earned_income=Decimal("30000"), capital_gains=Decimal("40000")))
assert b.capital_gains_tax == Decimal("7663.80")
def test_dividend_basic_rate() -> None:
# No other income, £10k dividends:
# allowance £500 → £9,500 taxable
# Stacked on top of taxable_ordinary=0, so basic band has £37,700 room.
# All £9,500 @ 8.75% = £831.25
b = UkTaxRegime().compute_tax(TaxInputs(dividends=Decimal("10000")))
assert b.dividend_tax == Decimal("831.2500")
def test_pension_25pc_tax_free() -> None:
# £40k pension drawdown, no other income:
# PCLS = £10k tax-free
# Taxable pension = £30k → £17,430 taxable @ 20% = £3,486
b = UkTaxRegime().compute_tax(TaxInputs(pension_withdrawal=Decimal("40000")))
assert b.income_tax == Decimal("3486.00")
assert b.national_insurance == Decimal("0") # NI not on pension
def test_total_equals_sum_of_components() -> None:
inputs = TaxInputs(
earned_income=Decimal("60000"),
capital_gains=Decimal("15000"),
dividends=Decimal("8000"),
)
b = UkTaxRegime().compute_tax(inputs)
assert (b.total == b.income_tax + b.national_insurance + b.capital_gains_tax + b.dividend_tax +
b.healthcare_levy + b.other)
@given(income=st.decimals(
min_value=0, max_value=500_000, places=2, allow_nan=False, allow_infinity=False))
def test_tax_monotone_in_earned_income(income: Decimal) -> None:
"""Adding earned income never decreases total tax."""
base = UkTaxRegime().compute_tax(TaxInputs(earned_income=income))
plus = UkTaxRegime().compute_tax(TaxInputs(earned_income=income + Decimal("1000")))
assert plus.total >= base.total
@given(gains=st.decimals(
min_value=0, max_value=500_000, places=2, allow_nan=False, allow_infinity=False))
def test_cgt_monotone_in_gains(gains: Decimal) -> None:
base = UkTaxRegime().compute_tax(TaxInputs(capital_gains=gains))
plus = UkTaxRegime().compute_tax(TaxInputs(capital_gains=gains + Decimal("1000")))
assert plus.capital_gains_tax >= base.capital_gains_tax