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,717 @@
|
|
|
1
|
+
"""UK contribution relief mechanics and pension allowances (roadmap 3.2, 3.3).
|
|
2
|
+
|
|
3
|
+
Implements the core
|
|
4
|
+
:class:`~glidepath.core.ContributionRuleset` protocol plus the UK's
|
|
5
|
+
cross-pension contribution measures: the annual allowance and its taper,
|
|
6
|
+
and the money purchase annual allowance (MPAA). Every figure — the
|
|
7
|
+
relief-at-source rate, the member relief basic amount, the allowances
|
|
8
|
+
and taper parameters — comes from the tax-year data files (§5.3);
|
|
9
|
+
nothing is hardcoded here (guard-tested).
|
|
10
|
+
|
|
11
|
+
**Relief mechanics** (planning §5.1, §6). Member amounts are *gross*:
|
|
12
|
+
|
|
13
|
+
- Relief at source: the member pays the gross amount less basic-rate
|
|
14
|
+
relief from taxed income and the provider reclaims the difference, so
|
|
15
|
+
the pot receives the gross amount; higher and additional rates arrive
|
|
16
|
+
via assessment (:class:`~glidepath.regions.uk.tax.UkTaxSystem` extends
|
|
17
|
+
its band thresholds by the gross amount). Member relief is limited to
|
|
18
|
+
100% of relevant UK earnings, or the basic amount for low/no earners —
|
|
19
|
+
a floor available through relief at source only (FA 2004 s190).
|
|
20
|
+
- Net pay: the gross amount leaves pay before tax, so full marginal
|
|
21
|
+
relief is immediate and no assessment adjustment applies. Relief
|
|
22
|
+
cannot exceed pay, and the basic-amount floor does not apply.
|
|
23
|
+
|
|
24
|
+
The relief limit is a per-person, per-tax-year aggregate over every
|
|
25
|
+
scheme and mechanic (PTM044220), threaded through
|
|
26
|
+
``already_relieved_gross`` on the request; and contributions paid from
|
|
27
|
+
the member's ``member_relief_max_age`` birthday on are never
|
|
28
|
+
relievable, whatever the earnings (FA 2004 s188(3)(a), PTM044100).
|
|
29
|
+
Contributions beyond the relief limit are clipped and reported, not
|
|
30
|
+
contributed unrelieved (planning §5.1 keeps wrappers relief-clean;
|
|
31
|
+
they are never rerouted — a schedule states intent for one wrapper,
|
|
32
|
+
and taxable saving is scheduled on a GIA directly, roadmap 9.2).
|
|
33
|
+
|
|
34
|
+
**Annual allowance** (planning §5.2 step 3, §6). The AA measures total
|
|
35
|
+
*pension input amounts* — member gross plus employer contributions and
|
|
36
|
+
DB accrual (:func:`db_pension_input_amount`, roadmap 9.6: closing less
|
|
37
|
+
CPI-uprated opening value at the data file's ``db_valuation_factor``,
|
|
38
|
+
FA 2004 s234 and s235, floored at nil per PTM053301) — distinct from the
|
|
39
|
+
member relief limit. High
|
|
40
|
+
income tapers it: £1 less per £2 of adjusted income over the threshold
|
|
41
|
+
(reduction rounded down to the whole pound, PTM057100), floored. After
|
|
42
|
+
flexible access the MPAA caps money-purchase inputs, leaving the
|
|
43
|
+
*alternative* annual allowance — the (possibly tapered) AA minus the
|
|
44
|
+
MPAA — for other accrual; the chargeable excess is the greater of the
|
|
45
|
+
default and alternative computations (FA 2004 s227ZA).
|
|
46
|
+
|
|
47
|
+
**Carry-forward** (roadmap 9.5; rules verified 2026-08-04 against the
|
|
48
|
+
gov.uk guidance). Unused allowance from the previous
|
|
49
|
+
``pension.aa_carry_forward_years`` tax years (three, shipped as data)
|
|
50
|
+
raises the year's allowance before the excess is charged (FA 2004
|
|
51
|
+
s228A). :func:`apply_carry_forward` sets the pool against an assessed
|
|
52
|
+
excess, consuming years earliest-first and only to the extent that
|
|
53
|
+
reduces the charge; the MPAA is never topped up, so the money-purchase
|
|
54
|
+
excess is a floor no carry-forward can offset (HS345).
|
|
55
|
+
:func:`carry_forward_generated` is what a year adds to the pool — only
|
|
56
|
+
the unused *alternative* allowance once money-purchase inputs exceed
|
|
57
|
+
the MPAA (PTM056510), and nothing from a year without
|
|
58
|
+
registered-scheme membership.
|
|
59
|
+
:func:`roll_carry_forward` advances the pool one tax year, expiring the
|
|
60
|
+
oldest entry.
|
|
61
|
+
|
|
62
|
+
**Charge funding** (roadmap 9.21, #124; verified 2026-08-06 against
|
|
63
|
+
PTM056410). :meth:`UkContributionRuleset.annual_allowance_funding`
|
|
64
|
+
splits the priced charge between Scheme Pays — the whole charge debited
|
|
65
|
+
from the pension wrapper with the largest qualifying input when the
|
|
66
|
+
mandatory conditions hold (charge over ``scheme_pays_min_charge``,
|
|
67
|
+
that wrapper's own input over the standard AA; FA 2004 s237B) — and
|
|
68
|
+
cash from the person's taxable accounts otherwise (planning §5.2
|
|
69
|
+
records the whole-charge simplification).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
from dataclasses import dataclass
|
|
73
|
+
from decimal import ROUND_DOWN, Decimal
|
|
74
|
+
from typing import TYPE_CHECKING
|
|
75
|
+
|
|
76
|
+
from glidepath.core import (
|
|
77
|
+
AnnualAllowanceFunding,
|
|
78
|
+
AnnualAllowanceOutcome,
|
|
79
|
+
MemberContributionOutcome,
|
|
80
|
+
Money,
|
|
81
|
+
ReliefMechanic,
|
|
82
|
+
SchemePayment,
|
|
83
|
+
date_age_attained,
|
|
84
|
+
)
|
|
85
|
+
from glidepath.regions.uk.loader import available_tax_years, load_tax_year
|
|
86
|
+
from glidepath.regions.uk.years import TaxYearSeries, UkTaxYearError
|
|
87
|
+
|
|
88
|
+
if TYPE_CHECKING:
|
|
89
|
+
from collections.abc import Sequence
|
|
90
|
+
from datetime import date
|
|
91
|
+
|
|
92
|
+
from glidepath.core import (
|
|
93
|
+
AnnualAllowanceMeasurement,
|
|
94
|
+
MemberContributionRequest,
|
|
95
|
+
Period,
|
|
96
|
+
SchemeInput,
|
|
97
|
+
)
|
|
98
|
+
from glidepath.regions.uk.extension import FutureYearsExtension
|
|
99
|
+
from glidepath.regions.uk.schema import PensionRules, TaxYearFile
|
|
100
|
+
|
|
101
|
+
_ZERO = Money(Decimal(0))
|
|
102
|
+
_POUND = Decimal(1)
|
|
103
|
+
_ONE = Decimal(1)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class UkContributionError(ValueError):
|
|
107
|
+
"""A contribution query the shipped UK data cannot answer."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _require_non_negative(amount: Money, name: str) -> None:
|
|
111
|
+
"""Reject a negative monetary input."""
|
|
112
|
+
if amount < _ZERO:
|
|
113
|
+
msg = f"{name} must be non-negative"
|
|
114
|
+
raise UkContributionError(msg)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _unrelieved(gross: Money) -> MemberContributionOutcome:
|
|
118
|
+
"""The whole contribution is clipped: no relief is available."""
|
|
119
|
+
return MemberContributionOutcome(
|
|
120
|
+
gross_to_pot=_ZERO,
|
|
121
|
+
member_cash_cost=_ZERO,
|
|
122
|
+
provider_relief=_ZERO,
|
|
123
|
+
taxable_pay_deduction=_ZERO,
|
|
124
|
+
assessment_relief_gross=_ZERO,
|
|
125
|
+
unrelieved_excess=gross,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _headroom(request: MemberContributionRequest, *, cap: Money) -> Money:
|
|
130
|
+
"""The relievable part of the request under a per-person cap.
|
|
131
|
+
|
|
132
|
+
The member relief limit is shared across every wrapper and
|
|
133
|
+
mechanic, so relief already granted this period comes off the cap
|
|
134
|
+
before this contribution is measured against it.
|
|
135
|
+
"""
|
|
136
|
+
remaining = max(cap - request.already_relieved_gross, _ZERO)
|
|
137
|
+
return min(request.gross, remaining)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True, slots=True)
|
|
141
|
+
class UkContributionRuleset:
|
|
142
|
+
"""UK implementation of the core ``ContributionRuleset`` protocol.
|
|
143
|
+
|
|
144
|
+
Holds the tax-year files its figures come from, sharing the tax
|
|
145
|
+
system's coverage semantics: shipped data beats extrapolation, and
|
|
146
|
+
a query outside coverage fails loudly rather than answering from
|
|
147
|
+
the wrong year.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
tax_years: tuple[TaxYearFile, ...]
|
|
151
|
+
future_years: FutureYearsExtension | None = None
|
|
152
|
+
|
|
153
|
+
def __post_init__(self) -> None:
|
|
154
|
+
"""Require at least one year, ascending and non-overlapping."""
|
|
155
|
+
try:
|
|
156
|
+
self._series()
|
|
157
|
+
except UkTaxYearError as exc:
|
|
158
|
+
raise UkContributionError(str(exc)) from exc
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def from_shipped_data(
|
|
162
|
+
cls, future_years: FutureYearsExtension | None = None
|
|
163
|
+
) -> UkContributionRuleset:
|
|
164
|
+
"""Build a ruleset over every shipped data file."""
|
|
165
|
+
years = available_tax_years()
|
|
166
|
+
return cls(
|
|
167
|
+
tax_years=tuple(load_tax_year(year) for year in years),
|
|
168
|
+
future_years=future_years,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def member_contribution(
|
|
172
|
+
self, request: MemberContributionRequest, period: Period
|
|
173
|
+
) -> MemberContributionOutcome:
|
|
174
|
+
"""Resolve one gross member contribution for ``period`` (module doc).
|
|
175
|
+
|
|
176
|
+
With no mechanic the contribution is plain post-tax cash (e.g.
|
|
177
|
+
an ISA): no relief, no limit, nothing for the assessment. The
|
|
178
|
+
period's tax year is always resolved — even on that path — so a
|
|
179
|
+
query outside data coverage fails loudly (class docstring).
|
|
180
|
+
|
|
181
|
+
Relief shuts off from the period in which the member's
|
|
182
|
+
``member_relief_max_age`` birthday falls: contributions paid
|
|
183
|
+
after that birthday are never relievable (FA 2004 s188(3)(a)),
|
|
184
|
+
and at annual resolution the whole period is treated that way —
|
|
185
|
+
conservative in the §4.1 sense, so the model never grants
|
|
186
|
+
relief the person could not get in reality.
|
|
187
|
+
"""
|
|
188
|
+
pension = self._year_for(period).pension
|
|
189
|
+
if request.mechanic is None:
|
|
190
|
+
return MemberContributionOutcome(
|
|
191
|
+
gross_to_pot=request.gross,
|
|
192
|
+
member_cash_cost=request.gross,
|
|
193
|
+
provider_relief=_ZERO,
|
|
194
|
+
taxable_pay_deduction=_ZERO,
|
|
195
|
+
assessment_relief_gross=_ZERO,
|
|
196
|
+
unrelieved_excess=_ZERO,
|
|
197
|
+
)
|
|
198
|
+
max_age_birthday = date_age_attained(
|
|
199
|
+
request.date_of_birth, pension.member_relief_max_age
|
|
200
|
+
)
|
|
201
|
+
if max_age_birthday <= period.end:
|
|
202
|
+
return _unrelieved(request.gross)
|
|
203
|
+
if request.mechanic is ReliefMechanic.NET_PAY:
|
|
204
|
+
relievable = _headroom(request, cap=request.relevant_earnings)
|
|
205
|
+
return MemberContributionOutcome(
|
|
206
|
+
gross_to_pot=relievable,
|
|
207
|
+
member_cash_cost=relievable,
|
|
208
|
+
provider_relief=_ZERO,
|
|
209
|
+
taxable_pay_deduction=relievable,
|
|
210
|
+
assessment_relief_gross=_ZERO,
|
|
211
|
+
unrelieved_excess=request.gross - relievable,
|
|
212
|
+
)
|
|
213
|
+
limit = max(request.relevant_earnings, pension.member_relief_basic_amount)
|
|
214
|
+
relievable = _headroom(request, cap=limit)
|
|
215
|
+
relief = pension.relief_at_source_rate.of(relievable)
|
|
216
|
+
return MemberContributionOutcome(
|
|
217
|
+
gross_to_pot=relievable,
|
|
218
|
+
member_cash_cost=relievable - relief,
|
|
219
|
+
provider_relief=relief,
|
|
220
|
+
taxable_pay_deduction=_ZERO,
|
|
221
|
+
assessment_relief_gross=relievable,
|
|
222
|
+
unrelieved_excess=request.gross - relievable,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def annual_allowance(
|
|
226
|
+
self, measurement: AnnualAllowanceMeasurement, period: Period
|
|
227
|
+
) -> AnnualAllowanceOutcome:
|
|
228
|
+
"""Measure a period's pension inputs against the UK allowances.
|
|
229
|
+
|
|
230
|
+
The full module-docstring pipeline for one tax year: DB
|
|
231
|
+
entitlements value into pension input amounts
|
|
232
|
+
(:func:`db_pension_input_amount`), the taper measures resolve
|
|
233
|
+
(:func:`threshold_income`, :func:`adjusted_income` — every DB
|
|
234
|
+
input counts as employer-funded, since the model has no member
|
|
235
|
+
DB contributions), the tapered allowance measures the inputs
|
|
236
|
+
(:func:`assess_annual_allowance`), and prior years' unused
|
|
237
|
+
allowance offsets the excess before this year's own unused
|
|
238
|
+
allowance joins the rolled pool (:func:`apply_carry_forward`,
|
|
239
|
+
:func:`roll_carry_forward`).
|
|
240
|
+
|
|
241
|
+
The MPAA is active from the period containing the trigger date
|
|
242
|
+
(:func:`is_mpaa_active`) — the measurement's trigger is the one
|
|
243
|
+
standing when the period's contributions were made, so inputs
|
|
244
|
+
paid before an in-period trigger are measured against the full
|
|
245
|
+
allowance, matching the statute's pre/post-trigger split at
|
|
246
|
+
the engine's own event order (HS345; planning §5.2).
|
|
247
|
+
|
|
248
|
+
A pool longer than this year's statutory window — possible
|
|
249
|
+
only if a data file ever shrinks ``aa_carry_forward_years``
|
|
250
|
+
between years — keeps its most recent years, the oldest
|
|
251
|
+
expiring, rather than failing the run.
|
|
252
|
+
"""
|
|
253
|
+
pension = self._year_for(period).pension
|
|
254
|
+
db_total = _ZERO
|
|
255
|
+
for arrangement in measurement.db_arrangements:
|
|
256
|
+
db_total = db_total + db_pension_input_amount(
|
|
257
|
+
pension,
|
|
258
|
+
opening_annual=arrangement.opening_annual,
|
|
259
|
+
closing_annual=arrangement.closing_annual,
|
|
260
|
+
cpi=measurement.cpi,
|
|
261
|
+
)
|
|
262
|
+
threshold = threshold_income(
|
|
263
|
+
total_income=measurement.total_income,
|
|
264
|
+
net_pay_contributions=measurement.net_pay_contributions,
|
|
265
|
+
relief_at_source_gross=measurement.relief_at_source_gross,
|
|
266
|
+
)
|
|
267
|
+
adjusted = adjusted_income(
|
|
268
|
+
total_income=measurement.total_income,
|
|
269
|
+
employer_pension_inputs=measurement.employer_money_purchase + db_total,
|
|
270
|
+
)
|
|
271
|
+
allowance = tapered_annual_allowance(
|
|
272
|
+
pension, threshold=threshold, adjusted=adjusted
|
|
273
|
+
)
|
|
274
|
+
assessment = assess_annual_allowance(
|
|
275
|
+
pension,
|
|
276
|
+
annual_allowance=allowance,
|
|
277
|
+
money_purchase_inputs=(
|
|
278
|
+
measurement.member_money_purchase + measurement.employer_money_purchase
|
|
279
|
+
),
|
|
280
|
+
other_inputs=db_total,
|
|
281
|
+
mpaa_active=is_mpaa_active(measurement.mpaa_triggered_on, period),
|
|
282
|
+
)
|
|
283
|
+
window = pension.aa_carry_forward_years
|
|
284
|
+
pool = measurement.carry_forward[-window:] if window else ()
|
|
285
|
+
set_off = apply_carry_forward(pension, assessment, pool)
|
|
286
|
+
generated = carry_forward_generated(
|
|
287
|
+
assessment, scheme_member=measurement.scheme_member
|
|
288
|
+
)
|
|
289
|
+
return AnnualAllowanceOutcome(
|
|
290
|
+
chargeable_excess=set_off.chargeable_excess,
|
|
291
|
+
carry_forward=roll_carry_forward(pension, set_off.remaining, generated),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def annual_allowance_funding(
|
|
295
|
+
self, charge: Money, schemes: tuple[SchemeInput, ...], period: Period
|
|
296
|
+
) -> AnnualAllowanceFunding:
|
|
297
|
+
"""Split a priced AA charge per the Scheme Pays conditions (#124).
|
|
298
|
+
|
|
299
|
+
Mandatory scheme pays (FA 2004 s237B; PTM056410) is modelled
|
|
300
|
+
on its two conditions: the year's total charge exceeds
|
|
301
|
+
``scheme_pays_min_charge`` and the wrapper's own pension input
|
|
302
|
+
amount exceeds the **standard** annual allowance — the s228
|
|
303
|
+
amount, with the tapered allowance and MPAA ignored. The whole
|
|
304
|
+
charge is then debited from the qualifying wrapper with the
|
|
305
|
+
largest input (planning §5.2 records the whole-charge
|
|
306
|
+
simplification: voluntary scheme pays covers the slice
|
|
307
|
+
mandatory scheme pays strictly would not). Outside the
|
|
308
|
+
conditions the charge falls to cash — the person's bare
|
|
309
|
+
taxable accounts at period close.
|
|
310
|
+
"""
|
|
311
|
+
_require_non_negative(charge, "charge")
|
|
312
|
+
pension = self._year_for(period).pension
|
|
313
|
+
if charge <= pension.scheme_pays_min_charge:
|
|
314
|
+
return AnnualAllowanceFunding(scheme_payments=(), cash=charge)
|
|
315
|
+
qualifying = [
|
|
316
|
+
scheme
|
|
317
|
+
for scheme in schemes
|
|
318
|
+
if scheme.input_amount > pension.annual_allowance
|
|
319
|
+
]
|
|
320
|
+
if not qualifying:
|
|
321
|
+
return AnnualAllowanceFunding(scheme_payments=(), cash=charge)
|
|
322
|
+
paying = max(qualifying, key=lambda scheme: scheme.input_amount.amount)
|
|
323
|
+
return AnnualAllowanceFunding(
|
|
324
|
+
scheme_payments=(
|
|
325
|
+
SchemePayment(wrapper_id=paying.wrapper_id, amount=charge),
|
|
326
|
+
),
|
|
327
|
+
cash=_ZERO,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def _series(self) -> TaxYearSeries:
|
|
331
|
+
"""The shared year-resolution series over this ruleset's files."""
|
|
332
|
+
return TaxYearSeries(tax_years=self.tax_years, future_years=self.future_years)
|
|
333
|
+
|
|
334
|
+
def _year_for(self, period: Period) -> TaxYearFile:
|
|
335
|
+
"""The shipped or synthesized file fully containing ``period``."""
|
|
336
|
+
try:
|
|
337
|
+
return self._series().year_for(period)
|
|
338
|
+
except UkTaxYearError as exc:
|
|
339
|
+
raise UkContributionError(str(exc)) from exc
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def threshold_income(
|
|
343
|
+
*,
|
|
344
|
+
total_income: Money,
|
|
345
|
+
net_pay_contributions: Money,
|
|
346
|
+
relief_at_source_gross: Money,
|
|
347
|
+
) -> Money:
|
|
348
|
+
"""Threshold income for the AA taper (planning §6).
|
|
349
|
+
|
|
350
|
+
``total_income`` is taxable income before any member pension
|
|
351
|
+
deduction; both member contribution routes come off it (net-pay
|
|
352
|
+
amounts never reached taxable pay; relief-at-source gross amounts
|
|
353
|
+
are deducted by definition).
|
|
354
|
+
|
|
355
|
+
Known limitation: HMRC adds back employment income given up under
|
|
356
|
+
salary-sacrifice arrangements made on or after 9 July 2015
|
|
357
|
+
(PTM057100). v1 has no salary-sacrifice concept, so there is
|
|
358
|
+
nothing to add back — a user who models a sacrifice arrangement as
|
|
359
|
+
employer contributions will understate threshold income here.
|
|
360
|
+
"""
|
|
361
|
+
_require_non_negative(total_income, "total_income")
|
|
362
|
+
_require_non_negative(net_pay_contributions, "net_pay_contributions")
|
|
363
|
+
_require_non_negative(relief_at_source_gross, "relief_at_source_gross")
|
|
364
|
+
remaining = total_income - net_pay_contributions - relief_at_source_gross
|
|
365
|
+
return max(remaining, _ZERO)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def adjusted_income(*, total_income: Money, employer_pension_inputs: Money) -> Money:
|
|
369
|
+
"""Adjusted income for the AA taper (planning §6).
|
|
370
|
+
|
|
371
|
+
``total_income`` is taxable income before any member pension
|
|
372
|
+
deduction, so member net-pay amounts are already included (HMRC
|
|
373
|
+
adds them back to net income). ``employer_pension_inputs`` is every
|
|
374
|
+
employer-funded pension input: DC employer contributions plus, for
|
|
375
|
+
DB arrangements, the pension input amount net of the member's own
|
|
376
|
+
contributions (PTM057100) — the whole of
|
|
377
|
+
:func:`db_pension_input_amount`, since the model has no member DB
|
|
378
|
+
contributions (planning §5.1). The engine supplies both through
|
|
379
|
+
each period's :meth:`UkContributionRuleset.annual_allowance`
|
|
380
|
+
measurement (planning §5.2).
|
|
381
|
+
"""
|
|
382
|
+
_require_non_negative(total_income, "total_income")
|
|
383
|
+
_require_non_negative(employer_pension_inputs, "employer_pension_inputs")
|
|
384
|
+
return total_income + employer_pension_inputs
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def db_pension_input_amount(
|
|
388
|
+
pension: PensionRules,
|
|
389
|
+
*,
|
|
390
|
+
opening_annual: Money,
|
|
391
|
+
closing_annual: Money,
|
|
392
|
+
cpi: Decimal,
|
|
393
|
+
) -> Money:
|
|
394
|
+
"""A DB arrangement's pension input amount for one tax year (§6).
|
|
395
|
+
|
|
396
|
+
Closing value less opening value, each value the arrangement's
|
|
397
|
+
annual pension times ``pension.db_valuation_factor`` (FA 2004
|
|
398
|
+
s234; the model has no separate lump sum entitlement — commutation
|
|
399
|
+
is not one, PTM053301). The opening value is uprated by ``cpi``
|
|
400
|
+
(s235's "appropriate percentage" is the 12-month CPI increase to
|
|
401
|
+
the September before the tax year; the run's CPI path stands in,
|
|
402
|
+
planning §5.1/§6), never reduced by deflation, and a negative
|
|
403
|
+
difference is nil (PTM053301) — so a deferred arrangement whose
|
|
404
|
+
revaluation never outruns CPI generates nothing.
|
|
405
|
+
:meth:`UkContributionRuleset.annual_allowance` calls this per
|
|
406
|
+
arrangement each period (planning §5.2); amounts feed
|
|
407
|
+
:func:`assess_annual_allowance` ``other_inputs`` and
|
|
408
|
+
:func:`adjusted_income` ``employer_pension_inputs``.
|
|
409
|
+
"""
|
|
410
|
+
_require_non_negative(opening_annual, "opening_annual")
|
|
411
|
+
_require_non_negative(closing_annual, "closing_annual")
|
|
412
|
+
factor = Decimal(pension.db_valuation_factor)
|
|
413
|
+
uplift = _ONE + max(cpi, Decimal(0))
|
|
414
|
+
opening_value = opening_annual * (factor * uplift)
|
|
415
|
+
closing_value = closing_annual * factor
|
|
416
|
+
return max(closing_value - opening_value, _ZERO)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def tapered_annual_allowance(
|
|
420
|
+
pension: PensionRules,
|
|
421
|
+
*,
|
|
422
|
+
threshold: Money,
|
|
423
|
+
adjusted: Money,
|
|
424
|
+
) -> Money:
|
|
425
|
+
"""The year's annual allowance after the high-income taper (§6).
|
|
426
|
+
|
|
427
|
+
No taper unless *both* incomes exceed their limits: threshold
|
|
428
|
+
income over ``aa_taper_threshold_income`` and adjusted income over
|
|
429
|
+
``aa_taper_adjusted_income``. The reduction — ``aa_taper_rate`` of
|
|
430
|
+
the adjusted-income excess, rounded down to the whole pound
|
|
431
|
+
(PTM057100) — is floored at ``aa_taper_floor``.
|
|
432
|
+
"""
|
|
433
|
+
_require_non_negative(threshold, "threshold")
|
|
434
|
+
_require_non_negative(adjusted, "adjusted")
|
|
435
|
+
if threshold <= pension.aa_taper_threshold_income:
|
|
436
|
+
return pension.annual_allowance
|
|
437
|
+
excess = adjusted - pension.aa_taper_adjusted_income
|
|
438
|
+
if excess <= _ZERO:
|
|
439
|
+
return pension.annual_allowance
|
|
440
|
+
reduction = Money(
|
|
441
|
+
pension.aa_taper_rate.of(excess).amount.quantize(_POUND, rounding=ROUND_DOWN)
|
|
442
|
+
)
|
|
443
|
+
return max(pension.annual_allowance - reduction, pension.aa_taper_floor)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def is_mpaa_active(triggered_on: date | None, period: Period) -> bool:
|
|
447
|
+
"""Whether the MPAA constrains money-purchase inputs in ``period``.
|
|
448
|
+
|
|
449
|
+
Active from the period containing the trigger date onward. Within
|
|
450
|
+
the trigger period itself, statute tests only money-purchase inputs
|
|
451
|
+
made *after* the trigger against the MPAA, counting earlier ones on
|
|
452
|
+
the other side of the comparison (HS345) — that split is the
|
|
453
|
+
caller's job via :func:`assess_annual_allowance`'s inputs. For the
|
|
454
|
+
v1 pre-plan trigger fact (planning §5.1) every projected period is
|
|
455
|
+
wholly post-trigger, so no split arises.
|
|
456
|
+
"""
|
|
457
|
+
return triggered_on is not None and triggered_on <= period.end
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@dataclass(frozen=True, slots=True)
|
|
461
|
+
class AnnualAllowanceAssessment:
|
|
462
|
+
"""One person's annual-allowance position for a period (§5.2 step 3).
|
|
463
|
+
|
|
464
|
+
``annual_allowance`` is the (possibly tapered) allowance measured
|
|
465
|
+
against the recorded pension input amounts. When the MPAA is
|
|
466
|
+
active, ``alternative_annual_allowance`` — the allowance minus the
|
|
467
|
+
MPAA — is what remains for non-money-purchase accrual, and
|
|
468
|
+
``money_purchase_excess`` is the money-purchase input over the
|
|
469
|
+
MPAA. ``chargeable_excess`` is the amount subject to the AA charge
|
|
470
|
+
(the greater of the default and alternative computations, FA 2004
|
|
471
|
+
s227ZA) before any carry-forward; taxing it is a later concern.
|
|
472
|
+
Carry-forward (:func:`apply_carry_forward`) can top up every
|
|
473
|
+
allowance here except the MPAA itself, and :attr:`unused_allowance`
|
|
474
|
+
is what the year offers future pools.
|
|
475
|
+
"""
|
|
476
|
+
|
|
477
|
+
annual_allowance: Money
|
|
478
|
+
money_purchase_inputs: Money
|
|
479
|
+
other_inputs: Money
|
|
480
|
+
mpaa_active: bool
|
|
481
|
+
money_purchase_excess: Money
|
|
482
|
+
alternative_annual_allowance: Money | None
|
|
483
|
+
chargeable_excess: Money
|
|
484
|
+
|
|
485
|
+
def __post_init__(self) -> None:
|
|
486
|
+
"""Require the MPAA-dependent fields exactly when the MPAA is active."""
|
|
487
|
+
if self.mpaa_active == (self.alternative_annual_allowance is None):
|
|
488
|
+
msg = (
|
|
489
|
+
"alternative_annual_allowance is required exactly when"
|
|
490
|
+
" the MPAA is active"
|
|
491
|
+
)
|
|
492
|
+
raise UkContributionError(msg)
|
|
493
|
+
|
|
494
|
+
@property
|
|
495
|
+
def unused_allowance(self) -> Money:
|
|
496
|
+
"""The year's unused allowance for carry-forward purposes.
|
|
497
|
+
|
|
498
|
+
In a year whose money-purchase inputs *exceed* the MPAA only
|
|
499
|
+
the unused *alternative* allowance carries forward — unused
|
|
500
|
+
MPAA headroom never does (HS345). Otherwise — including a
|
|
501
|
+
flexibly-accessed year whose money-purchase inputs stay within
|
|
502
|
+
the MPAA — the full allowance less total pension inputs
|
|
503
|
+
carries (PTM056510). Whether the year in fact generates
|
|
504
|
+
carry-forward also depends on scheme membership
|
|
505
|
+
(:func:`carry_forward_generated`).
|
|
506
|
+
"""
|
|
507
|
+
if (
|
|
508
|
+
self.alternative_annual_allowance is not None
|
|
509
|
+
and self.money_purchase_excess > _ZERO
|
|
510
|
+
):
|
|
511
|
+
return max(self.alternative_annual_allowance - self.other_inputs, _ZERO)
|
|
512
|
+
total = self.money_purchase_inputs + self.other_inputs
|
|
513
|
+
return max(self.annual_allowance - total, _ZERO)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def assess_annual_allowance(
|
|
517
|
+
pension: PensionRules,
|
|
518
|
+
*,
|
|
519
|
+
annual_allowance: Money,
|
|
520
|
+
money_purchase_inputs: Money,
|
|
521
|
+
other_inputs: Money,
|
|
522
|
+
mpaa_active: bool,
|
|
523
|
+
) -> AnnualAllowanceAssessment:
|
|
524
|
+
"""Measure a period's pension input amounts against the allowances.
|
|
525
|
+
|
|
526
|
+
``annual_allowance`` is the year's allowance after any taper
|
|
527
|
+
(:func:`tapered_annual_allowance`); ``money_purchase_inputs`` is
|
|
528
|
+
member gross plus employer DC contributions *made while the MPAA
|
|
529
|
+
applies*; ``other_inputs`` is everything else measured by the AA —
|
|
530
|
+
DB pension input amounts (:func:`db_pension_input_amount`) and, in
|
|
531
|
+
the MPAA trigger period, any money-purchase inputs made before the
|
|
532
|
+
trigger (HS345;
|
|
533
|
+
see :func:`is_mpaa_active`). Unused prior-year allowance is set
|
|
534
|
+
against the assessed excess separately
|
|
535
|
+
(:func:`apply_carry_forward`).
|
|
536
|
+
"""
|
|
537
|
+
_require_non_negative(annual_allowance, "annual_allowance")
|
|
538
|
+
_require_non_negative(money_purchase_inputs, "money_purchase_inputs")
|
|
539
|
+
_require_non_negative(other_inputs, "other_inputs")
|
|
540
|
+
total = money_purchase_inputs + other_inputs
|
|
541
|
+
default_excess = max(total - annual_allowance, _ZERO)
|
|
542
|
+
if not mpaa_active:
|
|
543
|
+
return AnnualAllowanceAssessment(
|
|
544
|
+
annual_allowance=annual_allowance,
|
|
545
|
+
money_purchase_inputs=money_purchase_inputs,
|
|
546
|
+
other_inputs=other_inputs,
|
|
547
|
+
mpaa_active=False,
|
|
548
|
+
money_purchase_excess=_ZERO,
|
|
549
|
+
alternative_annual_allowance=None,
|
|
550
|
+
chargeable_excess=default_excess,
|
|
551
|
+
)
|
|
552
|
+
alternative_allowance = max(annual_allowance - pension.mpaa, _ZERO)
|
|
553
|
+
money_purchase_excess = max(money_purchase_inputs - pension.mpaa, _ZERO)
|
|
554
|
+
chargeable = default_excess
|
|
555
|
+
if money_purchase_excess > _ZERO:
|
|
556
|
+
alternative_excess = money_purchase_excess + max(
|
|
557
|
+
other_inputs - alternative_allowance, _ZERO
|
|
558
|
+
)
|
|
559
|
+
chargeable = max(default_excess, alternative_excess)
|
|
560
|
+
return AnnualAllowanceAssessment(
|
|
561
|
+
annual_allowance=annual_allowance,
|
|
562
|
+
money_purchase_inputs=money_purchase_inputs,
|
|
563
|
+
other_inputs=other_inputs,
|
|
564
|
+
mpaa_active=True,
|
|
565
|
+
money_purchase_excess=money_purchase_excess,
|
|
566
|
+
alternative_annual_allowance=alternative_allowance,
|
|
567
|
+
chargeable_excess=chargeable,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@dataclass(frozen=True, slots=True)
|
|
572
|
+
class CarryForwardOutcome:
|
|
573
|
+
"""Carry-forward set against one year's annual-allowance excess.
|
|
574
|
+
|
|
575
|
+
``used`` and ``remaining`` align entry-by-entry with the supplied
|
|
576
|
+
pool, earliest year first; ``chargeable_excess`` is the
|
|
577
|
+
assessment's excess after the set-off.
|
|
578
|
+
"""
|
|
579
|
+
|
|
580
|
+
chargeable_excess: Money
|
|
581
|
+
used: tuple[Money, ...]
|
|
582
|
+
remaining: tuple[Money, ...]
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _validated_pool(
|
|
586
|
+
pension: PensionRules, pool: Sequence[Money], name: str
|
|
587
|
+
) -> tuple[Money, ...]:
|
|
588
|
+
"""Reject a pool longer than the statutory window or with negatives."""
|
|
589
|
+
years = tuple(pool)
|
|
590
|
+
if len(years) > pension.aa_carry_forward_years:
|
|
591
|
+
msg = (
|
|
592
|
+
f"{name} covers at most the previous"
|
|
593
|
+
f" {pension.aa_carry_forward_years} tax years"
|
|
594
|
+
)
|
|
595
|
+
raise UkContributionError(msg)
|
|
596
|
+
for amount in years:
|
|
597
|
+
_require_non_negative(amount, name)
|
|
598
|
+
return years
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _default_excess(assessment: AnnualAllowanceAssessment) -> Money:
|
|
602
|
+
"""The default computation's excess: total inputs over the allowance."""
|
|
603
|
+
total = assessment.money_purchase_inputs + assessment.other_inputs
|
|
604
|
+
return max(total - assessment.annual_allowance, _ZERO)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _set_off_needed(assessment: AnnualAllowanceAssessment) -> Money:
|
|
608
|
+
"""The smallest set-off that minimises the chargeable excess.
|
|
609
|
+
|
|
610
|
+
Without a money-purchase excess the whole excess is offsettable.
|
|
611
|
+
With one, carry-forward tops up the allowance in both s227ZA
|
|
612
|
+
computations but never the MPAA, so the set-off is worth applying
|
|
613
|
+
only until the default computation falls to the money-purchase
|
|
614
|
+
excess and the other inputs fit the topped-up alternative
|
|
615
|
+
allowance — beyond that the charge cannot fall further.
|
|
616
|
+
"""
|
|
617
|
+
alternative = assessment.alternative_annual_allowance
|
|
618
|
+
if alternative is None or assessment.money_purchase_excess == _ZERO:
|
|
619
|
+
return assessment.chargeable_excess
|
|
620
|
+
other_needed = max(assessment.other_inputs - alternative, _ZERO)
|
|
621
|
+
default_needed = max(
|
|
622
|
+
_default_excess(assessment) - assessment.money_purchase_excess, _ZERO
|
|
623
|
+
)
|
|
624
|
+
return max(other_needed, default_needed)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _chargeable_after_set_off(
|
|
628
|
+
assessment: AnnualAllowanceAssessment, set_off: Money
|
|
629
|
+
) -> Money:
|
|
630
|
+
"""Re-run the s227ZA comparison with the allowances topped up."""
|
|
631
|
+
default_after = max(_default_excess(assessment) - set_off, _ZERO)
|
|
632
|
+
alternative = assessment.alternative_annual_allowance
|
|
633
|
+
if alternative is None or assessment.money_purchase_excess == _ZERO:
|
|
634
|
+
return default_after
|
|
635
|
+
alternative_after = assessment.money_purchase_excess + max(
|
|
636
|
+
assessment.other_inputs - alternative - set_off, _ZERO
|
|
637
|
+
)
|
|
638
|
+
return max(default_after, alternative_after)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _consumed_earliest_first(
|
|
642
|
+
years: tuple[Money, ...], needed: Money
|
|
643
|
+
) -> tuple[tuple[Money, ...], tuple[Money, ...], Money]:
|
|
644
|
+
"""Draw ``needed`` across ``years`` in order: used, remaining, total."""
|
|
645
|
+
used: list[Money] = []
|
|
646
|
+
left = needed
|
|
647
|
+
for available in years:
|
|
648
|
+
take = min(available, left)
|
|
649
|
+
used.append(take)
|
|
650
|
+
left = left - take
|
|
651
|
+
remaining = tuple(
|
|
652
|
+
available - taken for available, taken in zip(years, used, strict=True)
|
|
653
|
+
)
|
|
654
|
+
return tuple(used), remaining, needed - left
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def apply_carry_forward(
|
|
658
|
+
pension: PensionRules,
|
|
659
|
+
assessment: AnnualAllowanceAssessment,
|
|
660
|
+
carry_forward: Sequence[Money],
|
|
661
|
+
) -> CarryForwardOutcome:
|
|
662
|
+
"""Set unused prior-year allowance against an assessed excess.
|
|
663
|
+
|
|
664
|
+
``carry_forward`` holds the unused allowance of the previous tax
|
|
665
|
+
years, earliest first — at most ``pension.aa_carry_forward_years``
|
|
666
|
+
of them (the statutory window). Unused years are drawn in order of
|
|
667
|
+
earliest to most recent and only to the extent that reduces the
|
|
668
|
+
charge (gov.uk guidance, verified 2026-08-04), so what survives
|
|
669
|
+
stays available — within its window — for later years
|
|
670
|
+
(:func:`roll_carry_forward`). Carry-forward raises the annual
|
|
671
|
+
allowance in both s227ZA computations but never the MPAA: the
|
|
672
|
+
money-purchase excess is a floor the set-off cannot reach (HS345).
|
|
673
|
+
"""
|
|
674
|
+
years = _validated_pool(pension, carry_forward, "carry_forward")
|
|
675
|
+
needed = _set_off_needed(assessment)
|
|
676
|
+
used, remaining, set_off = _consumed_earliest_first(years, needed)
|
|
677
|
+
return CarryForwardOutcome(
|
|
678
|
+
chargeable_excess=_chargeable_after_set_off(assessment, set_off),
|
|
679
|
+
used=used,
|
|
680
|
+
remaining=remaining,
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def carry_forward_generated(
|
|
685
|
+
assessment: AnnualAllowanceAssessment, *, scheme_member: bool
|
|
686
|
+
) -> Money:
|
|
687
|
+
"""The unused allowance a year adds to the carry-forward pool.
|
|
688
|
+
|
|
689
|
+
A tax year in which the person was not a member of at least one
|
|
690
|
+
registered pension scheme (or qualifying overseas scheme)
|
|
691
|
+
generates nothing, whatever allowance went unused (gov.uk
|
|
692
|
+
guidance, verified 2026-08-04).
|
|
693
|
+
"""
|
|
694
|
+
return assessment.unused_allowance if scheme_member else _ZERO
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def roll_carry_forward(
|
|
698
|
+
pension: PensionRules,
|
|
699
|
+
remaining: Sequence[Money],
|
|
700
|
+
generated: Money,
|
|
701
|
+
) -> tuple[Money, ...]:
|
|
702
|
+
"""Advance the carry-forward pool one tax year.
|
|
703
|
+
|
|
704
|
+
``remaining`` is what :func:`apply_carry_forward` left of the
|
|
705
|
+
previous years' pool — earliest first, consecutive tax years
|
|
706
|
+
ending with the immediately preceding one; ``generated`` is the
|
|
707
|
+
assessed year's own contribution
|
|
708
|
+
(:func:`carry_forward_generated`). The oldest entry expires as it
|
|
709
|
+
leaves the ``aa_carry_forward_years`` window.
|
|
710
|
+
"""
|
|
711
|
+
_require_non_negative(generated, "generated")
|
|
712
|
+
years = _validated_pool(pension, remaining, "remaining")
|
|
713
|
+
width = pension.aa_carry_forward_years
|
|
714
|
+
if width == 0:
|
|
715
|
+
return ()
|
|
716
|
+
kept = years[max(len(years) - (width - 1), 0) :]
|
|
717
|
+
return (*kept, generated)
|