@retiregolden/engine 0.1.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/LICENSE +661 -0
- package/README.md +79 -0
- package/dist/allocation/assetClasses.d.ts +94 -0
- package/dist/allocation/assetClasses.js +215 -0
- package/dist/decisions/annuitization.d.ts +72 -0
- package/dist/decisions/annuitization.js +184 -0
- package/dist/decisions/decisionFixtures.d.ts +48 -0
- package/dist/decisions/decisionFixtures.js +253 -0
- package/dist/decisions/evaluateCandidate.d.ts +61 -0
- package/dist/decisions/evaluateCandidate.js +193 -0
- package/dist/decisions/generators.d.ts +110 -0
- package/dist/decisions/generators.js +667 -0
- package/dist/decisions/index.d.ts +17 -0
- package/dist/decisions/index.js +17 -0
- package/dist/decisions/insightsAdapter.d.ts +27 -0
- package/dist/decisions/insightsAdapter.js +51 -0
- package/dist/decisions/objectives.d.ts +96 -0
- package/dist/decisions/objectives.js +319 -0
- package/dist/decisions/pensionElection.d.ts +112 -0
- package/dist/decisions/pensionElection.js +239 -0
- package/dist/decisions/search.d.ts +51 -0
- package/dist/decisions/search.js +110 -0
- package/dist/decisions/spendingSolver.d.ts +70 -0
- package/dist/decisions/spendingSolver.js +180 -0
- package/dist/decisions/spiaQuotes.d.ts +21 -0
- package/dist/decisions/spiaQuotes.js +43 -0
- package/dist/decisions/stochastic.d.ts +11 -0
- package/dist/decisions/stochastic.js +66 -0
- package/dist/decisions/swrComparator.d.ts +54 -0
- package/dist/decisions/swrComparator.js +89 -0
- package/dist/decisions/tournament.d.ts +53 -0
- package/dist/decisions/tournament.js +104 -0
- package/dist/decisions/types.d.ts +125 -0
- package/dist/decisions/types.js +12 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/insights/detectors/annuitizationHeadroom.d.ts +11 -0
- package/dist/insights/detectors/annuitizationHeadroom.js +77 -0
- package/dist/insights/detectors/assetLocation.d.ts +9 -0
- package/dist/insights/detectors/assetLocation.js +86 -0
- package/dist/insights/detectors/hecmBufferCandidate.d.ts +12 -0
- package/dist/insights/detectors/hecmBufferCandidate.js +76 -0
- package/dist/insights/detectors/incomeFloorFunded.d.ts +8 -0
- package/dist/insights/detectors/incomeFloorFunded.js +52 -0
- package/dist/insights/detectors/irmaaTierEdge.d.ts +2 -0
- package/dist/insights/detectors/irmaaTierEdge.js +102 -0
- package/dist/insights/detectors/pensionElectionPending.d.ts +11 -0
- package/dist/insights/detectors/pensionElectionPending.js +62 -0
- package/dist/insights/detectors/qcdEfficiency.d.ts +2 -0
- package/dist/insights/detectors/qcdEfficiency.js +62 -0
- package/dist/insights/detectors/rothBridgeHeadroom.d.ts +2 -0
- package/dist/insights/detectors/rothBridgeHeadroom.js +77 -0
- package/dist/insights/detectors/spendingGuardrails.d.ts +2 -0
- package/dist/insights/detectors/spendingGuardrails.js +77 -0
- package/dist/insights/detectors/spendingHeadroom.d.ts +12 -0
- package/dist/insights/detectors/spendingHeadroom.js +92 -0
- package/dist/insights/detectors/ssBridgeGap.d.ts +9 -0
- package/dist/insights/detectors/ssBridgeGap.js +92 -0
- package/dist/insights/detectors/stateRelocation.d.ts +12 -0
- package/dist/insights/detectors/stateRelocation.js +99 -0
- package/dist/insights/detectors/widowsPenalty.d.ts +12 -0
- package/dist/insights/detectors/widowsPenalty.js +145 -0
- package/dist/insights/registry.d.ts +4 -0
- package/dist/insights/registry.js +62 -0
- package/dist/insights/runInsights.d.ts +6 -0
- package/dist/insights/runInsights.js +15 -0
- package/dist/insights/types.d.ts +70 -0
- package/dist/insights/types.js +1 -0
- package/dist/ladder/bridge.d.ts +72 -0
- package/dist/ladder/bridge.js +59 -0
- package/dist/ladder/fedInvest.d.ts +55 -0
- package/dist/ladder/fedInvest.js +77 -0
- package/dist/ladder/fundedRatio.d.ts +41 -0
- package/dist/ladder/fundedRatio.js +46 -0
- package/dist/ladder/ladderMath.d.ts +88 -0
- package/dist/ladder/ladderMath.js +140 -0
- package/dist/longevity/ssaPeriod2022.d.ts +10 -0
- package/dist/longevity/ssaPeriod2022.js +51 -0
- package/dist/longevity/types.d.ts +39 -0
- package/dist/longevity/types.js +1 -0
- package/dist/model/migrations.d.ts +20 -0
- package/dist/model/migrations.js +67 -0
- package/dist/model/plan.d.ts +3108 -0
- package/dist/model/plan.js +1514 -0
- package/dist/montecarlo/frontiers.d.ts +22 -0
- package/dist/montecarlo/frontiers.js +61 -0
- package/dist/montecarlo/historicalReturns.d.ts +27 -0
- package/dist/montecarlo/historicalReturns.js +124 -0
- package/dist/montecarlo/historicalSuites.d.ts +45 -0
- package/dist/montecarlo/historicalSuites.js +102 -0
- package/dist/montecarlo/ltcShock.d.ts +42 -0
- package/dist/montecarlo/ltcShock.js +62 -0
- package/dist/montecarlo/marketModels.d.ts +248 -0
- package/dist/montecarlo/marketModels.js +672 -0
- package/dist/montecarlo/mortality.d.ts +31 -0
- package/dist/montecarlo/mortality.js +61 -0
- package/dist/montecarlo/riskBasedGuardrails.d.ts +93 -0
- package/dist/montecarlo/riskBasedGuardrails.js +200 -0
- package/dist/montecarlo/rng.d.ts +18 -0
- package/dist/montecarlo/rng.js +44 -0
- package/dist/montecarlo/run.d.ts +210 -0
- package/dist/montecarlo/run.js +353 -0
- package/dist/montecarlo/sharedPaths.d.ts +43 -0
- package/dist/montecarlo/sharedPaths.js +28 -0
- package/dist/montecarlo/survival.d.ts +49 -0
- package/dist/montecarlo/survival.js +126 -0
- package/dist/params/data/realYieldCurve2026.d.ts +17 -0
- package/dist/params/data/realYieldCurve2026.js +26 -0
- package/dist/params/data/year2026.d.ts +14 -0
- package/dist/params/data/year2026.js +199 -0
- package/dist/params/index.d.ts +63 -0
- package/dist/params/index.js +134 -0
- package/dist/params/provenance.d.ts +29 -0
- package/dist/params/provenance.js +123 -0
- package/dist/params/state/data/year2026.d.ts +18 -0
- package/dist/params/state/data/year2026.js +581 -0
- package/dist/params/state/index.d.ts +13 -0
- package/dist/params/state/index.js +26 -0
- package/dist/params/state/types.d.ts +73 -0
- package/dist/params/state/types.js +10 -0
- package/dist/params/types.d.ts +201 -0
- package/dist/params/types.js +7 -0
- package/dist/projection/annuityForms.d.ts +62 -0
- package/dist/projection/annuityForms.js +87 -0
- package/dist/projection/compare.d.ts +96 -0
- package/dist/projection/compare.js +216 -0
- package/dist/projection/flatTax.d.ts +7 -0
- package/dist/projection/flatTax.js +14 -0
- package/dist/projection/optimizePlan.d.ts +328 -0
- package/dist/projection/optimizePlan.js +1039 -0
- package/dist/projection/relocation.d.ts +149 -0
- package/dist/projection/relocation.js +317 -0
- package/dist/projection/simulate.d.ts +61 -0
- package/dist/projection/simulate.js +2754 -0
- package/dist/projection/types.d.ts +381 -0
- package/dist/projection/types.js +9 -0
- package/dist/rmd/jointLifeTable.d.ts +8 -0
- package/dist/rmd/jointLifeTable.js +663 -0
- package/dist/rmd/rmd.d.ts +29 -0
- package/dist/rmd/rmd.js +34 -0
- package/dist/scenarios/scenarios.d.ts +70 -0
- package/dist/scenarios/scenarios.js +130 -0
- package/dist/socialSecurity/benefitFactor.d.ts +13 -0
- package/dist/socialSecurity/benefitFactor.js +40 -0
- package/dist/socialSecurity/claimFactor.d.ts +33 -0
- package/dist/socialSecurity/claimFactor.js +58 -0
- package/dist/socialSecurity/disability.d.ts +36 -0
- package/dist/socialSecurity/disability.js +42 -0
- package/dist/socialSecurity/familyMaximum.d.ts +38 -0
- package/dist/socialSecurity/familyMaximum.js +48 -0
- package/dist/socialSecurity/maritalBenefits.d.ts +57 -0
- package/dist/socialSecurity/maritalBenefits.js +87 -0
- package/dist/socialSecurity/nra.d.ts +30 -0
- package/dist/socialSecurity/nra.js +83 -0
- package/dist/socialSecurity/piaFromEarnings.d.ts +100 -0
- package/dist/socialSecurity/piaFromEarnings.js +202 -0
- package/dist/socialSecurity/ssaWageData.d.ts +68 -0
- package/dist/socialSecurity/ssaWageData.js +306 -0
- package/dist/socialSecurity/survivorBenefit.d.ts +61 -0
- package/dist/socialSecurity/survivorBenefit.js +61 -0
- package/dist/spending/abw.d.ts +57 -0
- package/dist/spending/abw.js +80 -0
- package/dist/spending/flexibleGoals.d.ts +62 -0
- package/dist/spending/flexibleGoals.js +136 -0
- package/dist/spending/guardrails.d.ts +78 -0
- package/dist/spending/guardrails.js +100 -0
- package/dist/spending/layers.d.ts +81 -0
- package/dist/spending/layers.js +67 -0
- package/dist/spending/shapePresets.d.ts +42 -0
- package/dist/spending/shapePresets.js +77 -0
- package/dist/strategies/accountEligibility.d.ts +81 -0
- package/dist/strategies/accountEligibility.js +98 -0
- package/dist/strategies/inheritedIra.d.ts +45 -0
- package/dist/strategies/inheritedIra.js +43 -0
- package/dist/strategies/iraBasis.d.ts +48 -0
- package/dist/strategies/iraBasis.js +46 -0
- package/dist/strategies/optimizer.d.ts +307 -0
- package/dist/strategies/optimizer.js +551 -0
- package/dist/strategies/rothBasis.d.ts +67 -0
- package/dist/strategies/rothBasis.js +83 -0
- package/dist/strategies/rothConversion.d.ts +55 -0
- package/dist/strategies/rothConversion.js +83 -0
- package/dist/strategies/sepp.d.ts +38 -0
- package/dist/strategies/sepp.js +53 -0
- package/dist/tax/aca.d.ts +40 -0
- package/dist/tax/aca.js +82 -0
- package/dist/tax/federalTax.d.ts +110 -0
- package/dist/tax/federalTax.js +249 -0
- package/dist/tax/medicare.d.ts +20 -0
- package/dist/tax/medicare.js +36 -0
- package/dist/tax/propertySale.d.ts +46 -0
- package/dist/tax/propertySale.js +35 -0
- package/dist/tax/stateTax.d.ts +66 -0
- package/dist/tax/stateTax.js +198 -0
- package/dist/testing/money.d.ts +7 -0
- package/dist/testing/money.js +17 -0
- package/dist/testing/planFixtures.d.ts +29 -0
- package/dist/testing/planFixtures.js +141 -0
- package/package.json +75 -0
|
@@ -0,0 +1,1514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan schema v1 — the v2 domain model of a household's finances.
|
|
3
|
+
*
|
|
4
|
+
* Zod schemas are the source of truth: types are inferred from them, and the
|
|
5
|
+
* same schemas validate IndexedDB reads, JSON imports, and migration output.
|
|
6
|
+
* Forward-looking fields (strategies, scenarios) are schema-complete before
|
|
7
|
+
* their engines exist so early plans survive later phases via migrations.
|
|
8
|
+
*
|
|
9
|
+
* @see DOCS/architecture.md (data model)
|
|
10
|
+
* @see DOCS/features/README.md
|
|
11
|
+
*/
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
export const CURRENT_PLAN_SCHEMA_VERSION = 1;
|
|
14
|
+
const isoDateRe = /^\d{4}-\d{2}-\d{2}$/;
|
|
15
|
+
const idSchema = z.string().min(1);
|
|
16
|
+
const isoDate = z.string().regex(isoDateRe, 'expected YYYY-MM-DD');
|
|
17
|
+
const isoTimestamp = z.string().min(1);
|
|
18
|
+
const pct = z.number().gt(-100).lt(1000);
|
|
19
|
+
const nonNegative = z.number().nonnegative().finite();
|
|
20
|
+
const calendarYear = z.number().int().min(1900).max(2200);
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Household
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
export const personSchema = z.object({
|
|
25
|
+
id: idSchema,
|
|
26
|
+
name: z.string().min(1),
|
|
27
|
+
dob: isoDate,
|
|
28
|
+
/** 'average' supported for parity with the longevity model's baseline table. */
|
|
29
|
+
sex: z.enum(['female', 'male', 'average']),
|
|
30
|
+
/** Age at which wages stop and retirement-phase rules begin. */
|
|
31
|
+
retirementAge: z.number().min(30).max(80).nullable(),
|
|
32
|
+
longevity: z.object({
|
|
33
|
+
planningAge: z.number().int().min(60).max(120),
|
|
34
|
+
/** 'percentile' = derived from a survival-percentile pick (see `percentile`). */
|
|
35
|
+
source: z.enum(['model', 'manual', 'percentile']),
|
|
36
|
+
/**
|
|
37
|
+
* Provenance when source = 'percentile': the pick that produced
|
|
38
|
+
* `planningAge` ("the age I/we have a `pct`% chance of reaching", SSA 2022
|
|
39
|
+
* period table via engine/montecarlo/survival.ts). Recorded so the UI can
|
|
40
|
+
* restate and re-offer the pick — the age itself is computed once at pick
|
|
41
|
+
* time and never silently recomputed (the anti-drift rule presets follow).
|
|
42
|
+
*/
|
|
43
|
+
percentile: z
|
|
44
|
+
.object({
|
|
45
|
+
/** Survival probability threshold, e.g. 25 = "25% chance of reaching". */
|
|
46
|
+
pct: z.number().min(1).max(50),
|
|
47
|
+
/** Couple plans: true = "either of us still alive" (joint last survivor). */
|
|
48
|
+
joint: z.boolean(),
|
|
49
|
+
/** This person's longevity-questionnaire remaining-years multiplier, when applied as a hazard adjustment. */
|
|
50
|
+
healthMultiplier: z.number().positive().optional(),
|
|
51
|
+
/** Joint picks only: the partner's questionnaire multiplier, when it was also applied. */
|
|
52
|
+
partnerHealthMultiplier: z.number().positive().optional(),
|
|
53
|
+
})
|
|
54
|
+
.optional(),
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
/** Relocation to a new state of residence starting in `fromYear`. */
|
|
58
|
+
export const stateMoveSchema = z.object({
|
|
59
|
+
fromYear: calendarYear,
|
|
60
|
+
/**
|
|
61
|
+
* Month residence starts in the new state. Existing plans default to July,
|
|
62
|
+
* the planning-grade mid-year assumption used for split-year state tax.
|
|
63
|
+
*/
|
|
64
|
+
fromMonth: z.number().int().min(1).max(12).default(7),
|
|
65
|
+
state: z.string().length(2),
|
|
66
|
+
});
|
|
67
|
+
export const householdSchema = z.object({
|
|
68
|
+
filingStatus: z.enum(['single', 'marriedFilingJointly']),
|
|
69
|
+
/**
|
|
70
|
+
* Planning-grade opt-in for the IRS qualifying surviving spouse status: when
|
|
71
|
+
* a married household has a qualifying dependent, the survivor can use joint
|
|
72
|
+
* brackets/deduction for the two years after the spouse's death. RetireGolden
|
|
73
|
+
* does not model dependents directly, so this is a user assertion.
|
|
74
|
+
*/
|
|
75
|
+
hasQualifyingDependent: z.boolean().default(false),
|
|
76
|
+
/** Two-letter code for the starting state of residence; drives state tax. */
|
|
77
|
+
state: z.string().length(2),
|
|
78
|
+
/**
|
|
79
|
+
* Future relocations (sorted/applied by fromYear). Optional with a default
|
|
80
|
+
* so plans saved before V5 stay valid without a schema migration.
|
|
81
|
+
*/
|
|
82
|
+
stateMoves: z.array(stateMoveSchema).default([]),
|
|
83
|
+
/**
|
|
84
|
+
* Net capital loss carried forward from prior tax years (today's $). Offsets
|
|
85
|
+
* future realized gains, then up to the annual limit ($3,000) against ordinary
|
|
86
|
+
* income, until exhausted. Default 0 so pre-existing plans stay valid without a
|
|
87
|
+
* schema migration. @see DOCS/features/taxes.md
|
|
88
|
+
*/
|
|
89
|
+
capitalLossCarryforward: nonNegative.default(0),
|
|
90
|
+
people: z.array(personSchema).min(1).max(2),
|
|
91
|
+
});
|
|
92
|
+
/** State of residence in a given year: the latest move at/Before `year`, else the base state. */
|
|
93
|
+
export function stateForYear(household, year) {
|
|
94
|
+
let state = household.state;
|
|
95
|
+
let bestYear = -Infinity;
|
|
96
|
+
for (const move of household.stateMoves) {
|
|
97
|
+
if (move.fromYear <= year && move.fromYear > bestYear) {
|
|
98
|
+
bestYear = move.fromYear;
|
|
99
|
+
state = move.state;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return state;
|
|
103
|
+
}
|
|
104
|
+
/** State residency allocation in a tax year, splitting the move year by month. */
|
|
105
|
+
export function stateResidencySegmentsForYear(household, year) {
|
|
106
|
+
const move = household.stateMoves.find((m) => m.fromYear === year && stateForYear(household, year) === m.state);
|
|
107
|
+
const current = stateForYear(household, year);
|
|
108
|
+
if (!move)
|
|
109
|
+
return [{ state: current, months: 12 }];
|
|
110
|
+
const previous = stateForYear(household, year - 1);
|
|
111
|
+
const fromMonth = Math.min(12, Math.max(1, move.fromMonth));
|
|
112
|
+
const previousMonths = fromMonth - 1;
|
|
113
|
+
const currentMonths = 13 - fromMonth;
|
|
114
|
+
if (previous === current || previousMonths === 0)
|
|
115
|
+
return [{ state: current, months: 12 }];
|
|
116
|
+
return [
|
|
117
|
+
{ state: previous, months: previousMonths },
|
|
118
|
+
{ state: current, months: currentMonths },
|
|
119
|
+
].filter((segment) => segment.months > 0);
|
|
120
|
+
}
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Asset allocation (opt-in, asset-allocation-and-return-model-v2)
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
/** The v1 class set; alternatives arrive later. Order matters (weight vectors + correlation matrix). */
|
|
125
|
+
export const ASSET_CLASS_IDS = ['usStocks', 'intlStocks', 'bonds', 'cash'];
|
|
126
|
+
/**
|
|
127
|
+
* Percent of the account in each class, summing to 100. Kept as percents (not
|
|
128
|
+
* fractions) to match every other UI-facing rate in the schema.
|
|
129
|
+
*/
|
|
130
|
+
export const allocationWeightsSchema = z
|
|
131
|
+
.object({
|
|
132
|
+
usStocks: z.number().min(0).max(100).default(0),
|
|
133
|
+
intlStocks: z.number().min(0).max(100).default(0),
|
|
134
|
+
bonds: z.number().min(0).max(100).default(0),
|
|
135
|
+
cash: z.number().min(0).max(100).default(0),
|
|
136
|
+
})
|
|
137
|
+
.refine((w) => Math.abs(w.usStocks + w.intlStocks + w.bonds + w.cash - 100) <= 0.5, {
|
|
138
|
+
message: 'allocation weights must sum to 100%',
|
|
139
|
+
});
|
|
140
|
+
/**
|
|
141
|
+
* Rebalancing assumption for an allocated account: 'annual' (default) trades
|
|
142
|
+
* back to the year's target each January — taxable trades realize gains
|
|
143
|
+
* through the normal basis machinery; 'none' lets weights drift with returns
|
|
144
|
+
* (glidepath targets are then ignored after the starting year).
|
|
145
|
+
*/
|
|
146
|
+
const rebalancingField = { rebalancing: z.enum(['annual', 'none']).default('annual') };
|
|
147
|
+
const glidepathStageSchema = z.object({ fromYear: calendarYear, weights: allocationWeightsSchema });
|
|
148
|
+
const glidepathTargetSchema = z.object({ year: calendarYear, weights: allocationWeightsSchema });
|
|
149
|
+
/**
|
|
150
|
+
* Opt-in per-account allocation policy (account-level; household roll-up is
|
|
151
|
+
* derived). Absent = today's single-return behavior, unchanged. When present,
|
|
152
|
+
* it supersedes the account's `annualReturnPct`: growth is the class blend
|
|
153
|
+
* from Assumptions-level class parameters. Glidepaths compile to per-year
|
|
154
|
+
* target weights: 'linear' interpolates from → to between its years, 'staged'
|
|
155
|
+
* is a step function, 'custom' interpolates between explicit year targets.
|
|
156
|
+
*/
|
|
157
|
+
export const assetAllocationPolicySchema = z.discriminatedUnion('mode', [
|
|
158
|
+
z.object({ mode: z.literal('static'), ...rebalancingField, weights: allocationWeightsSchema }),
|
|
159
|
+
z.object({
|
|
160
|
+
mode: z.literal('linear'),
|
|
161
|
+
...rebalancingField,
|
|
162
|
+
from: allocationWeightsSchema,
|
|
163
|
+
to: allocationWeightsSchema,
|
|
164
|
+
startYear: calendarYear,
|
|
165
|
+
endYear: calendarYear,
|
|
166
|
+
}),
|
|
167
|
+
z.object({ mode: z.literal('staged'), ...rebalancingField, stages: z.array(glidepathStageSchema).min(1) }),
|
|
168
|
+
z.object({ mode: z.literal('custom'), ...rebalancingField, targets: z.array(glidepathTargetSchema).min(1) }),
|
|
169
|
+
]);
|
|
170
|
+
/**
|
|
171
|
+
* Per-class overrides of the sourced defaults in
|
|
172
|
+
* `engine/allocation/assetClasses.ts` (absent field = default). Yields feed
|
|
173
|
+
* the taxable-drag machinery for allocated brokerage accounts.
|
|
174
|
+
*/
|
|
175
|
+
const assetClassOverrideSchema = z.object({
|
|
176
|
+
returnPct: pct.optional(),
|
|
177
|
+
volatilityPct: nonNegative.optional(),
|
|
178
|
+
interestYieldPct: nonNegative.optional(),
|
|
179
|
+
dividendYieldPct: nonNegative.optional(),
|
|
180
|
+
qualifiedRatioPct: z.number().min(0).max(100).optional(),
|
|
181
|
+
});
|
|
182
|
+
export const assetClassParamOverridesSchema = z.object({
|
|
183
|
+
usStocks: assetClassOverrideSchema.optional(),
|
|
184
|
+
intlStocks: assetClassOverrideSchema.optional(),
|
|
185
|
+
bonds: assetClassOverrideSchema.optional(),
|
|
186
|
+
cash: assetClassOverrideSchema.optional(),
|
|
187
|
+
});
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Estate beneficiary destinations (guaranteed-income-and-estate-depth)
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
/**
|
|
192
|
+
* Where an account passes at the end of the plan, for the after-tax estate
|
|
193
|
+
* metric. Absent = the legacy default: pre-tax (traditional) and non-spouse HSA
|
|
194
|
+
* balances are taxed at the flat heir rate, everything else passes untaxed.
|
|
195
|
+
* When set:
|
|
196
|
+
* - 'spouse' — passes to a surviving spouse untaxed (rollover); no heir tax.
|
|
197
|
+
* - 'nonSpouse' — a non-spouse heir; pre-tax balances (traditional, non-spouse
|
|
198
|
+
* HSA) are taxed at the account class's heir rate, while Roth,
|
|
199
|
+
* taxable (stepped-up at death), and cash pass untaxed.
|
|
200
|
+
* - 'charity' — `charityPct` of the ending balance passes to charity fully
|
|
201
|
+
* untaxed (a charitable bequest / IRA-to-charity effect); the
|
|
202
|
+
* remainder follows the non-spouse heir rules.
|
|
203
|
+
* The HSA's older `beneficiary` field remains a shorthand for spouse/nonSpouse;
|
|
204
|
+
* when both are present this field wins.
|
|
205
|
+
*/
|
|
206
|
+
export const estateBeneficiarySchema = z.object({
|
|
207
|
+
destination: z.enum(['spouse', 'nonSpouse', 'charity']),
|
|
208
|
+
/** Charity share (percent) when destination = 'charity'; the rest goes to a non-spouse heir. */
|
|
209
|
+
charityPct: z.number().min(0).max(100).optional(),
|
|
210
|
+
});
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Accounts (discriminated union on `type`)
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
export const contributionPhaseSchema = z.object({
|
|
215
|
+
annualAmount: nonNegative,
|
|
216
|
+
fromAge: z.number().int().min(0).max(100).nullable().default(null),
|
|
217
|
+
toAge: z.number().int().min(0).max(100).nullable().default(null),
|
|
218
|
+
escalationPct: pct.default(0),
|
|
219
|
+
});
|
|
220
|
+
export const employerMatchSchema = z.object({
|
|
221
|
+
matchPct: pct,
|
|
222
|
+
capPctOfPay: pct,
|
|
223
|
+
});
|
|
224
|
+
const accountBase = {
|
|
225
|
+
id: idSchema,
|
|
226
|
+
name: z.string().min(1),
|
|
227
|
+
/** null = jointly owned. */
|
|
228
|
+
ownerPersonId: idSchema.nullable(),
|
|
229
|
+
/** When null, assumptions.defaultReturnPct applies. Superseded by an account's opt-in `allocation`. */
|
|
230
|
+
annualReturnPct: pct.nullable(),
|
|
231
|
+
/**
|
|
232
|
+
* Optional estate destination (spouse rollover / non-spouse heir / charity)
|
|
233
|
+
* for the after-tax estate metric. Absent = legacy flat-haircut treatment.
|
|
234
|
+
* @see estateBeneficiarySchema
|
|
235
|
+
*/
|
|
236
|
+
estateBeneficiary: estateBeneficiarySchema.optional(),
|
|
237
|
+
};
|
|
238
|
+
/** Nominal dollars contributed per year while the owner still has wages; capped by IRS limits where applicable. */
|
|
239
|
+
const annualContribution = nonNegative;
|
|
240
|
+
const individuallyOwnedAccountTypes = new Set(['traditional', 'roth', 'hsa']);
|
|
241
|
+
export const taxableAccountSchema = z.object({
|
|
242
|
+
...accountBase,
|
|
243
|
+
type: z.literal('taxable'),
|
|
244
|
+
balance: nonNegative,
|
|
245
|
+
/** Aggregate cost basis (single basis-ratio model in v1; no lots). */
|
|
246
|
+
costBasis: nonNegative,
|
|
247
|
+
/** Annual interest yield as a percent of start-of-year balance. */
|
|
248
|
+
interestYieldPct: nonNegative.optional(),
|
|
249
|
+
/** Annual dividend yield as a percent of start-of-year balance. */
|
|
250
|
+
dividendYieldPct: nonNegative.optional(),
|
|
251
|
+
/** Fraction of dividends taxed as qualified dividends. */
|
|
252
|
+
qualifiedRatio: z.number().min(0).max(1).optional(),
|
|
253
|
+
/** Reinvest generated yield into the account instead of paying it into cash flow. */
|
|
254
|
+
reinvestDividends: z.boolean().optional(),
|
|
255
|
+
/** Opt-in class allocation; supersedes annualReturnPct and (unless explicitly set) drives the yield fields. */
|
|
256
|
+
allocation: assetAllocationPolicySchema.optional(),
|
|
257
|
+
annualContribution,
|
|
258
|
+
contributionSchedule: z.array(contributionPhaseSchema).optional(),
|
|
259
|
+
});
|
|
260
|
+
export const equityCompAccountSchema = z.object({
|
|
261
|
+
...accountBase,
|
|
262
|
+
type: z.literal('equityComp'),
|
|
263
|
+
/** Current value of vested/unvested employer shares. */
|
|
264
|
+
balance: nonNegative,
|
|
265
|
+
/** Aggregate cost basis; once available, sales realize gains pro-rata like taxable brokerage. */
|
|
266
|
+
costBasis: nonNegative,
|
|
267
|
+
annualContribution,
|
|
268
|
+
contributionSchedule: z.array(contributionPhaseSchema).optional(),
|
|
269
|
+
/** final = fully available now; cliff = unavailable for spending until vestDate. */
|
|
270
|
+
vestingMode: z.enum(['final', 'cliff']),
|
|
271
|
+
vestDate: isoDate.nullable(),
|
|
272
|
+
});
|
|
273
|
+
/**
|
|
274
|
+
* 72(t) SEPP election (roadmap V8). Penalty-free substantially-equal periodic
|
|
275
|
+
* payments from this account starting at `startAge`, for the longer of 5 years
|
|
276
|
+
* or until 59½. Optional — omitted means no SEPP, so no migration is needed.
|
|
277
|
+
*/
|
|
278
|
+
export const seppElectionSchema = z.object({
|
|
279
|
+
/** Age the series begins (must be under 59½ to be worthwhile). */
|
|
280
|
+
startAge: z.number().int().min(40).max(59),
|
|
281
|
+
/** IRS method: 'rmd' recomputes yearly; 'amortization' fixes a level payment. */
|
|
282
|
+
method: z.enum(['rmd', 'amortization']),
|
|
283
|
+
});
|
|
284
|
+
/**
|
|
285
|
+
* Inherited (beneficiary) account under the SECURE Act 10-year rule (roadmap
|
|
286
|
+
* V8). Optional — omitted means a normal owned account, so no migration needed.
|
|
287
|
+
*/
|
|
288
|
+
export const inheritedAccountSchema = z.object({
|
|
289
|
+
/** Calendar year the original owner died (starts the 10-year clock). */
|
|
290
|
+
ownerDeathYear: calendarYear,
|
|
291
|
+
/** Decedent had reached their required beginning date → annual RMDs in years 1–9. */
|
|
292
|
+
decedentHadStartedRmds: z.boolean(),
|
|
293
|
+
});
|
|
294
|
+
export const traditionalAccountSchema = z.object({
|
|
295
|
+
...accountBase,
|
|
296
|
+
type: z.literal('traditional'),
|
|
297
|
+
/** 'employer' = 401(k)/403(b)-style; 'ira' = IRA. Affects RMD aggregation + rules later. */
|
|
298
|
+
kind: z.enum(['ira', 'employer']),
|
|
299
|
+
balance: nonNegative,
|
|
300
|
+
annualContribution,
|
|
301
|
+
inherited: inheritedAccountSchema.optional(),
|
|
302
|
+
/**
|
|
303
|
+
* Form-8606 nondeductible contribution basis (after-tax dollars already in
|
|
304
|
+
* this IRA). When any of an owner's traditional IRAs carries basis, every
|
|
305
|
+
* withdrawal and Roth conversion from that owner's IRAs applies the pro-rata
|
|
306
|
+
* rule across the aggregated IRA balances — the taxable portion is reduced by
|
|
307
|
+
* the basis fraction, and basis depletes as it is distributed. IRA kind only
|
|
308
|
+
* (employer plans track after-tax money separately and are not modeled);
|
|
309
|
+
* not allowed on inherited accounts (a beneficiary's 8606 is separate).
|
|
310
|
+
*/
|
|
311
|
+
nondeductibleBasis: nonNegative.optional(),
|
|
312
|
+
/**
|
|
313
|
+
* The spouse is the account's sole beneficiary. Required (with a >10-yr age
|
|
314
|
+
* gap) before the larger Joint Life & Last Survivor RMD divisor applies;
|
|
315
|
+
* otherwise the Uniform Lifetime Table is used. Optional — defaults to false.
|
|
316
|
+
*/
|
|
317
|
+
spouseSoleBeneficiary: z.boolean().optional(),
|
|
318
|
+
/** Optional 72(t) SEPP election for penalty-free early access (roadmap V8). */
|
|
319
|
+
sepp: seppElectionSchema.optional(),
|
|
320
|
+
employerMatch: employerMatchSchema.optional(),
|
|
321
|
+
contributionSchedule: z.array(contributionPhaseSchema).optional(),
|
|
322
|
+
/** Opt-in class allocation; supersedes annualReturnPct. Rebalancing here is tax-free. */
|
|
323
|
+
allocation: assetAllocationPolicySchema.optional(),
|
|
324
|
+
});
|
|
325
|
+
export const rothAccountSchema = z.object({
|
|
326
|
+
...accountBase,
|
|
327
|
+
type: z.literal('roth'),
|
|
328
|
+
kind: z.enum(['ira', 'employer']),
|
|
329
|
+
balance: nonNegative,
|
|
330
|
+
annualContribution,
|
|
331
|
+
/**
|
|
332
|
+
* Contribution basis (today's dollars): the portion of the starting balance
|
|
333
|
+
* that is direct contributions, withdrawable tax- and penalty-free at any age.
|
|
334
|
+
* Drives the Roth ordering + 5-year rules (roadmap V8). Optional — when omitted
|
|
335
|
+
* the engine treats the whole starting balance as seasoned basis, the safe
|
|
336
|
+
* default that keeps pre-existing plans penalty-free. New annual contributions
|
|
337
|
+
* add to basis; in-projection conversions start their own 5-year seasoning clocks.
|
|
338
|
+
*/
|
|
339
|
+
contributionBasis: nonNegative.optional(),
|
|
340
|
+
employerMatch: employerMatchSchema.optional(),
|
|
341
|
+
contributionSchedule: z.array(contributionPhaseSchema).optional(),
|
|
342
|
+
/** Opt-in class allocation; supersedes annualReturnPct. Rebalancing here is tax-free. */
|
|
343
|
+
allocation: assetAllocationPolicySchema.optional(),
|
|
344
|
+
});
|
|
345
|
+
export const hsaAccountSchema = z.object({
|
|
346
|
+
...accountBase,
|
|
347
|
+
type: z.literal('hsa'),
|
|
348
|
+
balance: nonNegative,
|
|
349
|
+
annualContribution,
|
|
350
|
+
contributionSchedule: z.array(contributionPhaseSchema).optional(),
|
|
351
|
+
/**
|
|
352
|
+
* How withdrawals are taxed (account/HSA/fixed-asset depth plan, steps 2–3):
|
|
353
|
+
* - omitted (legacy): the original v1 simplification — every withdrawal is
|
|
354
|
+
* tax-free but conservatively penalized 20% before 65, so pre-existing
|
|
355
|
+
* plans keep byte-identical results.
|
|
356
|
+
* - 'assumeAllQualified': every withdrawal is a qualified medical expense
|
|
357
|
+
* (tax- and penalty-free at any age) — the explicit simplification for
|
|
358
|
+
* users who track receipts outside the app.
|
|
359
|
+
* - 'capByMedicalExpenses': withdrawals are qualified only up to the
|
|
360
|
+
* household's modeled medical costs this year (healthcare premiums + net
|
|
361
|
+
* care costs), plus the accumulated reimburse-later pool when enabled; the
|
|
362
|
+
* excess is ordinary income, penalized 20% before 65 (IRS Pub 969).
|
|
363
|
+
*/
|
|
364
|
+
withdrawalTreatment: z.enum(['assumeAllQualified', 'capByMedicalExpenses']).optional(),
|
|
365
|
+
/**
|
|
366
|
+
* Accumulate unreimbursed modeled medical expenses (paid out of pocket in
|
|
367
|
+
* earlier years) as a carryover future withdrawals may draw tax-free — the
|
|
368
|
+
* "pay now, reimburse later" HSA strategy. Requires 'capByMedicalExpenses'.
|
|
369
|
+
*/
|
|
370
|
+
reimburseLater: z.boolean().optional(),
|
|
371
|
+
/**
|
|
372
|
+
* Who inherits this HSA, for the after-tax estate metric: a spouse inherits
|
|
373
|
+
* it as their own HSA (passes untaxed, like Roth); any other beneficiary
|
|
374
|
+
* receives a fully taxable distribution in the death year, so the estate
|
|
375
|
+
* metric taxes the remaining balance at the heir tax rate. Omitted = legacy
|
|
376
|
+
* untaxed pass-through (same as 'spouse').
|
|
377
|
+
*/
|
|
378
|
+
beneficiary: z.enum(['spouse', 'nonSpouse']).optional(),
|
|
379
|
+
/** Opt-in class allocation; supersedes annualReturnPct. Rebalancing here is tax-free. */
|
|
380
|
+
allocation: assetAllocationPolicySchema.optional(),
|
|
381
|
+
});
|
|
382
|
+
export const cashAccountSchema = z.object({
|
|
383
|
+
...accountBase,
|
|
384
|
+
type: z.literal('cash'),
|
|
385
|
+
balance: nonNegative,
|
|
386
|
+
annualContribution,
|
|
387
|
+
contributionSchedule: z.array(contributionPhaseSchema).optional(),
|
|
388
|
+
});
|
|
389
|
+
/**
|
|
390
|
+
* Optional lump-sum election facts on a pension (annuity-pension-and-home-
|
|
391
|
+
* equity decisions, step 3). `lumpSumOffer` records the offer for the decision
|
|
392
|
+
* view (PV comparison, sensitivity table) and the pension-election insight;
|
|
393
|
+
* it changes nothing in the ledger by itself. `lumpSumElection` commutes the
|
|
394
|
+
* pension: in the election year the offer amount rolls over tax-free into the
|
|
395
|
+
* named traditional account (a direct rollover, so no withholding or income),
|
|
396
|
+
* and the pension never pays its annuity. Both are additive and optional, so
|
|
397
|
+
* pre-existing plans are byte-identical.
|
|
398
|
+
*/
|
|
399
|
+
export const pensionLumpSumOfferSchema = z.object({
|
|
400
|
+
/** Lump sum offered instead of the annuity (nominal dollars at the election year). */
|
|
401
|
+
amount: nonNegative,
|
|
402
|
+
/** Calendar year the election is due / the lump sum would be paid. */
|
|
403
|
+
electionYear: calendarYear,
|
|
404
|
+
});
|
|
405
|
+
export const pensionLumpSumElectionSchema = z.object({
|
|
406
|
+
/** Traditional account (IRA/401k) receiving the tax-free direct rollover. */
|
|
407
|
+
rolloverAccountId: idSchema,
|
|
408
|
+
});
|
|
409
|
+
export const pensionSchema = z.object({
|
|
410
|
+
...accountBase,
|
|
411
|
+
type: z.literal('pension'),
|
|
412
|
+
/** Drives state tax treatment in states with public-pension exemptions. */
|
|
413
|
+
source: z.enum(['private', 'public']).optional(),
|
|
414
|
+
/** Owner's age when payments start. */
|
|
415
|
+
startAge: z.number().int().min(40).max(80),
|
|
416
|
+
monthlyAmount: nonNegative,
|
|
417
|
+
/** Annual COLA applied to payments; 0 = fixed nominal. */
|
|
418
|
+
colaPct: pct,
|
|
419
|
+
/** Percent of the benefit continuing to a surviving spouse. */
|
|
420
|
+
survivorPct: z.number().min(0).max(100),
|
|
421
|
+
/** Lump-sum offer on record (decision inputs only; no ledger effect by itself). */
|
|
422
|
+
lumpSumOffer: pensionLumpSumOfferSchema.optional(),
|
|
423
|
+
/** Take the lump sum: rollover in the offer's election year replaces the annuity. */
|
|
424
|
+
lumpSumElection: pensionLumpSumElectionSchema.optional(),
|
|
425
|
+
});
|
|
426
|
+
/**
|
|
427
|
+
* Optional purchase event that funds an annuity by trading a premium out of a
|
|
428
|
+
* liquid or qualified account (guaranteed-income-and-estate-depth). Absent = the
|
|
429
|
+
* annuity is an already-owned income stream (legacy behavior). When present:
|
|
430
|
+
* - the premium is withdrawn from `fundingAccountId` in `year` — a transfer,
|
|
431
|
+
* not spending, so it is never counted against the spending target. Cash and
|
|
432
|
+
* qualified (traditional) sources move at book value; a taxable/equity-comp
|
|
433
|
+
* source liquidates the position and realizes its gains pro-rata (folded into
|
|
434
|
+
* that year's realized capital gains), exactly like any other sale;
|
|
435
|
+
* - 'nonQualified' (cash/taxable-funded): payouts are taxed by the IRS Pub 939
|
|
436
|
+
* exclusion ratio — the premium is the investment in the contract — so the
|
|
437
|
+
* account's `taxablePct` is derived from the ratio and the stored value is a
|
|
438
|
+
* display fallback only;
|
|
439
|
+
* - 'qualified' (traditional-funded): payouts are fully ordinary income, and
|
|
440
|
+
* because the premium leaves the traditional balance, future RMDs shrink
|
|
441
|
+
* automatically. `qlac` marks a deferred-start qualified contract whose
|
|
442
|
+
* premium is held to the QLAC statutory cap (a warning fires above it).
|
|
443
|
+
*/
|
|
444
|
+
export const annuityPurchaseSchema = z.object({
|
|
445
|
+
/** Calendar year the premium is paid and the contract begins. */
|
|
446
|
+
year: calendarYear,
|
|
447
|
+
/** Premium paid (nominal dollars at the purchase year — a quoted figure). */
|
|
448
|
+
premium: nonNegative,
|
|
449
|
+
/** Account the premium is drawn from. */
|
|
450
|
+
fundingAccountId: idSchema,
|
|
451
|
+
/** 'nonQualified' → exclusion-ratio taxation; 'qualified' → fully taxable payouts. */
|
|
452
|
+
taxQualification: z.enum(['nonQualified', 'qualified']),
|
|
453
|
+
/** Deferred-start qualified longevity annuity (QLAC); requires `taxQualification: 'qualified'`. */
|
|
454
|
+
qlac: z.boolean().optional(),
|
|
455
|
+
});
|
|
456
|
+
/**
|
|
457
|
+
* Payout form of an annuity contract (annuity-pension-and-home-equity
|
|
458
|
+
* decisions, step 1). Absent = 'lifeOnly', the legacy behavior — payments
|
|
459
|
+
* stop at the owner's death — so pre-existing plans are byte-identical.
|
|
460
|
+
* - 'periodCertain': life annuity with an N-year guarantee measured from the
|
|
461
|
+
* payment start age. If the owner dies inside the guarantee window, the
|
|
462
|
+
* remaining certain payments continue to the household (beneficiary); after
|
|
463
|
+
* the window the annuity is life-contingent as usual.
|
|
464
|
+
* - 'jointSurvivor': payments continue to the other household member at
|
|
465
|
+
* `survivorPct` of the full amount after the owner's death, for the
|
|
466
|
+
* survivor's lifetime. Requires a two-person household.
|
|
467
|
+
* Both forms extend the IRS Pub 939 exclusion-ratio math for a non-qualified
|
|
468
|
+
* purchase (see engine/projection/annuityForms.ts for the method and its
|
|
469
|
+
* documented approximations).
|
|
470
|
+
*/
|
|
471
|
+
export const annuityPayoutFormSchema = z.discriminatedUnion('kind', [
|
|
472
|
+
z.object({ kind: z.literal('lifeOnly') }),
|
|
473
|
+
z.object({
|
|
474
|
+
kind: z.literal('periodCertain'),
|
|
475
|
+
/** Guaranteed payment years measured from the start age (e.g. 10 or 20). */
|
|
476
|
+
certainYears: z.number().int().min(1).max(40),
|
|
477
|
+
}),
|
|
478
|
+
z.object({
|
|
479
|
+
kind: z.literal('jointSurvivor'),
|
|
480
|
+
/** Percent of the payment continuing to the surviving joint annuitant. */
|
|
481
|
+
survivorPct: z.number().min(1).max(100),
|
|
482
|
+
}),
|
|
483
|
+
]);
|
|
484
|
+
export const annuitySchema = z.object({
|
|
485
|
+
...accountBase,
|
|
486
|
+
type: z.literal('annuity'),
|
|
487
|
+
startAge: z.number().int().min(40).max(95),
|
|
488
|
+
monthlyAmount: nonNegative,
|
|
489
|
+
colaPct: pct,
|
|
490
|
+
/**
|
|
491
|
+
* Percent of each payment that is taxable. For a purchased non-qualified
|
|
492
|
+
* annuity this is derived from the exclusion ratio and the stored value is
|
|
493
|
+
* ignored; for an already-owned annuity (no `purchase`) it is used directly.
|
|
494
|
+
*/
|
|
495
|
+
taxablePct: z.number().min(0).max(100),
|
|
496
|
+
/** Optional funding event; see annuityPurchaseSchema. Absent = already-owned income stream. */
|
|
497
|
+
purchase: annuityPurchaseSchema.optional(),
|
|
498
|
+
/** Payout form; absent = life-only (payments end at owner death, legacy behavior). */
|
|
499
|
+
payoutForm: annuityPayoutFormSchema.optional(),
|
|
500
|
+
});
|
|
501
|
+
/**
|
|
502
|
+
* Opt-in HECM (reverse-mortgage) line of credit on an owned home
|
|
503
|
+
* (annuity-pension-and-home-equity decisions, step 4). Models Pfau's
|
|
504
|
+
* buffer-asset strategy: open the line early, let the unused credit grow, and
|
|
505
|
+
* draw tax-free loan proceeds either after down market years ('coordinated')
|
|
506
|
+
* or only once the portfolio is exhausted ('lastResort'). The loan balance
|
|
507
|
+
* accrues at the same growth rate; payoff at sale or the end of the plan is
|
|
508
|
+
* non-recourse (never more than the home's value). Absent = no HECM — plans
|
|
509
|
+
* without one are unchanged.
|
|
510
|
+
*/
|
|
511
|
+
export const hecmLineOfCreditSchema = z.object({
|
|
512
|
+
/** Calendar year the line is opened (youngest borrower should be 62+). */
|
|
513
|
+
openYear: calendarYear,
|
|
514
|
+
/**
|
|
515
|
+
* Principal limit as a percent of the home's value at open. Enter the
|
|
516
|
+
* lender-quoted figure; omitted = the parameter pack's published
|
|
517
|
+
* principal-limit-factor approximation by borrower age.
|
|
518
|
+
*/
|
|
519
|
+
principalLimitPct: z.number().min(5).max(75).optional(),
|
|
520
|
+
/**
|
|
521
|
+
* Annual growth applied to BOTH the principal limit and the loan balance
|
|
522
|
+
* (note rate + 0.5% MIP; ~7–8% at 2026 rates). The unused line grows at
|
|
523
|
+
* this rate regardless of home value — the core of the buffer strategy.
|
|
524
|
+
*/
|
|
525
|
+
growthRatePct: z.number().min(0).max(15),
|
|
526
|
+
/** Upfront costs (origination, closing, initial MIP) financed into the loan at open, % of home value. */
|
|
527
|
+
upfrontCostPct: z.number().min(0).max(10).optional(),
|
|
528
|
+
/**
|
|
529
|
+
* 'coordinated' (Pfau): draw for spending in years following a negative
|
|
530
|
+
* market return, letting the portfolio recover; 'lastResort': draw only
|
|
531
|
+
* when the portfolio cannot cover spending.
|
|
532
|
+
*/
|
|
533
|
+
drawPolicy: z.enum(['coordinated', 'lastResort']),
|
|
534
|
+
});
|
|
535
|
+
export const propertySchema = z.object({
|
|
536
|
+
...accountBase,
|
|
537
|
+
type: z.literal('property'),
|
|
538
|
+
value: nonNegative,
|
|
539
|
+
plannedSaleYear: calendarYear.nullable(),
|
|
540
|
+
/**
|
|
541
|
+
* Net proceeds entering taxable savings in the sale year (user-estimated,
|
|
542
|
+
* treated as tax-free). Legacy path — ignored when `costBasis` is set, which
|
|
543
|
+
* switches the sale to exact basis/exclusion/recapture tax treatment.
|
|
544
|
+
*/
|
|
545
|
+
expectedNetProceeds: nonNegative.nullable(),
|
|
546
|
+
/**
|
|
547
|
+
* Adjusted cost basis (purchase price + improvements, historical dollars —
|
|
548
|
+
* deliberately not inflation-indexed). Setting it turns on exact disposition
|
|
549
|
+
* tax treatment: gain = sale price − selling costs − basis, reduced by the
|
|
550
|
+
* §121 exclusion for a primary residence, with any depreciation recapture
|
|
551
|
+
* taxed as ordinary income and the remainder as capital gain.
|
|
552
|
+
*/
|
|
553
|
+
costBasis: nonNegative.optional(),
|
|
554
|
+
/** Selling costs (commissions + closing) as a percent of the sale price. Absent = 0. */
|
|
555
|
+
sellingCostPct: z.number().min(0).max(25).optional(),
|
|
556
|
+
/**
|
|
557
|
+
* Primary residence: the §121 exclusion (per the parameter pack; $250k
|
|
558
|
+
* single / $500k MFJ, statutory and not indexed) shields gain on sale.
|
|
559
|
+
* Ownership/use tests (2 of 5 years) are asserted by the user, not modeled.
|
|
560
|
+
*/
|
|
561
|
+
primaryResidence: z.boolean().optional(),
|
|
562
|
+
/**
|
|
563
|
+
* Accumulated depreciation claimed (e.g. rental years, home office):
|
|
564
|
+
* recaptured as ordinary income on sale and never shielded by §121.
|
|
565
|
+
* Requires `costBasis`.
|
|
566
|
+
*/
|
|
567
|
+
depreciationRecapture: nonNegative.optional(),
|
|
568
|
+
/**
|
|
569
|
+
* Annual property tax (today's dollars), charged as a recurring expense while
|
|
570
|
+
* the property is owned and continuing after any mortgage is paid off — the
|
|
571
|
+
* carrying costs a PITI payment hides. Optional (treated as 0). @see insuranceAnnual
|
|
572
|
+
*/
|
|
573
|
+
propertyTaxAnnual: nonNegative.optional(),
|
|
574
|
+
/** Annual homeowner's/hazard insurance (today's dollars), charged while owned. Optional (treated as 0). */
|
|
575
|
+
insuranceAnnual: nonNegative.optional(),
|
|
576
|
+
/** Opt-in HECM reverse-mortgage line of credit on this home. Requires `primaryResidence`. */
|
|
577
|
+
hecm: hecmLineOfCreditSchema.optional(),
|
|
578
|
+
});
|
|
579
|
+
export const debtSchema = z.object({
|
|
580
|
+
...accountBase,
|
|
581
|
+
type: z.literal('debt'),
|
|
582
|
+
balance: nonNegative,
|
|
583
|
+
interestPct: pct,
|
|
584
|
+
/** Principal & interest only — escrowed property tax/insurance belong on the property account. */
|
|
585
|
+
monthlyPayment: nonNegative,
|
|
586
|
+
/**
|
|
587
|
+
* Optional lump-sum payoff: in this calendar year the entire remaining balance
|
|
588
|
+
* is paid (funded by the normal withdrawal waterfall, realizing any gains/tax),
|
|
589
|
+
* letting you A/B "keep the loan" vs. "pay it off early." null/omitted = run to term.
|
|
590
|
+
*/
|
|
591
|
+
payoffYear: calendarYear.nullable().optional(),
|
|
592
|
+
});
|
|
593
|
+
const accountUnionSchema = z.discriminatedUnion('type', [
|
|
594
|
+
taxableAccountSchema,
|
|
595
|
+
equityCompAccountSchema,
|
|
596
|
+
traditionalAccountSchema,
|
|
597
|
+
rothAccountSchema,
|
|
598
|
+
hsaAccountSchema,
|
|
599
|
+
cashAccountSchema,
|
|
600
|
+
pensionSchema,
|
|
601
|
+
annuitySchema,
|
|
602
|
+
propertySchema,
|
|
603
|
+
debtSchema,
|
|
604
|
+
]);
|
|
605
|
+
export const accountSchema = accountUnionSchema.superRefine((account, ctx) => {
|
|
606
|
+
if ((account.type === 'traditional' || account.type === 'roth') &&
|
|
607
|
+
account.kind !== 'employer' &&
|
|
608
|
+
account.employerMatch !== undefined) {
|
|
609
|
+
ctx.addIssue({
|
|
610
|
+
code: z.ZodIssueCode.custom,
|
|
611
|
+
path: ['employerMatch'],
|
|
612
|
+
message: 'Employer match can only be set on employer retirement accounts.',
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
// Insurance (discriminated union on `kind`) — roadmap V6
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
/**
|
|
620
|
+
* Premiums are a shared shape across kinds:
|
|
621
|
+
* 'lifetime' = charge annualPremium every year while the insured is alive
|
|
622
|
+
* 'paidUp' = charge nothing (fully paid up); annualPremium ignored
|
|
623
|
+
* 'untilAge' = charge annualPremium through premiumEndAge (required)
|
|
624
|
+
* Premiums are level (fixed nominal), not inflation-adjusted: permanent-life
|
|
625
|
+
* base premiums and most LTC premiums are contractually level.
|
|
626
|
+
*/
|
|
627
|
+
const premiumModeSchema = z.enum(['lifetime', 'paidUp', 'untilAge']);
|
|
628
|
+
export const ltcPolicySchema = z.object({
|
|
629
|
+
kind: z.literal('ltc'),
|
|
630
|
+
id: idSchema,
|
|
631
|
+
name: z.string().min(1),
|
|
632
|
+
owner: idSchema,
|
|
633
|
+
annualPremium: nonNegative,
|
|
634
|
+
premiumMode: premiumModeSchema,
|
|
635
|
+
/** Required iff premiumMode = 'untilAge'. Owner's age when premiums stop. */
|
|
636
|
+
premiumEndAge: z.number().int().min(40).max(110).optional(),
|
|
637
|
+
benefitMonthly: nonNegative,
|
|
638
|
+
benefitPeriodYears: z.union([z.number().positive(), z.literal('lifetime')]),
|
|
639
|
+
eliminationPeriodDays: z.number().int().min(0).max(365),
|
|
640
|
+
/** Compound annual growth of the benefit cap (inflation rider). */
|
|
641
|
+
inflationRiderPct: pct.optional(),
|
|
642
|
+
});
|
|
643
|
+
export const permanentLifePolicySchema = z.object({
|
|
644
|
+
kind: z.literal('permanentLife'),
|
|
645
|
+
id: idSchema,
|
|
646
|
+
name: z.string().min(1),
|
|
647
|
+
insured: idSchema,
|
|
648
|
+
beneficiary: z.union([idSchema, z.literal('estate')]),
|
|
649
|
+
annualPremium: nonNegative,
|
|
650
|
+
premiumMode: premiumModeSchema,
|
|
651
|
+
/** Required iff premiumMode = 'untilAge'. Insured's age when premiums stop. */
|
|
652
|
+
premiumEndAge: z.number().int().min(40).max(110).optional(),
|
|
653
|
+
/** Current face amount, paid income-tax-free on the insured's death. */
|
|
654
|
+
deathBenefit: nonNegative,
|
|
655
|
+
cashValue: nonNegative,
|
|
656
|
+
/** flatRate: compound cashValueGrowthPct. schedule: interpolate the illustration table by age. */
|
|
657
|
+
cashValueMode: z.enum(['flatRate', 'schedule']),
|
|
658
|
+
cashValueGrowthPct: pct.optional(),
|
|
659
|
+
cashValueSchedule: z.array(z.object({ age: z.number().int().min(0).max(120), value: nonNegative })).optional(),
|
|
660
|
+
dividendOption: z.enum(['cash', 'reducePremium', 'paidUpAdditions']).optional(),
|
|
661
|
+
});
|
|
662
|
+
export const insurancePolicySchema = z.discriminatedUnion('kind', [ltcPolicySchema, permanentLifePolicySchema]);
|
|
663
|
+
/**
|
|
664
|
+
* A deterministic late-life care episode: a spending spike (today's dollars,
|
|
665
|
+
* additive to baseline spending) that an LTC policy on the same person offsets.
|
|
666
|
+
* The "de-risking baseline" the policy is measured against (V6 §3).
|
|
667
|
+
*/
|
|
668
|
+
export const careEventSchema = z.object({
|
|
669
|
+
id: idSchema,
|
|
670
|
+
personId: idSchema,
|
|
671
|
+
/** Age of the person when care begins. */
|
|
672
|
+
startAge: z.number().int().min(40).max(110),
|
|
673
|
+
durationYears: z.number().int().min(1).max(25),
|
|
674
|
+
/** Annual care cost in today's dollars (additive to baseline spending). */
|
|
675
|
+
annualCost: nonNegative,
|
|
676
|
+
});
|
|
677
|
+
// ---------------------------------------------------------------------------
|
|
678
|
+
// Income floor: TIPS ladders (social-security-bridge-and-tips-ladder)
|
|
679
|
+
// ---------------------------------------------------------------------------
|
|
680
|
+
/**
|
|
681
|
+
* A TIPS ladder paying a level real income over a calendar window — the plan
|
|
682
|
+
* artifact behind a Social Security bridge or an essential-spending floor.
|
|
683
|
+
* Rungs are solved by the engine from the embedded real-yield curve
|
|
684
|
+
* (`engine/ladder/ladderMath.ts`); the plan stores only the intent (target
|
|
685
|
+
* income + window + optional purchase), so a curve refresh re-quotes the same
|
|
686
|
+
* ladder. Cash flows run through the ledger with TIPS taxation: coupon income
|
|
687
|
+
* and the annual inflation accretion (phantom OID) are federal ordinary income
|
|
688
|
+
* but exempt from state tax; maturing principal is a tax-free return of
|
|
689
|
+
* already-taxed dollars. Planning-grade simplifications (annual coupons, no
|
|
690
|
+
* lot rounding) are documented in the ladder module.
|
|
691
|
+
*/
|
|
692
|
+
export const tipsLadderSchema = z.object({
|
|
693
|
+
id: idSchema,
|
|
694
|
+
name: z.string().min(1),
|
|
695
|
+
/** What the ladder funds: an SS bridge window or an essential-spending floor. Labeling only. */
|
|
696
|
+
purpose: z.enum(['bridge', 'floor']),
|
|
697
|
+
/** First calendar year the ladder pays principal (rungs mature startYear..endYear). */
|
|
698
|
+
startYear: calendarYear,
|
|
699
|
+
/** Last calendar year the ladder pays (inclusive). */
|
|
700
|
+
endYear: calendarYear,
|
|
701
|
+
/** Level annual real income the ladder delivers in each payout year, today's dollars. */
|
|
702
|
+
annualRealAmount: nonNegative,
|
|
703
|
+
/**
|
|
704
|
+
* Optional purchase event: the ladder's quoted cost is withdrawn from this
|
|
705
|
+
* (cash/taxable/equity-comp) account in `year` — a transfer, not spending,
|
|
706
|
+
* realizing gains pro-rata like any sale. Absent = the ladder is already
|
|
707
|
+
* owned (no purchase flow; coupons start in the projection's first year).
|
|
708
|
+
*/
|
|
709
|
+
purchase: z
|
|
710
|
+
.object({
|
|
711
|
+
year: calendarYear,
|
|
712
|
+
fundingAccountId: idSchema,
|
|
713
|
+
})
|
|
714
|
+
.optional(),
|
|
715
|
+
});
|
|
716
|
+
/**
|
|
717
|
+
* Safety-first income floor (optional, additive): TIPS ladders whose cash
|
|
718
|
+
* flows live inside the ledger. Absent = no ladders, byte-identical results.
|
|
719
|
+
*/
|
|
720
|
+
export const incomeFloorSchema = z.object({
|
|
721
|
+
ladders: z.array(tipsLadderSchema),
|
|
722
|
+
});
|
|
723
|
+
// ---------------------------------------------------------------------------
|
|
724
|
+
// Income streams
|
|
725
|
+
// ---------------------------------------------------------------------------
|
|
726
|
+
export const yearEarningSchema = z.object({
|
|
727
|
+
year: calendarYear,
|
|
728
|
+
amount: nonNegative,
|
|
729
|
+
});
|
|
730
|
+
export const wagesIncomeSchema = z.object({
|
|
731
|
+
type: z.literal('wages'),
|
|
732
|
+
id: idSchema,
|
|
733
|
+
personId: idSchema,
|
|
734
|
+
annualGross: nonNegative,
|
|
735
|
+
/** Stop age; null = person's retirementAge. */
|
|
736
|
+
endAge: z.number().min(30).max(80).nullable(),
|
|
737
|
+
/** Annual real raise rate, applied on top of general inflation. */
|
|
738
|
+
realGrowthPct: pct.default(0),
|
|
739
|
+
});
|
|
740
|
+
/**
|
|
741
|
+
* A former spouse who can unlock a benefit on someone else's record: a living
|
|
742
|
+
* ex-spouse (divorced-spousal) or a deceased former spouse (survivor). Their PIA
|
|
743
|
+
* is a simple user estimate, not an earnings import (spec §1.3).
|
|
744
|
+
*/
|
|
745
|
+
export const formerSpouseSchema = z.object({
|
|
746
|
+
id: idSchema,
|
|
747
|
+
/** 'divorced' = living ex (divorced-spousal); 'deceased' = former spouse who died (survivor). */
|
|
748
|
+
relationship: z.enum(['divorced', 'deceased']),
|
|
749
|
+
/** Ex/deceased spouse's date of birth — drives their eligibility age. */
|
|
750
|
+
dob: isoDate,
|
|
751
|
+
/** User-estimated monthly PIA of the ex/deceased spouse, today's dollars. */
|
|
752
|
+
piaMonthly: nonNegative,
|
|
753
|
+
/** Years the marriage lasted: gates divorced-spousal (≥10) and survivor (≥9 months). */
|
|
754
|
+
marriageYears: nonNegative,
|
|
755
|
+
/**
|
|
756
|
+
* Deceased only: the claimant's age when they remarried after this death, if
|
|
757
|
+
* they did. Remarriage before 60 forfeits the survivor benefit; at/after 60
|
|
758
|
+
* preserves it. null = did not remarry after this spouse's death.
|
|
759
|
+
*/
|
|
760
|
+
remarriedAtAge: z.number().int().min(0).max(120).nullable(),
|
|
761
|
+
/**
|
|
762
|
+
* Deceased only: the age the deceased ex actually claimed their own benefit
|
|
763
|
+
* (62–70), used for the survivor base (claim-age-adjusted) and the RIB-LIM /
|
|
764
|
+
* widow's-limit cap. Omitted/null = claimed at the deceased's FRA (the safe
|
|
765
|
+
* default: actual benefit = PIA, no early reduction, no delayed credits), so
|
|
766
|
+
* existing plans are unchanged. @see app/src/socialSecurity/survivorBenefit.ts
|
|
767
|
+
*/
|
|
768
|
+
deceasedClaimAge: z
|
|
769
|
+
.object({
|
|
770
|
+
years: z.number().int().min(62).max(70),
|
|
771
|
+
months: z.number().int().min(0).max(11),
|
|
772
|
+
})
|
|
773
|
+
.nullable()
|
|
774
|
+
.optional(),
|
|
775
|
+
});
|
|
776
|
+
export const socialSecurityIncomeSchema = z.object({
|
|
777
|
+
type: z.literal('socialSecurity'),
|
|
778
|
+
id: idSchema,
|
|
779
|
+
personId: idSchema,
|
|
780
|
+
/** Quick mode: PIA entered directly. Earnings mode: derived via socialSecurity/ (V2 phase). */
|
|
781
|
+
piaMonthly: nonNegative.nullable(),
|
|
782
|
+
earnings: z.array(yearEarningSchema).nullable(),
|
|
783
|
+
/**
|
|
784
|
+
* Earnings mode only: project covered earnings past the last imported year up
|
|
785
|
+
* to a retirement age, instead of treating those years as zero (which
|
|
786
|
+
* understates PIA for someone still working but retiring early). Omitted = no
|
|
787
|
+
* projection (legacy behavior). `assumedAnnualEarnings` null = reuse the most
|
|
788
|
+
* recent reported year; `throughAge` null = the person's retirementAge.
|
|
789
|
+
*/
|
|
790
|
+
earningsProjection: z
|
|
791
|
+
.object({
|
|
792
|
+
assumedAnnualEarnings: nonNegative.nullable(),
|
|
793
|
+
throughAge: z.number().int().min(50).max(75).nullable(),
|
|
794
|
+
})
|
|
795
|
+
.nullable()
|
|
796
|
+
.optional(),
|
|
797
|
+
/**
|
|
798
|
+
* Optional self-reported quarters of coverage (SSA "credits"), 0–40. Used only
|
|
799
|
+
* for the 10-year eligibility warning; null = estimate from earnings history.
|
|
800
|
+
*/
|
|
801
|
+
coveredQuarters: z.number().int().min(0).max(40).nullable().optional(),
|
|
802
|
+
/**
|
|
803
|
+
* Former spouses who can unlock a divorced-spousal or survivor benefit on this
|
|
804
|
+
* person's record. Omitted = none (no migration for plans saved before V7).
|
|
805
|
+
*/
|
|
806
|
+
formerSpouses: z.array(formerSpouseSchema).optional(),
|
|
807
|
+
/**
|
|
808
|
+
* Social Security disability (SSDI). Optional — omitted/undefined ⇒ SSDI is
|
|
809
|
+
* off and the stream behaves as a normal retirement claim (no behavior change
|
|
810
|
+
* for existing plans). When set, the worker receives their full PIA (no
|
|
811
|
+
* early-retirement reduction) from the onset age, gated by Substantial Gainful
|
|
812
|
+
* Activity (earnings over SGA suspend it pre-FRA), converting to the retirement
|
|
813
|
+
* benefit at FRA at the same dollar amount (no delayed-retirement credits).
|
|
814
|
+
* @see app/src/socialSecurity/disability.ts · DOCS/domain/domain-rules-reference.md §4
|
|
815
|
+
*/
|
|
816
|
+
disability: z
|
|
817
|
+
.object({
|
|
818
|
+
/** Age at disability onset (the benefit starts here, not at `claimAge`). */
|
|
819
|
+
onsetAge: z.number().int().min(40).max(75),
|
|
820
|
+
})
|
|
821
|
+
.optional(),
|
|
822
|
+
claimAge: z.object({
|
|
823
|
+
years: z.number().int().min(62).max(70),
|
|
824
|
+
months: z.number().int().min(0).max(11),
|
|
825
|
+
}),
|
|
826
|
+
});
|
|
827
|
+
export const recurringIncomeSchema = z.object({
|
|
828
|
+
type: z.literal('recurring'),
|
|
829
|
+
id: idSchema,
|
|
830
|
+
/** Rental, part-time work, royalties, … */
|
|
831
|
+
label: z.string().min(1),
|
|
832
|
+
annualAmount: nonNegative,
|
|
833
|
+
startYear: calendarYear.nullable(),
|
|
834
|
+
endYear: calendarYear.nullable(),
|
|
835
|
+
inflationAdjusted: z.boolean(),
|
|
836
|
+
taxTreatment: z.enum(['ordinary', 'none']),
|
|
837
|
+
});
|
|
838
|
+
export const oneTimeIncomeSchema = z.object({
|
|
839
|
+
type: z.literal('oneTime'),
|
|
840
|
+
id: idSchema,
|
|
841
|
+
label: z.string().min(1),
|
|
842
|
+
year: calendarYear,
|
|
843
|
+
amount: nonNegative,
|
|
844
|
+
taxTreatment: z.enum(['ordinary', 'capitalGain', 'none']),
|
|
845
|
+
});
|
|
846
|
+
export const incomeStreamSchema = z.discriminatedUnion('type', [
|
|
847
|
+
wagesIncomeSchema,
|
|
848
|
+
socialSecurityIncomeSchema,
|
|
849
|
+
recurringIncomeSchema,
|
|
850
|
+
oneTimeIncomeSchema,
|
|
851
|
+
]);
|
|
852
|
+
// ---------------------------------------------------------------------------
|
|
853
|
+
// Expenses
|
|
854
|
+
// ---------------------------------------------------------------------------
|
|
855
|
+
export const expensePhaseSchema = z.object({
|
|
856
|
+
/** Phase applies from this age of the primary (first) person. */
|
|
857
|
+
fromAge: z.number().int().min(40).max(110),
|
|
858
|
+
/** Multiplier on baseAnnual (go-go / slow-go / no-go). */
|
|
859
|
+
multiplier: z.number().min(0).max(3),
|
|
860
|
+
});
|
|
861
|
+
/**
|
|
862
|
+
* Flexibility of spending, layered from must-fund to opportunistic. Drives the
|
|
863
|
+
* required-floor vs target-lifestyle distinction that guardrails act on. Absent
|
|
864
|
+
* on a goal ⇒ 'target' (the migration default; preserves today's behavior).
|
|
865
|
+
*/
|
|
866
|
+
export const spendingClassificationSchema = z.enum(['required', 'target', 'ideal', 'excess']);
|
|
867
|
+
/**
|
|
868
|
+
* How a one-time goal may move under a guardrail policy. 'fixed' funds in its
|
|
869
|
+
* target year exactly (today's behavior); 'movable' funds in its target year
|
|
870
|
+
* when spending is not being cut, and is delayed up to `latestYear` while the
|
|
871
|
+
* guardrail is cutting; if still unaffordable at `latestYear` under a cut, the
|
|
872
|
+
* unfunded amount is reported as a layer shortfall. 'skippable' is the same but
|
|
873
|
+
* dropped entirely if it is still unaffordable at `latestYear`. Absent ⇒ 'fixed'.
|
|
874
|
+
*/
|
|
875
|
+
export const goalFlexibilitySchema = z.enum(['fixed', 'movable', 'skippable']);
|
|
876
|
+
export const oneTimeGoalSchema = z.object({
|
|
877
|
+
id: idSchema,
|
|
878
|
+
label: z.string().min(1),
|
|
879
|
+
year: calendarYear,
|
|
880
|
+
/** Today's dollars; inflated to the goal year by the engine. */
|
|
881
|
+
amount: nonNegative,
|
|
882
|
+
/** Spending flexibility layer. Absent ⇒ 'target', so pre-existing goals stay fully funded. */
|
|
883
|
+
classification: spendingClassificationSchema.optional(),
|
|
884
|
+
/** How the goal may move under a guardrail policy. Absent ⇒ 'fixed' (funds in `year`). */
|
|
885
|
+
flexibility: goalFlexibilitySchema.optional(),
|
|
886
|
+
/** Movable/skippable goals only: earliest year the goal may be pulled forward to. Absent ⇒ `year`. */
|
|
887
|
+
earliestYear: calendarYear.optional(),
|
|
888
|
+
/** Movable/skippable goals only: latest year the goal may be delayed to (inclusive). Absent ⇒ `year`. */
|
|
889
|
+
latestYear: calendarYear.optional(),
|
|
890
|
+
/** Lower numbers fund first when guardrails ration flexible spending. Absent ⇒ list order. */
|
|
891
|
+
priority: z.number().int().optional(),
|
|
892
|
+
/** Minimum percent of the goal that must be fundable before a partial funding is allowed. */
|
|
893
|
+
minFundingPct: z.number().min(0).max(100).optional(),
|
|
894
|
+
/** Allow the scheduler to resolve this goal below full funding when the hard flexible-goal budget is tight. */
|
|
895
|
+
allowPartialFunding: z.boolean().optional(),
|
|
896
|
+
});
|
|
897
|
+
export const healthcareConfigSchema = z.object({
|
|
898
|
+
/** Full (unsubsidized) monthly marketplace premium per pre-65 person, today's dollars. */
|
|
899
|
+
pre65MonthlyPremiumPerPerson: nonNegative,
|
|
900
|
+
/** Model the ACA premium tax credit against MAGI, including the 400% FPL cliff. */
|
|
901
|
+
applyAcaCredit: z.boolean(),
|
|
902
|
+
/** Part D / Medigap / Advantage base premium per person 65+, today's dollars (Part B + IRMAA added automatically). */
|
|
903
|
+
medicareExtrasMonthlyPerPerson: nonNegative,
|
|
904
|
+
/**
|
|
905
|
+
* Model Form SSA-44 IRMAA redetermination after a qualifying life-changing
|
|
906
|
+
* event (domain rules §7). In the two years after an event — the premium
|
|
907
|
+
* years whose two-year lookback still references pre-event income — IRMAA is
|
|
908
|
+
* priced on the lower of the lookback MAGI and the prior year's MAGI (the
|
|
909
|
+
* planning-grade stand-in for the current-year estimate SSA accepts).
|
|
910
|
+
* Absent = off (today's two-year-lookback-only behavior); the form itself is
|
|
911
|
+
* the user's task.
|
|
912
|
+
*/
|
|
913
|
+
ssa44: z
|
|
914
|
+
.object({
|
|
915
|
+
/** Death of spouse: apply in the two years after a couple's first death. */
|
|
916
|
+
survivorYears: z.boolean(),
|
|
917
|
+
/** Work stoppage: also treat each person's retirement year as a qualifying event. */
|
|
918
|
+
retirementYears: z.boolean(),
|
|
919
|
+
})
|
|
920
|
+
.optional(),
|
|
921
|
+
});
|
|
922
|
+
/**
|
|
923
|
+
* Opt-in spending policy layered over the fixed baseline (planning-depth roadmap
|
|
924
|
+
* §4). 'fixedTarget' (or absent) reproduces today's behavior: the whole spending
|
|
925
|
+
* stack is funded every year. 'withdrawalRateGuardrails' rations the
|
|
926
|
+
* discretionary layer (baseAnnual − requiredAnnual, plus flexible goals) path by
|
|
927
|
+
* path — cutting when the current withdrawal rate runs too far above the
|
|
928
|
+
* starting rate and restoring when it falls back — while always funding the
|
|
929
|
+
* required floor. 'riskBasedGuardrails' uses the same rationing machinery but
|
|
930
|
+
* triggers on the real portfolio balance against solver-derived dollar
|
|
931
|
+
* thresholds: the balance levels at which the plan's Monte Carlo probability of
|
|
932
|
+
* success would leave the user's target band (Kitces / risk-based guardrail
|
|
933
|
+
* methodology). The thresholds are solved on shared Monte Carlo paths
|
|
934
|
+
* (engine/montecarlo/riskBasedGuardrails.ts) and stored here as percentages of
|
|
935
|
+
* the starting portfolio; until they are solved the mode adjusts nothing.
|
|
936
|
+
* @see app/src/engine/spending/guardrails.ts
|
|
937
|
+
*/
|
|
938
|
+
/**
|
|
939
|
+
* Amortization-based withdrawal (ABW) parameters — the Bogleheads-formalized
|
|
940
|
+
* family that VPW, TPAW, and CAPE-based rules are members of. Each year the
|
|
941
|
+
* ledger recomputes the lifestyle spending target by amortizing the actual
|
|
942
|
+
* start-of-year investable balance over the remaining horizon at an expected
|
|
943
|
+
* real return, with an optional spending tilt (payments planned to grow at
|
|
944
|
+
* `tiltPct` real per year; negative = front-loaded, Blanchett-consistent).
|
|
945
|
+
* Only used when spendingPolicy.mode = 'abw'. @see engine/spending/abw.ts
|
|
946
|
+
*/
|
|
947
|
+
export const abwPolicySchema = z.object({
|
|
948
|
+
/**
|
|
949
|
+
* Where the expected real return comes from:
|
|
950
|
+
* - 'fixed': `fixedRealReturnPct` as entered (VPW-style global-returns preset).
|
|
951
|
+
* - 'cape': equity real return = 100/CAPE (the CAEY), blended with
|
|
952
|
+
* `bondRealYieldPct` at `equitySharePct` (ERN/TPAW-style conditioning).
|
|
953
|
+
* - 'tips': the whole portfolio priced at `bondRealYieldPct` (TIPS-curve
|
|
954
|
+
* floor reading — the most conservative source).
|
|
955
|
+
*/
|
|
956
|
+
returnSource: z.enum(['fixed', 'cape', 'tips']),
|
|
957
|
+
/** Expected real return %/yr for 'fixed'. Absent ⇒ 3.8 (VPW global IRRs, 60/40; ABW_DEFAULTS). */
|
|
958
|
+
fixedRealReturnPct: z.number().min(-5).max(12).optional(),
|
|
959
|
+
/** Starting CAPE for 'cape'. Absent ⇒ 25 (matches the CAPE market model default). */
|
|
960
|
+
startingCape: z.number().min(5).max(60).optional(),
|
|
961
|
+
/** Equity share %, blends CAEY with the bond yield under 'cape'. Absent ⇒ 60. */
|
|
962
|
+
equitySharePct: z.number().min(0).max(100).optional(),
|
|
963
|
+
/** Real bond/TIPS yield %/yr for 'cape' blending and 'tips'. Absent ⇒ 2.0. */
|
|
964
|
+
bondRealYieldPct: z.number().min(-2).max(8).optional(),
|
|
965
|
+
/**
|
|
966
|
+
* Amortization horizon: 'planningAge' (the household's plan horizon) or the
|
|
967
|
+
* age the primary has a 25%/10% chance of reaching (couples: either member;
|
|
968
|
+
* survival percentiles from the same SSA table as the horizon picker).
|
|
969
|
+
*/
|
|
970
|
+
horizon: z.enum(['planningAge', 'survival25', 'survival10']).optional(),
|
|
971
|
+
/** Planned real spending growth %/yr (negative = spend more early). Absent ⇒ 0. */
|
|
972
|
+
tiltPct: z.number().min(-5).max(5).optional(),
|
|
973
|
+
});
|
|
974
|
+
export const spendingPolicySchema = z.object({
|
|
975
|
+
/**
|
|
976
|
+
* 'abw' replaces the fixed baseline with the amortization rule in `abw`:
|
|
977
|
+
* base + phase spending is ignored and the year's lifestyle target is the
|
|
978
|
+
* amortized payment (healthcare, debt, property, and one-time goals stay
|
|
979
|
+
* separately modeled on top). Guardrail fields are unused under 'abw'.
|
|
980
|
+
*/
|
|
981
|
+
mode: z.enum(['fixedTarget', 'withdrawalRateGuardrails', 'riskBasedGuardrails', 'abw']),
|
|
982
|
+
/** ABW parameters; only read when mode = 'abw'. Absent ⇒ VPW-style defaults. */
|
|
983
|
+
abw: abwPolicySchema.optional(),
|
|
984
|
+
/** Cut discretionary spending when the current withdrawal rate exceeds this % of the starting rate. Absent ⇒ 120. */
|
|
985
|
+
upperGuardrailPct: z.number().positive().optional(),
|
|
986
|
+
/** Raise discretionary spending when the current withdrawal rate falls below this % of the starting rate. 0 ⇒ never raise. Absent ⇒ 80. */
|
|
987
|
+
lowerGuardrailPct: z.number().min(0).optional(),
|
|
988
|
+
/** Risk-based: cut when the plan's probability of success would fall below this %. Absent ⇒ 70. */
|
|
989
|
+
targetSuccessLowerPct: z.number().min(1).max(99).optional(),
|
|
990
|
+
/** Risk-based: raise when the plan's probability of success would rise above this %. Absent ⇒ 95. */
|
|
991
|
+
targetSuccessUpperPct: z.number().min(1).max(100).optional(),
|
|
992
|
+
/** Risk-based, solver output: real balance as a % of the starting portfolio below which cuts trigger. */
|
|
993
|
+
lowerBalanceThresholdPct: z.number().positive().optional(),
|
|
994
|
+
/** Risk-based, solver output: real balance as a % of the starting portfolio above which raises trigger. */
|
|
995
|
+
upperBalanceThresholdPct: z.number().positive().optional(),
|
|
996
|
+
/** Size of each cut/raise as a percent of the full discretionary layer. Absent ⇒ 10. */
|
|
997
|
+
adjustmentPct: z.number().min(0).max(100).optional(),
|
|
998
|
+
/** Allow raises above the target lifestyle into ideal/excess annual layers and early flexible goals. */
|
|
999
|
+
allowRaisesAboveTarget: z.boolean().optional(),
|
|
1000
|
+
});
|
|
1001
|
+
export const expensePlanSchema = z.object({
|
|
1002
|
+
/** Baseline annual spending in today's dollars, excluding healthcare and debt payments. */
|
|
1003
|
+
baseAnnual: nonNegative,
|
|
1004
|
+
/**
|
|
1005
|
+
* Required-floor annual lifestyle spending in today's dollars: the must-fund
|
|
1006
|
+
* layer a guardrail policy will never cut. The gap baseAnnual − requiredAnnual
|
|
1007
|
+
* is the discretionary lifestyle layer. Absent ⇒ equals baseAnnual (no
|
|
1008
|
+
* discretionary lifestyle layer), so pre-existing plans keep today's behavior.
|
|
1009
|
+
*/
|
|
1010
|
+
requiredAnnual: nonNegative.optional(),
|
|
1011
|
+
/** Annual ideal spending above the target lifestyle, funded only in fixed-target mode or strong guardrail paths. */
|
|
1012
|
+
idealAnnual: nonNegative.optional(),
|
|
1013
|
+
/** Annual opportunistic spending above ideal, funded last. */
|
|
1014
|
+
excessAnnual: nonNegative.optional(),
|
|
1015
|
+
phases: z.array(expensePhaseSchema),
|
|
1016
|
+
oneTimeGoals: z.array(oneTimeGoalSchema),
|
|
1017
|
+
healthcare: healthcareConfigSchema,
|
|
1018
|
+
/** Opt-in spending policy; absent ⇒ fixed-target (today's behavior). */
|
|
1019
|
+
spendingPolicy: spendingPolicySchema.optional(),
|
|
1020
|
+
/**
|
|
1021
|
+
* Household spending in survivor years (exactly one member of a multi-person
|
|
1022
|
+
* household alive) as a percent of couple spending. Scales base + phase
|
|
1023
|
+
* spending only; one-time goals, healthcare, debt, and property costs are
|
|
1024
|
+
* unaffected. Absent = 100 (no change), so older saved plans stay valid.
|
|
1025
|
+
*/
|
|
1026
|
+
survivorSpendingPct: z.number().min(0).max(100).optional(),
|
|
1027
|
+
/**
|
|
1028
|
+
* Bequest target in today's dollars: the after-tax estate the plan should
|
|
1029
|
+
* still leave at the end. Feeds the sustainable-spending solver's estate
|
|
1030
|
+
* floor and the estate-floor objective policies. Absent or 0 = no target.
|
|
1031
|
+
*/
|
|
1032
|
+
bequestTargetDollars: nonNegative.optional(),
|
|
1033
|
+
});
|
|
1034
|
+
// ---------------------------------------------------------------------------
|
|
1035
|
+
// Strategies (schema-complete ahead of their engines; see roadmap V3)
|
|
1036
|
+
// ---------------------------------------------------------------------------
|
|
1037
|
+
export const rothConversionStrategySchema = z.discriminatedUnion('mode', [
|
|
1038
|
+
z.object({ mode: z.literal('none') }),
|
|
1039
|
+
z.object({
|
|
1040
|
+
mode: z.literal('manual'),
|
|
1041
|
+
conversions: z.array(z.object({ year: calendarYear, amount: nonNegative })),
|
|
1042
|
+
}),
|
|
1043
|
+
/**
|
|
1044
|
+
* An explicit per-year conversion schedule produced by the V8 optimizer.
|
|
1045
|
+
* Behaves exactly like `manual` in the ledger; the distinct mode preserves
|
|
1046
|
+
* provenance so the UI can label it and offer "accept as manual" (which just
|
|
1047
|
+
* rewrites it as `manual`). Optional `optimizedAtIso` records when it was run.
|
|
1048
|
+
*/
|
|
1049
|
+
z.object({
|
|
1050
|
+
mode: z.literal('optimized'),
|
|
1051
|
+
conversions: z.array(z.object({ year: calendarYear, amount: nonNegative })),
|
|
1052
|
+
optimizedAtIso: isoTimestamp.optional(),
|
|
1053
|
+
}),
|
|
1054
|
+
z.object({
|
|
1055
|
+
mode: z.literal('fillToTarget'),
|
|
1056
|
+
target: z.enum(['topOfBracket', 'irmaaTier', 'acaCliff', 'fixedMagi']),
|
|
1057
|
+
/** Bracket rate (e.g. 24) when target=topOfBracket; tier index when irmaaTier; MAGI when fixedMagi. */
|
|
1058
|
+
targetValue: z.number().nullable(),
|
|
1059
|
+
startYear: calendarYear,
|
|
1060
|
+
endYear: calendarYear,
|
|
1061
|
+
}),
|
|
1062
|
+
]);
|
|
1063
|
+
export const withdrawalStrategySchema = z.discriminatedUnion('mode', [
|
|
1064
|
+
/** Drain cash → taxable → vested equity comp → traditional → Roth → HSA (SEQUENTIAL_ORDER in simulate.ts). */
|
|
1065
|
+
z.object({ mode: z.literal('sequential') }),
|
|
1066
|
+
/** Pro-rata across cash/taxable/vested equity comp/traditional/Roth; HSA last. */
|
|
1067
|
+
z.object({ mode: z.literal('proportional') }),
|
|
1068
|
+
/** Traditional first up to the top of the given bracket, then sequential, then traditional again. */
|
|
1069
|
+
z.object({ mode: z.literal('bracketTargeted'), bracketPct: z.number().positive() }),
|
|
1070
|
+
]);
|
|
1071
|
+
/**
|
|
1072
|
+
* Itemized-deduction components in today's dollars (roadmap V8). Optional — when
|
|
1073
|
+
* present, federal tax uses the greater of the standard deduction and this total
|
|
1074
|
+
* (SALT capped by the pack). SALT is the user's own estimate of deductible
|
|
1075
|
+
* state/local/property tax, kept separate from the engine's computed state tax.
|
|
1076
|
+
*/
|
|
1077
|
+
export const itemizedDeductionsSchema = z.object({
|
|
1078
|
+
stateAndLocalTaxes: nonNegative,
|
|
1079
|
+
mortgageInterest: nonNegative,
|
|
1080
|
+
charitable: nonNegative,
|
|
1081
|
+
});
|
|
1082
|
+
export const strategiesSchema = z.object({
|
|
1083
|
+
withdrawalOrder: withdrawalStrategySchema,
|
|
1084
|
+
rothConversion: rothConversionStrategySchema,
|
|
1085
|
+
/** Qualified charitable distributions per year (today's dollars), routed from RMDs when age-eligible. */
|
|
1086
|
+
qcdAnnual: nonNegative,
|
|
1087
|
+
/** Optional itemized deductions; federal tax uses the greater of these vs. the standard deduction. */
|
|
1088
|
+
itemizedDeductions: itemizedDeductionsSchema.optional(),
|
|
1089
|
+
/**
|
|
1090
|
+
* Taxable safety-net floor (today's dollars, inflation-adjusted): a minimum
|
|
1091
|
+
* combined cash + taxable + vested equity-comp reserve that need-based
|
|
1092
|
+
* withdrawals preserve while other account types can still fund spending,
|
|
1093
|
+
* and that fill-to-target Roth conversions are trimmed to respect (the
|
|
1094
|
+
* conversion's tax bill must be payable from liquid dollars above the
|
|
1095
|
+
* floor). Breached only as a last resort, with a warning, rather than
|
|
1096
|
+
* reporting an artificial shortfall. Absent or 0 = no floor.
|
|
1097
|
+
*/
|
|
1098
|
+
taxableSafetyNetFloor: nonNegative.optional(),
|
|
1099
|
+
/**
|
|
1100
|
+
* Survivor reserve target (today's dollars, inflation-adjusted): a minimum
|
|
1101
|
+
* investable balance the household should retain through survivor years (when
|
|
1102
|
+
* exactly one member of a couple is alive). Consumed by the
|
|
1103
|
+
* `protect-survivor-liquidity` objective policy as a hard constraint — a
|
|
1104
|
+
* candidate whose worst survivor-year investable balance falls below the
|
|
1105
|
+
* (inflated) target is disqualified with a readable loss reason. Absent or
|
|
1106
|
+
* 0 = no reserve constraint. @see guaranteed-income-and-estate-depth
|
|
1107
|
+
*/
|
|
1108
|
+
survivorReserveTarget: nonNegative.optional(),
|
|
1109
|
+
});
|
|
1110
|
+
// ---------------------------------------------------------------------------
|
|
1111
|
+
// Assumptions
|
|
1112
|
+
// ---------------------------------------------------------------------------
|
|
1113
|
+
export const assumptionsSchema = z.object({
|
|
1114
|
+
inflationPct: pct,
|
|
1115
|
+
/** Healthcare costs grow at inflationPct + this. */
|
|
1116
|
+
healthcareExtraInflationPct: pct,
|
|
1117
|
+
/** Applied to any account with annualReturnPct = null. */
|
|
1118
|
+
defaultReturnPct: pct,
|
|
1119
|
+
ssCola: z.discriminatedUnion('mode', [
|
|
1120
|
+
z.object({ mode: z.literal('matchInflation') }),
|
|
1121
|
+
z.object({ mode: z.literal('fixed'), annualPct: pct }),
|
|
1122
|
+
]),
|
|
1123
|
+
/** Trust-fund scenario toggle; null = scheduled benefits. @see DOCS/domain/domain-rules-reference.md §4 */
|
|
1124
|
+
ssHaircut: z.object({ fromYear: calendarYear, cutPct: z.number().min(0).max(100) }).nullable(),
|
|
1125
|
+
/** Effective state income-tax rate until per-state tables ship (roadmap V5). */
|
|
1126
|
+
stateEffectiveTaxPct: z.number().min(0).max(20),
|
|
1127
|
+
/** Optional flat local income tax, applied to modeled state taxable income. */
|
|
1128
|
+
localIncomeTaxPct: z.number().min(0).max(10).default(0),
|
|
1129
|
+
/** Recent annual MAGI, used for IRMAA's 2-year lookback in the first projection years. */
|
|
1130
|
+
recentAnnualMagi: nonNegative,
|
|
1131
|
+
/**
|
|
1132
|
+
* Assumed marginal income-tax rate heirs pay on inherited pre-tax (traditional)
|
|
1133
|
+
* balances, used by the after-tax estate metric. Roth and stepped-up taxable
|
|
1134
|
+
* accounts pass through untaxed. Optional with a default so older saved plans
|
|
1135
|
+
* stay valid without a schema migration.
|
|
1136
|
+
*/
|
|
1137
|
+
heirTaxRatePct: z.number().min(0).max(50).default(25),
|
|
1138
|
+
/**
|
|
1139
|
+
* Optional per-account-class overrides of `heirTaxRatePct`, so the after-tax
|
|
1140
|
+
* estate can price pre-tax classes at different heir brackets (e.g. a large
|
|
1141
|
+
* inherited traditional balance pushing the heir into a higher bracket than a
|
|
1142
|
+
* modest HSA). An omitted class falls back to `heirTaxRatePct`, so absent =
|
|
1143
|
+
* today's flat treatment.
|
|
1144
|
+
*/
|
|
1145
|
+
heirTaxByClass: z
|
|
1146
|
+
.object({
|
|
1147
|
+
traditional: z.number().min(0).max(50).optional(),
|
|
1148
|
+
hsa: z.number().min(0).max(50).optional(),
|
|
1149
|
+
})
|
|
1150
|
+
.optional(),
|
|
1151
|
+
/** Assumed safe withdrawal rate percentage, used to derive the FI number. */
|
|
1152
|
+
safeWithdrawalRatePct: z.number().gt(0).lt(1000).default(4),
|
|
1153
|
+
/**
|
|
1154
|
+
* Optional overrides of the sourced asset-class defaults (return, volatility,
|
|
1155
|
+
* yields) used by allocated accounts. Absent = the documented defaults in
|
|
1156
|
+
* `engine/allocation/assetClasses.ts`; irrelevant to plans with no allocated
|
|
1157
|
+
* accounts, so older saved plans stay valid without a migration.
|
|
1158
|
+
*/
|
|
1159
|
+
assetClassParams: assetClassParamOverridesSchema.optional(),
|
|
1160
|
+
});
|
|
1161
|
+
// ---------------------------------------------------------------------------
|
|
1162
|
+
// Scenarios
|
|
1163
|
+
// ---------------------------------------------------------------------------
|
|
1164
|
+
export const scenarioSchema = z.object({
|
|
1165
|
+
id: idSchema,
|
|
1166
|
+
name: z.string().min(1),
|
|
1167
|
+
/**
|
|
1168
|
+
* Partial deep-override of the plan, applied before simulation (roadmap V4).
|
|
1169
|
+
* Stored loosely typed; values are validated when the merged plan is re-parsed.
|
|
1170
|
+
*/
|
|
1171
|
+
patch: z.record(z.string(), z.unknown()),
|
|
1172
|
+
});
|
|
1173
|
+
// ---------------------------------------------------------------------------
|
|
1174
|
+
// Plan
|
|
1175
|
+
// ---------------------------------------------------------------------------
|
|
1176
|
+
export const planOriginSchema = z.enum(['user', 'example']).default('user');
|
|
1177
|
+
export const planSchema = z
|
|
1178
|
+
.object({
|
|
1179
|
+
schemaVersion: z.literal(CURRENT_PLAN_SCHEMA_VERSION),
|
|
1180
|
+
id: idSchema,
|
|
1181
|
+
name: z.string().min(1),
|
|
1182
|
+
/** Distinguishes user-owned plans from library demos filtered out of My Plans. */
|
|
1183
|
+
origin: planOriginSchema,
|
|
1184
|
+
/** Registry example id when this plan came from (or was converted from) the library. */
|
|
1185
|
+
exampleSourceId: z.string().optional(),
|
|
1186
|
+
createdAtIso: isoTimestamp,
|
|
1187
|
+
updatedAtIso: isoTimestamp,
|
|
1188
|
+
household: householdSchema,
|
|
1189
|
+
accounts: z.array(accountSchema),
|
|
1190
|
+
/** Insurance policies (LTC + permanent life). Default [] so pre-V6 plans stay valid without a migration. */
|
|
1191
|
+
insurance: z.array(insurancePolicySchema).default([]),
|
|
1192
|
+
/** Deterministic LTC care episodes. Default [] so pre-V6 plans stay valid without a migration. */
|
|
1193
|
+
careEvents: z.array(careEventSchema).default([]),
|
|
1194
|
+
incomes: z.array(incomeStreamSchema),
|
|
1195
|
+
/** Optional TIPS-ladder income floor. Absent = no ladders (no migration needed). */
|
|
1196
|
+
incomeFloor: incomeFloorSchema.optional(),
|
|
1197
|
+
expenses: expensePlanSchema,
|
|
1198
|
+
strategies: strategiesSchema,
|
|
1199
|
+
assumptions: assumptionsSchema,
|
|
1200
|
+
scenarios: z.array(scenarioSchema),
|
|
1201
|
+
})
|
|
1202
|
+
.superRefine((plan, ctx) => {
|
|
1203
|
+
if (plan.household.filingStatus === 'marriedFilingJointly' && plan.household.people.length !== 2) {
|
|
1204
|
+
ctx.addIssue({
|
|
1205
|
+
code: 'custom',
|
|
1206
|
+
path: ['household', 'filingStatus'],
|
|
1207
|
+
message: 'marriedFilingJointly requires exactly two people',
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
const personIds = new Set(plan.household.people.map((p) => p.id));
|
|
1211
|
+
const accountTypeById = new Map(plan.accounts.map((a) => [a.id, a.type]));
|
|
1212
|
+
plan.accounts.forEach((a, i) => {
|
|
1213
|
+
if (a.type === 'equityComp' && a.vestingMode === 'cliff' && a.vestDate === null) {
|
|
1214
|
+
ctx.addIssue({
|
|
1215
|
+
code: 'custom',
|
|
1216
|
+
path: ['accounts', i, 'vestDate'],
|
|
1217
|
+
message: 'cliff-vesting equity compensation requires a vest date',
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
if (individuallyOwnedAccountTypes.has(a.type) && a.ownerPersonId === null) {
|
|
1221
|
+
ctx.addIssue({
|
|
1222
|
+
code: 'custom',
|
|
1223
|
+
path: ['accounts', i, 'ownerPersonId'],
|
|
1224
|
+
message: `${a.type} accounts must have an individual owner`,
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
if (a.ownerPersonId !== null && !personIds.has(a.ownerPersonId)) {
|
|
1228
|
+
ctx.addIssue({
|
|
1229
|
+
code: 'custom',
|
|
1230
|
+
path: ['accounts', i, 'ownerPersonId'],
|
|
1231
|
+
message: `unknown person id "${a.ownerPersonId}"`,
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
if (a.type === 'traditional' && a.nondeductibleBasis !== undefined) {
|
|
1235
|
+
if (a.kind !== 'ira') {
|
|
1236
|
+
ctx.addIssue({
|
|
1237
|
+
code: 'custom',
|
|
1238
|
+
path: ['accounts', i, 'nondeductibleBasis'],
|
|
1239
|
+
message: 'nondeductible (Form 8606) basis applies to traditional IRAs only',
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
else if (a.inherited !== undefined) {
|
|
1243
|
+
ctx.addIssue({
|
|
1244
|
+
code: 'custom',
|
|
1245
|
+
path: ['accounts', i, 'nondeductibleBasis'],
|
|
1246
|
+
message: 'nondeductible basis is not modeled on inherited IRAs (the beneficiary files a separate Form 8606)',
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
if (a.type === 'hsa' && a.reimburseLater === true && a.withdrawalTreatment !== 'capByMedicalExpenses') {
|
|
1251
|
+
ctx.addIssue({
|
|
1252
|
+
code: 'custom',
|
|
1253
|
+
path: ['accounts', i, 'reimburseLater'],
|
|
1254
|
+
message: "reimburse-later accumulation requires the 'capByMedicalExpenses' withdrawal treatment",
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
if (a.type === 'property' && a.depreciationRecapture !== undefined && a.costBasis === undefined) {
|
|
1258
|
+
ctx.addIssue({
|
|
1259
|
+
code: 'custom',
|
|
1260
|
+
path: ['accounts', i, 'depreciationRecapture'],
|
|
1261
|
+
message: 'depreciation recapture requires a cost basis',
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
if ('allocation' in a && a.allocation?.mode === 'linear' && a.allocation.endYear <= a.allocation.startYear) {
|
|
1265
|
+
ctx.addIssue({
|
|
1266
|
+
code: 'custom',
|
|
1267
|
+
path: ['accounts', i, 'allocation', 'endYear'],
|
|
1268
|
+
message: 'a linear glidepath must end after it starts',
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
if (a.type === 'annuity' && a.purchase) {
|
|
1272
|
+
const fundingType = accountTypeById.get(a.purchase.fundingAccountId);
|
|
1273
|
+
if (a.purchase.fundingAccountId === a.id || fundingType === undefined) {
|
|
1274
|
+
ctx.addIssue({
|
|
1275
|
+
code: 'custom',
|
|
1276
|
+
path: ['accounts', i, 'purchase', 'fundingAccountId'],
|
|
1277
|
+
message: 'annuity purchase must be funded from another existing account',
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
else if (a.purchase.taxQualification === 'qualified' && fundingType !== 'traditional') {
|
|
1281
|
+
ctx.addIssue({
|
|
1282
|
+
code: 'custom',
|
|
1283
|
+
path: ['accounts', i, 'purchase', 'fundingAccountId'],
|
|
1284
|
+
message: 'a qualified annuity purchase must be funded from a traditional account',
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
else if (a.purchase.taxQualification === 'nonQualified' &&
|
|
1288
|
+
fundingType !== 'cash' &&
|
|
1289
|
+
fundingType !== 'taxable' &&
|
|
1290
|
+
fundingType !== 'equityComp') {
|
|
1291
|
+
ctx.addIssue({
|
|
1292
|
+
code: 'custom',
|
|
1293
|
+
path: ['accounts', i, 'purchase', 'fundingAccountId'],
|
|
1294
|
+
message: 'a non-qualified annuity purchase must be funded from cash, taxable, or equity-comp savings',
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
if (a.purchase.qlac && a.purchase.taxQualification !== 'qualified') {
|
|
1298
|
+
ctx.addIssue({
|
|
1299
|
+
code: 'custom',
|
|
1300
|
+
path: ['accounts', i, 'purchase', 'qlac'],
|
|
1301
|
+
message: 'a QLAC must be a qualified (traditional-funded) purchase',
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
if (a.type === 'annuity' && a.payoutForm?.kind === 'jointSurvivor' && plan.household.people.length < 2) {
|
|
1306
|
+
ctx.addIssue({
|
|
1307
|
+
code: 'custom',
|
|
1308
|
+
path: ['accounts', i, 'payoutForm'],
|
|
1309
|
+
message: 'a joint-and-survivor annuity requires a two-person household',
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
if (a.type === 'pension' && a.lumpSumElection) {
|
|
1313
|
+
if (!a.lumpSumOffer) {
|
|
1314
|
+
ctx.addIssue({
|
|
1315
|
+
code: 'custom',
|
|
1316
|
+
path: ['accounts', i, 'lumpSumElection'],
|
|
1317
|
+
message: 'a lump-sum election requires a lump-sum offer (amount and election year)',
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
const rolloverType = accountTypeById.get(a.lumpSumElection.rolloverAccountId);
|
|
1321
|
+
if (rolloverType !== 'traditional') {
|
|
1322
|
+
ctx.addIssue({
|
|
1323
|
+
code: 'custom',
|
|
1324
|
+
path: ['accounts', i, 'lumpSumElection', 'rolloverAccountId'],
|
|
1325
|
+
message: 'a pension lump sum must roll over into an existing traditional account',
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
if (a.type === 'property' && a.hecm && a.primaryResidence !== true) {
|
|
1330
|
+
ctx.addIssue({
|
|
1331
|
+
code: 'custom',
|
|
1332
|
+
path: ['accounts', i, 'hecm'],
|
|
1333
|
+
message: 'a HECM line of credit requires the home to be a primary residence',
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
if (a.estateBeneficiary?.destination === 'charity' && a.estateBeneficiary.charityPct === undefined) {
|
|
1337
|
+
ctx.addIssue({
|
|
1338
|
+
code: 'custom',
|
|
1339
|
+
path: ['accounts', i, 'estateBeneficiary', 'charityPct'],
|
|
1340
|
+
message: 'a charity destination requires a charity percent',
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
});
|
|
1344
|
+
plan.incomes.forEach((s, i) => {
|
|
1345
|
+
if ((s.type === 'wages' || s.type === 'socialSecurity') && !personIds.has(s.personId)) {
|
|
1346
|
+
ctx.addIssue({
|
|
1347
|
+
code: 'custom',
|
|
1348
|
+
path: ['incomes', i, 'personId'],
|
|
1349
|
+
message: `unknown person id "${s.personId}"`,
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
plan.insurance.forEach((p, i) => {
|
|
1354
|
+
const subject = p.kind === 'ltc' ? p.owner : p.insured;
|
|
1355
|
+
const subjectField = p.kind === 'ltc' ? 'owner' : 'insured';
|
|
1356
|
+
if (!personIds.has(subject)) {
|
|
1357
|
+
ctx.addIssue({ code: 'custom', path: ['insurance', i, subjectField], message: `unknown person id "${subject}"` });
|
|
1358
|
+
}
|
|
1359
|
+
if (p.premiumMode === 'untilAge' && p.premiumEndAge === undefined) {
|
|
1360
|
+
ctx.addIssue({ code: 'custom', path: ['insurance', i, 'premiumEndAge'], message: "premiumEndAge is required when premiumMode is 'untilAge'" });
|
|
1361
|
+
}
|
|
1362
|
+
if (p.kind === 'permanentLife') {
|
|
1363
|
+
if (p.beneficiary !== 'estate' && !personIds.has(p.beneficiary)) {
|
|
1364
|
+
ctx.addIssue({ code: 'custom', path: ['insurance', i, 'beneficiary'], message: `unknown person id "${p.beneficiary}"` });
|
|
1365
|
+
}
|
|
1366
|
+
if (p.cashValueMode === 'schedule' && (!p.cashValueSchedule || p.cashValueSchedule.length === 0)) {
|
|
1367
|
+
ctx.addIssue({ code: 'custom', path: ['insurance', i, 'cashValueSchedule'], message: "cashValueSchedule is required when cashValueMode is 'schedule'" });
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
plan.careEvents.forEach((c, i) => {
|
|
1372
|
+
if (!personIds.has(c.personId)) {
|
|
1373
|
+
ctx.addIssue({ code: 'custom', path: ['careEvents', i, 'personId'], message: `unknown person id "${c.personId}"` });
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
plan.incomeFloor?.ladders.forEach((ladder, i) => {
|
|
1377
|
+
if (ladder.endYear < ladder.startYear) {
|
|
1378
|
+
ctx.addIssue({
|
|
1379
|
+
code: 'custom',
|
|
1380
|
+
path: ['incomeFloor', 'ladders', i, 'endYear'],
|
|
1381
|
+
message: 'a ladder must end in or after its first payout year',
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
if (ladder.purchase) {
|
|
1385
|
+
if (ladder.purchase.year >= ladder.startYear) {
|
|
1386
|
+
ctx.addIssue({
|
|
1387
|
+
code: 'custom',
|
|
1388
|
+
path: ['incomeFloor', 'ladders', i, 'purchase', 'year'],
|
|
1389
|
+
message: 'a ladder must be purchased before its first payout year',
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
const fundingType = accountTypeById.get(ladder.purchase.fundingAccountId);
|
|
1393
|
+
if (fundingType === undefined) {
|
|
1394
|
+
ctx.addIssue({
|
|
1395
|
+
code: 'custom',
|
|
1396
|
+
path: ['incomeFloor', 'ladders', i, 'purchase', 'fundingAccountId'],
|
|
1397
|
+
message: 'a ladder purchase must be funded from an existing account',
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
else if (fundingType !== 'cash' && fundingType !== 'taxable' && fundingType !== 'equityComp') {
|
|
1401
|
+
// Taxable-side only in v1: the ladder's tax treatment (state-exempt
|
|
1402
|
+
// interest, taxed accretion) models TIPS held in a brokerage. TIPS
|
|
1403
|
+
// inside an IRA are just traditional dollars — model those as the
|
|
1404
|
+
// account's own balance instead.
|
|
1405
|
+
ctx.addIssue({
|
|
1406
|
+
code: 'custom',
|
|
1407
|
+
path: ['incomeFloor', 'ladders', i, 'purchase', 'fundingAccountId'],
|
|
1408
|
+
message: 'a TIPS ladder purchase must be funded from cash, taxable, or equity-comp savings',
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
});
|
|
1413
|
+
// The required floor cannot exceed the target lifestyle it sits under.
|
|
1414
|
+
if (plan.expenses.requiredAnnual !== undefined && plan.expenses.requiredAnnual > plan.expenses.baseAnnual) {
|
|
1415
|
+
ctx.addIssue({
|
|
1416
|
+
code: 'custom',
|
|
1417
|
+
path: ['expenses', 'requiredAnnual'],
|
|
1418
|
+
message: 'required annual spending cannot exceed baseline (target) annual spending',
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
plan.expenses.oneTimeGoals.forEach((g, i) => {
|
|
1422
|
+
if (g.earliestYear !== undefined && g.latestYear !== undefined && g.earliestYear > g.latestYear) {
|
|
1423
|
+
ctx.addIssue({
|
|
1424
|
+
code: 'custom',
|
|
1425
|
+
path: ['expenses', 'oneTimeGoals', i, 'earliestYear'],
|
|
1426
|
+
message: 'earliestYear cannot be after latestYear',
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
if (g.earliestYear !== undefined && g.earliestYear > g.year) {
|
|
1430
|
+
ctx.addIssue({
|
|
1431
|
+
code: 'custom',
|
|
1432
|
+
path: ['expenses', 'oneTimeGoals', i, 'earliestYear'],
|
|
1433
|
+
message: 'earliestYear cannot be after the goal year',
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
if (g.latestYear !== undefined && g.latestYear < g.year) {
|
|
1437
|
+
ctx.addIssue({
|
|
1438
|
+
code: 'custom',
|
|
1439
|
+
path: ['expenses', 'oneTimeGoals', i, 'latestYear'],
|
|
1440
|
+
message: 'latestYear cannot be before the goal year',
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
if (g.allowPartialFunding && g.minFundingPct !== undefined && g.minFundingPct >= 100) {
|
|
1444
|
+
ctx.addIssue({
|
|
1445
|
+
code: 'custom',
|
|
1446
|
+
path: ['expenses', 'oneTimeGoals', i, 'minFundingPct'],
|
|
1447
|
+
message: 'partial funding requires a minimum funding percent below 100',
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
});
|
|
1451
|
+
});
|
|
1452
|
+
export function parsePlan(input) {
|
|
1453
|
+
const r = planSchema.safeParse(input);
|
|
1454
|
+
if (r.success)
|
|
1455
|
+
return { ok: true, plan: r.data };
|
|
1456
|
+
return {
|
|
1457
|
+
ok: false,
|
|
1458
|
+
issues: r.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`),
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
export function createEmptyPlan(opts = {}) {
|
|
1462
|
+
const newId = opts.newId ?? (() => crypto.randomUUID());
|
|
1463
|
+
const nowIso = (opts.now ?? (() => new Date()))().toISOString();
|
|
1464
|
+
const personId = newId();
|
|
1465
|
+
return {
|
|
1466
|
+
schemaVersion: CURRENT_PLAN_SCHEMA_VERSION,
|
|
1467
|
+
id: newId(),
|
|
1468
|
+
name: opts.name ?? 'My plan',
|
|
1469
|
+
origin: 'user',
|
|
1470
|
+
createdAtIso: nowIso,
|
|
1471
|
+
updatedAtIso: nowIso,
|
|
1472
|
+
household: {
|
|
1473
|
+
filingStatus: 'single',
|
|
1474
|
+
hasQualifyingDependent: false,
|
|
1475
|
+
state: 'KY',
|
|
1476
|
+
stateMoves: [],
|
|
1477
|
+
capitalLossCarryforward: 0,
|
|
1478
|
+
people: [
|
|
1479
|
+
{
|
|
1480
|
+
id: personId,
|
|
1481
|
+
name: 'Me',
|
|
1482
|
+
dob: '1970-01-01',
|
|
1483
|
+
sex: 'average',
|
|
1484
|
+
retirementAge: 65,
|
|
1485
|
+
longevity: { planningAge: 95, source: 'manual' },
|
|
1486
|
+
},
|
|
1487
|
+
],
|
|
1488
|
+
},
|
|
1489
|
+
accounts: [],
|
|
1490
|
+
insurance: [],
|
|
1491
|
+
careEvents: [],
|
|
1492
|
+
incomes: [],
|
|
1493
|
+
expenses: {
|
|
1494
|
+
baseAnnual: 0,
|
|
1495
|
+
phases: [],
|
|
1496
|
+
oneTimeGoals: [],
|
|
1497
|
+
healthcare: { pre65MonthlyPremiumPerPerson: 0, applyAcaCredit: false, medicareExtrasMonthlyPerPerson: 0 },
|
|
1498
|
+
},
|
|
1499
|
+
strategies: { withdrawalOrder: { mode: 'sequential' }, rothConversion: { mode: 'none' }, qcdAnnual: 0 },
|
|
1500
|
+
assumptions: {
|
|
1501
|
+
inflationPct: 2.5,
|
|
1502
|
+
healthcareExtraInflationPct: 3,
|
|
1503
|
+
defaultReturnPct: 5.5,
|
|
1504
|
+
ssCola: { mode: 'matchInflation' },
|
|
1505
|
+
ssHaircut: null,
|
|
1506
|
+
stateEffectiveTaxPct: 0,
|
|
1507
|
+
localIncomeTaxPct: 0,
|
|
1508
|
+
recentAnnualMagi: 0,
|
|
1509
|
+
heirTaxRatePct: 25,
|
|
1510
|
+
safeWithdrawalRatePct: 4,
|
|
1511
|
+
},
|
|
1512
|
+
scenarios: [],
|
|
1513
|
+
};
|
|
1514
|
+
}
|