Initial extraction from monorepo
This commit is contained in:
commit
f7ef7ca4ab
56 changed files with 6163 additions and 0 deletions
1
fire_planner/tax/__init__.py
Normal file
1
fire_planner/tax/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Per-jurisdiction tax engines."""
|
||||
91
fire_planner/tax/base.py
Normal file
91
fire_planner/tax/base.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""Tax-regime abstract base — every jurisdiction implements this.
|
||||
|
||||
Inputs are split by income source because each source carries different
|
||||
tax treatment (e.g. ISA withdrawals are always 0%, capital gains may be
|
||||
exempt in some jurisdictions, pension withdrawals are partially tax-free
|
||||
in the UK). The regime decides how to combine them.
|
||||
|
||||
Outputs are split per tax type so we can attribute lifetime tax — the
|
||||
Grafana panel shows e.g. "lifetime CGT paid" separately from "lifetime
|
||||
income tax".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaxInputs:
|
||||
"""Annual gross flows for a single tax year. All amounts in GBP, all
|
||||
non-negative — withdrawals are absolute values.
|
||||
|
||||
`years_since_uk_departure` lets the UK regime apply the 5-year
|
||||
Temporary Non-Residence claw-back: gains realised abroad get clawed
|
||||
back if you return within 5y. Non-UK regimes ignore it.
|
||||
"""
|
||||
earned_income: Decimal = Decimal("0")
|
||||
pension_withdrawal: Decimal = Decimal("0")
|
||||
capital_gains: Decimal = Decimal("0")
|
||||
dividends: Decimal = Decimal("0")
|
||||
isa_withdrawals: Decimal = Decimal("0")
|
||||
interest: Decimal = Decimal("0")
|
||||
years_since_uk_departure: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaxBreakdown:
|
||||
"""Tax due, split by category. `total` is the sum — every regime
|
||||
must keep `total == sum of categories` for the integrity check.
|
||||
"""
|
||||
income_tax: Decimal = Decimal("0")
|
||||
national_insurance: Decimal = Decimal("0")
|
||||
capital_gains_tax: Decimal = Decimal("0")
|
||||
dividend_tax: Decimal = Decimal("0")
|
||||
healthcare_levy: Decimal = Decimal("0")
|
||||
other: Decimal = Decimal("0")
|
||||
notes: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
@property
|
||||
def total(self) -> Decimal:
|
||||
return (self.income_tax + self.national_insurance + self.capital_gains_tax +
|
||||
self.dividend_tax + self.healthcare_levy + self.other)
|
||||
|
||||
|
||||
class TaxRegime(ABC):
|
||||
"""Per-jurisdiction tax engine. Stateless — every call gets fresh
|
||||
inputs. Sub-classes set `name` for the scenario key.
|
||||
"""
|
||||
name: str
|
||||
|
||||
@abstractmethod
|
||||
def compute_tax(self, inputs: TaxInputs) -> TaxBreakdown:
|
||||
"""Return the year's tax due given gross income/gains/dividends."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def apply_brackets(amount: Decimal, brackets: list[tuple[Decimal, Decimal]]) -> Decimal:
|
||||
"""Apply a progressive bracket schedule to `amount`.
|
||||
|
||||
`brackets` is a list of (band_top, marginal_rate) — band_top is the
|
||||
upper bound of the band (use Decimal('Infinity') for the last band).
|
||||
Bands are evaluated in order from lowest to highest.
|
||||
|
||||
Example UK PAYE 2026/27 above the personal allowance:
|
||||
[(50_270 - 12_570, Decimal("0.20")),
|
||||
(125_140 - 12_570, Decimal("0.40")),
|
||||
(Decimal("Infinity"), Decimal("0.45"))]
|
||||
where `amount` is taxable income net of the allowance.
|
||||
"""
|
||||
if amount <= 0:
|
||||
return Decimal("0")
|
||||
tax = Decimal("0")
|
||||
prev_top = Decimal("0")
|
||||
for band_top, rate in brackets:
|
||||
if amount <= prev_top:
|
||||
break
|
||||
slice_top = min(amount, band_top)
|
||||
tax += (slice_top - prev_top) * rate
|
||||
prev_top = band_top
|
||||
return tax
|
||||
32
fire_planner/tax/bulgaria.py
Normal file
32
fire_planner/tax/bulgaria.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""Bulgaria regime — 10% flat tax on worldwide income.
|
||||
|
||||
Article 48 of the Personal Income Tax Act sets a flat 10% on
|
||||
worldwide income for residents. Capital gains on EU/EEA-listed
|
||||
securities held over the relevant holding period are exempt
|
||||
(Art 13(1)(3)) — most of our portfolio qualifies. We approximate
|
||||
all capital gains as 10% to be conservative (CGT on US-listed
|
||||
ETFs from a Bulgarian resident is contested terrain; many funds
|
||||
the planner holds are Irish UCITS so the EU exemption likely
|
||||
applies, but we don't optimise for that here).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from fire_planner.tax.base import TaxBreakdown, TaxInputs, TaxRegime
|
||||
|
||||
FLAT_RATE = Decimal("0.10")
|
||||
|
||||
|
||||
class BulgariaTaxRegime(TaxRegime):
|
||||
name = "bulgaria"
|
||||
|
||||
def compute_tax(self, inputs: TaxInputs) -> TaxBreakdown:
|
||||
chargeable = (inputs.earned_income + inputs.pension_withdrawal + inputs.capital_gains +
|
||||
inputs.dividends + inputs.interest)
|
||||
return TaxBreakdown(
|
||||
income_tax=(inputs.earned_income + inputs.pension_withdrawal) * FLAT_RATE,
|
||||
capital_gains_tax=inputs.capital_gains * FLAT_RATE,
|
||||
dividend_tax=(inputs.dividends + inputs.interest) * FLAT_RATE,
|
||||
notes=("bulgaria-flat", f"chargeable={chargeable}"),
|
||||
)
|
||||
49
fire_planner/tax/cyprus.py
Normal file
49
fire_planner/tax/cyprus.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Cyprus regime — non-dom 17-year exemption on foreign dividends +
|
||||
interest, plus 2.65% GeSY healthcare levy capped at €180k.
|
||||
|
||||
The non-dom regime (Art 8(20)/(20A) Income Tax Law 118(I)/2002) gives
|
||||
17 years of full exemption from SDC (Special Defence Contribution) on
|
||||
foreign dividends and interest. Capital gains on shares are exempt
|
||||
under standard CGT rules (only Cypriot real estate is taxed). Earned
|
||||
income from employment is taxed under standard PIT bands — irrelevant
|
||||
for our retirement scenarios.
|
||||
|
||||
GeSY (Γε.Σ.Υ. — General Healthcare System) levies 2.65% on worldwide
|
||||
income with an annual cap of €180,000 of contributing income. We
|
||||
convert the €180k cap to GBP via the FX rate at scenario time;
|
||||
default = 0.86 GBP/EUR ≈ £154,800.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from fire_planner.tax.base import TaxBreakdown, TaxInputs, TaxRegime
|
||||
|
||||
DEFAULT_GESY_RATE = Decimal("0.0265")
|
||||
DEFAULT_GESY_CAP_EUR = Decimal("180000")
|
||||
|
||||
|
||||
class CyprusTaxRegime(TaxRegime):
|
||||
name = "cyprus"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gesy_rate: Decimal = DEFAULT_GESY_RATE,
|
||||
gesy_cap_gbp: Decimal | None = None,
|
||||
gbp_per_eur: Decimal = Decimal("0.86"),
|
||||
) -> None:
|
||||
self.gesy_rate = gesy_rate
|
||||
self.gesy_cap_gbp = (gesy_cap_gbp if gesy_cap_gbp is not None else DEFAULT_GESY_CAP_EUR *
|
||||
gbp_per_eur)
|
||||
|
||||
def compute_tax(self, inputs: TaxInputs) -> TaxBreakdown:
|
||||
# Foreign divs/interest exempt under non-dom (assumed within 17y window).
|
||||
# Foreign capital gains exempt unless the underlying is Cypriot real estate.
|
||||
chargeable = (inputs.earned_income + inputs.pension_withdrawal + inputs.capital_gains +
|
||||
inputs.dividends + inputs.interest)
|
||||
capped = min(chargeable, self.gesy_cap_gbp)
|
||||
return TaxBreakdown(
|
||||
healthcare_levy=capped * self.gesy_rate,
|
||||
notes=("cyprus-non-dom", f"gesy_rate={self.gesy_rate}",
|
||||
f"gesy_cap_gbp={self.gesy_cap_gbp}"),
|
||||
)
|
||||
24
fire_planner/tax/malaysia.py
Normal file
24
fire_planner/tax/malaysia.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""Malaysia regime — 0% on foreign-sourced income for individuals.
|
||||
|
||||
Under the Income Tax Act 1967 s.3 + para 28 sched 6, foreign-sourced
|
||||
income received by an individual is exempt — extended to 2036 by the
|
||||
Finance Act 2022. Our portfolio is wholly foreign (US/UK ETFs, GBP
|
||||
brokerage, RSU vests already taxed at source), so all flows fall
|
||||
outside Malaysian tax.
|
||||
|
||||
We do NOT model the MM2H visa fee, healthcare, or property purchase
|
||||
costs — those belong in the spending budget, not the tax engine.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from fire_planner.tax.base import TaxBreakdown, TaxInputs, TaxRegime
|
||||
|
||||
|
||||
class MalaysiaTaxRegime(TaxRegime):
|
||||
name = "malaysia"
|
||||
|
||||
def compute_tax(self, inputs: TaxInputs) -> TaxBreakdown:
|
||||
del inputs # all foreign income is exempt
|
||||
return TaxBreakdown(notes=("malaysia-foreign-exempt", ), other=Decimal("0"))
|
||||
31
fire_planner/tax/nomad.py
Normal file
31
fire_planner/tax/nomad.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""Perpetual-traveller / nomad regime — 0% income tax + 1% regulatory
|
||||
risk premium on all flows.
|
||||
|
||||
The 1% premium captures the real-world risk that a "no tax residence"
|
||||
posture eventually attracts adverse rulings, the OECD CRS net tightens,
|
||||
or a destination starts taxing previously-exempt foreign income (e.g.
|
||||
Thailand 2024 remittance rule). We don't try to model the actual
|
||||
mechanism — it's a Bayesian fudge factor. Tunable via the constructor.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from fire_planner.tax.base import TaxBreakdown, TaxInputs, TaxRegime
|
||||
|
||||
|
||||
class NomadTaxRegime(TaxRegime):
|
||||
name = "nomad"
|
||||
|
||||
def __init__(self, regulatory_premium_rate: Decimal = Decimal("0.01")) -> None:
|
||||
self.regulatory_premium_rate = regulatory_premium_rate
|
||||
|
||||
def compute_tax(self, inputs: TaxInputs) -> TaxBreakdown:
|
||||
# ISA withdrawals are tax-free in the UK; for a nomad they're
|
||||
# just cash. The risk premium applies to cash that flows
|
||||
# *outside* a UK wrapper because that's the boundary the
|
||||
# premium is hedging.
|
||||
chargeable = (inputs.earned_income + inputs.pension_withdrawal + inputs.capital_gains +
|
||||
inputs.dividends + inputs.interest)
|
||||
return TaxBreakdown(other=chargeable * self.regulatory_premium_rate,
|
||||
notes=("nomad", f"premium_rate={self.regulatory_premium_rate}"))
|
||||
23
fire_planner/tax/thailand.py
Normal file
23
fire_planner/tax/thailand.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""Thailand regime — 0% on foreign-sourced income, with a caveat.
|
||||
|
||||
Thailand's 2024 remittance rule (Por 162/2566) made foreign income
|
||||
*remitted* into Thailand in the year earned (or the next) taxable.
|
||||
Money kept abroad is still untouched, and we assume the planner
|
||||
holds investments in offshore custody (IBKR US/UK, Schwab) and
|
||||
remits only the £100k spend. The 2024 rule does mean some of that
|
||||
remittance could be taxable; for v1 we mirror the Malaysian
|
||||
"foreign exempt" treatment and revisit when prod data lands.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from fire_planner.tax.base import TaxBreakdown, TaxInputs, TaxRegime
|
||||
|
||||
|
||||
class ThailandTaxRegime(TaxRegime):
|
||||
name = "thailand"
|
||||
|
||||
def compute_tax(self, inputs: TaxInputs) -> TaxBreakdown:
|
||||
del inputs
|
||||
return TaxBreakdown(notes=("thailand-foreign-exempt-v1", ), other=Decimal("0"))
|
||||
28
fire_planner/tax/uae.py
Normal file
28
fire_planner/tax/uae.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""UAE regime — true 0% personal income tax with no equivalent levy.
|
||||
|
||||
The UAE has no personal income tax, no capital gains tax, no dividend
|
||||
tax, and no inheritance tax for individuals. The 9% federal corporate
|
||||
tax (effective 2023) applies only to in-country business profits over
|
||||
AED 375k — irrelevant to a passive investor drawing down a foreign
|
||||
brokerage account.
|
||||
|
||||
Unlike `NomadTaxRegime`, we do NOT apply a regulatory-risk premium:
|
||||
the UAE is a real tax residence with an extensive double-tax-treaty
|
||||
network (UK DTT in force; tax-residence certificates issued by the
|
||||
FTA). The downside of UAE is high cost of living and visa overhead,
|
||||
not tax uncertainty — those costs sit in the spending budget, not
|
||||
the tax engine.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from fire_planner.tax.base import TaxBreakdown, TaxInputs, TaxRegime
|
||||
|
||||
|
||||
class UaeTaxRegime(TaxRegime):
|
||||
name = "uae"
|
||||
|
||||
def compute_tax(self, inputs: TaxInputs) -> TaxBreakdown:
|
||||
del inputs # 0% on all personal income flows
|
||||
return TaxBreakdown(notes=("uae-zero-pit", ), other=Decimal("0"))
|
||||
175
fire_planner/tax/uk.py
Normal file
175
fire_planner/tax/uk.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"""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}"),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue