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