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
glidepath/app/forms.py
ADDED
|
@@ -0,0 +1,1750 @@
|
|
|
1
|
+
"""Facts entry forms (roadmap 8.2; planning §1, §4.7, §5.1).
|
|
2
|
+
|
|
3
|
+
The form a shell renders to capture every §5.1 fact — DOB, balances
|
|
4
|
+
with ``as_of`` dates, contributions, DB scheme parameters, the state
|
|
5
|
+
pension forecast, and the pre-existing access facts — plus the
|
|
6
|
+
decisions a projectable plan needs (retirement age,
|
|
7
|
+
contribution and commutation choices, planned annuity purchases). The
|
|
8
|
+
shell binds
|
|
9
|
+
:class:`FactsFormViewModel` to widgets and returns raw text via
|
|
10
|
+
:class:`FactsFormData`; parsing back into `Fact`/`Decision`-wrapped
|
|
11
|
+
domain objects happens here, so validation and messages stay
|
|
12
|
+
UI-agnostic.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field, fields, is_dataclass, replace
|
|
16
|
+
from datetime import date, datetime
|
|
17
|
+
from decimal import Decimal, InvalidOperation
|
|
18
|
+
from enum import Enum, auto
|
|
19
|
+
from itertools import chain
|
|
20
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
21
|
+
|
|
22
|
+
from glidepath.core import (
|
|
23
|
+
AnnuityBasis,
|
|
24
|
+
AnnuityPurchase,
|
|
25
|
+
AnnuityType,
|
|
26
|
+
AssetAllocation,
|
|
27
|
+
AssumptionKey,
|
|
28
|
+
ContributionSchedule,
|
|
29
|
+
DBActiveMembership,
|
|
30
|
+
DBPension,
|
|
31
|
+
Decision,
|
|
32
|
+
EntityId,
|
|
33
|
+
Fact,
|
|
34
|
+
FactorTable,
|
|
35
|
+
Household,
|
|
36
|
+
LifeStage,
|
|
37
|
+
Money,
|
|
38
|
+
Person,
|
|
39
|
+
Rate,
|
|
40
|
+
ReliefMechanic,
|
|
41
|
+
RevaluationBasis,
|
|
42
|
+
RevaluationReference,
|
|
43
|
+
Sex,
|
|
44
|
+
SpendingPlan,
|
|
45
|
+
StatePensionRecord,
|
|
46
|
+
TaxResidencyId,
|
|
47
|
+
Wrapper,
|
|
48
|
+
WrapperKindId,
|
|
49
|
+
new_entity_id,
|
|
50
|
+
validate_household_v1,
|
|
51
|
+
)
|
|
52
|
+
from glidepath.regions.uk import (
|
|
53
|
+
CASH_KIND,
|
|
54
|
+
GIA_KIND,
|
|
55
|
+
ISA_KIND,
|
|
56
|
+
LISA_KIND,
|
|
57
|
+
RUK_RESIDENCY,
|
|
58
|
+
SCOTLAND_RESIDENCY,
|
|
59
|
+
SIPP_KIND,
|
|
60
|
+
WORKPLACE_DC_KIND,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if TYPE_CHECKING:
|
|
64
|
+
from collections.abc import Mapping, Sequence
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class FieldKind(Enum):
|
|
68
|
+
"""How a shell should render one form field."""
|
|
69
|
+
|
|
70
|
+
TEXT = auto()
|
|
71
|
+
"""A free-text entry; parsing happens in the app layer."""
|
|
72
|
+
CHOICE = auto()
|
|
73
|
+
"""A pick-one list of :class:`ChoiceOption` entries."""
|
|
74
|
+
DATE = auto()
|
|
75
|
+
"""A date entry. The raw value stays ISO text (``YYYY-MM-DD``) and
|
|
76
|
+
blank keeps its meaning (today for ``as_of`` fields, none for
|
|
77
|
+
optional facts), so shells must keep typing and blankness first-
|
|
78
|
+
class — a calendar is an assist, never the only input."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class ChoiceOption:
|
|
83
|
+
"""One selectable option: the stable value and its display label."""
|
|
84
|
+
|
|
85
|
+
value: str
|
|
86
|
+
label: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class FieldSpec:
|
|
91
|
+
"""One form field a shell renders and returns raw text for."""
|
|
92
|
+
|
|
93
|
+
key: str
|
|
94
|
+
label: str
|
|
95
|
+
kind: FieldKind = FieldKind.TEXT
|
|
96
|
+
hint: str = ""
|
|
97
|
+
required: bool = False
|
|
98
|
+
choices: tuple[ChoiceOption, ...] = ()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class SectionSpec:
|
|
103
|
+
"""One titled group of fields; repeatable sections list entities."""
|
|
104
|
+
|
|
105
|
+
key: str
|
|
106
|
+
title: str
|
|
107
|
+
description: str
|
|
108
|
+
fields: tuple[FieldSpec, ...]
|
|
109
|
+
repeatable: bool = False
|
|
110
|
+
add_label: str = ""
|
|
111
|
+
remove_label: str = ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class FactsFormViewModel:
|
|
116
|
+
"""The whole facts entry screen (roadmap 8.2)."""
|
|
117
|
+
|
|
118
|
+
title: str
|
|
119
|
+
intro: str
|
|
120
|
+
person: SectionSpec
|
|
121
|
+
spending: SectionSpec
|
|
122
|
+
state_pension: SectionSpec
|
|
123
|
+
wrapper: SectionSpec
|
|
124
|
+
db_pension: SectionSpec
|
|
125
|
+
annuity_purchase: SectionSpec
|
|
126
|
+
submit_label: str
|
|
127
|
+
clear_label: str
|
|
128
|
+
"""The button that empties the whole form (planning §4.9)."""
|
|
129
|
+
example_note: str
|
|
130
|
+
"""Status shown while the launch example is on screen (§4.9)."""
|
|
131
|
+
cleared_note: str
|
|
132
|
+
"""Status shown after the form is cleared (§4.9)."""
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def sections(self) -> tuple[SectionSpec, ...]:
|
|
136
|
+
"""Every section, singular and repeatable alike."""
|
|
137
|
+
return (
|
|
138
|
+
self.person,
|
|
139
|
+
self.spending,
|
|
140
|
+
self.state_pension,
|
|
141
|
+
self.wrapper,
|
|
142
|
+
self.db_pension,
|
|
143
|
+
self.annuity_purchase,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class FactsFormData:
|
|
149
|
+
"""Raw text captured by a shell, keyed exactly like the specs.
|
|
150
|
+
|
|
151
|
+
Each repeatable-section row may additionally carry its entity id
|
|
152
|
+
under :data:`ENTITY_ID_KEY`, opaque to the rendered fields.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
person: Mapping[str, str] = field(default_factory=dict)
|
|
156
|
+
spending: Mapping[str, str] = field(default_factory=dict)
|
|
157
|
+
state_pension: Mapping[str, str] = field(default_factory=dict)
|
|
158
|
+
wrappers: tuple[Mapping[str, str], ...] = ()
|
|
159
|
+
db_pensions: tuple[Mapping[str, str], ...] = ()
|
|
160
|
+
annuity_purchases: tuple[Mapping[str, str], ...] = ()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
ENTITY_ID_KEY: Final = "entity_id"
|
|
164
|
+
"""Reserved key of a repeatable-section row's entity id (§4.3).
|
|
165
|
+
|
|
166
|
+
Never a rendered field: shells carry it opaquely per section instance
|
|
167
|
+
so wrappers, DB pensions, and annuity purchases keep their stable ids
|
|
168
|
+
— and with them any scenario overrides targeting them — through
|
|
169
|
+
edits, reordering, and row deletion alike. Empty or absent means a
|
|
170
|
+
new entity; any other value is the id, verbatim — ids only ever
|
|
171
|
+
originate from a parsed household."""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True)
|
|
175
|
+
class FormError:
|
|
176
|
+
"""One rejected field (or section, when ``field_key`` is empty)."""
|
|
177
|
+
|
|
178
|
+
section: str
|
|
179
|
+
index: int | None
|
|
180
|
+
field_key: str
|
|
181
|
+
message: str
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True)
|
|
185
|
+
class FactsFormResult:
|
|
186
|
+
"""A parsed household, or the errors preventing one."""
|
|
187
|
+
|
|
188
|
+
household: Household | None
|
|
189
|
+
errors: tuple[FormError, ...]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
_REQUIRED_MESSAGE = "this field is required"
|
|
193
|
+
_DATE_MESSAGE = "enter a date as YYYY-MM-DD"
|
|
194
|
+
_MONEY_MESSAGE = "enter an amount of money, e.g. 1250 or 1250.50"
|
|
195
|
+
_INT_MESSAGE = "enter a whole number"
|
|
196
|
+
_DECIMAL_MESSAGE = "enter a plain number, e.g. 0.05"
|
|
197
|
+
_CHOICE_MESSAGE = "pick one of the listed options"
|
|
198
|
+
_FACTORS_MESSAGE = "enter age:factor pairs separated by commas, e.g. 60:0.75, 65:1"
|
|
199
|
+
_CONTRIBUTIONS_NEED_EMPLOYEE = (
|
|
200
|
+
"enter the employee contribution (0 is fine) to record contributions"
|
|
201
|
+
)
|
|
202
|
+
_MEMBERSHIP_NEEDS_RATE = (
|
|
203
|
+
"enter the accrual rate to record active membership; blank means deferred"
|
|
204
|
+
)
|
|
205
|
+
_MEMBERSHIP_NEEDS_SALARY = "active membership needs the pensionable salary"
|
|
206
|
+
_FORECAST_REQUIRED = (
|
|
207
|
+
"an official DWP forecast is required — free and instant from "
|
|
208
|
+
"gov.uk/check-state-pension; leave the whole section blank to skip "
|
|
209
|
+
"the state pension"
|
|
210
|
+
)
|
|
211
|
+
_MULTIPLIERS_NEED_SPENDING = (
|
|
212
|
+
"enter the annual spending need for the stage multipliers to scale"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
_AS_OF_HINT = "YYYY-MM-DD; blank means today"
|
|
216
|
+
|
|
217
|
+
_STAGE_MULTIPLIER_FIELDS: Final[tuple[tuple[str, LifeStage], ...]] = (
|
|
218
|
+
("go_go_multiplier", LifeStage.GO_GO),
|
|
219
|
+
("slow_go_multiplier", LifeStage.SLOW_GO),
|
|
220
|
+
("no_go_multiplier", LifeStage.NO_GO),
|
|
221
|
+
)
|
|
222
|
+
"""The retirement sub-stage multiplier fields the spending section offers.
|
|
223
|
+
|
|
224
|
+
The whole-retirement ``DECUMULATION`` key stays form-less — scaling all
|
|
225
|
+
of retirement is what the annual spending amount itself expresses — so
|
|
226
|
+
a plan carrying one still refuses the facts form (§4.5).
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
_SELECT_OPTION = ChoiceOption(value="", label="Select…")
|
|
230
|
+
"""The blank first option of every required choice: a real value must
|
|
231
|
+
be picked, never pre-selected (planning §1 — facts are stated, not
|
|
232
|
+
guessed)."""
|
|
233
|
+
|
|
234
|
+
_SEXES: Mapping[str, Sex] = {"female": Sex.FEMALE, "male": Sex.MALE}
|
|
235
|
+
_RESIDENCIES: Mapping[str, TaxResidencyId] = {
|
|
236
|
+
str(RUK_RESIDENCY): RUK_RESIDENCY,
|
|
237
|
+
str(SCOTLAND_RESIDENCY): SCOTLAND_RESIDENCY,
|
|
238
|
+
}
|
|
239
|
+
_WRAPPER_KINDS: Mapping[str, WrapperKindId] = {
|
|
240
|
+
str(WORKPLACE_DC_KIND): WORKPLACE_DC_KIND,
|
|
241
|
+
str(SIPP_KIND): SIPP_KIND,
|
|
242
|
+
str(ISA_KIND): ISA_KIND,
|
|
243
|
+
str(LISA_KIND): LISA_KIND,
|
|
244
|
+
str(GIA_KIND): GIA_KIND,
|
|
245
|
+
str(CASH_KIND): CASH_KIND,
|
|
246
|
+
}
|
|
247
|
+
_PENSION_KINDS = frozenset({WORKPLACE_DC_KIND, SIPP_KIND})
|
|
248
|
+
"""Kinds whose wrappers may carry a crystallised (drawdown) balance."""
|
|
249
|
+
_CASH_ALLOCATION = AssetAllocation(equity=Decimal(0), bonds=Decimal(0), cash=Decimal(1))
|
|
250
|
+
_HUNDRED = Decimal(100)
|
|
251
|
+
_CASH_EQUITY_MESSAGE = "Cash accounts always hold cash — leave blank."
|
|
252
|
+
_EQUITY_PERCENT_MESSAGE = "Enter a percentage from 0 to 100."
|
|
253
|
+
"""A cash account holds cash — never the glide path (roadmap 9.2)."""
|
|
254
|
+
_RELIEF_MECHANICS: Mapping[str, ReliefMechanic] = {
|
|
255
|
+
"relief_at_source": ReliefMechanic.RELIEF_AT_SOURCE,
|
|
256
|
+
"net_pay": ReliefMechanic.NET_PAY,
|
|
257
|
+
}
|
|
258
|
+
_ESCALATIONS: Mapping[str, AssumptionKey] = {
|
|
259
|
+
"earnings": AssumptionKey.EARNINGS_GROWTH_REAL,
|
|
260
|
+
}
|
|
261
|
+
_REVALUATION_REFERENCES: Mapping[str, RevaluationReference] = {
|
|
262
|
+
"cpi": RevaluationReference.CPI,
|
|
263
|
+
"fixed": RevaluationReference.FIXED,
|
|
264
|
+
"none": RevaluationReference.NONE,
|
|
265
|
+
}
|
|
266
|
+
_ANNUITY_TYPES: Mapping[str, AnnuityType] = {
|
|
267
|
+
"level": AnnuityType.LEVEL,
|
|
268
|
+
"escalating": AnnuityType.ESCALATING,
|
|
269
|
+
"inflation_linked": AnnuityType.INFLATION_LINKED,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_SEX_KEYS: Mapping[Sex, str] = {value: key for key, value in _SEXES.items()}
|
|
273
|
+
_RELIEF_KEYS: Mapping[ReliefMechanic, str] = {
|
|
274
|
+
value: key for key, value in _RELIEF_MECHANICS.items()
|
|
275
|
+
}
|
|
276
|
+
_ESCALATION_KEYS: Mapping[AssumptionKey, str] = {
|
|
277
|
+
value: key for key, value in _ESCALATIONS.items()
|
|
278
|
+
}
|
|
279
|
+
_REVALUATION_KEYS: Mapping[RevaluationReference, str] = {
|
|
280
|
+
value: key for key, value in _REVALUATION_REFERENCES.items()
|
|
281
|
+
}
|
|
282
|
+
_ANNUITY_TYPE_KEYS: Mapping[AnnuityType, str] = {
|
|
283
|
+
value: key for key, value in _ANNUITY_TYPES.items()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclass
|
|
288
|
+
class _FormContext:
|
|
289
|
+
"""Shared parsing state for one submission."""
|
|
290
|
+
|
|
291
|
+
recorded_on: datetime
|
|
292
|
+
default_as_of: date
|
|
293
|
+
errors: list[FormError]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class _SectionReader:
|
|
297
|
+
"""Parses one section instance's raw values, accumulating errors."""
|
|
298
|
+
|
|
299
|
+
def __init__(
|
|
300
|
+
self,
|
|
301
|
+
context: _FormContext,
|
|
302
|
+
section: str,
|
|
303
|
+
values: Mapping[str, str],
|
|
304
|
+
index: int | None = None,
|
|
305
|
+
) -> None:
|
|
306
|
+
"""Bind the reader to one section instance's raw values."""
|
|
307
|
+
self._context = context
|
|
308
|
+
self._section = section
|
|
309
|
+
self._values = values
|
|
310
|
+
self._index = index
|
|
311
|
+
self._failed = False
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def ok(self) -> bool:
|
|
315
|
+
"""Whether every value read so far parsed cleanly."""
|
|
316
|
+
return not self._failed
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def recorded_on(self) -> datetime:
|
|
320
|
+
"""The submission's provenance timestamp."""
|
|
321
|
+
return self._context.recorded_on
|
|
322
|
+
|
|
323
|
+
def error(self, field_key: str, message: str) -> None:
|
|
324
|
+
"""Record a parse failure against ``field_key``."""
|
|
325
|
+
self._failed = True
|
|
326
|
+
self._context.errors.append(
|
|
327
|
+
FormError(self._section, self._index, field_key, message)
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def raw(self, field_key: str) -> str:
|
|
331
|
+
"""The stripped raw text for ``field_key`` (empty when absent)."""
|
|
332
|
+
return self._values.get(field_key, "").strip()
|
|
333
|
+
|
|
334
|
+
def any_entered(self) -> bool:
|
|
335
|
+
"""Whether the user typed anything at all into this section."""
|
|
336
|
+
return any(value.strip() for value in self._values.values())
|
|
337
|
+
|
|
338
|
+
def date_value(self, field_key: str, *, required: bool = False) -> date | None:
|
|
339
|
+
"""An ISO date, or ``None`` when blank or unparsable."""
|
|
340
|
+
text = self.raw(field_key)
|
|
341
|
+
if not text:
|
|
342
|
+
if required:
|
|
343
|
+
self.error(field_key, _REQUIRED_MESSAGE)
|
|
344
|
+
return None
|
|
345
|
+
try:
|
|
346
|
+
return date.fromisoformat(text)
|
|
347
|
+
except ValueError:
|
|
348
|
+
self.error(field_key, _DATE_MESSAGE)
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
def as_of(self, field_key: str) -> date:
|
|
352
|
+
"""An ``as_of`` date, defaulting to the submission day when blank."""
|
|
353
|
+
parsed = self.date_value(field_key)
|
|
354
|
+
return self._context.default_as_of if parsed is None else parsed
|
|
355
|
+
|
|
356
|
+
def money(self, field_key: str, *, required: bool = False) -> Money | None:
|
|
357
|
+
"""A money amount, tolerating a currency symbol and separators."""
|
|
358
|
+
text = self.raw(field_key)
|
|
359
|
+
if not text:
|
|
360
|
+
if required:
|
|
361
|
+
self.error(field_key, _REQUIRED_MESSAGE)
|
|
362
|
+
return None
|
|
363
|
+
cleaned = text.replace("£", "").replace(",", "").strip()
|
|
364
|
+
try:
|
|
365
|
+
amount = Decimal(cleaned)
|
|
366
|
+
except InvalidOperation:
|
|
367
|
+
self.error(field_key, _MONEY_MESSAGE)
|
|
368
|
+
return None
|
|
369
|
+
if not amount.is_finite():
|
|
370
|
+
self.error(field_key, _MONEY_MESSAGE)
|
|
371
|
+
return None
|
|
372
|
+
return Money(amount)
|
|
373
|
+
|
|
374
|
+
def int_value(self, field_key: str, *, required: bool = False) -> int | None:
|
|
375
|
+
"""A whole number."""
|
|
376
|
+
text = self.raw(field_key)
|
|
377
|
+
if not text:
|
|
378
|
+
if required:
|
|
379
|
+
self.error(field_key, _REQUIRED_MESSAGE)
|
|
380
|
+
return None
|
|
381
|
+
try:
|
|
382
|
+
return int(text, 10)
|
|
383
|
+
except ValueError:
|
|
384
|
+
self.error(field_key, _INT_MESSAGE)
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
def decimal_value(
|
|
388
|
+
self, field_key: str, *, required: bool = False
|
|
389
|
+
) -> Decimal | None:
|
|
390
|
+
"""A plain (finite) decimal number."""
|
|
391
|
+
text = self.raw(field_key)
|
|
392
|
+
if not text:
|
|
393
|
+
if required:
|
|
394
|
+
self.error(field_key, _REQUIRED_MESSAGE)
|
|
395
|
+
return None
|
|
396
|
+
try:
|
|
397
|
+
value = Decimal(text)
|
|
398
|
+
except InvalidOperation:
|
|
399
|
+
self.error(field_key, _DECIMAL_MESSAGE)
|
|
400
|
+
return None
|
|
401
|
+
if not value.is_finite():
|
|
402
|
+
self.error(field_key, _DECIMAL_MESSAGE)
|
|
403
|
+
return None
|
|
404
|
+
return value
|
|
405
|
+
|
|
406
|
+
def choice[T](self, field_key: str, options: Mapping[str, T]) -> T | None:
|
|
407
|
+
"""The domain value behind a choice field, ``None`` when blank."""
|
|
408
|
+
text = self.raw(field_key)
|
|
409
|
+
if not text:
|
|
410
|
+
return None
|
|
411
|
+
if text not in options:
|
|
412
|
+
self.error(field_key, _CHOICE_MESSAGE)
|
|
413
|
+
return None
|
|
414
|
+
return options[text]
|
|
415
|
+
|
|
416
|
+
def factor_table(self, field_key: str) -> FactorTable | None:
|
|
417
|
+
"""An age→factor table from ``age:factor`` comma-separated pairs."""
|
|
418
|
+
text = self.raw(field_key)
|
|
419
|
+
if not text:
|
|
420
|
+
return None
|
|
421
|
+
factors: dict[int, Decimal] = {}
|
|
422
|
+
for chunk in text.split(","):
|
|
423
|
+
age_text, sep, factor_text = chunk.partition(":")
|
|
424
|
+
if not sep:
|
|
425
|
+
self.error(field_key, _FACTORS_MESSAGE)
|
|
426
|
+
return None
|
|
427
|
+
try:
|
|
428
|
+
age = int(age_text.strip(), 10)
|
|
429
|
+
factor = Decimal(factor_text.strip())
|
|
430
|
+
except ValueError, InvalidOperation:
|
|
431
|
+
self.error(field_key, _FACTORS_MESSAGE)
|
|
432
|
+
return None
|
|
433
|
+
if not factor.is_finite():
|
|
434
|
+
self.error(field_key, _FACTORS_MESSAGE)
|
|
435
|
+
return None
|
|
436
|
+
factors[age] = factor
|
|
437
|
+
try:
|
|
438
|
+
return FactorTable(factors=factors)
|
|
439
|
+
except ValueError as exc:
|
|
440
|
+
self.error(field_key, str(exc))
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
def fact_of[T](
|
|
444
|
+
self, value: T | None, as_of_field: str | None = None
|
|
445
|
+
) -> Fact[T] | None:
|
|
446
|
+
"""Wrap a parsed value as a fact dated by ``as_of_field``.
|
|
447
|
+
|
|
448
|
+
Only statement-dated facts (balances, the DWP forecast) offer
|
|
449
|
+
an ``as_of`` field; every other fact passes no field and is
|
|
450
|
+
dated the day it was entered.
|
|
451
|
+
"""
|
|
452
|
+
if value is None:
|
|
453
|
+
return None
|
|
454
|
+
as_of = (
|
|
455
|
+
self._context.default_as_of
|
|
456
|
+
if as_of_field is None
|
|
457
|
+
else self.as_of(as_of_field)
|
|
458
|
+
)
|
|
459
|
+
return Fact(value=value, as_of=as_of, recorded_on=self.recorded_on)
|
|
460
|
+
|
|
461
|
+
def decision_of[T](self, value: T | None) -> Decision[T] | None:
|
|
462
|
+
"""Wrap a parsed value as a decision recorded at submission time."""
|
|
463
|
+
if value is None:
|
|
464
|
+
return None
|
|
465
|
+
return Decision(value=value, recorded_on=self.recorded_on)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _spending_from(reader: _SectionReader) -> SpendingPlan | None:
|
|
469
|
+
"""The spending plan, or ``None`` when the section is blank."""
|
|
470
|
+
spending_fact = reader.fact_of(reader.money("annual_spending_real"))
|
|
471
|
+
multipliers = {
|
|
472
|
+
stage: value
|
|
473
|
+
for field_key, stage in _STAGE_MULTIPLIER_FIELDS
|
|
474
|
+
if (value := reader.decimal_value(field_key)) is not None
|
|
475
|
+
}
|
|
476
|
+
if spending_fact is None:
|
|
477
|
+
# An unparsable amount already carries its own field error; only
|
|
478
|
+
# a genuinely blank one needs the "enter the need" prompt.
|
|
479
|
+
if multipliers and not reader.raw("annual_spending_real"):
|
|
480
|
+
reader.error("annual_spending_real", _MULTIPLIERS_NEED_SPENDING)
|
|
481
|
+
return None
|
|
482
|
+
try:
|
|
483
|
+
return SpendingPlan(
|
|
484
|
+
annual_spending_real=spending_fact,
|
|
485
|
+
stage_multipliers=multipliers or None,
|
|
486
|
+
)
|
|
487
|
+
except ValueError as exc:
|
|
488
|
+
reader.error("", str(exc))
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _state_pension_from(reader: _SectionReader) -> StatePensionRecord | None:
|
|
493
|
+
"""The state pension record, or ``None`` when wholly blank (§5.1)."""
|
|
494
|
+
if not reader.any_entered():
|
|
495
|
+
return None
|
|
496
|
+
forecast = reader.fact_of(reader.money("forecast_weekly_amount"), "forecast_as_of")
|
|
497
|
+
protected = reader.fact_of(reader.money("protected_payment"), "forecast_as_of")
|
|
498
|
+
deferral = reader.decimal_value("deferral_years")
|
|
499
|
+
if not reader.ok:
|
|
500
|
+
return None
|
|
501
|
+
if forecast is None:
|
|
502
|
+
reader.error("forecast_weekly_amount", _FORECAST_REQUIRED)
|
|
503
|
+
return None
|
|
504
|
+
try:
|
|
505
|
+
return StatePensionRecord(
|
|
506
|
+
forecast_weekly_amount=forecast,
|
|
507
|
+
protected_payment=protected,
|
|
508
|
+
deferral_years=Decision(
|
|
509
|
+
value=Decimal(0) if deferral is None else deferral,
|
|
510
|
+
recorded_on=reader.recorded_on,
|
|
511
|
+
),
|
|
512
|
+
)
|
|
513
|
+
except ValueError as exc:
|
|
514
|
+
reader.error("", str(exc))
|
|
515
|
+
return None
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _contributions_from(reader: _SectionReader) -> ContributionSchedule | None:
|
|
519
|
+
"""A wrapper's contribution schedule, or ``None`` when untouched."""
|
|
520
|
+
employee = reader.money("employee_contribution")
|
|
521
|
+
employer = reader.fact_of(reader.money("employer_contribution"))
|
|
522
|
+
relief = reader.choice("relief_mechanic", _RELIEF_MECHANICS)
|
|
523
|
+
escalation = reader.choice("escalation", _ESCALATIONS)
|
|
524
|
+
if employee is None:
|
|
525
|
+
if employer is not None or relief is not None or escalation is not None:
|
|
526
|
+
reader.error("employee_contribution", _CONTRIBUTIONS_NEED_EMPLOYEE)
|
|
527
|
+
return None
|
|
528
|
+
try:
|
|
529
|
+
return ContributionSchedule(
|
|
530
|
+
employee_amount=Decision(value=employee, recorded_on=reader.recorded_on),
|
|
531
|
+
employer_amount=employer,
|
|
532
|
+
relief_mechanic=relief,
|
|
533
|
+
escalation=escalation,
|
|
534
|
+
)
|
|
535
|
+
except ValueError as exc:
|
|
536
|
+
reader.error("", str(exc))
|
|
537
|
+
return None
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _wrapper_from(reader: _SectionReader, entity_id: EntityId) -> Wrapper | None:
|
|
541
|
+
"""One savings wrapper from its section values.
|
|
542
|
+
|
|
543
|
+
A crystallised balance is a pension concept — funds already
|
|
544
|
+
designated to drawdown — so any other kind rejects it here (the
|
|
545
|
+
engine enforces the same invariant, planning §5.1): accepting one
|
|
546
|
+
on an age-gated kind would let money bypass its access gate. A
|
|
547
|
+
cash account holds cash: its allocation is fixed at 100% cash
|
|
548
|
+
rather than following the glide path (roadmap 9.2).
|
|
549
|
+
"""
|
|
550
|
+
kind = reader.choice("kind", _WRAPPER_KINDS)
|
|
551
|
+
if kind is None:
|
|
552
|
+
reader.error("kind", _REQUIRED_MESSAGE)
|
|
553
|
+
balance = reader.fact_of(reader.money("balance", required=True), "balances_as_of")
|
|
554
|
+
crystallised = reader.fact_of(
|
|
555
|
+
reader.money("crystallised_balance"), "balances_as_of"
|
|
556
|
+
)
|
|
557
|
+
if kind is not None and kind not in _PENSION_KINDS and crystallised is not None:
|
|
558
|
+
reader.error(
|
|
559
|
+
"crystallised_balance",
|
|
560
|
+
"Only pension wrappers hold a crystallised balance — leave blank.",
|
|
561
|
+
)
|
|
562
|
+
contributions = _contributions_from(reader)
|
|
563
|
+
allocation = _allocation_from(reader, kind)
|
|
564
|
+
if kind is None or balance is None or not reader.ok:
|
|
565
|
+
return None
|
|
566
|
+
try:
|
|
567
|
+
return Wrapper(
|
|
568
|
+
id=entity_id,
|
|
569
|
+
kind=kind,
|
|
570
|
+
balance=balance,
|
|
571
|
+
crystallised_balance=crystallised,
|
|
572
|
+
contributions=contributions,
|
|
573
|
+
allocation=allocation,
|
|
574
|
+
)
|
|
575
|
+
except ValueError as exc:
|
|
576
|
+
reader.error("", str(exc))
|
|
577
|
+
return None
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _allocation_from(
|
|
581
|
+
reader: _SectionReader, kind: WrapperKindId | None
|
|
582
|
+
) -> AssetAllocation | None:
|
|
583
|
+
"""A wrapper's stated allocation, or ``None`` to follow the glide path.
|
|
584
|
+
|
|
585
|
+
The form takes one number — the equity share as a percentage, the
|
|
586
|
+
remainder in bonds — because that is the decision the glide path
|
|
587
|
+
itself models; a cash account is always 100% cash and rejects an
|
|
588
|
+
entry rather than silently ignoring it (roadmap 9.2).
|
|
589
|
+
"""
|
|
590
|
+
equity_percent = reader.decimal_value("equity_percent")
|
|
591
|
+
if kind == CASH_KIND:
|
|
592
|
+
if equity_percent is not None:
|
|
593
|
+
reader.error("equity_percent", _CASH_EQUITY_MESSAGE)
|
|
594
|
+
return _CASH_ALLOCATION
|
|
595
|
+
if equity_percent is None:
|
|
596
|
+
return None
|
|
597
|
+
if not Decimal(0) <= equity_percent <= _HUNDRED:
|
|
598
|
+
reader.error("equity_percent", _EQUITY_PERCENT_MESSAGE)
|
|
599
|
+
return None
|
|
600
|
+
equity = equity_percent / _HUNDRED
|
|
601
|
+
return AssetAllocation(equity=equity, bonds=Decimal(1) - equity)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _active_membership_from(
|
|
605
|
+
reader: _SectionReader, statement: date | None
|
|
606
|
+
) -> DBActiveMembership | None:
|
|
607
|
+
"""A DB pension's active membership, or ``None`` when untouched.
|
|
608
|
+
|
|
609
|
+
The accrual rate anchors the block (like the employee contribution
|
|
610
|
+
anchors a contribution schedule): blank means deferred, and filling
|
|
611
|
+
a sibling without it is an error rather than a silent guess.
|
|
612
|
+
"""
|
|
613
|
+
rate = reader.decimal_value("accrual_rate")
|
|
614
|
+
salary = reader.money("pensionable_salary")
|
|
615
|
+
until = reader.int_value("active_until_age")
|
|
616
|
+
if rate is None:
|
|
617
|
+
if salary is not None or until is not None:
|
|
618
|
+
reader.error("accrual_rate", _MEMBERSHIP_NEEDS_RATE)
|
|
619
|
+
return None
|
|
620
|
+
if salary is None:
|
|
621
|
+
reader.error("pensionable_salary", _MEMBERSHIP_NEEDS_SALARY)
|
|
622
|
+
return None
|
|
623
|
+
if statement is None:
|
|
624
|
+
return None
|
|
625
|
+
recorded = reader.recorded_on
|
|
626
|
+
try:
|
|
627
|
+
return DBActiveMembership(
|
|
628
|
+
accrual_rate=Fact(value=rate, as_of=statement, recorded_on=recorded),
|
|
629
|
+
pensionable_salary=Fact(
|
|
630
|
+
value=salary, as_of=statement, recorded_on=recorded
|
|
631
|
+
),
|
|
632
|
+
active_until_age=reader.decision_of(until),
|
|
633
|
+
)
|
|
634
|
+
except ValueError as exc:
|
|
635
|
+
reader.error("", str(exc))
|
|
636
|
+
return None
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _db_pension_from(reader: _SectionReader, entity_id: EntityId) -> DBPension | None:
|
|
640
|
+
"""One DB entitlement from its section values.
|
|
641
|
+
|
|
642
|
+
The statement date doubles as the ``as_of`` for the scheme facts it
|
|
643
|
+
dates (planning §5.1: accrued pension is "at date of leaving /
|
|
644
|
+
statement").
|
|
645
|
+
"""
|
|
646
|
+
accrued = reader.money("accrued_annual_pension", required=True)
|
|
647
|
+
statement = reader.date_value("statement_date", required=True)
|
|
648
|
+
npa = reader.int_value("normal_pension_age", required=True)
|
|
649
|
+
reference = reader.choice("revaluation_reference", _REVALUATION_REFERENCES)
|
|
650
|
+
cap = reader.decimal_value("revaluation_cap")
|
|
651
|
+
fixed_rate = reader.decimal_value("revaluation_fixed_rate")
|
|
652
|
+
factors = reader.factor_table("early_late_factors")
|
|
653
|
+
commutation = reader.decimal_value("commutation_factor")
|
|
654
|
+
taken_at = reader.int_value("taken_at_age")
|
|
655
|
+
fraction = reader.decimal_value("commuted_fraction")
|
|
656
|
+
membership = _active_membership_from(reader, statement)
|
|
657
|
+
if reference is None:
|
|
658
|
+
reader.error("revaluation_reference", _REQUIRED_MESSAGE)
|
|
659
|
+
return None
|
|
660
|
+
if accrued is None or statement is None or npa is None or not reader.ok:
|
|
661
|
+
return None
|
|
662
|
+
recorded = reader.recorded_on
|
|
663
|
+
try:
|
|
664
|
+
return DBPension(
|
|
665
|
+
id=entity_id,
|
|
666
|
+
accrued_annual_pension=Fact(
|
|
667
|
+
value=accrued, as_of=statement, recorded_on=recorded
|
|
668
|
+
),
|
|
669
|
+
statement_date=statement,
|
|
670
|
+
normal_pension_age=Fact(value=npa, as_of=statement, recorded_on=recorded),
|
|
671
|
+
revaluation_basis=RevaluationBasis(
|
|
672
|
+
reference=reference,
|
|
673
|
+
cap=None if cap is None else Rate(cap),
|
|
674
|
+
fixed_rate=None if fixed_rate is None else Rate(fixed_rate),
|
|
675
|
+
),
|
|
676
|
+
early_late_factors=(
|
|
677
|
+
FactorTable(factors={}) if factors is None else factors
|
|
678
|
+
),
|
|
679
|
+
commuted_fraction=Decision(
|
|
680
|
+
value=Decimal(0) if fraction is None else fraction,
|
|
681
|
+
recorded_on=recorded,
|
|
682
|
+
),
|
|
683
|
+
commutation_factor=(
|
|
684
|
+
None
|
|
685
|
+
if commutation is None
|
|
686
|
+
else Fact(value=commutation, as_of=statement, recorded_on=recorded)
|
|
687
|
+
),
|
|
688
|
+
taken_at_age=reader.decision_of(taken_at),
|
|
689
|
+
active_membership=membership,
|
|
690
|
+
)
|
|
691
|
+
except ValueError as exc:
|
|
692
|
+
reader.error("", str(exc))
|
|
693
|
+
return None
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _annuity_purchase_from(
|
|
697
|
+
reader: _SectionReader, entity_id: EntityId
|
|
698
|
+
) -> AnnuityPurchase | None:
|
|
699
|
+
"""One planned annuity purchase from its section values.
|
|
700
|
+
|
|
701
|
+
The record is wholly a decision (planning §5.1): the age, pot
|
|
702
|
+
fraction, and product type are all choices, priced from the
|
|
703
|
+
annuity-rate assumptions at run time. The v1 form is single-person
|
|
704
|
+
(§4.4), so every purchase it writes is single-life — joint-life
|
|
705
|
+
annuities wait for the couples spike (roadmap 9.4).
|
|
706
|
+
"""
|
|
707
|
+
at_age = reader.int_value("at_age", required=True)
|
|
708
|
+
fraction = reader.decimal_value("fraction_of_pot", required=True)
|
|
709
|
+
annuity_type = reader.choice("annuity_type", _ANNUITY_TYPES)
|
|
710
|
+
if annuity_type is None and not reader.raw("annuity_type"):
|
|
711
|
+
reader.error("annuity_type", _REQUIRED_MESSAGE)
|
|
712
|
+
if at_age is None or fraction is None or annuity_type is None or not reader.ok:
|
|
713
|
+
return None
|
|
714
|
+
recorded = reader.recorded_on
|
|
715
|
+
try:
|
|
716
|
+
return AnnuityPurchase(
|
|
717
|
+
id=entity_id,
|
|
718
|
+
at_age=Decision(value=at_age, recorded_on=recorded),
|
|
719
|
+
fraction_of_pot=Decision(value=fraction, recorded_on=recorded),
|
|
720
|
+
annuity_type=annuity_type,
|
|
721
|
+
basis=AnnuityBasis.SINGLE,
|
|
722
|
+
)
|
|
723
|
+
except ValueError as exc:
|
|
724
|
+
reader.error("", str(exc))
|
|
725
|
+
return None
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
@dataclass(frozen=True)
|
|
729
|
+
class _PersonParts:
|
|
730
|
+
"""The parsed sub-entities the person section assembles around."""
|
|
731
|
+
|
|
732
|
+
wrappers: tuple[Wrapper, ...]
|
|
733
|
+
db_pensions: tuple[DBPension, ...]
|
|
734
|
+
annuity_purchases: tuple[AnnuityPurchase, ...]
|
|
735
|
+
state_pension: StatePensionRecord | None
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _person_from(
|
|
739
|
+
reader: _SectionReader, parts: _PersonParts, entity_id: EntityId
|
|
740
|
+
) -> Person | None:
|
|
741
|
+
"""The (v1 single) person from the person section plus sub-entities."""
|
|
742
|
+
dob = reader.fact_of(reader.date_value("date_of_birth", required=True))
|
|
743
|
+
sex_value = reader.choice("sex_for_longevity", _SEXES)
|
|
744
|
+
residency = reader.choice("tax_residency", _RESIDENCIES)
|
|
745
|
+
if residency is None:
|
|
746
|
+
reader.error("tax_residency", _REQUIRED_MESSAGE)
|
|
747
|
+
target = reader.int_value("target_retirement_age", required=True)
|
|
748
|
+
employment = reader.fact_of(reader.money("employment_income"))
|
|
749
|
+
mpaa = reader.fact_of(reader.date_value("mpaa_triggered_on"))
|
|
750
|
+
lsa = reader.fact_of(reader.money("lsa_used"))
|
|
751
|
+
if dob is None or target is None or residency is None or not reader.ok:
|
|
752
|
+
return None
|
|
753
|
+
try:
|
|
754
|
+
return Person(
|
|
755
|
+
id=entity_id,
|
|
756
|
+
date_of_birth=dob,
|
|
757
|
+
target_retirement_age=Decision(
|
|
758
|
+
value=target, recorded_on=reader.recorded_on
|
|
759
|
+
),
|
|
760
|
+
tax_residency=residency,
|
|
761
|
+
sex_for_longevity=reader.fact_of(sex_value),
|
|
762
|
+
employment_income=employment,
|
|
763
|
+
mpaa_triggered_on=mpaa,
|
|
764
|
+
lsa_used=lsa,
|
|
765
|
+
wrappers=parts.wrappers,
|
|
766
|
+
db_pensions=parts.db_pensions,
|
|
767
|
+
annuity_purchases=parts.annuity_purchases,
|
|
768
|
+
state_pension=parts.state_pension,
|
|
769
|
+
)
|
|
770
|
+
except ValueError as exc:
|
|
771
|
+
reader.error("", str(exc))
|
|
772
|
+
return None
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _row_entity_id(values: Mapping[str, str]) -> EntityId:
|
|
776
|
+
"""The row's carried entity id, or a fresh one when empty (§4.3).
|
|
777
|
+
|
|
778
|
+
Preserved verbatim: persistence accepts any non-empty id, and
|
|
779
|
+
scenario overrides target the exact stored string — normalising
|
|
780
|
+
here (even stripping whitespace) would change the entity's
|
|
781
|
+
identity and orphan its overrides on resave.
|
|
782
|
+
"""
|
|
783
|
+
text = values.get(ENTITY_ID_KEY, "")
|
|
784
|
+
return EntityId(text) if text else new_entity_id()
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def parse_facts_form(
|
|
788
|
+
data: FactsFormData,
|
|
789
|
+
*,
|
|
790
|
+
recorded_on: datetime,
|
|
791
|
+
today: date,
|
|
792
|
+
previous: Household | None = None,
|
|
793
|
+
) -> FactsFormResult:
|
|
794
|
+
"""Parse a submission into a v1 household, or the errors preventing one.
|
|
795
|
+
|
|
796
|
+
Every fact is stamped with the submission's ``recorded_on`` and an
|
|
797
|
+
``as_of`` date — the one the user entered for statement-dated
|
|
798
|
+
facts, ``today`` otherwise (and when left blank) — so
|
|
799
|
+
provenance is complete at entry time (planning §1). ``today`` is
|
|
800
|
+
the caller's civil date — the same one the projection will run
|
|
801
|
+
with, never derived from the UTC ``recorded_on`` timestamp: around
|
|
802
|
+
midnight the two calendars disagree, and a blank ``as_of``
|
|
803
|
+
defaulted to the UTC date could sit a day after the run's
|
|
804
|
+
``today``, which §4.8 rejects as future-dated.
|
|
805
|
+
|
|
806
|
+
Each wrapper, DB pension, and annuity purchase row carries its own
|
|
807
|
+
entity id under :data:`ENTITY_ID_KEY` (empty means a new entity),
|
|
808
|
+
so scenario overrides targeting them by stable id (§4.3) survive a
|
|
809
|
+
facts edit — row deletion and reordering included — instead of
|
|
810
|
+
orphaning or silently retargeting. ``previous`` is the household a
|
|
811
|
+
re-submission replaces: the (single) person reuses its id, and
|
|
812
|
+
unchanged undated facts keep their stored ``as_of`` dates.
|
|
813
|
+
"""
|
|
814
|
+
context = _FormContext(
|
|
815
|
+
recorded_on=recorded_on,
|
|
816
|
+
default_as_of=today,
|
|
817
|
+
errors=[],
|
|
818
|
+
)
|
|
819
|
+
prior = previous.persons[0] if previous is not None and previous.persons else None
|
|
820
|
+
spending = _spending_from(_SectionReader(context, "spending", data.spending))
|
|
821
|
+
state_pension = _state_pension_from(
|
|
822
|
+
_SectionReader(context, "state_pension", data.state_pension)
|
|
823
|
+
)
|
|
824
|
+
wrappers = tuple(
|
|
825
|
+
wrapper
|
|
826
|
+
for index, values in enumerate(data.wrappers)
|
|
827
|
+
if (
|
|
828
|
+
wrapper := _wrapper_from(
|
|
829
|
+
_SectionReader(context, "wrapper", values, index=index),
|
|
830
|
+
_row_entity_id(values),
|
|
831
|
+
)
|
|
832
|
+
)
|
|
833
|
+
is not None
|
|
834
|
+
)
|
|
835
|
+
db_pensions = tuple(
|
|
836
|
+
pension
|
|
837
|
+
for index, values in enumerate(data.db_pensions)
|
|
838
|
+
if (
|
|
839
|
+
pension := _db_pension_from(
|
|
840
|
+
_SectionReader(context, "db_pension", values, index=index),
|
|
841
|
+
_row_entity_id(values),
|
|
842
|
+
)
|
|
843
|
+
)
|
|
844
|
+
is not None
|
|
845
|
+
)
|
|
846
|
+
annuity_purchases = tuple(
|
|
847
|
+
purchase
|
|
848
|
+
for index, values in enumerate(data.annuity_purchases)
|
|
849
|
+
if (
|
|
850
|
+
purchase := _annuity_purchase_from(
|
|
851
|
+
_SectionReader(context, "annuity_purchase", values, index=index),
|
|
852
|
+
_row_entity_id(values),
|
|
853
|
+
)
|
|
854
|
+
)
|
|
855
|
+
is not None
|
|
856
|
+
)
|
|
857
|
+
person = _person_from(
|
|
858
|
+
_SectionReader(context, "person", data.person),
|
|
859
|
+
_PersonParts(
|
|
860
|
+
wrappers=wrappers,
|
|
861
|
+
db_pensions=db_pensions,
|
|
862
|
+
annuity_purchases=annuity_purchases,
|
|
863
|
+
state_pension=state_pension,
|
|
864
|
+
),
|
|
865
|
+
prior.id if prior is not None else new_entity_id(),
|
|
866
|
+
)
|
|
867
|
+
if context.errors or person is None:
|
|
868
|
+
return FactsFormResult(household=None, errors=tuple(context.errors))
|
|
869
|
+
try:
|
|
870
|
+
household = Household(persons=(person,), spending=spending)
|
|
871
|
+
validate_household_v1(household)
|
|
872
|
+
except ValueError as exc:
|
|
873
|
+
return FactsFormResult(
|
|
874
|
+
household=None, errors=(FormError("person", None, "", str(exc)),)
|
|
875
|
+
)
|
|
876
|
+
return FactsFormResult(
|
|
877
|
+
household=_with_carried_dates(household, previous), errors=()
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _fact_dated_from[T](fact: Fact[T] | None, prior: Fact[T] | None) -> Fact[T] | None:
|
|
882
|
+
"""The fact re-dated from its prior statement when its value is unchanged."""
|
|
883
|
+
if fact is None or prior is None or fact.value != prior.value:
|
|
884
|
+
return fact
|
|
885
|
+
if fact.as_of == prior.as_of:
|
|
886
|
+
return fact
|
|
887
|
+
return Fact(
|
|
888
|
+
value=fact.value,
|
|
889
|
+
as_of=prior.as_of,
|
|
890
|
+
recorded_on=fact.recorded_on,
|
|
891
|
+
note=fact.note,
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _person_with_carried_dates(person: Person, prior: Person) -> Person:
|
|
896
|
+
"""The person with unchanged undated facts re-dated from ``prior``."""
|
|
897
|
+
changes: dict[str, Any] = {}
|
|
898
|
+
fact_pairs: dict[str, tuple[Fact[Any] | None, Fact[Any] | None]] = {
|
|
899
|
+
"date_of_birth": (person.date_of_birth, prior.date_of_birth),
|
|
900
|
+
"sex_for_longevity": (person.sex_for_longevity, prior.sex_for_longevity),
|
|
901
|
+
"employment_income": (person.employment_income, prior.employment_income),
|
|
902
|
+
"mpaa_triggered_on": (person.mpaa_triggered_on, prior.mpaa_triggered_on),
|
|
903
|
+
"lsa_used": (person.lsa_used, prior.lsa_used),
|
|
904
|
+
}
|
|
905
|
+
for name, (fact, prior_fact) in fact_pairs.items():
|
|
906
|
+
carried = _fact_dated_from(fact, prior_fact)
|
|
907
|
+
if carried is not fact:
|
|
908
|
+
changes[name] = carried
|
|
909
|
+
return replace(person, **changes) if changes else person
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _wrapper_with_carried_dates(wrapper: Wrapper, prior: Wrapper | None) -> Wrapper:
|
|
913
|
+
"""The wrapper with an unchanged employer fact re-dated from ``prior``."""
|
|
914
|
+
contributions = wrapper.contributions
|
|
915
|
+
prior_contributions = prior.contributions if prior is not None else None
|
|
916
|
+
changes: dict[str, Any] = {}
|
|
917
|
+
if contributions is not None and prior_contributions is not None:
|
|
918
|
+
employer = _fact_dated_from(
|
|
919
|
+
contributions.employer_amount, prior_contributions.employer_amount
|
|
920
|
+
)
|
|
921
|
+
if employer is not contributions.employer_amount:
|
|
922
|
+
changes["contributions"] = replace(contributions, employer_amount=employer)
|
|
923
|
+
return replace(wrapper, **changes) if changes else wrapper
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _with_carried_dates(household: Household, previous: Household | None) -> Household:
|
|
927
|
+
"""Unchanged undated facts keep the replaced plan's ``as_of`` (§4.5).
|
|
928
|
+
|
|
929
|
+
The form offers no ``as_of`` input for facts that are not
|
|
930
|
+
statement-dated, so a bare reparse dates them the submission day.
|
|
931
|
+
When the submission replaces a loaded plan (``previous``) and the
|
|
932
|
+
value is unchanged, that would silently rewrite persisted
|
|
933
|
+
provenance — the prior date carries forward instead. An edited
|
|
934
|
+
value is a fresh statement and keeps the submission day. Wrappers
|
|
935
|
+
pair up by form position, exactly like the §4.3 id reuse.
|
|
936
|
+
"""
|
|
937
|
+
if previous is None or not previous.persons or not household.persons:
|
|
938
|
+
return household
|
|
939
|
+
prior = previous.persons[0]
|
|
940
|
+
prior_by_id = {wrapper.id: wrapper for wrapper in prior.wrappers}
|
|
941
|
+
person = _person_with_carried_dates(household.persons[0], prior)
|
|
942
|
+
wrappers = tuple(
|
|
943
|
+
_wrapper_with_carried_dates(wrapper, prior_by_id.get(wrapper.id))
|
|
944
|
+
for wrapper in person.wrappers
|
|
945
|
+
)
|
|
946
|
+
if wrappers != person.wrappers:
|
|
947
|
+
person = replace(person, wrappers=wrappers)
|
|
948
|
+
changes: dict[str, Any] = {}
|
|
949
|
+
if person is not household.persons[0]:
|
|
950
|
+
changes["persons"] = (person,)
|
|
951
|
+
spending = household.spending
|
|
952
|
+
if spending is not None and previous.spending is not None:
|
|
953
|
+
carried = _fact_dated_from(
|
|
954
|
+
spending.annual_spending_real, previous.spending.annual_spending_real
|
|
955
|
+
)
|
|
956
|
+
if carried is not None and carried is not spending.annual_spending_real:
|
|
957
|
+
changes["spending"] = replace(spending, annual_spending_real=carried)
|
|
958
|
+
return replace(household, **changes) if changes else household
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _person_values(person: Person) -> dict[str, str]:
|
|
962
|
+
"""The person section's raw text for ``person``."""
|
|
963
|
+
sex = person.sex_for_longevity
|
|
964
|
+
employment = person.employment_income
|
|
965
|
+
mpaa = person.mpaa_triggered_on
|
|
966
|
+
lsa = person.lsa_used
|
|
967
|
+
return {
|
|
968
|
+
"date_of_birth": person.date_of_birth.value.isoformat(),
|
|
969
|
+
"sex_for_longevity": "" if sex is None else _SEX_KEYS[sex.value],
|
|
970
|
+
"tax_residency": str(person.tax_residency),
|
|
971
|
+
"employment_income": (
|
|
972
|
+
"" if employment is None else str(employment.value.amount)
|
|
973
|
+
),
|
|
974
|
+
"target_retirement_age": str(person.target_retirement_age.value),
|
|
975
|
+
"mpaa_triggered_on": "" if mpaa is None else mpaa.value.isoformat(),
|
|
976
|
+
"lsa_used": "" if lsa is None else str(lsa.value.amount),
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _spending_values(spending: SpendingPlan | None) -> dict[str, str]:
|
|
981
|
+
"""The spending section's raw text, empty when spending is unmodelled."""
|
|
982
|
+
if spending is None:
|
|
983
|
+
return {}
|
|
984
|
+
fact = spending.annual_spending_real
|
|
985
|
+
multipliers = spending.stage_multipliers or {}
|
|
986
|
+
values = {"annual_spending_real": str(fact.value.amount)}
|
|
987
|
+
for field_key, stage in _STAGE_MULTIPLIER_FIELDS:
|
|
988
|
+
multiplier = multipliers.get(stage)
|
|
989
|
+
values[field_key] = "" if multiplier is None else str(multiplier)
|
|
990
|
+
return values
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _state_pension_values(record: StatePensionRecord | None) -> dict[str, str]:
|
|
994
|
+
"""The state pension section's raw text, empty when skipped."""
|
|
995
|
+
if record is None:
|
|
996
|
+
return {}
|
|
997
|
+
forecast = record.forecast_weekly_amount
|
|
998
|
+
protected = record.protected_payment
|
|
999
|
+
# The form dates the forecast pair from one shared as_of field, so
|
|
1000
|
+
# either member of the pair can carry it back.
|
|
1001
|
+
forecast_dated = forecast if forecast is not None else protected
|
|
1002
|
+
return {
|
|
1003
|
+
"forecast_weekly_amount": (
|
|
1004
|
+
"" if forecast is None else str(forecast.value.amount)
|
|
1005
|
+
),
|
|
1006
|
+
"protected_payment": "" if protected is None else str(protected.value.amount),
|
|
1007
|
+
"forecast_as_of": (
|
|
1008
|
+
"" if forecast_dated is None else forecast_dated.as_of.isoformat()
|
|
1009
|
+
),
|
|
1010
|
+
"deferral_years": str(record.deferral_years.value),
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _wrapper_values(wrapper: Wrapper) -> dict[str, str]:
|
|
1015
|
+
"""One wrapper section instance's raw text."""
|
|
1016
|
+
crystallised = wrapper.crystallised_balance
|
|
1017
|
+
values = {
|
|
1018
|
+
ENTITY_ID_KEY: str(wrapper.id),
|
|
1019
|
+
"kind": str(wrapper.kind),
|
|
1020
|
+
"balance": str(wrapper.balance.value.amount),
|
|
1021
|
+
"crystallised_balance": (
|
|
1022
|
+
"" if crystallised is None else str(crystallised.value.amount)
|
|
1023
|
+
),
|
|
1024
|
+
"balances_as_of": wrapper.balance.as_of.isoformat(),
|
|
1025
|
+
"equity_percent": _equity_percent_text(wrapper),
|
|
1026
|
+
"employee_contribution": "",
|
|
1027
|
+
"employer_contribution": "",
|
|
1028
|
+
"relief_mechanic": "",
|
|
1029
|
+
"escalation": "",
|
|
1030
|
+
}
|
|
1031
|
+
contributions = wrapper.contributions
|
|
1032
|
+
if contributions is not None:
|
|
1033
|
+
employer = contributions.employer_amount
|
|
1034
|
+
relief = contributions.relief_mechanic
|
|
1035
|
+
escalation = contributions.escalation
|
|
1036
|
+
values["employee_contribution"] = str(
|
|
1037
|
+
contributions.employee_amount.value.amount
|
|
1038
|
+
)
|
|
1039
|
+
values["employer_contribution"] = (
|
|
1040
|
+
"" if employer is None else str(employer.value.amount)
|
|
1041
|
+
)
|
|
1042
|
+
values["relief_mechanic"] = "" if relief is None else _RELIEF_KEYS[relief]
|
|
1043
|
+
values["escalation"] = (
|
|
1044
|
+
"" if escalation is None else _ESCALATION_KEYS[escalation]
|
|
1045
|
+
)
|
|
1046
|
+
return values
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _factor_table_text(table: FactorTable) -> str:
|
|
1050
|
+
"""The factor table as the form's ``age:factor`` pair syntax."""
|
|
1051
|
+
return ", ".join(f"{age}:{factor}" for age, factor in sorted(table.factors.items()))
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _db_pension_values(pension: DBPension) -> dict[str, str]:
|
|
1055
|
+
"""One DB pension section instance's raw text."""
|
|
1056
|
+
basis = pension.revaluation_basis
|
|
1057
|
+
commutation = pension.commutation_factor
|
|
1058
|
+
taken = pension.taken_at_age
|
|
1059
|
+
values = {
|
|
1060
|
+
ENTITY_ID_KEY: str(pension.id),
|
|
1061
|
+
"accrued_annual_pension": str(pension.accrued_annual_pension.value.amount),
|
|
1062
|
+
"statement_date": pension.statement_date.isoformat(),
|
|
1063
|
+
"normal_pension_age": str(pension.normal_pension_age.value),
|
|
1064
|
+
"revaluation_reference": _REVALUATION_KEYS[basis.reference],
|
|
1065
|
+
"revaluation_cap": "" if basis.cap is None else str(basis.cap.value),
|
|
1066
|
+
"revaluation_fixed_rate": (
|
|
1067
|
+
"" if basis.fixed_rate is None else str(basis.fixed_rate.value)
|
|
1068
|
+
),
|
|
1069
|
+
"early_late_factors": _factor_table_text(pension.early_late_factors),
|
|
1070
|
+
"commutation_factor": "" if commutation is None else str(commutation.value),
|
|
1071
|
+
"taken_at_age": "" if taken is None else str(taken.value),
|
|
1072
|
+
"commuted_fraction": str(pension.commuted_fraction.value),
|
|
1073
|
+
"accrual_rate": "",
|
|
1074
|
+
"pensionable_salary": "",
|
|
1075
|
+
"active_until_age": "",
|
|
1076
|
+
}
|
|
1077
|
+
membership = pension.active_membership
|
|
1078
|
+
if membership is not None:
|
|
1079
|
+
until = membership.active_until_age
|
|
1080
|
+
values["accrual_rate"] = str(membership.accrual_rate.value)
|
|
1081
|
+
values["pensionable_salary"] = str(membership.pensionable_salary.value.amount)
|
|
1082
|
+
values["active_until_age"] = "" if until is None else str(until.value)
|
|
1083
|
+
return values
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _annuity_purchase_values(purchase: AnnuityPurchase) -> dict[str, str]:
|
|
1087
|
+
"""One annuity purchase section instance's raw text."""
|
|
1088
|
+
return {
|
|
1089
|
+
ENTITY_ID_KEY: str(purchase.id),
|
|
1090
|
+
"at_age": str(purchase.at_age.value),
|
|
1091
|
+
"fraction_of_pot": str(purchase.fraction_of_pot.value),
|
|
1092
|
+
"annuity_type": _ANNUITY_TYPE_KEYS[purchase.annuity_type],
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _equity_percent_text(wrapper: Wrapper) -> str:
|
|
1097
|
+
"""The stated equity share as the percent text the form echoes.
|
|
1098
|
+
|
|
1099
|
+
Blank for a glide-path follower and for cash accounts (whose
|
|
1100
|
+
pinned allocation is a rule, not an entry); trailing zeros are
|
|
1101
|
+
trimmed so a submitted "62.5" round-trips as "62.5".
|
|
1102
|
+
"""
|
|
1103
|
+
allocation = wrapper.allocation
|
|
1104
|
+
if allocation is None or wrapper.kind == CASH_KIND:
|
|
1105
|
+
return ""
|
|
1106
|
+
text = format(allocation.equity * _HUNDRED, "f")
|
|
1107
|
+
if "." in text:
|
|
1108
|
+
text = text.rstrip("0").rstrip(".")
|
|
1109
|
+
return text
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _allocation_cannot_represent(wrapper: Wrapper) -> str | None:
|
|
1113
|
+
"""Why the equity-percent field cannot express ``wrapper.allocation``.
|
|
1114
|
+
|
|
1115
|
+
The field states an equity share with the remainder in bonds, so a
|
|
1116
|
+
cash-bearing allocation (possible only in a hand-edited plan file)
|
|
1117
|
+
has no faithful text; a cash account must carry exactly its pinned
|
|
1118
|
+
all-cash allocation.
|
|
1119
|
+
"""
|
|
1120
|
+
if wrapper.kind == CASH_KIND:
|
|
1121
|
+
if wrapper.allocation != _CASH_ALLOCATION:
|
|
1122
|
+
return "a non-cash allocation on a cash account"
|
|
1123
|
+
return None
|
|
1124
|
+
if wrapper.allocation is not None and wrapper.allocation.cash != Decimal(0):
|
|
1125
|
+
return "an asset allocation holding cash"
|
|
1126
|
+
return None
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _wrapper_cannot_represent(wrapper: Wrapper) -> str | None:
|
|
1130
|
+
"""Why the form cannot faithfully edit ``wrapper``; ``None`` if it can."""
|
|
1131
|
+
if str(wrapper.kind) not in _WRAPPER_KINDS:
|
|
1132
|
+
return f"the wrapper kind {str(wrapper.kind)!r}"
|
|
1133
|
+
if wrapper.fees is not None:
|
|
1134
|
+
return "a wrapper fee schedule"
|
|
1135
|
+
allocation_reason = _allocation_cannot_represent(wrapper)
|
|
1136
|
+
if allocation_reason is not None:
|
|
1137
|
+
return allocation_reason
|
|
1138
|
+
crystallised = wrapper.crystallised_balance
|
|
1139
|
+
if crystallised is not None and crystallised.as_of != wrapper.balance.as_of:
|
|
1140
|
+
return "wrapper balances dated on different days"
|
|
1141
|
+
contributions = wrapper.contributions
|
|
1142
|
+
if (
|
|
1143
|
+
contributions is not None
|
|
1144
|
+
and contributions.escalation is not None
|
|
1145
|
+
and contributions.escalation not in _ESCALATION_KEYS
|
|
1146
|
+
):
|
|
1147
|
+
return f"the contribution escalation {contributions.escalation.value!r}"
|
|
1148
|
+
return None
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _state_pension_cannot_represent(record: StatePensionRecord | None) -> str | None:
|
|
1152
|
+
"""Why the form cannot faithfully edit ``record``; ``None`` if it can."""
|
|
1153
|
+
if record is None:
|
|
1154
|
+
return None
|
|
1155
|
+
forecast = record.forecast_weekly_amount
|
|
1156
|
+
protected = record.protected_payment
|
|
1157
|
+
if (
|
|
1158
|
+
forecast is not None
|
|
1159
|
+
and protected is not None
|
|
1160
|
+
and forecast.as_of != protected.as_of
|
|
1161
|
+
):
|
|
1162
|
+
return "state pension forecast facts dated on different days"
|
|
1163
|
+
return None
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def _db_pension_cannot_represent(pension: DBPension) -> str | None:
|
|
1167
|
+
"""Why the form cannot faithfully edit ``pension``; ``None`` if it can."""
|
|
1168
|
+
dated = [pension.accrued_annual_pension.as_of, pension.normal_pension_age.as_of]
|
|
1169
|
+
if pension.commutation_factor is not None:
|
|
1170
|
+
dated.append(pension.commutation_factor.as_of)
|
|
1171
|
+
membership = pension.active_membership
|
|
1172
|
+
if membership is not None:
|
|
1173
|
+
dated.append(membership.accrual_rate.as_of)
|
|
1174
|
+
dated.append(membership.pensionable_salary.as_of)
|
|
1175
|
+
if any(as_of != pension.statement_date for as_of in dated):
|
|
1176
|
+
return "DB scheme facts dated off the statement date"
|
|
1177
|
+
return None
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def _annuity_purchase_cannot_represent(purchase: AnnuityPurchase) -> str | None:
|
|
1181
|
+
"""Why the form cannot faithfully edit ``purchase``; ``None`` if it can.
|
|
1182
|
+
|
|
1183
|
+
The v1 form is single-person, so it offers no basis choice: a
|
|
1184
|
+
joint-life purchase would silently become single-life on resave.
|
|
1185
|
+
"""
|
|
1186
|
+
if purchase.basis is not AnnuityBasis.SINGLE:
|
|
1187
|
+
return "a joint-life annuity purchase"
|
|
1188
|
+
return None
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def _carries_note(value: object) -> bool:
|
|
1192
|
+
"""Whether any fact or decision inside ``value`` carries a note.
|
|
1193
|
+
|
|
1194
|
+
A structural walk (dataclass fields and tuples) rather than a
|
|
1195
|
+
field-by-field enumeration, so a note on a fact the model grows
|
|
1196
|
+
tomorrow is still caught without this function changing.
|
|
1197
|
+
"""
|
|
1198
|
+
if isinstance(value, Fact | Decision):
|
|
1199
|
+
return value.note is not None
|
|
1200
|
+
if isinstance(value, tuple):
|
|
1201
|
+
return any(_carries_note(item) for item in value)
|
|
1202
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
1203
|
+
return any(_carries_note(getattr(value, spec.name)) for spec in fields(value))
|
|
1204
|
+
return False
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _person_cannot_represent(person: Person) -> str | None:
|
|
1208
|
+
"""Why the form cannot faithfully edit ``person``; ``None`` if it can."""
|
|
1209
|
+
if person.glide_path is not None:
|
|
1210
|
+
return "a personal glide path"
|
|
1211
|
+
if str(person.tax_residency) not in _RESIDENCIES:
|
|
1212
|
+
return f"the tax residency {str(person.tax_residency)!r}"
|
|
1213
|
+
reasons = chain(
|
|
1214
|
+
(_state_pension_cannot_represent(person.state_pension),),
|
|
1215
|
+
(_wrapper_cannot_represent(wrapper) for wrapper in person.wrappers),
|
|
1216
|
+
(_db_pension_cannot_represent(pension) for pension in person.db_pensions),
|
|
1217
|
+
(
|
|
1218
|
+
_annuity_purchase_cannot_represent(purchase)
|
|
1219
|
+
for purchase in person.annuity_purchases
|
|
1220
|
+
),
|
|
1221
|
+
)
|
|
1222
|
+
return next((reason for reason in reasons if reason is not None), None)
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def form_cannot_represent(household: Household) -> str | None:
|
|
1226
|
+
"""Why the v1 facts form cannot faithfully edit ``household``.
|
|
1227
|
+
|
|
1228
|
+
``None`` when every stored detail lands in a form field. The domain
|
|
1229
|
+
model legitimately holds more than the form yet offers (extra
|
|
1230
|
+
persons, planned outflows, joint-life annuity purchases, personal
|
|
1231
|
+
glide paths, whole-retirement spending multipliers, wrapper
|
|
1232
|
+
allocations and fees, independently dated fact pairs, fact and
|
|
1233
|
+
decision notes) —
|
|
1234
|
+
resubmitting the populated form would silently rebuild a reduced
|
|
1235
|
+
household, so a shell must refuse to open such a plan rather than
|
|
1236
|
+
lose the data (§4.5).
|
|
1237
|
+
"""
|
|
1238
|
+
if len(household.persons) != 1:
|
|
1239
|
+
return "more than one person"
|
|
1240
|
+
if household.planned_outflows:
|
|
1241
|
+
return "planned outflows"
|
|
1242
|
+
spending = household.spending
|
|
1243
|
+
if spending is not None and spending.stage_multipliers is not None:
|
|
1244
|
+
offered = {stage for _, stage in _STAGE_MULTIPLIER_FIELDS}
|
|
1245
|
+
if set(spending.stage_multipliers) - offered:
|
|
1246
|
+
return "spending stage multipliers beyond the go-go/slow-go/no-go fields"
|
|
1247
|
+
if _carries_note(household):
|
|
1248
|
+
return "notes on facts or decisions"
|
|
1249
|
+
return _person_cannot_represent(household.persons[0])
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def facts_form_data_from_household(household: Household) -> FactsFormData:
|
|
1253
|
+
"""The household re-rendered as raw form text (plan load, §4.7).
|
|
1254
|
+
|
|
1255
|
+
The inverse of :func:`parse_facts_form`, used to repopulate the
|
|
1256
|
+
facts form from a loaded plan. Statement-dated facts (wrapper
|
|
1257
|
+
balances, the DWP forecast, DB scheme facts) emit
|
|
1258
|
+
their ``as_of`` date explicitly; the other facts offer no ``as_of``
|
|
1259
|
+
field, and on resubmission :func:`parse_facts_form` carries their
|
|
1260
|
+
stored dates forward from ``previous`` wherever the value is
|
|
1261
|
+
unchanged — so a plan-load round trip re-dates nothing silently
|
|
1262
|
+
either way. Provenance timestamps are not carried — a resubmission
|
|
1263
|
+
re-records its facts at submission time, exactly like any edit.
|
|
1264
|
+
Every repeatable row carries its entity id (:data:`ENTITY_ID_KEY`),
|
|
1265
|
+
so identity survives edits, reordering, and row deletion (§4.3).
|
|
1266
|
+
"""
|
|
1267
|
+
person = household.persons[0]
|
|
1268
|
+
return FactsFormData(
|
|
1269
|
+
person=_person_values(person),
|
|
1270
|
+
spending=_spending_values(household.spending),
|
|
1271
|
+
state_pension=_state_pension_values(person.state_pension),
|
|
1272
|
+
wrappers=tuple(_wrapper_values(entry) for entry in person.wrappers),
|
|
1273
|
+
db_pensions=tuple(_db_pension_values(entry) for entry in person.db_pensions),
|
|
1274
|
+
annuity_purchases=tuple(
|
|
1275
|
+
_annuity_purchase_values(entry) for entry in person.annuity_purchases
|
|
1276
|
+
),
|
|
1277
|
+
)
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
@dataclass(frozen=True)
|
|
1281
|
+
class PlanEntityIds:
|
|
1282
|
+
"""The plan's repeatable-entity ids, one per form row, in order."""
|
|
1283
|
+
|
|
1284
|
+
wrappers: tuple[str, ...] = ()
|
|
1285
|
+
db_pensions: tuple[str, ...] = ()
|
|
1286
|
+
annuity_purchases: tuple[str, ...] = ()
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def plan_entity_ids(household: Household) -> PlanEntityIds:
|
|
1290
|
+
"""The ids a shell seeds back into the form after a successful save.
|
|
1291
|
+
|
|
1292
|
+
A hand-typed row carries no id, so its entity is minted fresh at
|
|
1293
|
+
parse time; without this write-back the *next* submission would
|
|
1294
|
+
mint again, orphaning any scenario override created in between.
|
|
1295
|
+
On success the parsed household holds exactly one entity per form
|
|
1296
|
+
row, in row order, so the ids line up positionally.
|
|
1297
|
+
"""
|
|
1298
|
+
person = household.persons[0]
|
|
1299
|
+
return PlanEntityIds(
|
|
1300
|
+
wrappers=tuple(str(entry.id) for entry in person.wrappers),
|
|
1301
|
+
db_pensions=tuple(str(entry.id) for entry in person.db_pensions),
|
|
1302
|
+
annuity_purchases=tuple(str(entry.id) for entry in person.annuity_purchases),
|
|
1303
|
+
)
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def format_form_errors(form: FactsFormViewModel, errors: Sequence[FormError]) -> str:
|
|
1307
|
+
"""Render form errors as one line per problem, labelled for humans."""
|
|
1308
|
+
labels = {
|
|
1309
|
+
(section.key, spec.key): spec.label
|
|
1310
|
+
for section in form.sections
|
|
1311
|
+
for spec in section.fields
|
|
1312
|
+
}
|
|
1313
|
+
titles = {section.key: section.title for section in form.sections}
|
|
1314
|
+
lines = []
|
|
1315
|
+
for error in errors:
|
|
1316
|
+
where = titles.get(error.section, error.section)
|
|
1317
|
+
if error.index is not None:
|
|
1318
|
+
where = f"{where} {error.index + 1}"
|
|
1319
|
+
label = labels.get((error.section, error.field_key))
|
|
1320
|
+
if label is None:
|
|
1321
|
+
lines.append(f"{where}: {error.message}")
|
|
1322
|
+
else:
|
|
1323
|
+
lines.append(f"{where} — {label}: {error.message}")
|
|
1324
|
+
return "\n".join(lines)
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def _as_of_field(key: str, label: str) -> FieldSpec:
|
|
1328
|
+
"""A standard ``as_of`` companion field."""
|
|
1329
|
+
return FieldSpec(key=key, label=label, kind=FieldKind.DATE, hint=_AS_OF_HINT)
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def _person_section() -> SectionSpec:
|
|
1333
|
+
"""The person section: identity, income, and pre-existing access facts."""
|
|
1334
|
+
return SectionSpec(
|
|
1335
|
+
key="person",
|
|
1336
|
+
title="About you",
|
|
1337
|
+
description=(
|
|
1338
|
+
"Facts you state about yourself, plus your target retirement "
|
|
1339
|
+
"age — a choice, and the anchor the whole projection swings on."
|
|
1340
|
+
),
|
|
1341
|
+
fields=(
|
|
1342
|
+
FieldSpec(
|
|
1343
|
+
key="date_of_birth",
|
|
1344
|
+
label="Date of birth",
|
|
1345
|
+
kind=FieldKind.DATE,
|
|
1346
|
+
hint="YYYY-MM-DD, e.g. 1980-04-12",
|
|
1347
|
+
required=True,
|
|
1348
|
+
),
|
|
1349
|
+
FieldSpec(
|
|
1350
|
+
key="sex_for_longevity",
|
|
1351
|
+
label="Sex (longevity default only)",
|
|
1352
|
+
kind=FieldKind.CHOICE,
|
|
1353
|
+
choices=(
|
|
1354
|
+
ChoiceOption(value="", label="Not stated"),
|
|
1355
|
+
ChoiceOption(value="female", label="Female"),
|
|
1356
|
+
ChoiceOption(value="male", label="Male"),
|
|
1357
|
+
),
|
|
1358
|
+
),
|
|
1359
|
+
FieldSpec(
|
|
1360
|
+
key="tax_residency",
|
|
1361
|
+
label="Tax residency",
|
|
1362
|
+
kind=FieldKind.CHOICE,
|
|
1363
|
+
required=True,
|
|
1364
|
+
choices=(
|
|
1365
|
+
_SELECT_OPTION,
|
|
1366
|
+
ChoiceOption(
|
|
1367
|
+
value=str(RUK_RESIDENCY),
|
|
1368
|
+
label="England, Wales or Northern Ireland",
|
|
1369
|
+
),
|
|
1370
|
+
ChoiceOption(
|
|
1371
|
+
value=str(SCOTLAND_RESIDENCY),
|
|
1372
|
+
label="Scotland",
|
|
1373
|
+
),
|
|
1374
|
+
),
|
|
1375
|
+
),
|
|
1376
|
+
FieldSpec(
|
|
1377
|
+
key="employment_income",
|
|
1378
|
+
label="Employment income (gross, per year)",
|
|
1379
|
+
hint="e.g. 52000",
|
|
1380
|
+
),
|
|
1381
|
+
FieldSpec(
|
|
1382
|
+
key="target_retirement_age",
|
|
1383
|
+
label="Target retirement age (your choice)",
|
|
1384
|
+
hint="e.g. 62",
|
|
1385
|
+
required=True,
|
|
1386
|
+
),
|
|
1387
|
+
FieldSpec(
|
|
1388
|
+
key="mpaa_triggered_on",
|
|
1389
|
+
label="MPAA triggered on",
|
|
1390
|
+
kind=FieldKind.DATE,
|
|
1391
|
+
hint="YYYY-MM-DD; blank if you have never flexibly accessed a pension",
|
|
1392
|
+
),
|
|
1393
|
+
FieldSpec(
|
|
1394
|
+
key="lsa_used",
|
|
1395
|
+
label="Lump sum allowance already used",
|
|
1396
|
+
hint="blank if none",
|
|
1397
|
+
),
|
|
1398
|
+
),
|
|
1399
|
+
)
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def _spending_section() -> SectionSpec:
|
|
1403
|
+
"""The household spending section."""
|
|
1404
|
+
return SectionSpec(
|
|
1405
|
+
key="spending",
|
|
1406
|
+
title="Household spending",
|
|
1407
|
+
description=(
|
|
1408
|
+
"Your annual spending need in today's money, after tax. Blank "
|
|
1409
|
+
"means spending is not modelled yet. The optional stage "
|
|
1410
|
+
"multipliers scale that need across retirement's phases — "
|
|
1411
|
+
"the first decade (go-go), the second (slow-go), and beyond "
|
|
1412
|
+
"(no-go); blank means 1."
|
|
1413
|
+
),
|
|
1414
|
+
fields=(
|
|
1415
|
+
FieldSpec(
|
|
1416
|
+
key="annual_spending_real",
|
|
1417
|
+
label="Annual spending (today's money, net)",
|
|
1418
|
+
hint="e.g. 28000",
|
|
1419
|
+
),
|
|
1420
|
+
FieldSpec(
|
|
1421
|
+
key="go_go_multiplier",
|
|
1422
|
+
label="Go-go multiplier (first decade retired)",
|
|
1423
|
+
hint="e.g. 1.2; blank means 1",
|
|
1424
|
+
),
|
|
1425
|
+
FieldSpec(
|
|
1426
|
+
key="slow_go_multiplier",
|
|
1427
|
+
label="Slow-go multiplier (second decade retired)",
|
|
1428
|
+
hint="e.g. 0.9; blank means 1",
|
|
1429
|
+
),
|
|
1430
|
+
FieldSpec(
|
|
1431
|
+
key="no_go_multiplier",
|
|
1432
|
+
label="No-go multiplier (beyond two decades retired)",
|
|
1433
|
+
hint="e.g. 0.8; blank means 1",
|
|
1434
|
+
),
|
|
1435
|
+
),
|
|
1436
|
+
)
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _state_pension_section() -> SectionSpec:
|
|
1440
|
+
"""The state pension section (official DWP forecast only, §5.1)."""
|
|
1441
|
+
return SectionSpec(
|
|
1442
|
+
key="state_pension",
|
|
1443
|
+
title="State pension",
|
|
1444
|
+
description=(
|
|
1445
|
+
"Your official DWP forecast is the fact — free and instant "
|
|
1446
|
+
"from gov.uk/check-state-pension — and the only route to a "
|
|
1447
|
+
"state pension amount. A forecast dated a whole month or "
|
|
1448
|
+
"more before today is uprated to today at the assumed "
|
|
1449
|
+
"uprating policy. Leave the whole section blank to skip "
|
|
1450
|
+
"the state pension."
|
|
1451
|
+
),
|
|
1452
|
+
fields=(
|
|
1453
|
+
FieldSpec(
|
|
1454
|
+
key="forecast_weekly_amount",
|
|
1455
|
+
label="Forecast weekly amount",
|
|
1456
|
+
hint="from your DWP forecast, e.g. 230.25",
|
|
1457
|
+
),
|
|
1458
|
+
FieldSpec(
|
|
1459
|
+
key="protected_payment",
|
|
1460
|
+
label="Protected payment (part of the forecast)",
|
|
1461
|
+
hint="blank if none; uprates by CPI only",
|
|
1462
|
+
),
|
|
1463
|
+
_as_of_field("forecast_as_of", "Forecast as of"),
|
|
1464
|
+
FieldSpec(
|
|
1465
|
+
key="deferral_years",
|
|
1466
|
+
label="Years you plan to defer claiming (your choice)",
|
|
1467
|
+
hint="whole months, e.g. 1.25; blank means none",
|
|
1468
|
+
),
|
|
1469
|
+
),
|
|
1470
|
+
)
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
def _wrapper_section() -> SectionSpec:
|
|
1474
|
+
"""The repeatable savings-wrapper section."""
|
|
1475
|
+
return SectionSpec(
|
|
1476
|
+
key="wrapper",
|
|
1477
|
+
title="Savings wrapper",
|
|
1478
|
+
description=(
|
|
1479
|
+
"One pension, ISA, or taxable account. Balances are facts "
|
|
1480
|
+
"from a statement; contributions are your choices plus your "
|
|
1481
|
+
"employer's terms. A balance dated a whole month or more "
|
|
1482
|
+
"before today is rolled forward to today at the assumed "
|
|
1483
|
+
"return — contributions in the gap are not added, so "
|
|
1484
|
+
"restate the balance if your statement is old."
|
|
1485
|
+
),
|
|
1486
|
+
repeatable=True,
|
|
1487
|
+
add_label="Add wrapper",
|
|
1488
|
+
remove_label="Remove this wrapper",
|
|
1489
|
+
fields=(
|
|
1490
|
+
FieldSpec(
|
|
1491
|
+
key="kind",
|
|
1492
|
+
label="Kind",
|
|
1493
|
+
kind=FieldKind.CHOICE,
|
|
1494
|
+
required=True,
|
|
1495
|
+
choices=(
|
|
1496
|
+
_SELECT_OPTION,
|
|
1497
|
+
ChoiceOption(
|
|
1498
|
+
value=str(WORKPLACE_DC_KIND), label="Workplace DC pension"
|
|
1499
|
+
),
|
|
1500
|
+
ChoiceOption(value=str(SIPP_KIND), label="SIPP"),
|
|
1501
|
+
ChoiceOption(value=str(ISA_KIND), label="Stocks & shares ISA"),
|
|
1502
|
+
ChoiceOption(value=str(LISA_KIND), label="Lifetime ISA"),
|
|
1503
|
+
ChoiceOption(
|
|
1504
|
+
value=str(GIA_KIND), label="General investment account"
|
|
1505
|
+
),
|
|
1506
|
+
ChoiceOption(value=str(CASH_KIND), label="Cash savings"),
|
|
1507
|
+
),
|
|
1508
|
+
),
|
|
1509
|
+
FieldSpec(
|
|
1510
|
+
key="balance",
|
|
1511
|
+
label="Balance (pensions: uncrystallised)",
|
|
1512
|
+
hint="e.g. 45000",
|
|
1513
|
+
required=True,
|
|
1514
|
+
),
|
|
1515
|
+
FieldSpec(
|
|
1516
|
+
key="crystallised_balance",
|
|
1517
|
+
label="Crystallised balance (already in drawdown)",
|
|
1518
|
+
hint="pensions only; blank if none",
|
|
1519
|
+
),
|
|
1520
|
+
_as_of_field("balances_as_of", "Balances as of"),
|
|
1521
|
+
FieldSpec(
|
|
1522
|
+
key="equity_percent",
|
|
1523
|
+
label="Equity allocation, % (your choice)",
|
|
1524
|
+
hint=(
|
|
1525
|
+
"e.g. 100 for all-equity; the rest is bonds. Blank "
|
|
1526
|
+
"follows the de-risking glide path; cash accounts "
|
|
1527
|
+
"are always cash"
|
|
1528
|
+
),
|
|
1529
|
+
),
|
|
1530
|
+
FieldSpec(
|
|
1531
|
+
key="employee_contribution",
|
|
1532
|
+
label="Your contribution (gross, per year — your choice)",
|
|
1533
|
+
hint="e.g. 6000; blank if none",
|
|
1534
|
+
),
|
|
1535
|
+
FieldSpec(
|
|
1536
|
+
key="employer_contribution",
|
|
1537
|
+
label="Employer contribution (per year)",
|
|
1538
|
+
hint="from your employment terms; blank if none",
|
|
1539
|
+
),
|
|
1540
|
+
FieldSpec(
|
|
1541
|
+
key="relief_mechanic",
|
|
1542
|
+
label="Tax relief mechanic",
|
|
1543
|
+
kind=FieldKind.CHOICE,
|
|
1544
|
+
choices=(
|
|
1545
|
+
ChoiceOption(
|
|
1546
|
+
value="",
|
|
1547
|
+
label="None (ISAs, LISAs, GIA, cash; pensions must pick one)",
|
|
1548
|
+
),
|
|
1549
|
+
ChoiceOption(value="relief_at_source", label="Relief at source"),
|
|
1550
|
+
ChoiceOption(value="net_pay", label="Net pay"),
|
|
1551
|
+
),
|
|
1552
|
+
),
|
|
1553
|
+
FieldSpec(
|
|
1554
|
+
key="escalation",
|
|
1555
|
+
label="Contribution escalation",
|
|
1556
|
+
kind=FieldKind.CHOICE,
|
|
1557
|
+
choices=(
|
|
1558
|
+
ChoiceOption(value="", label="Fixed amount"),
|
|
1559
|
+
ChoiceOption(value="earnings", label="Grows with earnings"),
|
|
1560
|
+
),
|
|
1561
|
+
),
|
|
1562
|
+
),
|
|
1563
|
+
)
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
def _db_pension_section() -> SectionSpec:
|
|
1567
|
+
"""The repeatable DB-pension section."""
|
|
1568
|
+
return SectionSpec(
|
|
1569
|
+
key="db_pension",
|
|
1570
|
+
title="Defined benefit pension",
|
|
1571
|
+
description=(
|
|
1572
|
+
"Scheme parameters are facts from your benefit statement — "
|
|
1573
|
+
"schemes vary too much to guess. The statement date dates "
|
|
1574
|
+
"every scheme fact. Still accruing? Enter the accrual rate "
|
|
1575
|
+
"and pensionable salary; leave them blank for a deferred "
|
|
1576
|
+
"pension."
|
|
1577
|
+
),
|
|
1578
|
+
repeatable=True,
|
|
1579
|
+
add_label="Add DB pension",
|
|
1580
|
+
remove_label="Remove this DB pension",
|
|
1581
|
+
fields=(
|
|
1582
|
+
FieldSpec(
|
|
1583
|
+
key="accrued_annual_pension",
|
|
1584
|
+
label="Accrued annual pension",
|
|
1585
|
+
hint="per year at the statement date, e.g. 8500",
|
|
1586
|
+
required=True,
|
|
1587
|
+
),
|
|
1588
|
+
FieldSpec(
|
|
1589
|
+
key="statement_date",
|
|
1590
|
+
label="Statement date (dates the scheme facts)",
|
|
1591
|
+
kind=FieldKind.DATE,
|
|
1592
|
+
hint="YYYY-MM-DD",
|
|
1593
|
+
required=True,
|
|
1594
|
+
),
|
|
1595
|
+
FieldSpec(
|
|
1596
|
+
key="normal_pension_age",
|
|
1597
|
+
label="Normal pension age",
|
|
1598
|
+
hint="e.g. 65",
|
|
1599
|
+
required=True,
|
|
1600
|
+
),
|
|
1601
|
+
FieldSpec(
|
|
1602
|
+
key="revaluation_reference",
|
|
1603
|
+
label="Revaluation basis",
|
|
1604
|
+
kind=FieldKind.CHOICE,
|
|
1605
|
+
required=True,
|
|
1606
|
+
choices=(
|
|
1607
|
+
_SELECT_OPTION,
|
|
1608
|
+
ChoiceOption(value="cpi", label="CPI (optionally capped)"),
|
|
1609
|
+
ChoiceOption(value="fixed", label="Fixed annual rate"),
|
|
1610
|
+
ChoiceOption(value="none", label="No revaluation"),
|
|
1611
|
+
),
|
|
1612
|
+
),
|
|
1613
|
+
FieldSpec(
|
|
1614
|
+
key="revaluation_cap",
|
|
1615
|
+
label="CPI cap (annual, as a fraction)",
|
|
1616
|
+
hint="e.g. 0.05 for CPI capped at 5%; blank if uncapped",
|
|
1617
|
+
),
|
|
1618
|
+
FieldSpec(
|
|
1619
|
+
key="revaluation_fixed_rate",
|
|
1620
|
+
label="Fixed rate (annual, as a fraction)",
|
|
1621
|
+
hint="fixed basis only, e.g. 0.03",
|
|
1622
|
+
),
|
|
1623
|
+
FieldSpec(
|
|
1624
|
+
key="early_late_factors",
|
|
1625
|
+
label="Early/late retirement factors",
|
|
1626
|
+
hint=_FACTORS_MESSAGE.removeprefix("enter "),
|
|
1627
|
+
),
|
|
1628
|
+
FieldSpec(
|
|
1629
|
+
key="commutation_factor",
|
|
1630
|
+
label="Commutation factor (£ lump sum per £1 pension)",
|
|
1631
|
+
hint="e.g. 12; blank if not commuting",
|
|
1632
|
+
),
|
|
1633
|
+
FieldSpec(
|
|
1634
|
+
key="taken_at_age",
|
|
1635
|
+
label="Take benefits at age (your choice)",
|
|
1636
|
+
hint="blank means the normal pension age",
|
|
1637
|
+
),
|
|
1638
|
+
FieldSpec(
|
|
1639
|
+
key="commuted_fraction",
|
|
1640
|
+
label="Fraction commuted to lump sum (your choice)",
|
|
1641
|
+
hint="0 to 1, e.g. 0.25; blank means none",
|
|
1642
|
+
),
|
|
1643
|
+
FieldSpec(
|
|
1644
|
+
key="accrual_rate",
|
|
1645
|
+
label="Accrual rate (fraction of salary per year of service)",
|
|
1646
|
+
hint="e.g. 0.0166667 for a 1/60th scheme; blank if deferred",
|
|
1647
|
+
),
|
|
1648
|
+
FieldSpec(
|
|
1649
|
+
key="pensionable_salary",
|
|
1650
|
+
label="Pensionable salary (annual)",
|
|
1651
|
+
hint="active members only; often below total pay",
|
|
1652
|
+
),
|
|
1653
|
+
FieldSpec(
|
|
1654
|
+
key="active_until_age",
|
|
1655
|
+
label="Active until age (your choice)",
|
|
1656
|
+
hint="blank means until benefits start",
|
|
1657
|
+
),
|
|
1658
|
+
),
|
|
1659
|
+
)
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
def _annuity_purchase_section() -> SectionSpec:
|
|
1663
|
+
"""The repeatable annuity-purchase section.
|
|
1664
|
+
|
|
1665
|
+
Every field is a choice (planning §5.1): the record is wholly a
|
|
1666
|
+
decision, priced at run time from the annuity-rate assumptions in
|
|
1667
|
+
the inspector. Single-life only until couples activate (9.4).
|
|
1668
|
+
"""
|
|
1669
|
+
return SectionSpec(
|
|
1670
|
+
key="annuity_purchase",
|
|
1671
|
+
title="Annuity purchase",
|
|
1672
|
+
description=(
|
|
1673
|
+
"A plan to convert part of your pension pot into guaranteed "
|
|
1674
|
+
"lifetime income at a chosen age. Anything not annuitised "
|
|
1675
|
+
"stays invested in drawdown; add several purchases at "
|
|
1676
|
+
"different ages to annuitise in stages. Everything here is "
|
|
1677
|
+
"your choice; the annuity rates applied are assumptions, "
|
|
1678
|
+
"shown in the stated-vs-assumed view. Single-life products "
|
|
1679
|
+
"only for now."
|
|
1680
|
+
),
|
|
1681
|
+
repeatable=True,
|
|
1682
|
+
add_label="Add annuity purchase",
|
|
1683
|
+
remove_label="Remove this annuity purchase",
|
|
1684
|
+
fields=(
|
|
1685
|
+
FieldSpec(
|
|
1686
|
+
key="at_age",
|
|
1687
|
+
label="Buy at age (your choice)",
|
|
1688
|
+
hint="e.g. 68",
|
|
1689
|
+
required=True,
|
|
1690
|
+
),
|
|
1691
|
+
FieldSpec(
|
|
1692
|
+
key="fraction_of_pot",
|
|
1693
|
+
label="Fraction of pension pot (your choice)",
|
|
1694
|
+
hint="over 0 up to 1, e.g. 0.5; 1 annuitises the whole pot",
|
|
1695
|
+
required=True,
|
|
1696
|
+
),
|
|
1697
|
+
FieldSpec(
|
|
1698
|
+
key="annuity_type",
|
|
1699
|
+
label="Annuity type (your choice)",
|
|
1700
|
+
kind=FieldKind.CHOICE,
|
|
1701
|
+
required=True,
|
|
1702
|
+
choices=(
|
|
1703
|
+
_SELECT_OPTION,
|
|
1704
|
+
ChoiceOption(value="level", label="Level (constant income)"),
|
|
1705
|
+
ChoiceOption(
|
|
1706
|
+
value="escalating",
|
|
1707
|
+
label="Escalating (fixed annual increases)",
|
|
1708
|
+
),
|
|
1709
|
+
ChoiceOption(
|
|
1710
|
+
value="inflation_linked",
|
|
1711
|
+
label="Inflation-linked (tracks CPI)",
|
|
1712
|
+
),
|
|
1713
|
+
),
|
|
1714
|
+
),
|
|
1715
|
+
),
|
|
1716
|
+
)
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
def build_facts_form_view_model() -> FactsFormViewModel:
|
|
1720
|
+
"""Assemble the facts entry screen (roadmap 8.2).
|
|
1721
|
+
|
|
1722
|
+
The acceptance criterion is that every §5.1 fact is enterable with
|
|
1723
|
+
its ``as_of`` date; the guard is ``tests/test_app_forms.py``'s
|
|
1724
|
+
coverage sweep over the §5.1 fact list.
|
|
1725
|
+
"""
|
|
1726
|
+
return FactsFormViewModel(
|
|
1727
|
+
title="Your plan's facts",
|
|
1728
|
+
intro=(
|
|
1729
|
+
"Everything here is either a fact you state or a choice you "
|
|
1730
|
+
"make — never a guess. Balances and your state pension "
|
|
1731
|
+
'forecast carry an "as of" date that defaults to today '
|
|
1732
|
+
"when blank; estimates and defaults live in the "
|
|
1733
|
+
"assumptions inspector."
|
|
1734
|
+
),
|
|
1735
|
+
person=_person_section(),
|
|
1736
|
+
spending=_spending_section(),
|
|
1737
|
+
state_pension=_state_pension_section(),
|
|
1738
|
+
wrapper=_wrapper_section(),
|
|
1739
|
+
db_pension=_db_pension_section(),
|
|
1740
|
+
annuity_purchase=_annuity_purchase_section(),
|
|
1741
|
+
submit_label="Save facts and project",
|
|
1742
|
+
clear_label="Clear the form",
|
|
1743
|
+
example_note=(
|
|
1744
|
+
"This is an example plan so you can see glidepath's output "
|
|
1745
|
+
"straight away — nothing here is your data. Replace the "
|
|
1746
|
+
"values with your own facts and save, or clear the form to "
|
|
1747
|
+
"start blank."
|
|
1748
|
+
),
|
|
1749
|
+
cleared_note="Form cleared — enter your facts and save to project.",
|
|
1750
|
+
)
|