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,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
+ )