147 lines
5.3 KiB
Python
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
|