@retiregolden/planner-ui 0.2.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.
- package/README.md +54 -0
- package/package.json +3 -1
- package/src/planner/ResultsPage.tsx +25 -31
- package/src/planner/SurvivalPercentileModal.tsx +1 -1
- package/src/planner/chartTooltip.tsx +18 -0
- package/src/planner/insights/InsightCardView.tsx +5 -5
- package/src/planner/insights/InsightsPage.tsx +6 -5
- package/src/planner/marketModelPicker.ts +1 -1
- package/src/report/reportHtml.ts +174 -226
- package/src/report/reportModel.ts +715 -0
|
@@ -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
|
+
}
|