glidepath 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- glidepath/__init__.py +3 -0
- glidepath/app/__init__.py +364 -0
- glidepath/app/backtest.py +281 -0
- glidepath/app/charts.py +759 -0
- glidepath/app/copy.py +174 -0
- glidepath/app/display.py +148 -0
- glidepath/app/drawdown.py +436 -0
- glidepath/app/example.py +66 -0
- glidepath/app/exports.py +487 -0
- glidepath/app/files.py +249 -0
- glidepath/app/firstrun.py +114 -0
- glidepath/app/forms.py +1750 -0
- glidepath/app/inspector.py +506 -0
- glidepath/app/labels.py +66 -0
- glidepath/app/montecarlo.py +399 -0
- glidepath/app/plan.py +354 -0
- glidepath/app/retirement.py +446 -0
- glidepath/app/scenarios.py +831 -0
- glidepath/app/shell.py +185 -0
- glidepath/app/tables.py +138 -0
- glidepath/core/__init__.py +390 -0
- glidepath/core/annuities.py +240 -0
- glidepath/core/backtest.py +514 -0
- glidepath/core/comparison.py +278 -0
- glidepath/core/config.py +82 -0
- glidepath/core/contributions.py +337 -0
- glidepath/core/engine.py +2811 -0
- glidepath/core/entities.py +264 -0
- glidepath/core/glide.py +289 -0
- glidepath/core/investments.py +175 -0
- glidepath/core/money.py +107 -0
- glidepath/core/montecarlo.py +609 -0
- glidepath/core/pensions.py +298 -0
- glidepath/core/periods.py +367 -0
- glidepath/core/provenance.py +271 -0
- glidepath/core/randomness.py +128 -0
- glidepath/core/region.py +46 -0
- glidepath/core/reporting.py +231 -0
- glidepath/core/results.py +504 -0
- glidepath/core/retirement.py +291 -0
- glidepath/core/returns.py +312 -0
- glidepath/core/scenarios.py +579 -0
- glidepath/core/state_pension.py +264 -0
- glidepath/core/tax.py +139 -0
- glidepath/core/withdrawals.py +461 -0
- glidepath/core/wrappers.py +278 -0
- glidepath/gui/__init__.py +6 -0
- glidepath/gui/assets/icon_128.png +0 -0
- glidepath/gui/assets/icon_16.png +0 -0
- glidepath/gui/assets/icon_24.png +0 -0
- glidepath/gui/assets/icon_256.png +0 -0
- glidepath/gui/assets/icon_32.png +0 -0
- glidepath/gui/assets/icon_48.png +0 -0
- glidepath/gui/assets/icon_64.png +0 -0
- glidepath/gui/assets/wordmark.png +0 -0
- glidepath/gui/charts.py +829 -0
- glidepath/gui/forms.py +359 -0
- glidepath/gui/inspector.py +186 -0
- glidepath/gui/main.py +51 -0
- glidepath/gui/scenarios.py +402 -0
- glidepath/gui/style.py +376 -0
- glidepath/gui/tableview.py +67 -0
- glidepath/gui/widgets.py +989 -0
- glidepath/persistence/__init__.py +48 -0
- glidepath/persistence/assumptions.py +112 -0
- glidepath/persistence/decode.py +747 -0
- glidepath/persistence/document.py +101 -0
- glidepath/persistence/encode.py +433 -0
- glidepath/persistence/migrations.py +158 -0
- glidepath/persistence/values.py +298 -0
- glidepath/py.typed +0 -0
- glidepath/regions/__init__.py +7 -0
- glidepath/regions/uk/__init__.py +189 -0
- glidepath/regions/uk/ages.py +156 -0
- glidepath/regions/uk/contributions.py +717 -0
- glidepath/regions/uk/data/age_rules.toml +78 -0
- glidepath/regions/uk/data/assumptions_default.toml +170 -0
- glidepath/regions/uk/data/returns_history.toml +150 -0
- glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
- glidepath/regions/uk/extension.py +479 -0
- glidepath/regions/uk/loader.py +704 -0
- glidepath/regions/uk/region.py +160 -0
- glidepath/regions/uk/schema.py +563 -0
- glidepath/regions/uk/state_pension.py +129 -0
- glidepath/regions/uk/tax.py +466 -0
- glidepath/regions/uk/wrappers.py +283 -0
- glidepath/regions/uk/years.py +92 -0
- glidepath-0.2.0.dist-info/METADATA +189 -0
- glidepath-0.2.0.dist-info/RECORD +93 -0
- glidepath-0.2.0.dist-info/WHEEL +4 -0
- glidepath-0.2.0.dist-info/entry_points.txt +3 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE-DATA +28 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""UK income tax assessment (roadmap 2.3, 9.2; planning §4.2, §5.3, §6).
|
|
2
|
+
|
|
3
|
+
Implements the core :class:`~glidepath.core.TaxSystem` protocol. Every
|
|
4
|
+
figure — personal allowance, taper threshold and rate, the band
|
|
5
|
+
ladders, the savings and dividend nil rates — comes from the tax-year
|
|
6
|
+
data files (§5.3); nothing is hardcoded here (guard-tested).
|
|
7
|
+
|
|
8
|
+
Income stacks in HMRC's default order — non-savings, then savings,
|
|
9
|
+
then dividends — up one ladder of band widths (planning §6).
|
|
10
|
+
Non-savings income is assessed under the rUK or Scottish schedule per
|
|
11
|
+
the taxpayer's residency (roadmap 9.1); savings and dividend income is
|
|
12
|
+
UK-wide and always uses the rUK bands, positioned above the taxpayer's
|
|
13
|
+
non-savings taxable income (roadmap 9.2). The savings layer applies
|
|
14
|
+
the starting rate for savings (reduced £1 per £1 of non-savings
|
|
15
|
+
taxable income) and then the personal savings allowance; the dividend
|
|
16
|
+
layer applies the dividend allowance and then the per-band dividend
|
|
17
|
+
rates. All three reliefs are nil *rates*, not deductions: nil-rated
|
|
18
|
+
income still consumes band width (§6). The PSA tier follows the band
|
|
19
|
+
the taxpayer's total taxable income reaches on the (relief-extended)
|
|
20
|
+
rUK ladder.
|
|
21
|
+
|
|
22
|
+
Rounding follows HMRC's published calculation logic (the Tax Logic
|
|
23
|
+
service guide,
|
|
24
|
+
https://developer.service.hmrc.gov.uk/guides/tax-logic-service-guide/):
|
|
25
|
+
the personal-allowance reduction is rounded *down* to the whole pound
|
|
26
|
+
and the resulting allowance *up* to the whole pound, and each band's
|
|
27
|
+
tax is rounded *down* to the penny. These statutory roundings are the
|
|
28
|
+
region's own — the core ledger policy (half-even at ledger writes,
|
|
29
|
+
planning §4.6) is unchanged elsewhere.
|
|
30
|
+
|
|
31
|
+
Relief-at-source pension contributions (roadmap 3.2) receive their
|
|
32
|
+
higher and additional rates of relief here, by HMRC's own mechanism:
|
|
33
|
+
the basic rate limit and every rate limit above it are extended by the
|
|
34
|
+
gross contribution (limits below basic — the Scottish starter rate —
|
|
35
|
+
never move), and adjusted net income — the personal-allowance taper
|
|
36
|
+
measure — deducts it. The extension applies to the rUK ladder wherever
|
|
37
|
+
it is used, so savings and dividend band positions move with it too
|
|
38
|
+
(SI 2018/459 extends the limits for Scottish taxpayers likewise).
|
|
39
|
+
Net-pay contributions need no assessment adjustment: they leave pay
|
|
40
|
+
before tax, so the caller excludes them from the assessed income.
|
|
41
|
+
|
|
42
|
+
The annual-allowance charge (roadmap 3.3) prices separately from the
|
|
43
|
+
assessment: the chargeable excess a period's pension inputs produced
|
|
44
|
+
(:meth:`~glidepath.regions.uk.contributions.UkContributionRuleset.annual_allowance`)
|
|
45
|
+
is charged as the top slice of the taxpayer's income at their own
|
|
46
|
+
schedule's rates (FA 2004 s227B) via
|
|
47
|
+
:meth:`UkTaxSystem.annual_allowance_charge`, whose lines the engine
|
|
48
|
+
appends to the period's final result — never fed back through
|
|
49
|
+
``assess``, since the excess is a charge, not income.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from dataclasses import dataclass
|
|
53
|
+
from decimal import ROUND_DOWN, ROUND_UP, Decimal
|
|
54
|
+
from typing import TYPE_CHECKING
|
|
55
|
+
|
|
56
|
+
from glidepath.core import Money, Rate, TaxInput, TaxLine, TaxResidencyId, TaxResult
|
|
57
|
+
from glidepath.regions.uk.loader import available_tax_years, load_tax_year
|
|
58
|
+
from glidepath.regions.uk.schema import BASIC_BAND_NAME, TaxBand
|
|
59
|
+
from glidepath.regions.uk.years import TaxYearSeries, UkTaxYearError
|
|
60
|
+
|
|
61
|
+
if TYPE_CHECKING:
|
|
62
|
+
from collections.abc import Callable
|
|
63
|
+
|
|
64
|
+
from glidepath.core import Period
|
|
65
|
+
from glidepath.regions.uk.extension import FutureYearsExtension
|
|
66
|
+
from glidepath.regions.uk.schema import (
|
|
67
|
+
DividendRules,
|
|
68
|
+
IncomeTaxSchedule,
|
|
69
|
+
SavingsRules,
|
|
70
|
+
TaxYearFile,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
RUK_RESIDENCY = TaxResidencyId("uk.ruk")
|
|
74
|
+
"""Residency id for England, Wales and Northern Ireland (rUK)."""
|
|
75
|
+
|
|
76
|
+
SCOTLAND_RESIDENCY = TaxResidencyId("uk.scotland")
|
|
77
|
+
"""Residency id for Scottish taxpayers."""
|
|
78
|
+
|
|
79
|
+
SAVINGS_STARTING_RATE_BAND = "savings_starting_rate"
|
|
80
|
+
"""Line label of the 0% starting rate for savings (planning §6)."""
|
|
81
|
+
|
|
82
|
+
SAVINGS_NIL_RATE_BAND = "savings_nil_rate"
|
|
83
|
+
"""Line label of the personal savings allowance nil rate (planning §6)."""
|
|
84
|
+
|
|
85
|
+
DIVIDEND_NIL_RATE_BAND = "dividend_nil_rate"
|
|
86
|
+
"""Line label of the dividend allowance nil rate (planning §6)."""
|
|
87
|
+
|
|
88
|
+
_ZERO = Money(Decimal(0))
|
|
89
|
+
_POUND = Decimal(1)
|
|
90
|
+
_PENNY = Decimal("0.01")
|
|
91
|
+
_NIL_RATE = Rate(Decimal(0))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _round_down(money: Money, unit: Decimal) -> Money:
|
|
95
|
+
"""HMRC ``roundDown``: truncate a non-negative amount to ``unit``."""
|
|
96
|
+
return Money(money.amount.quantize(unit, rounding=ROUND_DOWN))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _round_up(money: Money, unit: Decimal) -> Money:
|
|
100
|
+
"""HMRC ``roundUp``: round a non-negative amount up to ``unit``."""
|
|
101
|
+
return Money(money.amount.quantize(unit, rounding=ROUND_UP))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class UkTaxError(ValueError):
|
|
105
|
+
"""An assessment the shipped UK data cannot perform."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True, slots=True)
|
|
109
|
+
class UkTaxSystem:
|
|
110
|
+
"""UK implementation of the core ``TaxSystem`` protocol.
|
|
111
|
+
|
|
112
|
+
Holds the tax-year files it may assess against. Periods past the
|
|
113
|
+
last shipped year are synthesized per the future-years extension
|
|
114
|
+
when one is configured (roadmap 2.5; planning §5.3); shipped data
|
|
115
|
+
always beats extrapolation. Without an extension — and always for
|
|
116
|
+
periods before the first shipped year — assessment fails.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
tax_years: tuple[TaxYearFile, ...]
|
|
120
|
+
future_years: FutureYearsExtension | None = None
|
|
121
|
+
|
|
122
|
+
def __post_init__(self) -> None:
|
|
123
|
+
"""Require at least one year, ascending and non-overlapping."""
|
|
124
|
+
try:
|
|
125
|
+
self._series()
|
|
126
|
+
except UkTaxYearError as exc:
|
|
127
|
+
raise UkTaxError(str(exc)) from exc
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def from_shipped_data(
|
|
131
|
+
cls, future_years: FutureYearsExtension | None = None
|
|
132
|
+
) -> UkTaxSystem:
|
|
133
|
+
"""Build a system over every shipped ``tax_year_*.toml``."""
|
|
134
|
+
years = available_tax_years()
|
|
135
|
+
return cls(
|
|
136
|
+
tax_years=tuple(load_tax_year(year) for year in years),
|
|
137
|
+
future_years=future_years,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def assess(self, period: Period, tax_input: TaxInput) -> TaxResult:
|
|
141
|
+
"""Assess one period's categorised income (planning §4.2)."""
|
|
142
|
+
return _assess_year(self._tax_year_for(period), tax_input)
|
|
143
|
+
|
|
144
|
+
def annual_allowance_charge(
|
|
145
|
+
self, period: Period, tax_input: TaxInput, excess: Money
|
|
146
|
+
) -> tuple[TaxLine, ...]:
|
|
147
|
+
"""Price the annual-allowance charge on a pension-input excess.
|
|
148
|
+
|
|
149
|
+
FA 2004 s227B: the chargeable amount is treated as the top
|
|
150
|
+
slice of the individual's reduced net income and charged at
|
|
151
|
+
the income-tax rates it falls into — a freestanding charge,
|
|
152
|
+
not income, so the personal allowance and its taper never
|
|
153
|
+
move. The lines stack from the assessment's total taxable
|
|
154
|
+
income (the s23 step-3 position) up the taxpayer's own
|
|
155
|
+
schedule — Scottish rates for Scottish taxpayers — with the
|
|
156
|
+
rate limits extended by any relief-at-source gross exactly as
|
|
157
|
+
the assessment extends them (FA 2004 s192(4) applies for all
|
|
158
|
+
income-tax purposes). Known simplification: savings and
|
|
159
|
+
dividend income consume width on the taxpayer's schedule here
|
|
160
|
+
even though the assessment positions those layers on the rUK
|
|
161
|
+
ladder — the divergence only affects Scottish taxpayers with
|
|
162
|
+
portfolio income, and only the charge's band boundaries. Each
|
|
163
|
+
line's tax is rounded down to the penny like every band line
|
|
164
|
+
(module docstring).
|
|
165
|
+
"""
|
|
166
|
+
if excess <= _ZERO:
|
|
167
|
+
return ()
|
|
168
|
+
year = self._tax_year_for(period)
|
|
169
|
+
position = _assess_year(year, tax_input).taxable_income
|
|
170
|
+
bands = _extended_bands(
|
|
171
|
+
_schedule_for(year, tax_input.residency).bands,
|
|
172
|
+
tax_input.relief_at_source_contributions,
|
|
173
|
+
)
|
|
174
|
+
return _ladder_lines(
|
|
175
|
+
bands,
|
|
176
|
+
start=position,
|
|
177
|
+
amount=excess,
|
|
178
|
+
line_name=_aa_charge_band_name,
|
|
179
|
+
line_rate=_band_own_rate,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _series(self) -> TaxYearSeries:
|
|
183
|
+
"""The shared year-resolution series over this system's files."""
|
|
184
|
+
return TaxYearSeries(tax_years=self.tax_years, future_years=self.future_years)
|
|
185
|
+
|
|
186
|
+
def _tax_year_for(self, period: Period) -> TaxYearFile:
|
|
187
|
+
"""The shipped or synthesized file fully containing ``period``."""
|
|
188
|
+
try:
|
|
189
|
+
return self._series().year_for(period)
|
|
190
|
+
except UkTaxYearError as exc:
|
|
191
|
+
raise UkTaxError(str(exc)) from exc
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _schedule_for(year: TaxYearFile, residency: TaxResidencyId) -> IncomeTaxSchedule:
|
|
195
|
+
"""The income-tax schedule for ``residency`` in ``year``."""
|
|
196
|
+
if residency == RUK_RESIDENCY:
|
|
197
|
+
return year.income_tax_ruk
|
|
198
|
+
if residency == SCOTLAND_RESIDENCY:
|
|
199
|
+
return year.income_tax_scotland
|
|
200
|
+
msg = f"unknown UK tax residency {residency!r}"
|
|
201
|
+
raise UkTaxError(msg)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _tapered_allowance(
|
|
205
|
+
schedule: IncomeTaxSchedule, adjusted_net_income: Money
|
|
206
|
+
) -> Money:
|
|
207
|
+
"""The personal allowance after the taper (planning §6).
|
|
208
|
+
|
|
209
|
+
Per HMRC's calculation, the reduction (``pa_taper_rate`` of adjusted
|
|
210
|
+
net income above ``pa_taper_threshold``) is rounded down to the
|
|
211
|
+
whole pound and the resulting allowance rounded up to the whole
|
|
212
|
+
pound, so the allowance steps down £1 per full £2 of excess with the
|
|
213
|
+
shipped rate, floored at zero.
|
|
214
|
+
"""
|
|
215
|
+
excess = adjusted_net_income - schedule.pa_taper_threshold
|
|
216
|
+
if excess <= _ZERO:
|
|
217
|
+
return schedule.personal_allowance
|
|
218
|
+
reduction = _round_down(schedule.pa_taper_rate.of(excess), _POUND)
|
|
219
|
+
allowance = _round_up(schedule.personal_allowance - reduction, _POUND)
|
|
220
|
+
return max(allowance, _ZERO)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _ladder_lines(
|
|
224
|
+
bands: tuple[TaxBand, ...],
|
|
225
|
+
*,
|
|
226
|
+
start: Money,
|
|
227
|
+
amount: Money,
|
|
228
|
+
line_name: Callable[[int, TaxBand], str],
|
|
229
|
+
line_rate: Callable[[int, TaxBand], Rate],
|
|
230
|
+
) -> tuple[TaxLine, ...]:
|
|
231
|
+
"""Charge ``amount`` of income stacked from ``start`` up the ladder.
|
|
232
|
+
|
|
233
|
+
Band uppers are cumulative taxable income above the allowance
|
|
234
|
+
(§5.3): the slice of each band between ``start`` and
|
|
235
|
+
``start + amount`` is charged at the band's line rate — the band's
|
|
236
|
+
own for non-savings and savings layers, the aligned dividend rate
|
|
237
|
+
for dividends — each rounded down to the penny per HMRC's
|
|
238
|
+
calculation. Bands with nothing in them are omitted.
|
|
239
|
+
"""
|
|
240
|
+
lines: list[TaxLine] = []
|
|
241
|
+
end = start + amount
|
|
242
|
+
lower = _ZERO
|
|
243
|
+
for index, band in enumerate(bands):
|
|
244
|
+
ceiling = end if band.upper is None else min(band.upper, end)
|
|
245
|
+
floor = max(lower, start)
|
|
246
|
+
in_band = ceiling - floor
|
|
247
|
+
if in_band > _ZERO:
|
|
248
|
+
rate = line_rate(index, band)
|
|
249
|
+
lines.append(
|
|
250
|
+
TaxLine(
|
|
251
|
+
band=line_name(index, band),
|
|
252
|
+
rate=rate,
|
|
253
|
+
taxed=in_band,
|
|
254
|
+
tax=_round_down(rate.of(in_band), _PENNY),
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
if band.upper is None or end <= band.upper:
|
|
258
|
+
break
|
|
259
|
+
lower = band.upper
|
|
260
|
+
return tuple(lines)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _extended_bands(
|
|
264
|
+
bands: tuple[TaxBand, ...], extension: Money
|
|
265
|
+
) -> tuple[TaxBand, ...]:
|
|
266
|
+
"""Extend the basic and later band thresholds by a relief-at-source gross.
|
|
267
|
+
|
|
268
|
+
HMRC's relief-at-source mechanism: the basic rate limit and every
|
|
269
|
+
rate limit above it grow by the gross contribution, so more income
|
|
270
|
+
is taxed at the lower rates. Limits below the basic band — the
|
|
271
|
+
Scottish starter rate — never move (FA 2004 s192; SI 2018/459 for
|
|
272
|
+
Scottish taxpayers), and the unbounded top band needs no move.
|
|
273
|
+
"""
|
|
274
|
+
if extension <= _ZERO:
|
|
275
|
+
return bands
|
|
276
|
+
basic_index = next(
|
|
277
|
+
index for index, band in enumerate(bands) if band.name == BASIC_BAND_NAME
|
|
278
|
+
)
|
|
279
|
+
return tuple(
|
|
280
|
+
TaxBand(name=band.name, rate=band.rate, upper=band.upper + extension)
|
|
281
|
+
if index >= basic_index and band.upper is not None
|
|
282
|
+
else band
|
|
283
|
+
for index, band in enumerate(bands)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _band_own_name(_index: int, band: TaxBand) -> str:
|
|
288
|
+
"""A band's own name — the non-savings line label."""
|
|
289
|
+
return band.name
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _band_own_rate(_index: int, band: TaxBand) -> Rate:
|
|
293
|
+
"""A band's own rate — the non-savings and savings line rate."""
|
|
294
|
+
return band.rate
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _savings_band_name(_index: int, band: TaxBand) -> str:
|
|
298
|
+
"""A savings line label: the band name under the layer prefix."""
|
|
299
|
+
return f"savings_{band.name}"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _aa_charge_band_name(_index: int, band: TaxBand) -> str:
|
|
303
|
+
"""An annual-allowance-charge line label under the layer prefix."""
|
|
304
|
+
return f"aa_charge_{band.name}"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _nil_line(name: str, taxed: Money) -> TaxLine:
|
|
308
|
+
"""A zero-tax line that still consumes band width (planning §6)."""
|
|
309
|
+
return TaxLine(band=name, rate=_NIL_RATE, taxed=taxed, tax=_ZERO)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _psa_amount(
|
|
313
|
+
savings: SavingsRules, bands: tuple[TaxBand, ...], total_taxable: Money
|
|
314
|
+
) -> Money:
|
|
315
|
+
"""The personal savings allowance tier (planning §6).
|
|
316
|
+
|
|
317
|
+
The tier follows the band ``total_taxable`` reaches on the
|
|
318
|
+
(relief-extended) rUK ladder: within the basic band's limit the
|
|
319
|
+
basic tier applies, in the unbounded top band the additional tier,
|
|
320
|
+
and the higher tier between.
|
|
321
|
+
"""
|
|
322
|
+
basic_upper = next(band.upper for band in bands if band.name == BASIC_BAND_NAME)
|
|
323
|
+
if basic_upper is None or total_taxable <= basic_upper:
|
|
324
|
+
return savings.psa_basic
|
|
325
|
+
top_floor = bands[-2].upper if len(bands) > 1 else None
|
|
326
|
+
if top_floor is not None and total_taxable > top_floor:
|
|
327
|
+
return savings.psa_additional
|
|
328
|
+
return savings.psa_higher
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _savings_lines(
|
|
332
|
+
savings: SavingsRules,
|
|
333
|
+
bands: tuple[TaxBand, ...],
|
|
334
|
+
*,
|
|
335
|
+
taxable_savings: Money,
|
|
336
|
+
taxable_non_savings: Money,
|
|
337
|
+
total_taxable: Money,
|
|
338
|
+
) -> tuple[Money, tuple[TaxLine, ...]]:
|
|
339
|
+
"""The savings layer: starting rate, PSA, then the band rates.
|
|
340
|
+
|
|
341
|
+
The layer stacks directly above the non-savings taxable income on
|
|
342
|
+
the rUK ladder. The starting rate for savings covers what remains
|
|
343
|
+
of its limit after non-savings taxable income eats it £1 per £1
|
|
344
|
+
(planning §6); the PSA nil rate follows; the remainder is charged
|
|
345
|
+
at the rUK band rates (savings rates separate from the main rates
|
|
346
|
+
ship as data from 2027/28, §6). Returns the ladder position after
|
|
347
|
+
the layer plus the layer's lines.
|
|
348
|
+
"""
|
|
349
|
+
lines: list[TaxLine] = []
|
|
350
|
+
position = taxable_non_savings
|
|
351
|
+
remaining = taxable_savings
|
|
352
|
+
starting_headroom = max(savings.starting_rate_limit - taxable_non_savings, _ZERO)
|
|
353
|
+
starting = min(remaining, starting_headroom)
|
|
354
|
+
if starting > _ZERO:
|
|
355
|
+
lines.append(_nil_line(SAVINGS_STARTING_RATE_BAND, starting))
|
|
356
|
+
position = position + starting
|
|
357
|
+
remaining = remaining - starting
|
|
358
|
+
psa = min(remaining, _psa_amount(savings, bands, total_taxable))
|
|
359
|
+
if psa > _ZERO:
|
|
360
|
+
lines.append(_nil_line(SAVINGS_NIL_RATE_BAND, psa))
|
|
361
|
+
position = position + psa
|
|
362
|
+
remaining = remaining - psa
|
|
363
|
+
if remaining > _ZERO:
|
|
364
|
+
lines.extend(
|
|
365
|
+
_ladder_lines(
|
|
366
|
+
bands,
|
|
367
|
+
start=position,
|
|
368
|
+
amount=remaining,
|
|
369
|
+
line_name=_savings_band_name,
|
|
370
|
+
line_rate=_band_own_rate,
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
position = position + remaining
|
|
374
|
+
return position, tuple(lines)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _dividend_lines(
|
|
378
|
+
dividend: DividendRules,
|
|
379
|
+
bands: tuple[TaxBand, ...],
|
|
380
|
+
*,
|
|
381
|
+
position: Money,
|
|
382
|
+
taxable_dividends: Money,
|
|
383
|
+
) -> tuple[TaxLine, ...]:
|
|
384
|
+
"""The dividend layer: the allowance nil rate, then dividend rates.
|
|
385
|
+
|
|
386
|
+
The dividend allowance is a nil rate consuming band width (planning
|
|
387
|
+
§6); the remainder is charged at the dividend rates aligned
|
|
388
|
+
positionally with the rUK bands (schema invariant).
|
|
389
|
+
"""
|
|
390
|
+
lines: list[TaxLine] = []
|
|
391
|
+
remaining = taxable_dividends
|
|
392
|
+
nil = min(remaining, dividend.allowance)
|
|
393
|
+
if nil > _ZERO:
|
|
394
|
+
lines.append(_nil_line(DIVIDEND_NIL_RATE_BAND, nil))
|
|
395
|
+
position = position + nil
|
|
396
|
+
remaining = remaining - nil
|
|
397
|
+
if remaining > _ZERO:
|
|
398
|
+
lines.extend(
|
|
399
|
+
_ladder_lines(
|
|
400
|
+
bands,
|
|
401
|
+
start=position,
|
|
402
|
+
amount=remaining,
|
|
403
|
+
line_name=lambda index, _band: f"dividend_{dividend.rates[index].name}",
|
|
404
|
+
line_rate=lambda index, _band: dividend.rates[index].rate,
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
return tuple(lines)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _assess_year(year: TaxYearFile, tax_input: TaxInput) -> TaxResult:
|
|
411
|
+
"""Assess one period's categorised income (module docstring).
|
|
412
|
+
|
|
413
|
+
The personal allowance is set against income in the stacking order
|
|
414
|
+
— non-savings, savings, dividends (HMRC's default ordering) — and
|
|
415
|
+
each category's taxable remainder is charged through its layer.
|
|
416
|
+
Savings and dividends always stack on the rUK ladder, above the
|
|
417
|
+
non-savings taxable income, whatever schedule taxed that income
|
|
418
|
+
(planning §6).
|
|
419
|
+
"""
|
|
420
|
+
schedule = _schedule_for(year, tax_input.residency)
|
|
421
|
+
ras_gross = tax_input.relief_at_source_contributions
|
|
422
|
+
non_savings = tax_input.non_savings_income
|
|
423
|
+
savings = tax_input.savings_income
|
|
424
|
+
dividends = tax_input.dividend_income
|
|
425
|
+
adjusted_net_income = max(non_savings + savings + dividends - ras_gross, _ZERO)
|
|
426
|
+
allowance = _tapered_allowance(schedule, adjusted_net_income)
|
|
427
|
+
taxable_non_savings = max(non_savings - allowance, _ZERO)
|
|
428
|
+
allowance_left = max(allowance - non_savings, _ZERO)
|
|
429
|
+
taxable_savings = max(savings - allowance_left, _ZERO)
|
|
430
|
+
allowance_left = max(allowance_left - savings, _ZERO)
|
|
431
|
+
taxable_dividends = max(dividends - allowance_left, _ZERO)
|
|
432
|
+
total_taxable = taxable_non_savings + taxable_savings + taxable_dividends
|
|
433
|
+
lines = list(
|
|
434
|
+
_ladder_lines(
|
|
435
|
+
_extended_bands(schedule.bands, ras_gross),
|
|
436
|
+
start=_ZERO,
|
|
437
|
+
amount=taxable_non_savings,
|
|
438
|
+
line_name=_band_own_name,
|
|
439
|
+
line_rate=_band_own_rate,
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
if taxable_savings > _ZERO or taxable_dividends > _ZERO:
|
|
443
|
+
ruk_bands = _extended_bands(year.income_tax_ruk.bands, ras_gross)
|
|
444
|
+
position, savings_lines = _savings_lines(
|
|
445
|
+
year.savings,
|
|
446
|
+
ruk_bands,
|
|
447
|
+
taxable_savings=taxable_savings,
|
|
448
|
+
taxable_non_savings=taxable_non_savings,
|
|
449
|
+
total_taxable=total_taxable,
|
|
450
|
+
)
|
|
451
|
+
lines.extend(savings_lines)
|
|
452
|
+
lines.extend(
|
|
453
|
+
_dividend_lines(
|
|
454
|
+
year.dividend,
|
|
455
|
+
ruk_bands,
|
|
456
|
+
position=position,
|
|
457
|
+
taxable_dividends=taxable_dividends,
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
tax_due = sum((line.tax for line in lines), start=_ZERO)
|
|
461
|
+
return TaxResult(
|
|
462
|
+
tax_due=tax_due,
|
|
463
|
+
taxable_income=total_taxable,
|
|
464
|
+
tax_free_allowance=allowance,
|
|
465
|
+
lines=tuple(lines),
|
|
466
|
+
)
|