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.
Files changed (93) hide show
  1. glidepath/__init__.py +3 -0
  2. glidepath/app/__init__.py +364 -0
  3. glidepath/app/backtest.py +281 -0
  4. glidepath/app/charts.py +759 -0
  5. glidepath/app/copy.py +174 -0
  6. glidepath/app/display.py +148 -0
  7. glidepath/app/drawdown.py +436 -0
  8. glidepath/app/example.py +66 -0
  9. glidepath/app/exports.py +487 -0
  10. glidepath/app/files.py +249 -0
  11. glidepath/app/firstrun.py +114 -0
  12. glidepath/app/forms.py +1750 -0
  13. glidepath/app/inspector.py +506 -0
  14. glidepath/app/labels.py +66 -0
  15. glidepath/app/montecarlo.py +399 -0
  16. glidepath/app/plan.py +354 -0
  17. glidepath/app/retirement.py +446 -0
  18. glidepath/app/scenarios.py +831 -0
  19. glidepath/app/shell.py +185 -0
  20. glidepath/app/tables.py +138 -0
  21. glidepath/core/__init__.py +390 -0
  22. glidepath/core/annuities.py +240 -0
  23. glidepath/core/backtest.py +514 -0
  24. glidepath/core/comparison.py +278 -0
  25. glidepath/core/config.py +82 -0
  26. glidepath/core/contributions.py +337 -0
  27. glidepath/core/engine.py +2811 -0
  28. glidepath/core/entities.py +264 -0
  29. glidepath/core/glide.py +289 -0
  30. glidepath/core/investments.py +175 -0
  31. glidepath/core/money.py +107 -0
  32. glidepath/core/montecarlo.py +609 -0
  33. glidepath/core/pensions.py +298 -0
  34. glidepath/core/periods.py +367 -0
  35. glidepath/core/provenance.py +271 -0
  36. glidepath/core/randomness.py +128 -0
  37. glidepath/core/region.py +46 -0
  38. glidepath/core/reporting.py +231 -0
  39. glidepath/core/results.py +504 -0
  40. glidepath/core/retirement.py +291 -0
  41. glidepath/core/returns.py +312 -0
  42. glidepath/core/scenarios.py +579 -0
  43. glidepath/core/state_pension.py +264 -0
  44. glidepath/core/tax.py +139 -0
  45. glidepath/core/withdrawals.py +461 -0
  46. glidepath/core/wrappers.py +278 -0
  47. glidepath/gui/__init__.py +6 -0
  48. glidepath/gui/assets/icon_128.png +0 -0
  49. glidepath/gui/assets/icon_16.png +0 -0
  50. glidepath/gui/assets/icon_24.png +0 -0
  51. glidepath/gui/assets/icon_256.png +0 -0
  52. glidepath/gui/assets/icon_32.png +0 -0
  53. glidepath/gui/assets/icon_48.png +0 -0
  54. glidepath/gui/assets/icon_64.png +0 -0
  55. glidepath/gui/assets/wordmark.png +0 -0
  56. glidepath/gui/charts.py +829 -0
  57. glidepath/gui/forms.py +359 -0
  58. glidepath/gui/inspector.py +186 -0
  59. glidepath/gui/main.py +51 -0
  60. glidepath/gui/scenarios.py +402 -0
  61. glidepath/gui/style.py +376 -0
  62. glidepath/gui/tableview.py +67 -0
  63. glidepath/gui/widgets.py +989 -0
  64. glidepath/persistence/__init__.py +48 -0
  65. glidepath/persistence/assumptions.py +112 -0
  66. glidepath/persistence/decode.py +747 -0
  67. glidepath/persistence/document.py +101 -0
  68. glidepath/persistence/encode.py +433 -0
  69. glidepath/persistence/migrations.py +158 -0
  70. glidepath/persistence/values.py +298 -0
  71. glidepath/py.typed +0 -0
  72. glidepath/regions/__init__.py +7 -0
  73. glidepath/regions/uk/__init__.py +189 -0
  74. glidepath/regions/uk/ages.py +156 -0
  75. glidepath/regions/uk/contributions.py +717 -0
  76. glidepath/regions/uk/data/age_rules.toml +78 -0
  77. glidepath/regions/uk/data/assumptions_default.toml +170 -0
  78. glidepath/regions/uk/data/returns_history.toml +150 -0
  79. glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
  80. glidepath/regions/uk/extension.py +479 -0
  81. glidepath/regions/uk/loader.py +704 -0
  82. glidepath/regions/uk/region.py +160 -0
  83. glidepath/regions/uk/schema.py +563 -0
  84. glidepath/regions/uk/state_pension.py +129 -0
  85. glidepath/regions/uk/tax.py +466 -0
  86. glidepath/regions/uk/wrappers.py +283 -0
  87. glidepath/regions/uk/years.py +92 -0
  88. glidepath-0.2.0.dist-info/METADATA +189 -0
  89. glidepath-0.2.0.dist-info/RECORD +93 -0
  90. glidepath-0.2.0.dist-info/WHEEL +4 -0
  91. glidepath-0.2.0.dist-info/entry_points.txt +3 -0
  92. glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
  93. 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)