175 lines
7.1 KiB
Python
175 lines
7.1 KiB
Python
"""UK tax regime — 2026/27 PAYE/NI/CGT/dividend rules.
|
|
|
|
Rates are baked-in for 2026/27 and held in module-level constants so
|
|
they can be patched per-test or upgraded for a future tax year. Only
|
|
the *income* side is modelled at year resolution — pension wrapper
|
|
contributions and accumulation-phase tax-relief are handled by the
|
|
simulator's ISA/SIPP bucket plumbing, not here.
|
|
|
|
Sources:
|
|
- HMRC rates and thresholds 2026-27 (gov.uk/income-tax-rates).
|
|
- TCGA 1992 s.10A — 5-year temporary non-residence claw-back.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from decimal import Decimal
|
|
|
|
from fire_planner.tax.base import TaxBreakdown, TaxInputs, TaxRegime, apply_brackets
|
|
|
|
INF = Decimal("Infinity")
|
|
|
|
# 2026/27 thresholds — frozen by Treasury until at least 2028-04 per the
|
|
# autumn 2024 budget. PA tapers above £100k at £1 per £2 → 0 at £125,140.
|
|
PERSONAL_ALLOWANCE = Decimal("12570")
|
|
PA_TAPER_FLOOR = Decimal("100000")
|
|
PA_TAPER_CEILING = Decimal("125140")
|
|
BASIC_RATE_BAND = Decimal("37700")
|
|
ADDITIONAL_RATE_THRESHOLD = Decimal("125140")
|
|
|
|
# PAYE income-tax brackets, applied to TAXABLE income (after PA).
|
|
# HMRC defines the additional-rate threshold at £125,140 of *taxable*
|
|
# income — independent of the PA-taper outcome — so the higher-rate
|
|
# band width depends on PA. With full PA the 40% band runs from
|
|
# £37,701 to £125,140 (width £87,440); with PA fully tapered the 40%
|
|
# band still runs to £125,140 of taxable, just from a lower starting
|
|
# gross.
|
|
INCOME_TAX_BRACKETS: list[tuple[Decimal, Decimal]] = [
|
|
(BASIC_RATE_BAND, Decimal("0.20")),
|
|
(ADDITIONAL_RATE_THRESHOLD, Decimal("0.40")),
|
|
(INF, Decimal("0.45")),
|
|
]
|
|
|
|
# NI Class 1 employee 2026/27 — annualised. Real-world NI is calculated
|
|
# per-period but for retirement modelling annual approximation is fine.
|
|
NI_PRIMARY_THRESHOLD = Decimal("12570")
|
|
NI_UPPER_EARNINGS_LIMIT = Decimal("50270")
|
|
NI_BRACKETS: list[tuple[Decimal, Decimal]] = [
|
|
(NI_UPPER_EARNINGS_LIMIT - NI_PRIMARY_THRESHOLD, Decimal("0.08")),
|
|
(INF, Decimal("0.02")),
|
|
]
|
|
|
|
# Capital gains — Autumn Budget 2024 equalised property + non-property
|
|
# rates within each band. £3,000 annual exempt amount.
|
|
CGT_ANNUAL_EXEMPTION = Decimal("3000")
|
|
CGT_BASIC_RATE = Decimal("0.18")
|
|
CGT_HIGHER_RATE = Decimal("0.24")
|
|
|
|
# Dividend tax 2026/27 — £500 allowance, then 8.75 / 33.75 / 39.35.
|
|
DIVIDEND_ALLOWANCE = Decimal("500")
|
|
DIVIDEND_BASIC = Decimal("0.0875")
|
|
DIVIDEND_HIGHER = Decimal("0.3375")
|
|
DIVIDEND_ADDITIONAL = Decimal("0.3935")
|
|
|
|
# Personal Savings Allowance — only basic and higher rate get any.
|
|
PSA_BASIC = Decimal("1000")
|
|
PSA_HIGHER = Decimal("500")
|
|
|
|
# Pension withdrawal — 25% tax-free up to the lump-sum allowance, rest
|
|
# taxed as ordinary income. Cap is per-lifetime; we apply it per-year
|
|
# because the simulator doesn't track cumulative PCLS yet (all
|
|
# withdrawals stay below the cap on the £100k spend assumption).
|
|
PCLS_FRACTION = Decimal("0.25")
|
|
|
|
|
|
def taper_personal_allowance(adjusted_net_income: Decimal) -> Decimal:
|
|
"""Apply the PA taper: 0 above £125,140, full PA below £100k."""
|
|
if adjusted_net_income <= PA_TAPER_FLOOR:
|
|
return PERSONAL_ALLOWANCE
|
|
if adjusted_net_income >= PA_TAPER_CEILING:
|
|
return Decimal("0")
|
|
reduction = (adjusted_net_income - PA_TAPER_FLOOR) / Decimal("2")
|
|
return max(Decimal("0"), PERSONAL_ALLOWANCE - reduction)
|
|
|
|
|
|
def _income_tax(taxable_income: Decimal) -> Decimal:
|
|
return apply_brackets(taxable_income, INCOME_TAX_BRACKETS)
|
|
|
|
|
|
def _ni(earned_income: Decimal) -> Decimal:
|
|
above_threshold = max(Decimal("0"), earned_income - NI_PRIMARY_THRESHOLD)
|
|
return apply_brackets(above_threshold, NI_BRACKETS)
|
|
|
|
|
|
def _cgt(gains: Decimal, taxable_non_gains_income: Decimal) -> Decimal:
|
|
"""Apply CGT — fills the unused basic rate band first, then 24%."""
|
|
after_exemption = max(Decimal("0"), gains - CGT_ANNUAL_EXEMPTION)
|
|
if after_exemption == 0:
|
|
return Decimal("0")
|
|
basic_band_remaining = max(Decimal("0"), BASIC_RATE_BAND - taxable_non_gains_income)
|
|
in_basic = min(after_exemption, basic_band_remaining)
|
|
in_higher = after_exemption - in_basic
|
|
return in_basic * CGT_BASIC_RATE + in_higher * CGT_HIGHER_RATE
|
|
|
|
|
|
def _dividend_tax(dividends: Decimal, taxable_non_div_income: Decimal) -> Decimal:
|
|
"""Dividends are stacked on top of other income, so the band
|
|
boundaries depend on what's already used. £500 allowance off the top.
|
|
"""
|
|
after_allowance = max(Decimal("0"), dividends - DIVIDEND_ALLOWANCE)
|
|
if after_allowance == 0:
|
|
return Decimal("0")
|
|
basic_band_remaining = max(Decimal("0"), BASIC_RATE_BAND - taxable_non_div_income)
|
|
higher_band_remaining = max(
|
|
Decimal("0"), ADDITIONAL_RATE_THRESHOLD - max(taxable_non_div_income, BASIC_RATE_BAND))
|
|
|
|
in_basic = min(after_allowance, basic_band_remaining)
|
|
rest = after_allowance - in_basic
|
|
in_higher = min(rest, higher_band_remaining)
|
|
in_additional = rest - in_higher
|
|
return (in_basic * DIVIDEND_BASIC + in_higher * DIVIDEND_HIGHER +
|
|
in_additional * DIVIDEND_ADDITIONAL)
|
|
|
|
|
|
def _psa_for_band(taxable_non_savings_income: Decimal) -> Decimal:
|
|
"""Personal Savings Allowance scales by tax band:
|
|
- basic rate (income within £37,700): £1,000
|
|
- higher rate: £500
|
|
- additional rate (above £125,140 net of PA): £0
|
|
"""
|
|
if taxable_non_savings_income <= BASIC_RATE_BAND:
|
|
return PSA_BASIC
|
|
if taxable_non_savings_income <= ADDITIONAL_RATE_THRESHOLD:
|
|
return PSA_HIGHER
|
|
return Decimal("0")
|
|
|
|
|
|
class UkTaxRegime(TaxRegime):
|
|
"""UK 2026/27. ISA withdrawals are pre-filtered out (always 0%);
|
|
pension withdrawals get 25% tax-free, the rest is added to earned
|
|
income for PAYE.
|
|
|
|
The 5-year Temporary Non-Residence claw-back is the simulator's
|
|
job: when a path returns to the UK within 5y of departure, it
|
|
sums the non-UK regime's pre-tax flows for those years and runs
|
|
them through this regime to compute the recapture. This class
|
|
just computes "tax in a single UK year".
|
|
"""
|
|
name = "uk"
|
|
|
|
def compute_tax(self, inputs: TaxInputs) -> TaxBreakdown:
|
|
# 25% PCLS, rest taxed as income.
|
|
pcls_tax_free = inputs.pension_withdrawal * PCLS_FRACTION
|
|
pension_taxable = inputs.pension_withdrawal - pcls_tax_free
|
|
|
|
ordinary_income = inputs.earned_income + pension_taxable
|
|
adjusted_net_income = ordinary_income + inputs.dividends + inputs.interest
|
|
pa = taper_personal_allowance(adjusted_net_income)
|
|
taxable_ordinary = max(Decimal("0"), ordinary_income - pa)
|
|
|
|
income_tax = _income_tax(taxable_ordinary)
|
|
ni = _ni(inputs.earned_income)
|
|
|
|
psa = _psa_for_band(taxable_ordinary)
|
|
taxable_interest = max(Decimal("0"), inputs.interest - psa)
|
|
income_tax += apply_brackets(taxable_interest, INCOME_TAX_BRACKETS)
|
|
|
|
cgt = _cgt(inputs.capital_gains, taxable_ordinary)
|
|
div_tax = _dividend_tax(inputs.dividends, taxable_ordinary)
|
|
|
|
return TaxBreakdown(
|
|
income_tax=income_tax,
|
|
national_insurance=ni,
|
|
capital_gains_tax=cgt,
|
|
dividend_tax=div_tax,
|
|
notes=("uk-2026-27", f"pcls_tax_free={pcls_tax_free}", f"pa_used={pa}"),
|
|
)
|