@retiregolden/planner-ui 0.1.0 → 0.3.0

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.
@@ -0,0 +1,715 @@
1
+ /**
2
+ * Edition-neutral report model — the complete data a report needs, assembled
3
+ * from an already-computed projection, independent of any DOM/HTML/theme.
4
+ *
5
+ * This is the shared seam of the advisor-branding plan: every edition renders
6
+ * reports from the same `ReportModel`, and only branding/layout differ
7
+ * downstream. The model therefore carries data and semantic block identity
8
+ * (`REPORT_BLOCK_IDS`), never CSS, page, or firm concepts.
9
+ *
10
+ * Boundary note (decision-support-boundary.md): the model distinguishes
11
+ * RetireGolden-computed modeled findings — objective-attributed evidence in
12
+ * the `modeled-findings` block — from advisor-authored recommendations, which
13
+ * exist in the model only as content the host explicitly supplies.
14
+ * `buildReportModel` copies that content verbatim and never derives it.
15
+ *
16
+ * Assembly selects and formats already-computed results; all money math stays
17
+ * in `@retiregolden/engine`. The model records the provenance of what it
18
+ * selected (parameter pack years, data vintage, generation timestamp).
19
+ *
20
+ * Published as the stable `@retiregolden/planner-ui/report-model` subpath —
21
+ * see the package README's "Published API surface".
22
+ */
23
+
24
+ import type { Account, IncomeStream, Plan } from '@retiregolden/engine/model/plan'
25
+ import {
26
+ LATEST_PACK_YEAR,
27
+ PARAMETER_DATA_AS_OF,
28
+ PARAMETER_DATA_BASIS,
29
+ PARAMETER_PROVENANCE,
30
+ } from '@retiregolden/engine/params'
31
+ import { LATEST_STATE_PACK_YEAR } from '@retiregolden/engine/params/state'
32
+ import type { ProjectionSummary } from '@retiregolden/engine/projection/compare'
33
+ import type { ProjectionResult, YearResult } from '@retiregolden/engine/projection/types'
34
+ import { fmtMoney } from '../planner/format'
35
+ import { isPlanIncomplete } from '../planner/planCompleteness'
36
+
37
+ export const REPORT_MODEL_KIND = 'retiregolden.report-model'
38
+ export const REPORT_MODEL_VERSION = 1
39
+
40
+ /**
41
+ * Stable identities of every block the model carries. Downstream templates
42
+ * reference these ids; renderers that meet an id they don't know should warn
43
+ * rather than silently drop content.
44
+ */
45
+ export const REPORT_BLOCK_IDS = [
46
+ 'headline-results',
47
+ 'modeled-findings',
48
+ 'household',
49
+ 'accounts',
50
+ 'income-sources',
51
+ 'assumptions',
52
+ 'modeling-notes',
53
+ 'year-ledger',
54
+ 'chart-data',
55
+ 'parameter-sources',
56
+ 'disclosures',
57
+ 'advisor-recommendations',
58
+ ] as const
59
+
60
+ export type ReportBlockId = (typeof REPORT_BLOCK_IDS)[number]
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Modeled findings (RetireGolden-computed, objective-attributed evidence)
64
+ // ---------------------------------------------------------------------------
65
+
66
+ export interface ReportDecisionCandidateRow {
67
+ afterTaxEstateDelta: number
68
+ candidateId: string
69
+ label: string
70
+ lifetimeTaxDelta: number
71
+ lossReason: string
72
+ moneyLastsYearsDelta: number
73
+ }
74
+
75
+ export interface ReportClaimAgeEvidence {
76
+ combinationsEvaluated: number
77
+ /** Null when the current claim ages won the joint grid. */
78
+ winningClaimLabel: string | null
79
+ jointExactEstate: number
80
+ currentClaimExactEstate: number
81
+ }
82
+
83
+ /**
84
+ * Exact-ledger validation of the winning candidate, restated as flat report
85
+ * figures. Deliberately not the engine's `ExactLedgerValidation`: that type
86
+ * embeds two full projection summaries, and carrying it verbatim would couple
87
+ * this serialized contract to engine-internal shape. The model states only
88
+ * the figures a report presents.
89
+ */
90
+ export interface ReportValidationEvidence {
91
+ baselineAfterTaxEstate: number
92
+ candidateAfterTaxEstate: number
93
+ afterTaxEstateDelta: number
94
+ endingNetWorthDelta: number
95
+ lifetimeTaxDelta: number
96
+ moneyLastsYearsDelta: number
97
+ requestedConversionTotal: number
98
+ executedConversionTotal: number
99
+ executedConversionRatio: number
100
+ /** Null when every requested conversion year executed materially in full. */
101
+ firstMateriallyUnexecutedYear: number | null
102
+ traditionalDepletionYear: number | null
103
+ recommendationState: string
104
+ }
105
+
106
+ /**
107
+ * Evidence for a modeled finding: what objective the user selected, which
108
+ * candidate won under it, the exact-ledger validation deltas, and why the
109
+ * other candidates lost. This is calculation output attributed to the stated
110
+ * objective — not a suitability determination.
111
+ */
112
+ export interface ReportRecommendationEvidence {
113
+ objectiveId: string
114
+ objectiveLabel: string
115
+ recommendationState: string
116
+ winnerLabel: string
117
+ winnerSource: string
118
+ validation: ReportValidationEvidence | null
119
+ candidates: ReportDecisionCandidateRow[]
120
+ /** Non-null only when claim-age co-optimization ran (Step 5). */
121
+ claimAge: ReportClaimAgeEvidence | null
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Blocks
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Headline projection metrics from the projection summary. Dollar figures are
130
+ * whole nominal dollars — the precision the report presents (and the same
131
+ * whole-dollar discipline as the repo's case-runner manifests), which also
132
+ * keeps serialized models byte-stable across platforms whose math libraries
133
+ * differ in the last float digit.
134
+ */
135
+ export interface ReportHeadlineResultsBlock {
136
+ endingNetWorth: number
137
+ /** Net of heir income tax on inherited pre-tax balances. */
138
+ endingAfterTaxEstate: number
139
+ endingInvestable: number
140
+ /** Null when investable assets last through the whole projection. */
141
+ depletionYear: number | null
142
+ lifetimeTaxesAndPenalties: number
143
+ lifetimeRothConversions: number
144
+ /** Today-dollar portfolio target at the plan's safe withdrawal rate. */
145
+ fiNumber: number
146
+ fiYear: number | null
147
+ fiAge: number | null
148
+ coastFireNumber: number
149
+ averagePreRetirementSavingsRatePct: number
150
+ }
151
+
152
+ export interface ReportPersonRow {
153
+ name: string
154
+ dob: string
155
+ retirementAge: number | null
156
+ planningAge: number
157
+ }
158
+
159
+ export interface ReportHouseholdBlock {
160
+ filingStatus: Plan['household']['filingStatus']
161
+ state: string
162
+ people: ReportPersonRow[]
163
+ /**
164
+ * True while the plan has no income sources and nothing that can fund
165
+ * spending — the plan is still being set up. Renderers must keep this
166
+ * missing-data caveat visible; it is not removable compression.
167
+ */
168
+ incompleteData: boolean
169
+ }
170
+
171
+ export interface ReportAccountRow {
172
+ id: string
173
+ name: string
174
+ type: Account['type']
175
+ /** Report vocabulary for the account type (e.g. taxable → "Brokerage"). */
176
+ typeLabel: string
177
+ owner: string
178
+ balance: number
179
+ /** Null means the plan's default return assumption applies. */
180
+ annualReturnPct: number | null
181
+ }
182
+
183
+ export interface ReportAccountsBlock {
184
+ rows: ReportAccountRow[]
185
+ }
186
+
187
+ export interface ReportIncomeSourceRow {
188
+ id: string
189
+ type: IncomeStream['type']
190
+ label: string
191
+ /** Human-readable summary of the stream's terms (amount, timing, claim age). */
192
+ detail: string
193
+ }
194
+
195
+ export interface ReportIncomeSourcesBlock {
196
+ rows: ReportIncomeSourceRow[]
197
+ }
198
+
199
+ /**
200
+ * The user's modeling assumptions and strategy selections, as configured on
201
+ * the plan (visible, editable modeling inputs — not determinations).
202
+ */
203
+ export interface ReportAssumptionsBlock {
204
+ inflationPct: number
205
+ healthcareExtraInflationPct: number
206
+ defaultReturnPct: number
207
+ /** The plan's value with the modeling default applied when unset. */
208
+ safeWithdrawalRatePct: number
209
+ stateEffectiveTaxPct: number
210
+ localIncomeTaxPct: number
211
+ heirTaxRatePct: number
212
+ rothConversionSummary: string
213
+ withdrawalOrderSummary: string
214
+ spendingPolicySummary: string
215
+ }
216
+
217
+ /** Engine warnings for this run — model limitations the report must surface. */
218
+ export interface ReportModelingNotesBlock {
219
+ warnings: string[]
220
+ }
221
+
222
+ /** One ledger year; dollar fields are whole nominal dollars (see headline block note). */
223
+ export interface ReportYearLedgerRow {
224
+ year: number
225
+ income: number
226
+ expenses: number
227
+ contributions: number
228
+ rmd: number
229
+ rothConversion: number
230
+ /** Combined tax + penalties, as the ledger appendix reports it. */
231
+ taxAndPenalties: number
232
+ magi: number
233
+ withdrawals: number
234
+ investable: number
235
+ netWorth: number
236
+ }
237
+
238
+ export interface ReportYearLedgerBlock {
239
+ rows: ReportYearLedgerRow[]
240
+ }
241
+
242
+ export const REPORT_CHART_CATEGORIES = ['cash', 'taxable', 'equityComp', 'traditional', 'roth', 'hsa'] as const
243
+
244
+ export type ReportChartCategory = (typeof REPORT_CHART_CATEGORIES)[number]
245
+
246
+ /** Whole-dollar per-year series behind the balance/income chart and CSV. */
247
+ export interface ReportChartDataRow {
248
+ year: number
249
+ cash: number
250
+ taxable: number
251
+ equityComp: number
252
+ traditional: number
253
+ roth: number
254
+ hsa: number
255
+ income: number
256
+ spendingPlusTax: number
257
+ }
258
+
259
+ export interface ReportChartDataBlock {
260
+ rows: ReportChartDataRow[]
261
+ }
262
+
263
+ export interface ReportParameterSourceRow {
264
+ label: string
265
+ figures: string
266
+ publisher: string
267
+ url: string
268
+ }
269
+
270
+ export interface ReportParameterSourcesBlock {
271
+ sources: ReportParameterSourceRow[]
272
+ }
273
+
274
+ /** Disclosure statements every rendering must keep visible. */
275
+ export interface ReportDisclosuresBlock {
276
+ statements: string[]
277
+ }
278
+
279
+ /**
280
+ * Professional judgment affirmatively authored or adopted by an advisor.
281
+ * The model carries this content only when the host supplies it;
282
+ * RetireGolden never generates or pre-fills it (decision-support boundary).
283
+ */
284
+ export interface ReportAdvisorRecommendation {
285
+ heading: string
286
+ body: string
287
+ /** Attribution — the professional who authored or adopted this entry. */
288
+ authoredBy: string
289
+ /** When the advisor recorded adopting the entry, if the host tracks that. */
290
+ adoptedAtIso?: string | null
291
+ }
292
+
293
+ export interface ReportAdvisorRecommendationsBlock {
294
+ entries: ReportAdvisorRecommendation[]
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Model
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /** Where the numbers came from: law/data vintage and build identifiers. */
302
+ export interface ReportProvenance {
303
+ federalParameterPackYear: number
304
+ stateParameterPackYear: number
305
+ parameterDataAsOf: string
306
+ parameterDataBasis: string
307
+ /** Engine build the host ran, when the host supplies it — never guessed. */
308
+ engineVersion: string | null
309
+ /** Host application build, when the host supplies it. */
310
+ hostVersion: string | null
311
+ }
312
+
313
+ export interface ReportModel {
314
+ kind: typeof REPORT_MODEL_KIND
315
+ version: typeof REPORT_MODEL_VERSION
316
+ planName: string
317
+ generatedAtIso: string
318
+ startYear: number
319
+ endYear: number
320
+ provenance: ReportProvenance
321
+ blocks: {
322
+ 'headline-results': ReportHeadlineResultsBlock
323
+ /** Null when no optimization evidence accompanies this report. */
324
+ 'modeled-findings': ReportRecommendationEvidence | null
325
+ 'household': ReportHouseholdBlock
326
+ 'accounts': ReportAccountsBlock
327
+ 'income-sources': ReportIncomeSourcesBlock
328
+ 'assumptions': ReportAssumptionsBlock
329
+ 'modeling-notes': ReportModelingNotesBlock
330
+ 'year-ledger': ReportYearLedgerBlock
331
+ 'chart-data': ReportChartDataBlock
332
+ 'parameter-sources': ReportParameterSourcesBlock
333
+ 'disclosures': ReportDisclosuresBlock
334
+ /** Host-supplied only; null unless the host passed advisor content in. */
335
+ 'advisor-recommendations': ReportAdvisorRecommendationsBlock | null
336
+ }
337
+ }
338
+
339
+ export interface ReportModelInput {
340
+ plan: Plan
341
+ result: ProjectionResult
342
+ summary: ProjectionSummary
343
+ startYear: number
344
+ /** Defaults to the current time; pass a fixed value for reproducible output. */
345
+ generatedAtIso?: string
346
+ /** Optimization evidence from a run the user directed, if one accompanied this report. */
347
+ modeledFindings?: ReportRecommendationEvidence | null
348
+ /**
349
+ * Advisor-authored content, copied into the model verbatim. Omitting it —
350
+ * the only behavior RetireGolden's own surfaces use — leaves the block null.
351
+ */
352
+ advisorRecommendations?: ReportAdvisorRecommendation[] | null
353
+ /** Build identifiers recorded into provenance when the host knows them. */
354
+ build?: { engineVersion?: string; hostVersion?: string } | null
355
+ }
356
+
357
+ export const REPORT_ACCOUNT_TYPE_LABEL: Record<Account['type'], string> = {
358
+ annuity: 'Annuity',
359
+ cash: 'Cash',
360
+ debt: 'Debt',
361
+ equityComp: 'Equity comp',
362
+ hsa: 'HSA',
363
+ pension: 'Pension',
364
+ property: 'Property',
365
+ roth: 'Roth',
366
+ taxable: 'Brokerage',
367
+ traditional: 'Traditional',
368
+ }
369
+
370
+ export const REPORT_EDUCATIONAL_DISCLAIMER =
371
+ 'Educational illustration only - not tax, legal, financial, or medical advice. Figures are projections based on the assumptions below and will differ from actual results.'
372
+
373
+ /**
374
+ * Whole-dollar rounding, half away from zero — the same convention
375
+ * Intl.NumberFormat's default (halfExpand) applies when the report formats
376
+ * these values, so pre-rounded figures render to identical bytes.
377
+ */
378
+ function roundDollar(value: number): number {
379
+ return value >= 0 ? Math.round(value) : -Math.round(-value)
380
+ }
381
+
382
+ function ownerName(plan: Plan, ownerPersonId: string | null): string {
383
+ if (ownerPersonId === null) return 'Joint'
384
+ return plan.household.people.find((p) => p.id === ownerPersonId)?.name ?? '-'
385
+ }
386
+
387
+ function accountBalance(a: Account): number {
388
+ if ('balance' in a) return a.balance
389
+ if (a.type === 'property') return a.value
390
+ return 0
391
+ }
392
+
393
+ function incomeLabel(plan: Plan, s: IncomeStream): string {
394
+ if (s.type === 'wages') return `Wages - ${ownerName(plan, s.personId)}`
395
+ if (s.type === 'socialSecurity') return `Social Security - ${ownerName(plan, s.personId)}`
396
+ return s.label
397
+ }
398
+
399
+ function incomeDetail(s: IncomeStream): string {
400
+ switch (s.type) {
401
+ case 'wages':
402
+ return `${fmtMoney(s.annualGross)}/yr until ${s.endAge ?? 'retirement'}`
403
+ case 'socialSecurity':
404
+ return `${s.piaMonthly !== null ? `${fmtMoney(s.piaMonthly)}/mo PIA` : 'from earnings record'}, claim ${s.claimAge.years}y${s.claimAge.months ? ` ${s.claimAge.months}m` : ''}`
405
+ case 'recurring':
406
+ return `${fmtMoney(s.annualAmount)}/yr${s.inflationAdjusted ? ', inflation-adjusted' : ''}`
407
+ case 'oneTime':
408
+ return `${fmtMoney(s.amount)} in ${s.year}`
409
+ }
410
+ }
411
+
412
+ function conversionSummary(plan: Plan): string {
413
+ const rc = plan.strategies.rothConversion
414
+ if (rc.mode === 'none') return 'None'
415
+ if (rc.mode === 'manual') return `Manual - ${rc.conversions.length} year(s)`
416
+ if (rc.mode === 'optimized') return `Optimized - ${rc.conversions.length} year(s)`
417
+ return `Fill to ${rc.target}${rc.targetValue !== null ? ` (${rc.targetValue})` : ''}, ${rc.startYear}-${rc.endYear}`
418
+ }
419
+
420
+ function withdrawalSummary(plan: Plan): string {
421
+ const w = plan.strategies.withdrawalOrder
422
+ if (w.mode === 'sequential') return 'Sequential: cash, taxable, equity comp, traditional, Roth, HSA'
423
+ if (w.mode === 'proportional') return 'Proportional across accounts'
424
+ return `Bracket-targeted to ${w.bracketPct}%`
425
+ }
426
+
427
+ function spendingPolicySummary(plan: Plan): string {
428
+ const policy = plan.expenses.spendingPolicy
429
+ if (!policy || policy.mode === 'fixedTarget') return 'Fixed target (no guardrails)'
430
+ if (policy.mode === 'withdrawalRateGuardrails') {
431
+ return `Withdrawal-rate guardrails: cut above ${policy.upperGuardrailPct ?? 120}% / restore below ${policy.lowerGuardrailPct ?? 80}% of the starting rate, ${policy.adjustmentPct ?? 10}% steps`
432
+ }
433
+ if (policy.mode === 'abw') {
434
+ const source =
435
+ policy.abw?.returnSource === 'cape'
436
+ ? `CAPE ${policy.abw?.startingCape ?? 25} earnings yield`
437
+ : policy.abw?.returnSource === 'tips'
438
+ ? `TIPS real yield ${policy.abw?.bondRealYieldPct ?? 2}%`
439
+ : `fixed ${policy.abw?.fixedRealReturnPct ?? 3.8}% real`
440
+ const horizon =
441
+ policy.abw?.horizon === 'survival25'
442
+ ? '25% survival age'
443
+ : policy.abw?.horizon === 'survival10'
444
+ ? '10% survival age'
445
+ : 'planning age'
446
+ return `Amortized spending (ABW): ${source}, to ${horizon}, tilt ${policy.abw?.tiltPct ?? 0}%/yr`
447
+ }
448
+ const lower =
449
+ policy.lowerBalanceThresholdPct !== undefined
450
+ ? `cut below ${policy.lowerBalanceThresholdPct.toFixed(0)}%`
451
+ : 'no cut threshold'
452
+ const upper =
453
+ policy.upperBalanceThresholdPct !== undefined
454
+ ? `raise above ${policy.upperBalanceThresholdPct.toFixed(0)}%`
455
+ : 'no raise threshold'
456
+ return `Risk-based guardrails (${policy.targetSuccessLowerPct ?? 70}-${policy.targetSuccessUpperPct ?? 95}% success band): ${lower} / ${upper} of the starting portfolio, ${policy.adjustmentPct ?? 10}% steps`
457
+ }
458
+
459
+ function chartDataRows(plan: Plan, result: ProjectionResult): ReportChartDataRow[] {
460
+ return result.years.map((year) => {
461
+ const categories = Object.fromEntries(REPORT_CHART_CATEGORIES.map((category) => [category, 0])) as Record<
462
+ ReportChartCategory,
463
+ number
464
+ >
465
+ for (const account of plan.accounts) {
466
+ if ((REPORT_CHART_CATEGORIES as readonly string[]).includes(account.type)) {
467
+ categories[account.type as ReportChartCategory] += year.balances[account.id] ?? 0
468
+ }
469
+ }
470
+ return {
471
+ year: year.year,
472
+ cash: Math.round(categories.cash),
473
+ taxable: Math.round(categories.taxable),
474
+ equityComp: Math.round(categories.equityComp),
475
+ traditional: Math.round(categories.traditional),
476
+ roth: Math.round(categories.roth),
477
+ hsa: Math.round(categories.hsa),
478
+ income: Math.round(year.incomes.total),
479
+ spendingPlusTax: Math.round(year.expenses.total + year.tax + year.penalties),
480
+ }
481
+ })
482
+ }
483
+
484
+ /**
485
+ * The model is a snapshot: copy the findings instead of aliasing the caller's
486
+ * object, so later mutation of optimizer output can't silently change an
487
+ * already-built model (all leaf fields are primitives, so shallow copies of
488
+ * each layer suffice).
489
+ */
490
+ function snapshotFindings(findings: ReportRecommendationEvidence | null | undefined): ReportRecommendationEvidence | null {
491
+ if (!findings) return null
492
+ return {
493
+ ...findings,
494
+ validation: findings.validation ? { ...findings.validation } : null,
495
+ candidates: findings.candidates.map((candidate) => ({ ...candidate })),
496
+ claimAge: findings.claimAge ? { ...findings.claimAge } : null,
497
+ }
498
+ }
499
+
500
+ function yearLedgerRow(y: YearResult): ReportYearLedgerRow {
501
+ return {
502
+ year: y.year,
503
+ income: roundDollar(y.incomes.total),
504
+ expenses: roundDollar(y.expenses.total),
505
+ contributions: roundDollar(y.contributions),
506
+ rmd: roundDollar(y.rmd),
507
+ rothConversion: roundDollar(y.rothConversion),
508
+ taxAndPenalties: roundDollar(y.tax + y.penalties),
509
+ magi: roundDollar(y.magi),
510
+ withdrawals: roundDollar(y.withdrawals.total),
511
+ investable: roundDollar(y.investableTotal),
512
+ netWorth: roundDollar(y.netWorth),
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Assemble the edition-neutral report model from an already-computed
518
+ * projection. Pure selection/formatting — this function runs no simulation
519
+ * and performs no money math beyond restating engine-computed values.
520
+ */
521
+ export function buildReportModel(input: ReportModelInput): ReportModel {
522
+ const { plan, result, summary } = input
523
+ return {
524
+ kind: REPORT_MODEL_KIND,
525
+ version: REPORT_MODEL_VERSION,
526
+ planName: plan.name,
527
+ generatedAtIso: input.generatedAtIso ?? new Date().toISOString(),
528
+ startYear: input.startYear,
529
+ endYear: result.endYear,
530
+ provenance: {
531
+ federalParameterPackYear: LATEST_PACK_YEAR,
532
+ stateParameterPackYear: LATEST_STATE_PACK_YEAR,
533
+ parameterDataAsOf: PARAMETER_DATA_AS_OF,
534
+ parameterDataBasis: PARAMETER_DATA_BASIS,
535
+ engineVersion: input.build?.engineVersion ?? null,
536
+ hostVersion: input.build?.hostVersion ?? null,
537
+ },
538
+ blocks: {
539
+ 'headline-results': {
540
+ endingNetWorth: roundDollar(summary.endingNetWorth),
541
+ endingAfterTaxEstate: roundDollar(summary.endingAfterTaxEstate),
542
+ endingInvestable: roundDollar(summary.endingInvestable),
543
+ depletionYear: summary.depletionYear,
544
+ lifetimeTaxesAndPenalties: roundDollar(summary.lifetimeTaxesAndPenalties),
545
+ lifetimeRothConversions: roundDollar(summary.lifetimeRothConversions),
546
+ fiNumber: roundDollar(summary.fiNumber),
547
+ fiYear: summary.fiYear,
548
+ fiAge: summary.fiAge,
549
+ coastFireNumber: roundDollar(summary.coastFireNumber),
550
+ // Presentation precision (the UI shows one decimal), which also keeps
551
+ // this derived percent platform-stable in serialized output.
552
+ averagePreRetirementSavingsRatePct: Math.round(summary.averagePreRetirementSavingsRatePct * 10) / 10,
553
+ },
554
+ 'modeled-findings': snapshotFindings(input.modeledFindings),
555
+ 'household': {
556
+ filingStatus: plan.household.filingStatus,
557
+ state: plan.household.state,
558
+ people: plan.household.people.map((p) => ({
559
+ name: p.name,
560
+ dob: p.dob,
561
+ retirementAge: p.retirementAge,
562
+ planningAge: p.longevity.planningAge,
563
+ })),
564
+ incompleteData: isPlanIncomplete(plan),
565
+ },
566
+ 'accounts': {
567
+ rows: plan.accounts.map((a) => ({
568
+ id: a.id,
569
+ name: a.name,
570
+ type: a.type,
571
+ typeLabel: REPORT_ACCOUNT_TYPE_LABEL[a.type],
572
+ owner: ownerName(plan, a.ownerPersonId),
573
+ balance: accountBalance(a),
574
+ annualReturnPct: 'annualReturnPct' in a ? a.annualReturnPct : null,
575
+ })),
576
+ },
577
+ 'income-sources': {
578
+ rows: plan.incomes.map((s) => ({
579
+ id: s.id,
580
+ type: s.type,
581
+ label: incomeLabel(plan, s),
582
+ detail: incomeDetail(s),
583
+ })),
584
+ },
585
+ 'assumptions': {
586
+ inflationPct: plan.assumptions.inflationPct,
587
+ healthcareExtraInflationPct: plan.assumptions.healthcareExtraInflationPct,
588
+ defaultReturnPct: plan.assumptions.defaultReturnPct,
589
+ safeWithdrawalRatePct: plan.assumptions.safeWithdrawalRatePct ?? 4,
590
+ stateEffectiveTaxPct: plan.assumptions.stateEffectiveTaxPct,
591
+ localIncomeTaxPct: plan.assumptions.localIncomeTaxPct,
592
+ heirTaxRatePct: plan.assumptions.heirTaxRatePct,
593
+ rothConversionSummary: conversionSummary(plan),
594
+ withdrawalOrderSummary: withdrawalSummary(plan),
595
+ spendingPolicySummary: spendingPolicySummary(plan),
596
+ },
597
+ 'modeling-notes': { warnings: [...result.warnings] },
598
+ 'year-ledger': { rows: result.years.map(yearLedgerRow) },
599
+ 'chart-data': { rows: chartDataRows(plan, result) },
600
+ 'parameter-sources': {
601
+ sources: PARAMETER_PROVENANCE.map((source) => ({
602
+ label: source.label,
603
+ figures: source.figures,
604
+ publisher: source.publisher,
605
+ url: source.url,
606
+ })),
607
+ },
608
+ 'disclosures': { statements: [REPORT_EDUCATIONAL_DISCLAIMER] },
609
+ 'advisor-recommendations':
610
+ input.advisorRecommendations && input.advisorRecommendations.length > 0
611
+ ? {
612
+ entries: input.advisorRecommendations.map((entry) => ({
613
+ heading: entry.heading,
614
+ body: entry.body,
615
+ authoredBy: entry.authoredBy,
616
+ adoptedAtIso: entry.adoptedAtIso ?? null,
617
+ })),
618
+ }
619
+ : null,
620
+ },
621
+ }
622
+ }
623
+
624
+ // ---------------------------------------------------------------------------
625
+ // Serialization and table export (Workstream 7's structured-data half)
626
+ // ---------------------------------------------------------------------------
627
+
628
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
629
+
630
+ function normalizeJson(value: unknown): JsonValue | undefined {
631
+ if (value === undefined) return undefined
632
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
633
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null
634
+ if (Array.isArray(value)) return value.map((item) => normalizeJson(item) ?? null)
635
+ if (typeof value === 'object') {
636
+ const out: { [key: string]: JsonValue } = {}
637
+ // Codepoint order, not localeCompare: collation varies by runtime locale
638
+ // and would break the same-bytes-everywhere guarantee.
639
+ for (const [key, child] of Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
640
+ const normalized = normalizeJson(child)
641
+ if (normalized !== undefined) out[key] = normalized
642
+ }
643
+ return out
644
+ }
645
+ return String(value)
646
+ }
647
+
648
+ /**
649
+ * Deterministic JSON for the model: sorted keys, two-space indent, trailing
650
+ * newline, non-finite numbers as null. The same input always produces the
651
+ * same bytes, so serialized models diff cleanly and suit golden fixtures.
652
+ */
653
+ export function serializeReportModel(model: ReportModel): string {
654
+ return `${JSON.stringify(normalizeJson(model) ?? null, null, 2)}\n`
655
+ }
656
+
657
+ function csvCell(value: string | number | null): string {
658
+ if (value === null) return ''
659
+ if (typeof value === 'number') return String(value)
660
+ // Text cells can carry user-entered names. Neutralize spreadsheet formula
661
+ // injection: a cell starting with = + - @ or a tab/CR is evaluated by
662
+ // Excel/Sheets even when quoted, so prefix it with an apostrophe (the
663
+ // standard render-as-text marker). Then quote anything containing commas,
664
+ // quotes, or line breaks (\r included — a bare CR also splits rows).
665
+ const neutralized = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value
666
+ return /[",\n\r]/.test(neutralized) ? `"${neutralized.replace(/"/g, '""')}"` : neutralized
667
+ }
668
+
669
+ function csvTable(header: string[], rows: (string | number | null)[][]): string {
670
+ return [header.join(','), ...rows.map((row) => row.map(csvCell).join(','))].join('\n')
671
+ }
672
+
673
+ /**
674
+ * The chart/automation series as CSV — byte-identical to the CSV embedded in
675
+ * the standalone HTML report for the same projection.
676
+ */
677
+ export function chartDataCsv(block: ReportChartDataBlock): string {
678
+ return csvTable(
679
+ ['year', ...REPORT_CHART_CATEGORIES, 'income', 'spendingPlusTax'],
680
+ block.rows.map((row) => [
681
+ row.year,
682
+ ...REPORT_CHART_CATEGORIES.map((category) => row[category]),
683
+ row.income,
684
+ row.spendingPlusTax,
685
+ ]),
686
+ )
687
+ }
688
+
689
+ /** The year-by-year ledger appendix as CSV (whole nominal dollars, as the model carries them). */
690
+ export function yearLedgerCsv(block: ReportYearLedgerBlock): string {
691
+ return csvTable(
692
+ ['year', 'income', 'expenses', 'contributions', 'rmd', 'rothConversion', 'taxAndPenalties', 'magi', 'withdrawals', 'investable', 'netWorth'],
693
+ block.rows.map((row) => [
694
+ row.year,
695
+ row.income,
696
+ row.expenses,
697
+ row.contributions,
698
+ row.rmd,
699
+ row.rothConversion,
700
+ row.taxAndPenalties,
701
+ row.magi,
702
+ row.withdrawals,
703
+ row.investable,
704
+ row.netWorth,
705
+ ]),
706
+ )
707
+ }
708
+
709
+ /** The accounts table as CSV. */
710
+ export function accountsCsv(block: ReportAccountsBlock): string {
711
+ return csvTable(
712
+ ['name', 'type', 'typeLabel', 'owner', 'balance', 'annualReturnPct'],
713
+ block.rows.map((row) => [row.name, row.type, row.typeLabel, row.owner, row.balance, row.annualReturnPct]),
714
+ )
715
+ }