@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.
Files changed (199) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +79 -0
  3. package/dist/allocation/assetClasses.d.ts +94 -0
  4. package/dist/allocation/assetClasses.js +215 -0
  5. package/dist/decisions/annuitization.d.ts +72 -0
  6. package/dist/decisions/annuitization.js +184 -0
  7. package/dist/decisions/decisionFixtures.d.ts +48 -0
  8. package/dist/decisions/decisionFixtures.js +253 -0
  9. package/dist/decisions/evaluateCandidate.d.ts +61 -0
  10. package/dist/decisions/evaluateCandidate.js +193 -0
  11. package/dist/decisions/generators.d.ts +110 -0
  12. package/dist/decisions/generators.js +667 -0
  13. package/dist/decisions/index.d.ts +17 -0
  14. package/dist/decisions/index.js +17 -0
  15. package/dist/decisions/insightsAdapter.d.ts +27 -0
  16. package/dist/decisions/insightsAdapter.js +51 -0
  17. package/dist/decisions/objectives.d.ts +96 -0
  18. package/dist/decisions/objectives.js +319 -0
  19. package/dist/decisions/pensionElection.d.ts +112 -0
  20. package/dist/decisions/pensionElection.js +239 -0
  21. package/dist/decisions/search.d.ts +51 -0
  22. package/dist/decisions/search.js +110 -0
  23. package/dist/decisions/spendingSolver.d.ts +70 -0
  24. package/dist/decisions/spendingSolver.js +180 -0
  25. package/dist/decisions/spiaQuotes.d.ts +21 -0
  26. package/dist/decisions/spiaQuotes.js +43 -0
  27. package/dist/decisions/stochastic.d.ts +11 -0
  28. package/dist/decisions/stochastic.js +66 -0
  29. package/dist/decisions/swrComparator.d.ts +54 -0
  30. package/dist/decisions/swrComparator.js +89 -0
  31. package/dist/decisions/tournament.d.ts +53 -0
  32. package/dist/decisions/tournament.js +104 -0
  33. package/dist/decisions/types.d.ts +125 -0
  34. package/dist/decisions/types.js +12 -0
  35. package/dist/index.d.ts +13 -0
  36. package/dist/index.js +12 -0
  37. package/dist/insights/detectors/annuitizationHeadroom.d.ts +11 -0
  38. package/dist/insights/detectors/annuitizationHeadroom.js +77 -0
  39. package/dist/insights/detectors/assetLocation.d.ts +9 -0
  40. package/dist/insights/detectors/assetLocation.js +86 -0
  41. package/dist/insights/detectors/hecmBufferCandidate.d.ts +12 -0
  42. package/dist/insights/detectors/hecmBufferCandidate.js +76 -0
  43. package/dist/insights/detectors/incomeFloorFunded.d.ts +8 -0
  44. package/dist/insights/detectors/incomeFloorFunded.js +52 -0
  45. package/dist/insights/detectors/irmaaTierEdge.d.ts +2 -0
  46. package/dist/insights/detectors/irmaaTierEdge.js +102 -0
  47. package/dist/insights/detectors/pensionElectionPending.d.ts +11 -0
  48. package/dist/insights/detectors/pensionElectionPending.js +62 -0
  49. package/dist/insights/detectors/qcdEfficiency.d.ts +2 -0
  50. package/dist/insights/detectors/qcdEfficiency.js +62 -0
  51. package/dist/insights/detectors/rothBridgeHeadroom.d.ts +2 -0
  52. package/dist/insights/detectors/rothBridgeHeadroom.js +77 -0
  53. package/dist/insights/detectors/spendingGuardrails.d.ts +2 -0
  54. package/dist/insights/detectors/spendingGuardrails.js +77 -0
  55. package/dist/insights/detectors/spendingHeadroom.d.ts +12 -0
  56. package/dist/insights/detectors/spendingHeadroom.js +92 -0
  57. package/dist/insights/detectors/ssBridgeGap.d.ts +9 -0
  58. package/dist/insights/detectors/ssBridgeGap.js +92 -0
  59. package/dist/insights/detectors/stateRelocation.d.ts +12 -0
  60. package/dist/insights/detectors/stateRelocation.js +99 -0
  61. package/dist/insights/detectors/widowsPenalty.d.ts +12 -0
  62. package/dist/insights/detectors/widowsPenalty.js +145 -0
  63. package/dist/insights/registry.d.ts +4 -0
  64. package/dist/insights/registry.js +62 -0
  65. package/dist/insights/runInsights.d.ts +6 -0
  66. package/dist/insights/runInsights.js +15 -0
  67. package/dist/insights/types.d.ts +70 -0
  68. package/dist/insights/types.js +1 -0
  69. package/dist/ladder/bridge.d.ts +72 -0
  70. package/dist/ladder/bridge.js +59 -0
  71. package/dist/ladder/fedInvest.d.ts +55 -0
  72. package/dist/ladder/fedInvest.js +77 -0
  73. package/dist/ladder/fundedRatio.d.ts +41 -0
  74. package/dist/ladder/fundedRatio.js +46 -0
  75. package/dist/ladder/ladderMath.d.ts +88 -0
  76. package/dist/ladder/ladderMath.js +140 -0
  77. package/dist/longevity/ssaPeriod2022.d.ts +10 -0
  78. package/dist/longevity/ssaPeriod2022.js +51 -0
  79. package/dist/longevity/types.d.ts +39 -0
  80. package/dist/longevity/types.js +1 -0
  81. package/dist/model/migrations.d.ts +20 -0
  82. package/dist/model/migrations.js +67 -0
  83. package/dist/model/plan.d.ts +3108 -0
  84. package/dist/model/plan.js +1514 -0
  85. package/dist/montecarlo/frontiers.d.ts +22 -0
  86. package/dist/montecarlo/frontiers.js +61 -0
  87. package/dist/montecarlo/historicalReturns.d.ts +27 -0
  88. package/dist/montecarlo/historicalReturns.js +124 -0
  89. package/dist/montecarlo/historicalSuites.d.ts +45 -0
  90. package/dist/montecarlo/historicalSuites.js +102 -0
  91. package/dist/montecarlo/ltcShock.d.ts +42 -0
  92. package/dist/montecarlo/ltcShock.js +62 -0
  93. package/dist/montecarlo/marketModels.d.ts +248 -0
  94. package/dist/montecarlo/marketModels.js +672 -0
  95. package/dist/montecarlo/mortality.d.ts +31 -0
  96. package/dist/montecarlo/mortality.js +61 -0
  97. package/dist/montecarlo/riskBasedGuardrails.d.ts +93 -0
  98. package/dist/montecarlo/riskBasedGuardrails.js +200 -0
  99. package/dist/montecarlo/rng.d.ts +18 -0
  100. package/dist/montecarlo/rng.js +44 -0
  101. package/dist/montecarlo/run.d.ts +210 -0
  102. package/dist/montecarlo/run.js +353 -0
  103. package/dist/montecarlo/sharedPaths.d.ts +43 -0
  104. package/dist/montecarlo/sharedPaths.js +28 -0
  105. package/dist/montecarlo/survival.d.ts +49 -0
  106. package/dist/montecarlo/survival.js +126 -0
  107. package/dist/params/data/realYieldCurve2026.d.ts +17 -0
  108. package/dist/params/data/realYieldCurve2026.js +26 -0
  109. package/dist/params/data/year2026.d.ts +14 -0
  110. package/dist/params/data/year2026.js +199 -0
  111. package/dist/params/index.d.ts +63 -0
  112. package/dist/params/index.js +134 -0
  113. package/dist/params/provenance.d.ts +29 -0
  114. package/dist/params/provenance.js +123 -0
  115. package/dist/params/state/data/year2026.d.ts +18 -0
  116. package/dist/params/state/data/year2026.js +581 -0
  117. package/dist/params/state/index.d.ts +13 -0
  118. package/dist/params/state/index.js +26 -0
  119. package/dist/params/state/types.d.ts +73 -0
  120. package/dist/params/state/types.js +10 -0
  121. package/dist/params/types.d.ts +201 -0
  122. package/dist/params/types.js +7 -0
  123. package/dist/projection/annuityForms.d.ts +62 -0
  124. package/dist/projection/annuityForms.js +87 -0
  125. package/dist/projection/compare.d.ts +96 -0
  126. package/dist/projection/compare.js +216 -0
  127. package/dist/projection/flatTax.d.ts +7 -0
  128. package/dist/projection/flatTax.js +14 -0
  129. package/dist/projection/optimizePlan.d.ts +328 -0
  130. package/dist/projection/optimizePlan.js +1039 -0
  131. package/dist/projection/relocation.d.ts +149 -0
  132. package/dist/projection/relocation.js +317 -0
  133. package/dist/projection/simulate.d.ts +61 -0
  134. package/dist/projection/simulate.js +2754 -0
  135. package/dist/projection/types.d.ts +381 -0
  136. package/dist/projection/types.js +9 -0
  137. package/dist/rmd/jointLifeTable.d.ts +8 -0
  138. package/dist/rmd/jointLifeTable.js +663 -0
  139. package/dist/rmd/rmd.d.ts +29 -0
  140. package/dist/rmd/rmd.js +34 -0
  141. package/dist/scenarios/scenarios.d.ts +70 -0
  142. package/dist/scenarios/scenarios.js +130 -0
  143. package/dist/socialSecurity/benefitFactor.d.ts +13 -0
  144. package/dist/socialSecurity/benefitFactor.js +40 -0
  145. package/dist/socialSecurity/claimFactor.d.ts +33 -0
  146. package/dist/socialSecurity/claimFactor.js +58 -0
  147. package/dist/socialSecurity/disability.d.ts +36 -0
  148. package/dist/socialSecurity/disability.js +42 -0
  149. package/dist/socialSecurity/familyMaximum.d.ts +38 -0
  150. package/dist/socialSecurity/familyMaximum.js +48 -0
  151. package/dist/socialSecurity/maritalBenefits.d.ts +57 -0
  152. package/dist/socialSecurity/maritalBenefits.js +87 -0
  153. package/dist/socialSecurity/nra.d.ts +30 -0
  154. package/dist/socialSecurity/nra.js +83 -0
  155. package/dist/socialSecurity/piaFromEarnings.d.ts +100 -0
  156. package/dist/socialSecurity/piaFromEarnings.js +202 -0
  157. package/dist/socialSecurity/ssaWageData.d.ts +68 -0
  158. package/dist/socialSecurity/ssaWageData.js +306 -0
  159. package/dist/socialSecurity/survivorBenefit.d.ts +61 -0
  160. package/dist/socialSecurity/survivorBenefit.js +61 -0
  161. package/dist/spending/abw.d.ts +57 -0
  162. package/dist/spending/abw.js +80 -0
  163. package/dist/spending/flexibleGoals.d.ts +62 -0
  164. package/dist/spending/flexibleGoals.js +136 -0
  165. package/dist/spending/guardrails.d.ts +78 -0
  166. package/dist/spending/guardrails.js +100 -0
  167. package/dist/spending/layers.d.ts +81 -0
  168. package/dist/spending/layers.js +67 -0
  169. package/dist/spending/shapePresets.d.ts +42 -0
  170. package/dist/spending/shapePresets.js +77 -0
  171. package/dist/strategies/accountEligibility.d.ts +81 -0
  172. package/dist/strategies/accountEligibility.js +98 -0
  173. package/dist/strategies/inheritedIra.d.ts +45 -0
  174. package/dist/strategies/inheritedIra.js +43 -0
  175. package/dist/strategies/iraBasis.d.ts +48 -0
  176. package/dist/strategies/iraBasis.js +46 -0
  177. package/dist/strategies/optimizer.d.ts +307 -0
  178. package/dist/strategies/optimizer.js +551 -0
  179. package/dist/strategies/rothBasis.d.ts +67 -0
  180. package/dist/strategies/rothBasis.js +83 -0
  181. package/dist/strategies/rothConversion.d.ts +55 -0
  182. package/dist/strategies/rothConversion.js +83 -0
  183. package/dist/strategies/sepp.d.ts +38 -0
  184. package/dist/strategies/sepp.js +53 -0
  185. package/dist/tax/aca.d.ts +40 -0
  186. package/dist/tax/aca.js +82 -0
  187. package/dist/tax/federalTax.d.ts +110 -0
  188. package/dist/tax/federalTax.js +249 -0
  189. package/dist/tax/medicare.d.ts +20 -0
  190. package/dist/tax/medicare.js +36 -0
  191. package/dist/tax/propertySale.d.ts +46 -0
  192. package/dist/tax/propertySale.js +35 -0
  193. package/dist/tax/stateTax.d.ts +66 -0
  194. package/dist/tax/stateTax.js +198 -0
  195. package/dist/testing/money.d.ts +7 -0
  196. package/dist/testing/money.js +17 -0
  197. package/dist/testing/planFixtures.d.ts +29 -0
  198. package/dist/testing/planFixtures.js +141 -0
  199. package/package.json +75 -0
@@ -0,0 +1,2754 @@
1
+ /**
2
+ * Deterministic annual-ledger simulation (roadmap V1).
3
+ *
4
+ * Year ordering: ages → income → expenses (incl. debt service) → capped
5
+ * contributions → fixed-point tax/withdrawal iteration → apply flows →
6
+ * property events → growth → snapshot. All amounts are nominal dollars;
7
+ * today's-dollar display is a render-time transform.
8
+ *
9
+ * V1 simplifications (each lifts in a later roadmap phase):
10
+ * - Wages, contributions, base spending, and goals inflate at the general rate;
11
+ * wages stop in the year the person attains retirement age.
12
+ * - SS COLA compounds from the projection start, and first-year benefits are
13
+ * prorated by claim months only (no birthday-month precision). PIA comes
14
+ * from the stream directly or from its earnings history (AIME → bend
15
+ * points). The earnings test withholds own, spousal, and survivor benefits
16
+ * annually ($1/$2 below FRA, $1/$3 in the FRA year) and credits the withheld
17
+ * months back at FRA (ARF, annual approximation). Spousal benefits apply the
18
+ * retirement/survivor family maximum; survivors step up to the deceased's
19
+ * benefit with the early-claim widow(er) reduction and RIB-LIM widow's-limit cap.
20
+ * - RMDs are forced from traditional accounts at SECURE 2.0 start ages
21
+ * (Uniform Lifetime Table; no April-1 first-year deferral). QCDs route
22
+ * charitable dollars out of the RMD (age 70½ ≈ age attained 71).
23
+ * Early-withdrawal penalties: 10% traditional pre-59½ (≈ age < 60), 20%
24
+ * HSA non-medical pre-65. Healthcare expenses: ACA-credited marketplace
25
+ * premiums pre-65 (credit vs prior-year MAGI; 400% FPL cliff), Medicare
26
+ * Part B + IRMAA (MAGI 2-year lookback) + Part D surcharge + extras from
27
+ * 65. Roth conversions run after RMDs (manual amounts or fill-to-target
28
+ * sized against the federal engine; conversion taxes ride the normal
29
+ * withdrawal flow, so they come from cash/taxable first and are never
30
+ * penalized). Annuities end at owner death; pensions pay survivorPct to a
31
+ * surviving spouse once payments have started.
32
+ * - Contribution limits beyond the latest parameter pack are indexed forward
33
+ * at the assumed inflation rate (statutory limits are inflation-indexed).
34
+ */
35
+ import { ASSET_CLASS_IDS, stateForYear, stateResidencySegmentsForYear } from '../model/plan.js';
36
+ import { accountAllocation, blendedTaxableYield, driftWeights, rebalanceTurnoverFraction, resolveAssetClassParams, targetWeightsAt, } from '../allocation/assetClasses.js';
37
+ import { packForYear, LATEST_PACK_YEAR, hecmPrincipalLimitFactorPct, EMBEDDED_REAL_YIELD_CURVE } from '../params/index.js';
38
+ import { annuityExclusionMultiple, annuityPayoutForm, annuityPayoutFraction } from './annuityForms.js';
39
+ import { buildLadder, ladderRealFlowsAtOffset, ladderRemainingFace } from '../ladder/ladderMath.js';
40
+ import { stateParamsFor } from '../params/state/index.js';
41
+ import { requiredMinimumDistribution } from '../rmd/rmd.js';
42
+ import { claimFactor, spousalBenefitFactor } from '../socialSecurity/claimFactor.js';
43
+ import { bestMaritalBenefit } from '../socialSecurity/maritalBenefits.js';
44
+ import { capAuxiliaryForFamilyMaximum, claimAgeTotalMonths } from '../socialSecurity/familyMaximum.js';
45
+ import { sizeRothConversion } from '../strategies/rothConversion.js';
46
+ import { splitRothWithdrawal } from '../strategies/rothBasis.js';
47
+ import { seppActive, seppAnnualAmount } from '../strategies/sepp.js';
48
+ import { inheritedForcedAmount } from '../strategies/inheritedIra.js';
49
+ import { acceptsContributions, followsOwnerRmds, hsaNonQualifiedPenaltyRate, isAggregatedIra, isConvertibleToRoth, isSpendableInYear, traditionalWithdrawalPenaltyRate, } from '../strategies/accountEligibility.js';
50
+ import { openIraProRataYear, splitIraDistribution } from '../strategies/iraBasis.js';
51
+ import { propertySaleTax } from '../tax/propertySale.js';
52
+ import { effectiveBirthYear, fraForBirthYear, fraTotalMonths, survivorFraForBirthYear } from '../socialSecurity/nra.js';
53
+ import { computePiaFromEarnings, isPiaFromEarningsError, piaInputFromEarnings, resolveEarningsProjection, } from '../socialSecurity/piaFromEarnings.js';
54
+ import { survivorBenefitMonthly } from '../socialSecurity/survivorBenefit.js';
55
+ import { inSsdiWindow, ssdiMonthlyBenefit, ssdiSuspendedBySga } from '../socialSecurity/disability.js';
56
+ import { attributeShortfall, splitAnnualSpendingLayers } from '../spending/layers.js';
57
+ import { ABW_DEFAULTS, abwAnnualPayment, abwExpectedRealReturnPct } from '../spending/abw.js';
58
+ import { jointSurvivalPercentileAge, survivalPercentileAge } from '../montecarlo/survival.js';
59
+ import { nextBalanceGuardrailMultiplier, nextGuardrailMultiplier, } from '../spending/guardrails.js';
60
+ import { createGoalScheduler, toSchedulableGoal } from '../spending/flexibleGoals.js';
61
+ import { acaNetAnnualPremiumByMonth } from '../tax/aca.js';
62
+ import { applyCapitalLossCarryforward, computeFederalTax, taxableSocialSecurity } from '../tax/federalTax.js';
63
+ import { medicareAnnualPremiumPerPerson } from '../tax/medicare.js';
64
+ import { taxParameterFilingStatus, } from './types.js';
65
+ const EPSILON = 0.005;
66
+ const MAX_TAX_ITERATIONS = 8;
67
+ function dobParts(person) {
68
+ return {
69
+ y: Number(person.dob.slice(0, 4)),
70
+ m: Number(person.dob.slice(5, 7)),
71
+ d: Number(person.dob.slice(8, 10)),
72
+ };
73
+ }
74
+ function claimAgeFromTotalMonths(totalMonths) {
75
+ return { years: Math.floor(totalMonths / 12), months: totalMonths % 12 };
76
+ }
77
+ /** Annual-ledger approximation: a same-year claim pays only months after the claim month. */
78
+ function payableMonthsAtAge(ageAttained, claimAge) {
79
+ if (ageAttained < claimAge.years)
80
+ return 0;
81
+ if (ageAttained > claimAge.years)
82
+ return 12;
83
+ return Math.max(0, 12 - claimAge.months);
84
+ }
85
+ /**
86
+ * Linear interpolation of an illustration cash-value table by age. Clamps to the
87
+ * endpoints outside the table's range (front-loaded-poor / back-loaded-rich whole-
88
+ * life cash value is exactly why a schedule beats a flat rate).
89
+ */
90
+ function interpolateByAge(schedule, age) {
91
+ if (schedule.length === 0)
92
+ return 0;
93
+ const sorted = [...schedule].sort((a, b) => a.age - b.age);
94
+ if (age <= sorted[0].age)
95
+ return sorted[0].value;
96
+ if (age >= sorted[sorted.length - 1].age)
97
+ return sorted[sorted.length - 1].value;
98
+ for (let i = 0; i < sorted.length - 1; i++) {
99
+ const lo = sorted[i];
100
+ const hi = sorted[i + 1];
101
+ if (age >= lo.age && age <= hi.age) {
102
+ const t = (age - lo.age) / (hi.age - lo.age);
103
+ return lo.value + t * (hi.value - lo.value);
104
+ }
105
+ }
106
+ return sorted[sorted.length - 1].value;
107
+ }
108
+ const SEQUENTIAL_ORDER = ['cash', 'taxable', 'equityComp', 'traditional', 'roth', 'hsa'];
109
+ const PROPORTIONAL_POOL = ['cash', 'taxable', 'equityComp', 'traditional', 'roth'];
110
+ function spendableBalance(state, year) {
111
+ return isSpendableInYear(state.account, year) ? state.balance : 0;
112
+ }
113
+ /** Drain plan over a copy of balances; pure with respect to engine state. */
114
+ function planWithdrawals(amount, states, strategy = { mode: 'sequential' }, year = 0, liquidReserve = 0) {
115
+ const byCategory = { cash: 0, taxable: 0, traditional: 0, roth: 0, hsa: 0, total: 0 };
116
+ const byAccountId = new Map();
117
+ const available = new Map(states.map((s) => [s.account.id, spendableBalance(s, year)]));
118
+ let realizedGains = 0;
119
+ let remaining = amount;
120
+ // Taxable safety-net floor (step 7): hold `liquidReserve` back from the
121
+ // liquid (cash/taxable/vested equity-comp) accounts so other account types
122
+ // fund spending first. Protection is allocated to the last-drained accounts
123
+ // first, and released below only when everything else still falls short —
124
+ // the floor is a preference, never a manufactured shortfall.
125
+ const reservedByAccount = new Map();
126
+ if (liquidReserve > 0) {
127
+ let toReserve = liquidReserve;
128
+ for (const type of ['equityComp', 'taxable', 'cash']) {
129
+ for (let i = states.length - 1; i >= 0 && toReserve > EPSILON; i--) {
130
+ const s = states[i];
131
+ if (s.account.type !== type)
132
+ continue;
133
+ const avail = available.get(s.account.id) ?? 0;
134
+ const hold = Math.min(avail, toReserve);
135
+ if (hold <= 0)
136
+ continue;
137
+ available.set(s.account.id, avail - hold);
138
+ reservedByAccount.set(s.account.id, hold);
139
+ toReserve -= hold;
140
+ }
141
+ }
142
+ }
143
+ const takeFrom = (state, want) => {
144
+ const take = Math.min(available.get(state.account.id) ?? 0, want, remaining);
145
+ if (take <= 0)
146
+ return 0;
147
+ if ((state.account.type === 'taxable' || state.account.type === 'equityComp') && state.balance > 0) {
148
+ const basisRatio = Math.min(1, state.costBasis / state.balance);
149
+ realizedGains += take * (1 - basisRatio);
150
+ }
151
+ const category = state.account.type === 'equityComp' ? 'taxable' : state.account.type;
152
+ byCategory[category] += take;
153
+ byAccountId.set(state.account.id, (byAccountId.get(state.account.id) ?? 0) + take);
154
+ available.set(state.account.id, (available.get(state.account.id) ?? 0) - take);
155
+ remaining -= take;
156
+ return take;
157
+ };
158
+ const drainCategory = (category, cap = Infinity) => {
159
+ let capLeft = cap;
160
+ for (const state of states) {
161
+ if (state.account.type !== category)
162
+ continue;
163
+ if (remaining <= EPSILON || capLeft <= EPSILON)
164
+ break;
165
+ capLeft -= takeFrom(state, capLeft);
166
+ }
167
+ };
168
+ if (strategy.mode === 'proportional') {
169
+ // Pro-rata passes; accounts that empty shift their share to the rest.
170
+ for (let pass = 0; pass < 6 && remaining > EPSILON; pass++) {
171
+ const poolStates = states.filter((s) => PROPORTIONAL_POOL.includes(s.account.type) && (available.get(s.account.id) ?? 0) > 0);
172
+ const poolTotal = poolStates.reduce((sum, s) => sum + (available.get(s.account.id) ?? 0), 0);
173
+ if (poolTotal <= 0)
174
+ break;
175
+ const target = remaining;
176
+ for (const state of poolStates) {
177
+ takeFrom(state, (target * (available.get(state.account.id) ?? 0)) / poolTotal);
178
+ }
179
+ }
180
+ for (const category of PROPORTIONAL_POOL)
181
+ drainCategory(category); // numerical cleanup
182
+ drainCategory('hsa');
183
+ }
184
+ else if (strategy.mode === 'bracketTargeted') {
185
+ drainCategory('traditional', strategy.traditionalCap);
186
+ drainCategory('cash');
187
+ drainCategory('taxable');
188
+ drainCategory('equityComp');
189
+ drainCategory('roth');
190
+ drainCategory('traditional');
191
+ drainCategory('hsa');
192
+ }
193
+ else {
194
+ for (const category of SEQUENTIAL_ORDER)
195
+ drainCategory(category);
196
+ }
197
+ // Release the safety-net reserve as a last resort.
198
+ let reserveUsed = 0;
199
+ if (remaining > EPSILON && reservedByAccount.size > 0) {
200
+ const before = remaining;
201
+ for (const [id, hold] of reservedByAccount) {
202
+ available.set(id, (available.get(id) ?? 0) + hold);
203
+ }
204
+ for (const category of ['cash', 'taxable', 'equityComp'])
205
+ drainCategory(category);
206
+ reserveUsed = before - remaining;
207
+ }
208
+ byCategory.total = byCategory.cash + byCategory.taxable + byCategory.traditional + byCategory.roth + byCategory.hsa;
209
+ return { byCategory, byAccountId, realizedGains, shortfall: Math.max(0, remaining), reserveUsed };
210
+ }
211
+ export function simulatePlan(plan, opts) {
212
+ const { startYear, taxCalculator, market } = opts;
213
+ const warnings = new Set();
214
+ const inflation = plan.assumptions.inflationPct / 100;
215
+ const people = plan.household.people;
216
+ const primary = people[0];
217
+ const personById = new Map(people.map((p) => [p.id, p]));
218
+ // Clamped: the dob schema enforces YYYY-MM-DD shape but not month range, and
219
+ // an out-of-range month must not produce negative or >12 coverage months.
220
+ const birthMonthByPerson = new Map(people.map((p) => [p.id, Math.min(12, Math.max(1, dobParts(p).m || 1))]));
221
+ const dobYear = (p) => dobParts(p).y;
222
+ /** Last full year alive: a stochastic-longevity override if given, else the plan's planning age. */
223
+ const lifeAgeOf = (p) => opts.deathAgeByPersonId?.[p.id] ?? p.longevity.planningAge;
224
+ const lastAliveYearOf = (p) => dobYear(p) + lifeAgeOf(p);
225
+ const filingStatusFor = (year, aliveCount) => {
226
+ if (plan.household.filingStatus !== 'marriedFilingJointly')
227
+ return plan.household.filingStatus;
228
+ if (aliveCount >= 2)
229
+ return 'marriedFilingJointly';
230
+ if (aliveCount === 1 && people.length === 2 && plan.household.hasQualifyingDependent) {
231
+ const firstDeathYear = Math.min(...people.map(lastAliveYearOf));
232
+ if (year > firstDeathYear && year <= firstDeathYear + 2)
233
+ return 'qualifyingSurvivingSpouse';
234
+ }
235
+ return 'single';
236
+ };
237
+ // SSA-44 IRMAA redetermination (opt-in; domain rules §7). A qualifying
238
+ // life-changing event — death of spouse, and optionally each person's work
239
+ // stoppage — lets the beneficiary ask SSA to price IRMAA on the current
240
+ // year's estimated MAGI instead of the two-year lookback. Planning-grade: in
241
+ // the two years after an event, the premium MAGI is min(lookback, prior
242
+ // year). The prior year stands in for the current-year estimate (current-year
243
+ // MAGI would be circular with withdrawals — same convention as the ACA
244
+ // credit), and the min reflects that a redetermination is only filed when it
245
+ // helps. Two documented under-modelings of the real form follow from that
246
+ // stand-in: (a) the event year itself stays on the plain lookback — a real
247
+ // filing can re-price it, but the prior-year estimate there is pre-event
248
+ // income, so modeling it would show no relief anyway; (b) in the first
249
+ // post-event year the estimate is the event year's MAGI (a death year is
250
+ // still a full joint year), so year-one relief is understated when income
251
+ // runs high through the event. Off/absent = the plain two-year lookback.
252
+ const ssa44 = plan.expenses.healthcare.ssa44;
253
+ const ssa44EventYears = [];
254
+ if (ssa44?.survivorYears && plan.household.filingStatus === 'marriedFilingJointly' && people.length === 2) {
255
+ ssa44EventYears.push(Math.min(...people.map(lastAliveYearOf)));
256
+ }
257
+ if (ssa44?.retirementYears) {
258
+ for (const p of people) {
259
+ // Only a retirement that actually happens: someone who dies (planning
260
+ // age or a stochastic/scenario override) before reaching retirementAge
261
+ // never has a work-stoppage event to report.
262
+ if (typeof p.retirementAge === 'number' && p.retirementAge <= lifeAgeOf(p)) {
263
+ ssa44EventYears.push(dobYear(p) + p.retirementAge);
264
+ }
265
+ }
266
+ }
267
+ const ssa44ActiveInYear = (y) => ssa44EventYears.some((e) => y > e && y <= e + 2);
268
+ const endYear = opts.horizonEndYear ?? Math.max(...people.map((p) => dobYear(p) + lifeAgeOf(p)));
269
+ // --- per-year market series (deterministic assumptions unless overridden) --
270
+ const horizon = endYear - startYear + 1;
271
+ const inflRateAt = (year) => {
272
+ const series = market?.inflationPct;
273
+ if (year < startYear || !series || series.length === 0)
274
+ return inflation;
275
+ return (series[Math.min(year - startYear, series.length - 1)] ?? plan.assumptions.inflationPct) / 100;
276
+ };
277
+ const returnShockAt = (year) => {
278
+ const series = market?.returnShockPct;
279
+ if (!series || series.length === 0)
280
+ return 0;
281
+ return series[Math.min(year - startYear, series.length - 1)] ?? 0;
282
+ };
283
+ /**
284
+ * Additive shock for one asset class: its own series when supplied, else the
285
+ * single-factor market shock for non-cash classes (cash is stable value).
286
+ */
287
+ const classShockAt = (year, classIndex) => {
288
+ const id = ASSET_CLASS_IDS[classIndex];
289
+ const series = market?.classReturnShockPct?.[id];
290
+ if (series && series.length > 0)
291
+ return series[Math.min(year - startYear, series.length - 1)] ?? 0;
292
+ return id === 'cash' ? 0 : returnShockAt(year);
293
+ };
294
+ const healthExtra = plan.assumptions.healthcareExtraInflationPct / 100;
295
+ // cum*[i] = cumulative factor from startYear through startYear + i (exclusive).
296
+ const cumInfl = [1];
297
+ const cumHealthInfl = [1];
298
+ for (let i = 0; i < horizon; i++) {
299
+ const r = inflRateAt(startYear + i);
300
+ cumInfl.push(cumInfl[i] * (1 + r));
301
+ cumHealthInfl.push(cumHealthInfl[i] * (1 + r + healthExtra));
302
+ }
303
+ const factorFrom = (cum, preStartRate, fromYear, toYear) => {
304
+ if (toYear <= fromYear)
305
+ return 1;
306
+ let f = 1;
307
+ if (fromYear < startYear)
308
+ f = Math.pow(1 + preStartRate, Math.min(toYear, startYear) - fromYear);
309
+ const a = Math.min(Math.max(fromYear, startYear) - startYear, horizon);
310
+ const b = Math.min(Math.max(toYear, startYear) - startYear, horizon);
311
+ return f * (cum[b] / cum[a]);
312
+ };
313
+ /** Cumulative general-inflation factor between two years (per-year series from startYear on). */
314
+ const inflFactorFrom = (fromYear, toYear) => factorFrom(cumInfl, inflation, fromYear, toYear);
315
+ /** Same for healthcare (general inflation + the healthcare premium). */
316
+ const healthInflFactorFrom = (fromYear, toYear) => factorFrom(cumHealthInfl, inflation + healthExtra, fromYear, toYear);
317
+ /** Statutory limits are indexed; project them past the latest pack at the inflation path. */
318
+ const limitScale = (pack, isStandIn, year) => !isStandIn || year <= LATEST_PACK_YEAR ? 1 : inflFactorFrom(pack.year, year);
319
+ // --- mutable engine state ---------------------------------------------
320
+ const balances = [];
321
+ const propertyValues = new Map();
322
+ const debtBalances = new Map();
323
+ for (const account of plan.accounts) {
324
+ if (account.type === 'cash' ||
325
+ account.type === 'taxable' ||
326
+ account.type === 'equityComp' ||
327
+ account.type === 'traditional' ||
328
+ account.type === 'roth' ||
329
+ account.type === 'hsa') {
330
+ balances.push({
331
+ account,
332
+ balance: account.balance,
333
+ costBasis: account.type === 'taxable' || account.type === 'equityComp' ? account.costBasis : 0,
334
+ });
335
+ }
336
+ else if (account.type === 'property') {
337
+ propertyValues.set(account.id, account.value);
338
+ }
339
+ else if (account.type === 'debt') {
340
+ debtBalances.set(account.id, account.balance);
341
+ }
342
+ }
343
+ let unassignedCash = 0;
344
+ // Annuity purchases (guaranteed-income-and-estate-depth). The premium actually
345
+ // funded becomes the contract's investment for the non-qualified exclusion
346
+ // ratio; the ratio and remaining excludable investment are memoized on first
347
+ // payout. Both persist across years (funding and payout can be years apart for
348
+ // a QLAC), so they live at engine-state scope.
349
+ const annuityInvestmentInContract = new Map();
350
+ const annuityExclusionState = new Map();
351
+ // A non-qualified purchase dated before the projection start already funded the
352
+ // contract in the past — its premium is assumed already out of the funding
353
+ // account — so the per-year funding transfer below never runs for it. Seed the
354
+ // investment-in-contract directly so exclusion-ratio taxation still recovers the
355
+ // premium instead of treating every payout as fully taxable. (Qualified/QLAC
356
+ // purchases are fully ordinary regardless, so they need no seeding.)
357
+ for (const account of plan.accounts) {
358
+ if (account.type !== 'annuity' || account.purchase?.taxQualification !== 'nonQualified')
359
+ continue;
360
+ if (account.purchase.year >= startYear)
361
+ continue;
362
+ annuityInvestmentInContract.set(account.id, account.purchase.premium);
363
+ }
364
+ // HECM lines of credit (annuity-pension-and-home-equity, step 4), keyed by
365
+ // property id. The principal limit and the loan balance both compound at the
366
+ // line's growth rate; available credit is their difference. A sold property
367
+ // repays the loan non-recourse (never more than the proceeds) and closes the
368
+ // line, so a deleted entry means "closed", not "never opened".
369
+ const hecmStates = new Map();
370
+ // Realized wealth-weighted portfolio return applied by the previous year's
371
+ // growth pass (percent). The coordinated HECM draw policy triggers on an
372
+ // actual portfolio loss — not on the raw additive shock, which can be
373
+ // negative in a year the portfolio still gained. 0 before the first year.
374
+ let priorYearPortfolioReturnPct = 0;
375
+ // TIPS income-floor ladders (social-security-bridge-and-tips-ladder). Rungs
376
+ // are solved once from the embedded real-yield curve; per-year cash flows
377
+ // scale with the path's inflation factors — exactly the TIPS indexation
378
+ // (principal and coupons both track CPI). `scale` < 1 when a purchase-year
379
+ // funding account couldn't cover the full quoted cost. A purchase dated
380
+ // before the projection start is assumed already funded (like a seeded
381
+ // annuity premium), so no transfer runs for it.
382
+ const ladderStates = [];
383
+ // Last calendar year anyone is alive: after it, rungs stop maturing and the
384
+ // remaining face is frozen as an estate asset (MC horizons run well past
385
+ // death, and offset-space maturation must not evaporate unmatured principal).
386
+ const ladderLastAliveYear = Math.max(...people.map((p) => dobYear(p) + lifeAgeOf(p)));
387
+ for (const ladder of plan.incomeFloor?.ladders ?? []) {
388
+ // Anchor = the year the rungs exist from: the purchase year, or (already
389
+ // owned) the year before the projection so coupons pay from year one.
390
+ const anchorYear = ladder.purchase ? ladder.purchase.year : startYear - 1;
391
+ const effectiveStartYear = Math.max(ladder.startYear, anchorYear + 1);
392
+ if (ladder.endYear < effectiveStartYear || ladder.annualRealAmount <= 0)
393
+ continue;
394
+ const build = buildLadder({
395
+ annualRealIncome: ladder.annualRealAmount,
396
+ firstPayoutOffset: effectiveStartYear - anchorYear,
397
+ payoutYears: ladder.endYear - effectiveStartYear + 1,
398
+ curve: EMBEDDED_REAL_YIELD_CURVE,
399
+ });
400
+ ladderStates.push({
401
+ id: ladder.id,
402
+ anchorYear,
403
+ rungs: build.rungs,
404
+ costReal: build.totalCost,
405
+ purchase: ladder.purchase,
406
+ scale: 1,
407
+ });
408
+ }
409
+ // Opt-in asset allocation (asset-allocation-and-return-model-v2). Withdrawals
410
+ // and deposits are assumed pro-rata across classes, so only differential class
411
+ // growth moves an account's weights — tracking the weight vector (not class
412
+ // dollars) is exact under that assumption. Accounts without an allocation are
413
+ // untouched (feature-off is unchanged).
414
+ const classParams = resolveAssetClassParams(plan.assumptions.assetClassParams);
415
+ const allocationTrack = new Map();
416
+ for (const state of balances) {
417
+ const policy = accountAllocation(state.account);
418
+ if (policy)
419
+ allocationTrack.set(state.account.id, { policy, weights: targetWeightsAt(policy, startYear) });
420
+ }
421
+ // Permanent-life cash values, grown/interpolated each year; an asset on the
422
+ // balance sheet but held out of withdrawals (no surrender/loan in v1).
423
+ const insuranceCashValues = new Map();
424
+ for (const policy of plan.insurance) {
425
+ if (policy.kind === 'permanentLife')
426
+ insuranceCashValues.set(policy.id, policy.cashValue);
427
+ }
428
+ // Years each LTC policy has paid a benefit, to enforce benefitPeriodYears.
429
+ const ltcBenefitYearsUsed = new Map();
430
+ /** First-year (fixed) 72(t) amortization payment per account id, cached for the series. */
431
+ const seppAmortAmount = new Map();
432
+ // Capital-loss carryforward pool, depleting across years: nets against realized
433
+ // gains first, then up to the annual limit against ordinary income. Entered in
434
+ // today's $ but treated as flat nominal (capital losses never index), so it's
435
+ // not inflation-scaled. @see DOCS/features/taxes.md
436
+ let capitalLossPool = plan.household.capitalLossCarryforward;
437
+ // Roth basis pools (contributions + conversion 5-year clocks) driving the Roth
438
+ // ordering rules. The IRS aggregates an owner's Roth IRAs for ordering, so all
439
+ // of one owner's Roth IRAs share a single pool; employer Roth (401k) accounts
440
+ // stay separate. An omitted contributionBasis means "treat the whole starting
441
+ // balance as seasoned basis" — the penalty-free default.
442
+ const rothPoolKey = (account) => account.kind === 'ira' ? `rothira:${account.ownerPersonId ?? primary.id}` : `roth:${account.id}`;
443
+ const rothBasis = new Map();
444
+ for (const account of plan.accounts) {
445
+ if (account.type !== 'roth')
446
+ continue;
447
+ const key = rothPoolKey(account);
448
+ const startBasis = account.contributionBasis ?? account.balance;
449
+ const existing = rothBasis.get(key);
450
+ if (existing)
451
+ existing.contributionBasis += startBasis;
452
+ else
453
+ rothBasis.set(key, { contributionBasis: startBasis, conversionLayers: [] });
454
+ }
455
+ // HSA medical-expense subledger (account/HSA/fixed-asset depth plan, steps
456
+ // 2–3). Qualified withdrawals from cap-mode HSAs are limited to the
457
+ // household's modeled medical costs each year; with reimburse-later enabled,
458
+ // unreimbursed expenses accumulate in this pool (nominal $) and lift the cap
459
+ // in later years — the "pay out of pocket now, reimburse yourself later"
460
+ // strategy. Legacy HSAs (no withdrawalTreatment) keep v1 behavior exactly.
461
+ const hsaReimburseLaterActive = plan.accounts.some((a) => a.type === 'hsa' && a.withdrawalTreatment === 'capByMedicalExpenses' && a.reimburseLater === true);
462
+ let hsaReimbursablePool = 0;
463
+ // Nondeductible traditional-IRA basis pools (Form 8606 pro-rata, step 5),
464
+ // aggregated per owner across their own (non-inherited) IRAs. Depletes as
465
+ // distributions/conversions return basis.
466
+ const iraBasisByOwner = new Map();
467
+ for (const account of plan.accounts) {
468
+ if (!isAggregatedIra(account))
469
+ continue;
470
+ const basis = account.nondeductibleBasis ?? 0;
471
+ if (basis <= 0)
472
+ continue;
473
+ const ownerId = account.ownerPersonId ?? primary.id;
474
+ iraBasisByOwner.set(ownerId, (iraBasisByOwner.get(ownerId) ?? 0) + basis);
475
+ }
476
+ // Taxable safety-net floor (step 7): a minimum liquid (cash/taxable/vested
477
+ // equity-comp) reserve, in today's dollars, that withdrawals preserve and
478
+ // fill-to-target conversions respect. 0 = off (today's behavior).
479
+ const safetyNetFloorToday = plan.strategies.taxableSafetyNetFloor ?? 0;
480
+ /** Realized MAGI by year; pre-projection years fall back to assumptions.recentAnnualMagi. */
481
+ const magiHistory = new Map();
482
+ const magiFor = (y) => magiHistory.get(y) ?? plan.assumptions.recentAnnualMagi;
483
+ const deposit = (amount) => {
484
+ if (amount <= 0)
485
+ return;
486
+ const target = balances.find((b) => b.account.type === 'cash') ?? balances.find((b) => b.account.type === 'taxable');
487
+ if (!target) {
488
+ warnings.add('Surplus cash had no cash/taxable account to land in; tracked as unassigned (0% growth).');
489
+ unassignedCash += amount;
490
+ return;
491
+ }
492
+ target.balance += amount;
493
+ if (target.account.type === 'taxable' || target.account.type === 'equityComp')
494
+ target.costBasis += amount;
495
+ };
496
+ // Resolve each SS stream's PIA once: entered directly, or derived from the
497
+ // earnings history via the AIME → bend-point engine.
498
+ const resolvedPiaByStreamId = new Map();
499
+ for (const stream of plan.incomes) {
500
+ if (stream.type !== 'socialSecurity')
501
+ continue;
502
+ if (stream.piaMonthly !== null) {
503
+ resolvedPiaByStreamId.set(stream.id, stream.piaMonthly);
504
+ continue;
505
+ }
506
+ if (!stream.earnings || stream.earnings.length === 0) {
507
+ warnings.add('A Social Security stream has no PIA amount and no earnings history; it was skipped.');
508
+ continue;
509
+ }
510
+ const person = personById.get(stream.personId);
511
+ const { y, m, d } = dobParts(person);
512
+ const projection = resolveEarningsProjection(stream.earningsProjection, person.retirementAge);
513
+ const result = computePiaFromEarnings(piaInputFromEarnings(y, m, d, stream.earnings, projection));
514
+ if (isPiaFromEarningsError(result)) {
515
+ warnings.add(`A Social Security earnings history could not be used (${result.code}); the stream was skipped.`);
516
+ continue;
517
+ }
518
+ if (result.usesStandInForFutureTables) {
519
+ warnings.add('PIA from earnings uses stand-in SSA tables for years beyond the published data.');
520
+ }
521
+ resolvedPiaByStreamId.set(stream.id, result.piaMonthly);
522
+ }
523
+ const years = [];
524
+ let depletionYear = null;
525
+ // Spending policy (planning-depth roadmap §4). Under withdrawal-rate or
526
+ // risk-based guardrails the ledger rations the discretionary spending layer
527
+ // path by path and routes flexible goals through a scheduler; fixed-target (or
528
+ // absent) keeps today's behavior. The two modes share the rationing machinery
529
+ // and differ only in the trigger signal: withdrawal-rate compares the current
530
+ // withdrawal rate to the starting rate; risk-based compares the real balance
531
+ // to solver-derived probability-band thresholds (% of the starting portfolio).
532
+ // The running multiplier and starting signal persist across the year loop
533
+ // (path state), so this is set up once per simulation.
534
+ const spendingPolicy = plan.expenses.spendingPolicy;
535
+ const riskBasedGuardrails = spendingPolicy?.mode === 'riskBasedGuardrails';
536
+ const guardrailsActive = spendingPolicy?.mode === 'withdrawalRateGuardrails' || riskBasedGuardrails;
537
+ const guardrailPolicy = {
538
+ mode: riskBasedGuardrails ? 'risk-based' : 'withdrawal-rate',
539
+ upperGuardrailPct: spendingPolicy?.upperGuardrailPct,
540
+ lowerGuardrailPct: spendingPolicy?.lowerGuardrailPct,
541
+ lowerBalanceThresholdPct: spendingPolicy?.lowerBalanceThresholdPct,
542
+ upperBalanceThresholdPct: spendingPolicy?.upperBalanceThresholdPct,
543
+ adjustmentPct: spendingPolicy?.adjustmentPct,
544
+ allowRaisesAboveTarget: spendingPolicy?.allowRaisesAboveTarget,
545
+ };
546
+ // Amortization-based withdrawal (spending-paths & SWR-lenses plan, Goal 2).
547
+ // Under 'abw' the year's lifestyle target is the actual start-of-year
548
+ // portfolio re-amortized over the remaining horizon (engine/spending/abw.ts)
549
+ // instead of baseAnnual × phases; the payment funds through the same
550
+ // tax/withdrawal cascade as every other expense. The horizon and expected
551
+ // real return are resolved once here — presets-don't-drift style — so every
552
+ // year of one simulation amortizes toward the same end age.
553
+ const abwActive = spendingPolicy?.mode === 'abw';
554
+ const abwRealReturnPct = abwActive ? abwExpectedRealReturnPct(spendingPolicy?.abw) : 0;
555
+ const abwTiltPct = abwActive ? (spendingPolicy?.abw?.tiltPct ?? ABW_DEFAULTS.tiltPct) : 0;
556
+ let abwHorizonYear = endYear;
557
+ if (abwActive) {
558
+ const horizonMode = spendingPolicy?.abw?.horizon ?? ABW_DEFAULTS.horizon;
559
+ if (horizonMode === 'survival25' || horizonMode === 'survival10') {
560
+ // Deliberately the unadjusted SSA table (hazard = 1): the ledger never
561
+ // reads questionnaire state, and a health-adjusted percentile pick on
562
+ // Household is provenance on that person's planning age, not a plan-wide
563
+ // mortality override. The UI labels this horizon "unadjusted SSA".
564
+ const pct = horizonMode === 'survival25' ? 25 : 10;
565
+ const partner = people[1];
566
+ const primaryAgeNow = startYear - dobYear(primary);
567
+ const horizonAge = partner
568
+ ? jointSurvivalPercentileAge({ age: primaryAgeNow, sex: primary.sex }, { age: startYear - dobYear(partner), sex: partner.sex }, pct)
569
+ : survivalPercentileAge(primaryAgeNow, primary.sex, pct);
570
+ abwHorizonYear = dobYear(primary) + horizonAge;
571
+ }
572
+ }
573
+ const goalScheduler = guardrailsActive
574
+ ? createGoalScheduler(plan.expenses.oneTimeGoals.map((g, i) => toSchedulableGoal(g, i)))
575
+ : null;
576
+ let discretionaryMultiplier = 1;
577
+ let startingWithdrawalRate = null;
578
+ let startingRealPortfolio = null;
579
+ // Earnings-test FRA credit: months of benefit fully withheld before FRA are
580
+ // credited back at FRA by recomputing the benefit as if claimed that many
581
+ // months later. Accumulated across the pre-FRA years (persists across the loop).
582
+ const withheldMonthsByPerson = new Map();
583
+ const creditedClaimAgeFor = (person, claimAge, ageAttained, capMonths) => {
584
+ const originalMonths = claimAgeTotalMonths(claimAge);
585
+ if (originalMonths >= capMonths || ageAttained < Math.floor(capMonths / 12))
586
+ return claimAge;
587
+ const credited = Math.min(capMonths, originalMonths + (withheldMonthsByPerson.get(person.id) ?? 0));
588
+ return claimAgeFromTotalMonths(credited);
589
+ };
590
+ for (let year = startYear; year <= endYear; year++) {
591
+ const inflFactor = inflFactorFrom(startYear, year);
592
+ const { pack, isStandIn } = packForYear(year);
593
+ const limitGrowth = limitScale(pack, isStandIn, year);
594
+ // Prior Dec 31 balances (RMD base) — captured before this year's flows.
595
+ const startOfYearBalance = new Map(balances.map((b) => [b.account.id, b.balance]));
596
+ // --- annual rebalance to target (start-of-year trade) -------------------
597
+ // Allocated accounts trade drifted weights back to this year's glidepath
598
+ // target. Taxable sells realize gains pro-rata through the same basis-ratio
599
+ // machinery as withdrawals (basis rises by the realized gain: sold basis
600
+ // leaves, the reinvested proceeds enter at market); traditional/Roth/HSA
601
+ // rebalances are tax-free. rebalancing: 'none' opts out — weights drift.
602
+ let rebalanceRealizedGains = 0;
603
+ if (year > startYear) {
604
+ for (const state of balances) {
605
+ const track = allocationTrack.get(state.account.id);
606
+ if (!track || track.policy.rebalancing === 'none')
607
+ continue;
608
+ const target = targetWeightsAt(track.policy, year);
609
+ const turnover = rebalanceTurnoverFraction(track.weights, target);
610
+ if (turnover > 1e-9 && state.account.type === 'taxable' && state.balance > 0) {
611
+ const sellAmount = turnover * state.balance;
612
+ const basisRatio = Math.min(1, state.costBasis / state.balance);
613
+ const gain = sellAmount * (1 - basisRatio);
614
+ rebalanceRealizedGains += gain;
615
+ state.costBasis += gain;
616
+ }
617
+ track.weights = target;
618
+ }
619
+ }
620
+ // --- annuity purchase funding (guaranteed-income-and-estate-depth) -------
621
+ // A purchased annuity trades a premium out of a funding account in its
622
+ // purchase year. The move is a transfer, not spending: cash and qualified
623
+ // (traditional) sources move at book value; a taxable/equity-comp source
624
+ // realizes gains pro-rata like any sale, folded into this year's realized
625
+ // gains, and the premium leaves the account. A qualified premium leaving a
626
+ // traditional balance shrinks future RMDs automatically. A QLAC premium is
627
+ // held to the statutory cap. The premium actually funded becomes the
628
+ // contract's investment for the non-qualified exclusion ratio.
629
+ for (const account of plan.accounts) {
630
+ if (account.type !== 'annuity' || !account.purchase || account.purchase.year !== year)
631
+ continue;
632
+ const funding = balances.find((b) => b.account.id === account.purchase.fundingAccountId);
633
+ if (!funding)
634
+ continue;
635
+ let premium = account.purchase.premium;
636
+ const qlacCap = pack.annuities.qlacPremiumCap * limitGrowth;
637
+ if (account.purchase.qlac && premium > qlacCap) {
638
+ premium = qlacCap;
639
+ warnings.add(`A QLAC premium above the $${Math.round(qlacCap).toLocaleString()} cap was reduced to the cap (the excess is not QLAC-eligible).`);
640
+ }
641
+ // Only spendable funds can pay the premium: cliff-vesting equity comp with
642
+ // a future vest date is not liquidatable yet, so it cannot fund a purchase
643
+ // (mirrors the withdrawal planner's isSpendableInYear gate).
644
+ const funded = Math.min(premium, spendableBalance(funding, year));
645
+ if (funded < premium - EPSILON) {
646
+ warnings.add('An annuity premium exceeded its funding account balance and was reduced to the available amount.');
647
+ }
648
+ if ((funding.account.type === 'taxable' || funding.account.type === 'equityComp') && funding.balance > 0) {
649
+ const basisRatio = Math.min(1, funding.costBasis / funding.balance);
650
+ rebalanceRealizedGains += funded * (1 - basisRatio);
651
+ funding.costBasis = Math.max(0, funding.costBasis - funded * basisRatio);
652
+ }
653
+ funding.balance -= funded;
654
+ annuityInvestmentInContract.set(account.id, (annuityInvestmentInContract.get(account.id) ?? 0) + funded);
655
+ }
656
+ // --- pension lump-sum rollover (annuity-pension-and-home-equity, step 3) -
657
+ // An elected lump sum commutes the pension: the offer amount arrives as a
658
+ // tax-free direct rollover into the named traditional account in the
659
+ // election year (external plan money — nothing leaves another account),
660
+ // and the pension income stream never pays (skipped in the income block).
661
+ for (const account of plan.accounts) {
662
+ if (account.type !== 'pension' || !account.lumpSumElection || !account.lumpSumOffer)
663
+ continue;
664
+ if (account.lumpSumOffer.electionYear !== year)
665
+ continue;
666
+ const target = balances.find((b) => b.account.id === account.lumpSumElection.rolloverAccountId);
667
+ if (!target)
668
+ continue;
669
+ target.balance += account.lumpSumOffer.amount;
670
+ }
671
+ // --- HECM line open (annuity-pension-and-home-equity, step 4) -----------
672
+ // The initial principal limit is the user's quoted percent of the home's
673
+ // value at open (or the pack's published PLF approximation by the youngest
674
+ // borrower's age); financed upfront costs start the loan balance. A line
675
+ // dated before the projection opens in the first projection year at
676
+ // today's value (its pre-projection growth is not reconstructed).
677
+ for (const account of plan.accounts) {
678
+ if (account.type !== 'property' || !account.hecm)
679
+ continue;
680
+ if (year !== Math.max(account.hecm.openYear, startYear))
681
+ continue;
682
+ if (hecmStates.has(account.id))
683
+ continue;
684
+ const value = propertyValues.get(account.id) ?? 0;
685
+ if (value <= 0)
686
+ continue;
687
+ const youngestAge = Math.min(...people.map((p) => year - dobYear(p)));
688
+ if (youngestAge < 62) {
689
+ warnings.add('A HECM line of credit was modeled before the youngest borrower turns 62 (real HECMs require age 62+).');
690
+ }
691
+ const plfPct = account.hecm.principalLimitPct ?? hecmPrincipalLimitFactorPct(pack, youngestAge);
692
+ hecmStates.set(account.id, {
693
+ principalLimit: (plfPct / 100) * value,
694
+ loanBalance: ((account.hecm.upfrontCostPct ?? 0) / 100) * value,
695
+ });
696
+ }
697
+ // --- TIPS-ladder purchase funding ---------------------------------------
698
+ // Same transfer semantics as an annuity premium: the quoted real cost
699
+ // (inflated to the purchase year) leaves the funding account at book value
700
+ // for cash, realizing gains pro-rata for taxable/equity-comp. A partial
701
+ // fill scales every rung down so the ladder delivers exactly what the
702
+ // money bought.
703
+ for (const ls of ladderStates) {
704
+ if (!ls.purchase || ls.purchase.year !== year)
705
+ continue;
706
+ const funding = balances.find((b) => b.account.id === ls.purchase.fundingAccountId);
707
+ if (!funding)
708
+ continue;
709
+ const cost = ls.costReal * inflFactor;
710
+ const funded = Math.min(cost, spendableBalance(funding, year));
711
+ if (funded < cost - EPSILON) {
712
+ ls.scale = cost > 0 ? funded / cost : 0;
713
+ warnings.add('A TIPS ladder purchase exceeded its funding account balance; the ladder was scaled down to what the available money buys.');
714
+ }
715
+ if ((funding.account.type === 'taxable' || funding.account.type === 'equityComp') && funding.balance > 0) {
716
+ const basisRatio = Math.min(1, funding.costBasis / funding.balance);
717
+ rebalanceRealizedGains += funded * (1 - basisRatio);
718
+ funding.costBasis = Math.max(0, funding.costBasis - funded * basisRatio);
719
+ }
720
+ funding.balance -= funded;
721
+ }
722
+ const peopleStates = people.map((p) => {
723
+ const ageAttained = year - dobYear(p);
724
+ return { personId: p.id, ageAttained, alive: ageAttained <= lifeAgeOf(p) };
725
+ });
726
+ const stateOf = (personId) => peopleStates.find((s) => s.personId === personId);
727
+ const anyAlive = peopleStates.some((s) => s.alive);
728
+ const aliveCount = peopleStates.filter((s) => s.alive).length;
729
+ const filingStatusForYear = filingStatusFor(year, aliveCount);
730
+ const taxFilingStatusForYear = taxParameterFilingStatus(filingStatusForYear);
731
+ // --- income ----------------------------------------------------------
732
+ const incomes = {
733
+ wages: 0,
734
+ socialSecurity: 0,
735
+ pension: 0,
736
+ annuity: 0,
737
+ tipsLadder: 0,
738
+ recurring: 0,
739
+ oneTime: 0,
740
+ taxableInterest: 0,
741
+ ordinaryDividends: 0,
742
+ qualifiedDividends: 0,
743
+ taxableYield: 0,
744
+ total: 0,
745
+ };
746
+ let ordinaryIncome = 0;
747
+ /** Subsets of income eligible for state retirement-income exclusions. */
748
+ let privateRetirementOrdinary = 0;
749
+ let publicPensionOrdinary = 0;
750
+ let oneTimeGains = 0;
751
+ let taxableYieldReinvested = 0;
752
+ const taxableYieldByAccountId = new Map();
753
+ const wagesByPerson = new Map();
754
+ for (const state of balances) {
755
+ if (state.account.type !== 'taxable')
756
+ continue;
757
+ const startBalance = Math.max(0, startOfYearBalance.get(state.account.id) ?? state.balance);
758
+ if (startBalance <= 0)
759
+ continue;
760
+ // An allocated brokerage account derives its yield fields from the class
761
+ // blend at this year's weights (step 2 of the allocation plan); explicit
762
+ // account-level fields still override the blend.
763
+ const track = allocationTrack.get(state.account.id);
764
+ const blendedYield = track ? blendedTaxableYield(track.weights, classParams) : null;
765
+ const interestYieldPct = Math.max(0, state.account.interestYieldPct ?? blendedYield?.interestYieldPct ?? 0);
766
+ const dividendYieldPct = Math.max(0, state.account.dividendYieldPct ?? blendedYield?.dividendYieldPct ?? 0);
767
+ const totalYieldPct = interestYieldPct + dividendYieldPct;
768
+ if (totalYieldPct <= 0)
769
+ continue;
770
+ const interest = startBalance * (interestYieldPct / 100);
771
+ const dividends = startBalance * (dividendYieldPct / 100);
772
+ const qualified = dividends * Math.min(1, Math.max(0, state.account.qualifiedRatio ?? blendedYield?.qualifiedRatio ?? 0.85));
773
+ const ordinaryDividends = dividends - qualified;
774
+ const gross = interest + dividends;
775
+ incomes.taxableInterest += interest;
776
+ incomes.ordinaryDividends += ordinaryDividends;
777
+ incomes.qualifiedDividends += qualified;
778
+ incomes.taxableYield += gross;
779
+ ordinaryIncome += interest + ordinaryDividends;
780
+ const reinvest = state.account.reinvestDividends ?? true;
781
+ if (reinvest)
782
+ taxableYieldReinvested += gross;
783
+ taxableYieldByAccountId.set(state.account.id, { gross, totalYieldPct, reinvest });
784
+ }
785
+ // Pass 1: wages (must precede Social Security for the earnings test).
786
+ for (const stream of plan.incomes) {
787
+ if (stream.type !== 'wages')
788
+ continue;
789
+ const person = personById.get(stream.personId);
790
+ const s = stateOf(stream.personId);
791
+ const stopAge = stream.endAge ?? person.retirementAge;
792
+ if (!s.alive || (stopAge !== null && s.ageAttained >= stopAge))
793
+ continue;
794
+ const raiseFactor = Math.pow(1 + (stream.realGrowthPct ?? 0) / 100, year - startYear);
795
+ const amount = stream.annualGross * raiseFactor * inflFactor;
796
+ incomes.wages += amount;
797
+ ordinaryIncome += amount;
798
+ wagesByPerson.set(stream.personId, (wagesByPerson.get(stream.personId) ?? 0) + amount);
799
+ }
800
+ // Pass 2: other non-SS streams.
801
+ for (const stream of plan.incomes) {
802
+ if (stream.type === 'recurring') {
803
+ if ((stream.startYear !== null && year < stream.startYear) || (stream.endYear !== null && year > stream.endYear))
804
+ continue;
805
+ if (!anyAlive)
806
+ continue;
807
+ const amount = stream.annualAmount * (stream.inflationAdjusted ? inflFactor : 1);
808
+ incomes.recurring += amount;
809
+ if (stream.taxTreatment === 'ordinary')
810
+ ordinaryIncome += amount;
811
+ }
812
+ else if (stream.type === 'oneTime') {
813
+ if (stream.year !== year)
814
+ continue;
815
+ incomes.oneTime += stream.amount;
816
+ if (stream.taxTreatment === 'ordinary')
817
+ ordinaryIncome += stream.amount;
818
+ if (stream.taxTreatment === 'capitalGain')
819
+ oneTimeGains += stream.amount;
820
+ }
821
+ }
822
+ // Pass 3: Social Security. Benefits are computed for everyone (a deceased
823
+ // spouse's hypothetical benefit drives the survivor step-up), then the
824
+ // earnings test withholds from living workers, then survivors step up to
825
+ // max(own, deceased's) — the v1 couples simplification of survivor rules.
826
+ const ssColaFactor = plan.assumptions.ssCola.mode === 'matchInflation'
827
+ ? inflFactorFrom(startYear, year)
828
+ : Math.pow(1 + plan.assumptions.ssCola.annualPct / 100, year - startYear);
829
+ const ssHaircutFactor = plan.assumptions.ssHaircut && year >= plan.assumptions.ssHaircut.fromYear
830
+ ? 1 - plan.assumptions.ssHaircut.cutPct / 100
831
+ : 1;
832
+ const ssOwnByPerson = new Map();
833
+ const ssActualMonthlyByPerson = new Map();
834
+ /** PIA + claim age per SS-claiming person, for the spousal top-up below. */
835
+ const ssStreamByPerson = new Map();
836
+ /** Per-person SSDI info this year (onset age + the pre-SGA annual benefit), for SGA gating + reporting. */
837
+ const ssdiByPerson = new Map();
838
+ for (const stream of plan.incomes) {
839
+ if (stream.type !== 'socialSecurity')
840
+ continue;
841
+ const pia = resolvedPiaByStreamId.get(stream.id);
842
+ if (pia === undefined)
843
+ continue; // warned during resolution
844
+ ssStreamByPerson.set(stream.personId, { pia, claimAge: stream.claimAge });
845
+ const person = personById.get(stream.personId);
846
+ const s = stateOf(stream.personId);
847
+ const { y, m, d } = dobParts(person);
848
+ const fra = fraForBirthYear(effectiveBirthYear(y, m, d));
849
+ // SSDI path: a disabled worker receives their full PIA (no early-retirement
850
+ // reduction) from the onset age, gated by SGA pre-FRA, converting to the
851
+ // retirement benefit at FRA at the same dollar amount (no delayed credits).
852
+ // SSDI cannot start at/after FRA (it would have already converted), so an
853
+ // onsetAge >= FRA is treated as invalid — fall through to normal retirement.
854
+ const onsetAge = stream.disability?.onsetAge;
855
+ if (onsetAge !== undefined && onsetAge < fra.years) {
856
+ if (s.ageAttained >= onsetAge) {
857
+ const monthly = ssdiMonthlyBenefit(pia);
858
+ const annual = monthly * 12 * ssColaFactor * ssHaircutFactor;
859
+ ssOwnByPerson.set(stream.personId, (ssOwnByPerson.get(stream.personId) ?? 0) + annual);
860
+ ssActualMonthlyByPerson.set(stream.personId, (ssActualMonthlyByPerson.get(stream.personId) ?? 0) + monthly);
861
+ ssdiByPerson.set(stream.personId, { onsetAge, benefit: annual, fraYears: fra.years });
862
+ }
863
+ continue; // SSDI replaces the retirement-claim path for this stream
864
+ }
865
+ const payableMonths = payableMonthsAtAge(s.ageAttained, stream.claimAge);
866
+ if (payableMonths <= 0)
867
+ continue;
868
+ // From FRA on, credit any months the earnings test withheld earlier by
869
+ // treating the benefit as if claimed that many months later (capped at FRA).
870
+ const fraMonths = fraTotalMonths(fra);
871
+ const claimForFactor = creditedClaimAgeFor(person, stream.claimAge, s.ageAttained, fraMonths);
872
+ const factor = claimFactor(y, m, d, claimForFactor);
873
+ const monthly = pia * factor;
874
+ let annual = monthly * payableMonths * ssColaFactor;
875
+ annual *= ssHaircutFactor;
876
+ ssOwnByPerson.set(stream.personId, (ssOwnByPerson.get(stream.personId) ?? 0) + annual);
877
+ ssActualMonthlyByPerson.set(stream.personId, (ssActualMonthlyByPerson.get(stream.personId) ?? 0) + monthly);
878
+ }
879
+ // Marital-history menu: a divorced-spousal or survivor benefit on a *former*
880
+ // spouse's record. A person receives the larger of their own benefit and the
881
+ // best eligible such benefit, at their claim age. Divorced-spousal needs a
882
+ // currently-unmarried claimant; survivor is governed by remarriage rules.
883
+ // Runs before the earnings test so that benefit is withheld too (SSA applies
884
+ // the earnings test to dependent/survivor benefits, not just retirement).
885
+ const householdIsSingle = people.length === 1;
886
+ for (const stream of plan.incomes) {
887
+ if (stream.type !== 'socialSecurity')
888
+ continue;
889
+ if (!stream.formerSpouses || stream.formerSpouses.length === 0)
890
+ continue;
891
+ const s = stateOf(stream.personId);
892
+ const payableMonths = payableMonthsAtAge(s.ageAttained, stream.claimAge);
893
+ if (!s.alive || payableMonths <= 0)
894
+ continue;
895
+ const claimant = personById.get(stream.personId);
896
+ const { y, m, d } = dobParts(claimant);
897
+ const retirementFraMonths = fraTotalMonths(fraForBirthYear(effectiveBirthYear(y, m, d)));
898
+ const survivorFraMonths = fraTotalMonths(survivorFraForBirthYear(effectiveBirthYear(y, m, d)));
899
+ const best = bestMaritalBenefit(stream.formerSpouses, {
900
+ claimantDob: { year: y, month: m, day: d },
901
+ claimantClaimAge: creditedClaimAgeFor(claimant, stream.claimAge, s.ageAttained, retirementFraMonths),
902
+ claimantSurvivorClaimAge: creditedClaimAgeFor(claimant, stream.claimAge, s.ageAttained, survivorFraMonths),
903
+ claimantAge: s.ageAttained,
904
+ year,
905
+ claimantIsSingle: householdIsSingle,
906
+ });
907
+ if (best) {
908
+ const annual = best.monthly * payableMonths * ssColaFactor * ssHaircutFactor;
909
+ if (annual > (ssOwnByPerson.get(stream.personId) ?? 0))
910
+ ssOwnByPerson.set(stream.personId, annual);
911
+ }
912
+ }
913
+ // Spousal top-up: while both spouses are alive and both have claimed, the
914
+ // lower earner receives max(own, 50% of the higher earner's PIA reduced for
915
+ // the lower earner's claim age). Runs before the earnings test so auxiliary
916
+ // benefits can be withheld, and caps the current-spouse auxiliary to the room
917
+ // left under the worker's retirement/survivor family maximum.
918
+ if (people.length === 2) {
919
+ const [a, b] = people;
920
+ const aSs = ssStreamByPerson.get(a.id);
921
+ const bSs = ssStreamByPerson.get(b.id);
922
+ if (aSs && bSs) {
923
+ const higher = aSs.pia >= bSs.pia ? { p: a, ss: aSs } : { p: b, ss: bSs };
924
+ const lower = aSs.pia >= bSs.pia ? { p: b, ss: bSs } : { p: a, ss: aSs };
925
+ const lowerState = stateOf(lower.p.id);
926
+ const higherState = stateOf(higher.p.id);
927
+ const lowerPayableMonths = payableMonthsAtAge(lowerState.ageAttained, lower.ss.claimAge);
928
+ const higherPayableMonths = payableMonthsAtAge(higherState.ageAttained, higher.ss.claimAge);
929
+ const spousalPayableMonths = Math.min(lowerPayableMonths, higherPayableMonths);
930
+ if (lowerState.alive && higherState.alive && spousalPayableMonths > 0) {
931
+ const { y, m, d } = dobParts(lower.p);
932
+ const lowerFraMonths = fraTotalMonths(fraForBirthYear(effectiveBirthYear(y, m, d)));
933
+ const spousalClaimAge = creditedClaimAgeFor(lower.p, lower.ss.claimAge, lowerState.ageAttained, lowerFraMonths);
934
+ const rawSpousalMonthly = 0.5 * higher.ss.pia * spousalBenefitFactor(y, m, d, spousalClaimAge);
935
+ const higherDob = dobParts(higher.p);
936
+ const workerActualMonthly = ssActualMonthlyByPerson.get(higher.p.id) ??
937
+ higher.ss.pia *
938
+ claimFactor(higherDob.y, higherDob.m, higherDob.d, creditedClaimAgeFor(higher.p, higher.ss.claimAge, higherState.ageAttained, fraTotalMonths(fraForBirthYear(effectiveBirthYear(higherDob.y, higherDob.m, higherDob.d)))));
939
+ // Only the auxiliary excess (spousal rate above the lower earner's own
940
+ // benefit) is paid on the higher earner's record, so only that excess is
941
+ // subject to the worker's family maximum. The lower earner's own benefit
942
+ // is on their own record and is preserved, then the capped excess is added.
943
+ const lowerOwnMonthly = ssActualMonthlyByPerson.get(lower.p.id) ?? 0;
944
+ const excessSpousalMonthly = Math.max(0, rawSpousalMonthly - lowerOwnMonthly);
945
+ const cappedExcessMonthly = capAuxiliaryForFamilyMaximum({
946
+ workerPiaMonthly: higher.ss.pia,
947
+ workerActualMonthly,
948
+ workerDob: { year: higherDob.y, month: higherDob.m, day: higherDob.d },
949
+ auxiliaryMonthly: excessSpousalMonthly,
950
+ });
951
+ const spousalTotalMonthly = lowerOwnMonthly + cappedExcessMonthly;
952
+ const spousalAnnual = spousalTotalMonthly * spousalPayableMonths * ssColaFactor * ssHaircutFactor;
953
+ const own = ssOwnByPerson.get(lower.p.id) ?? 0;
954
+ if (spousalAnnual > own)
955
+ ssOwnByPerson.set(lower.p.id, spousalAnnual);
956
+ }
957
+ }
958
+ }
959
+ // Survivor step-up before the earnings test, then the withholding pass below
960
+ // can reduce survivor benefits for a working survivor before FRA. The
961
+ // survivor keeps the larger of their own benefit and the deceased's benefit,
962
+ // computed with full precision: the survivor base is the deceased's actual
963
+ // monthly benefit, RIB-LIM floors it at 82.5% of the deceased's PIA when the
964
+ // deceased claimed early, and the early-claim widow(er) reduction applies to
965
+ // the survivor's credited claim age.
966
+ if (people.length === 2) {
967
+ const [a, b] = people;
968
+ for (const [deceased, survivor] of [
969
+ [a, b],
970
+ [b, a],
971
+ ]) {
972
+ const survivorState = stateOf(survivor.id);
973
+ if (stateOf(deceased.id).alive || !survivorState.alive)
974
+ continue;
975
+ const survivorStream = ssStreamByPerson.get(survivor.id);
976
+ const deceasedPia = ssStreamByPerson.get(deceased.id)?.pia;
977
+ const deceasedActualMonthly = ssActualMonthlyByPerson.get(deceased.id) ?? 0;
978
+ if (!survivorStream || deceasedPia === undefined || deceasedActualMonthly <= 0)
979
+ continue;
980
+ const payableMonths = payableMonthsAtAge(survivorState.ageAttained, survivorStream.claimAge);
981
+ if (payableMonths <= 0)
982
+ continue;
983
+ const ownBenefit = ssOwnByPerson.get(survivor.id) ?? 0;
984
+ const { y, m, d } = dobParts(survivor);
985
+ const survivorFraMonths = fraTotalMonths(survivorFraForBirthYear(effectiveBirthYear(y, m, d)));
986
+ const survivorClaimAge = creditedClaimAgeFor(survivor, survivorStream.claimAge, survivorState.ageAttained, survivorFraMonths);
987
+ const survivorAnnual = survivorBenefitMonthly({
988
+ deceasedPiaMonthly: deceasedPia,
989
+ deceasedActualMonthly,
990
+ survivorClaimAge,
991
+ survivorFraMonths,
992
+ }) *
993
+ payableMonths *
994
+ ssColaFactor *
995
+ ssHaircutFactor;
996
+ if (survivorAnnual > ownBenefit)
997
+ ssOwnByPerson.set(survivor.id, survivorAnnual);
998
+ }
999
+ }
1000
+ // Earnings test: claiming before FRA while working withholds benefits
1001
+ // ($1 per $2 below FRA; $1 per $3 in the FRA calendar year — annual
1002
+ // approximation). Withheld whole months accumulate and are credited back at
1003
+ // FRA above (the benefit is recomputed as if claimed that many months later).
1004
+ // SSDI recipients are gated by Substantial Gainful Activity instead (SSA
1005
+ // replaces the retirement earnings test with SGA for disabled workers).
1006
+ let ssEarningsTestWithheld = 0;
1007
+ let ssdiPaid = 0;
1008
+ for (const [personId, benefit] of ssOwnByPerson) {
1009
+ const s = stateOf(personId);
1010
+ if (!s.alive || benefit <= 0)
1011
+ continue;
1012
+ const ssdi = ssdiByPerson.get(personId);
1013
+ if (ssdi) {
1014
+ // SSDI recipient: SGA gates the pre-FRA window only (post-FRA it has
1015
+ // converted to retirement; before onset no benefit is paid). No ARF.
1016
+ let paid = benefit;
1017
+ if (inSsdiWindow(s.ageAttained, ssdi.onsetAge, ssdi.fraYears)) {
1018
+ const wages = wagesByPerson.get(personId) ?? 0;
1019
+ const annualSga = pack.socialSecurity.sgaMonthlyNonBlind * 12 * limitGrowth;
1020
+ if (wages > 0 && ssdiSuspendedBySga(wages, annualSga)) {
1021
+ paid = 0;
1022
+ ssOwnByPerson.set(personId, 0);
1023
+ warnings.add('Earnings above Substantial Gainful Activity (SGA) suspended Social Security disability (SSDI) for a working year.');
1024
+ }
1025
+ }
1026
+ ssdiPaid += paid;
1027
+ continue; // no retirement earnings test for SSDI recipients
1028
+ }
1029
+ const wages = wagesByPerson.get(personId) ?? 0;
1030
+ if (wages <= 0)
1031
+ continue;
1032
+ const person = personById.get(personId);
1033
+ const { y, m, d } = dobParts(person);
1034
+ const fraYears = fraForBirthYear(effectiveBirthYear(y, m, d)).years;
1035
+ let withheld = 0;
1036
+ if (s.ageAttained < fraYears) {
1037
+ withheld = Math.max(0, (wages - pack.socialSecurity.earningsTestBelowFraAnnual * limitGrowth) / 2);
1038
+ }
1039
+ else if (s.ageAttained === fraYears) {
1040
+ withheld = Math.max(0, (wages - pack.socialSecurity.earningsTestFraYearAnnual * limitGrowth) / 3);
1041
+ }
1042
+ withheld = Math.min(withheld, benefit);
1043
+ if (withheld > 0) {
1044
+ ssOwnByPerson.set(personId, benefit - withheld);
1045
+ ssEarningsTestWithheld += withheld;
1046
+ // Whole months of benefit withheld this year (annual approximation),
1047
+ // credited back at FRA. COLA cancels in the ratio. Capped at the months
1048
+ // actually payable this year — the first claim year is prorated when the
1049
+ // claim starts mid-year, so it has fewer than 12 payable months.
1050
+ const claimAge = ssStreamByPerson.get(personId)?.claimAge;
1051
+ const payableMonths = claimAge ? payableMonthsAtAge(s.ageAttained, claimAge) : 12;
1052
+ const monthsWithheld = Math.min(payableMonths, Math.round((withheld / benefit) * payableMonths));
1053
+ withheldMonthsByPerson.set(personId, (withheldMonthsByPerson.get(personId) ?? 0) + monthsWithheld);
1054
+ warnings.add('The earnings test withheld benefits for working early claimants; withheld months are credited back at full retirement age (annual approximation).');
1055
+ }
1056
+ }
1057
+ // Sum the living household's post-withholding Social Security benefits.
1058
+ for (const [personId, benefit] of ssOwnByPerson) {
1059
+ if (stateOf(personId).alive)
1060
+ incomes.socialSecurity += benefit;
1061
+ }
1062
+ for (const account of plan.accounts) {
1063
+ if (account.type === 'pension' || account.type === 'annuity') {
1064
+ // A commuted pension (lump-sum election) stops paying once the
1065
+ // election takes effect — the offer amount rolls over in the election
1066
+ // year instead. A pension already in pay before a later election year
1067
+ // keeps its normal payments until then.
1068
+ if (account.type === 'pension' &&
1069
+ account.lumpSumElection &&
1070
+ account.lumpSumOffer &&
1071
+ year >= account.lumpSumOffer.electionYear) {
1072
+ continue;
1073
+ }
1074
+ const ownerId = account.ownerPersonId ?? primary.id;
1075
+ const owner = personById.get(ownerId);
1076
+ const ownerState = stateOf(ownerId);
1077
+ const startCalendarYear = dobYear(owner) + account.startAge;
1078
+ if (year < startCalendarYear)
1079
+ continue;
1080
+ // A purchased annuity cannot pay before its premium is funded — the
1081
+ // contract begins in the purchase year. Guard against a startAge that
1082
+ // would otherwise pay (and cache an investment=0 exclusion state that
1083
+ // stays fully taxable) in years before the premium is withdrawn.
1084
+ if (account.type === 'annuity' && account.purchase && year < account.purchase.year)
1085
+ continue;
1086
+ const yearsSinceStart = year - startCalendarYear;
1087
+ const grown = account.monthlyAmount * 12 * Math.pow(1 + account.colaPct / 100, yearsSinceStart);
1088
+ if (account.type === 'annuity') {
1089
+ // Payout form (life-only / period-certain / joint & survivor) sets
1090
+ // how much of the full payment is paid this year; life-only (the
1091
+ // default) pays only while the owner is alive, exactly as before.
1092
+ const otherState = peopleStates.find((s) => s.personId !== ownerId);
1093
+ const paidFraction = annuityPayoutFraction(annuityPayoutForm(account), {
1094
+ ownerAlive: ownerState.alive,
1095
+ otherAlive: otherState?.alive ?? false,
1096
+ anyAlive,
1097
+ yearsSinceStart,
1098
+ });
1099
+ if (paidFraction <= 0)
1100
+ continue;
1101
+ const paid = grown * paidFraction;
1102
+ incomes.annuity += paid;
1103
+ // Taxable portion of the payment:
1104
+ // - qualified purchase → fully ordinary (pre-tax dollars funded it);
1105
+ // - non-qualified purchase → IRS Pub 939 exclusion ratio, so a fixed
1106
+ // share of each payment is a tax-free return of the premium until
1107
+ // the whole investment has been recovered, then fully taxable
1108
+ // (the ratio reflects the payout form; a survivor/beneficiary
1109
+ // continues the same excludable share);
1110
+ // - no purchase (already-owned stream) → the entered taxablePct.
1111
+ let annuityTaxable;
1112
+ if (account.purchase?.taxQualification === 'qualified') {
1113
+ annuityTaxable = paid;
1114
+ }
1115
+ else if (account.purchase) {
1116
+ let ex = annuityExclusionState.get(account.id);
1117
+ if (!ex) {
1118
+ const investment = annuityInvestmentInContract.get(account.id) ?? 0;
1119
+ const jointAnnuitant = plan.household.people.find((p) => p.id !== ownerId);
1120
+ // Expected return = full annual payment × the form's multiple
1121
+ // (the multiple already weights any reduced survivor share).
1122
+ const expectedReturn = grown * annuityExclusionMultiple(pack, account, owner, jointAnnuitant);
1123
+ const ratio = expectedReturn > 0 ? Math.min(1, investment / expectedReturn) : 0;
1124
+ ex = { ratio, remaining: investment };
1125
+ annuityExclusionState.set(account.id, ex);
1126
+ }
1127
+ const excludable = Math.min(paid * ex.ratio, ex.remaining);
1128
+ ex.remaining -= excludable;
1129
+ annuityTaxable = paid - excludable;
1130
+ }
1131
+ else {
1132
+ annuityTaxable = paid * (account.taxablePct / 100);
1133
+ }
1134
+ ordinaryIncome += annuityTaxable;
1135
+ privateRetirementOrdinary += annuityTaxable;
1136
+ }
1137
+ else {
1138
+ const survivor = peopleStates.find((s) => s.personId !== ownerId && s.alive);
1139
+ // Survivor benefit requires payments to have started before the owner died.
1140
+ const ownerStartedBeforeDeath = lifeAgeOf(owner) >= account.startAge;
1141
+ if (ownerState.alive) {
1142
+ incomes.pension += grown;
1143
+ ordinaryIncome += grown;
1144
+ if ((account.source ?? 'private') === 'public')
1145
+ publicPensionOrdinary += grown;
1146
+ else
1147
+ privateRetirementOrdinary += grown;
1148
+ }
1149
+ else if (survivor && ownerStartedBeforeDeath) {
1150
+ const amount = grown * (account.survivorPct / 100);
1151
+ incomes.pension += amount;
1152
+ ordinaryIncome += amount;
1153
+ if ((account.source ?? 'private') === 'public')
1154
+ publicPensionOrdinary += amount;
1155
+ else
1156
+ privateRetirementOrdinary += amount;
1157
+ }
1158
+ }
1159
+ }
1160
+ }
1161
+ // --- TIPS-ladder cash flows ---------------------------------------------
1162
+ // Coupons + maturing principal are cash income; the taxable amount is the
1163
+ // coupons plus this year's inflation accretion on the outstanding face
1164
+ // (the phantom-income OID a taxable TIPS holder reports) — maturing
1165
+ // principal itself is a tax-free return of already-taxed dollars. Federal
1166
+ // ordinary income (incl. NIIT); state-exempt as U.S. government interest.
1167
+ let ladderTaxableInterest = 0;
1168
+ let ladderValueTotal = 0;
1169
+ for (const ls of ladderStates) {
1170
+ const offset = year - ls.anchorYear;
1171
+ if (offset < 1) {
1172
+ // Purchase year (offset 0): the rungs are owned — no flows yet, but
1173
+ // their full face rides in net worth so the transfer is value-neutral.
1174
+ if (ls.purchase && year >= ls.purchase.year) {
1175
+ ladderValueTotal += ladderRemainingFace(ls.rungs, 0) * ls.scale * inflFactor;
1176
+ }
1177
+ continue;
1178
+ }
1179
+ if (anyAlive) {
1180
+ const flows = ladderRealFlowsAtOffset(ls.rungs, offset);
1181
+ const cash = (flows.coupons + flows.maturingPrincipal) * ls.scale * inflFactor;
1182
+ const prevInflFactor = inflFactorFrom(startYear, year - 1);
1183
+ const accretion = flows.outstandingFace * ls.scale * Math.max(0, inflFactor - prevInflFactor);
1184
+ const taxable = flows.coupons * ls.scale * inflFactor + accretion;
1185
+ incomes.tipsLadder += cash;
1186
+ ordinaryIncome += taxable;
1187
+ ladderTaxableInterest += taxable;
1188
+ ladderValueTotal += ladderRemainingFace(ls.rungs, offset) * ls.scale * inflFactor;
1189
+ }
1190
+ else {
1191
+ // No one alive: rungs stop maturing — freeze the remaining face as of
1192
+ // the last living year (the rung maturing that year already paid cash)
1193
+ // so unmatured principal rides in the estate at its inflation-indexed
1194
+ // book value instead of shrinking as offset-space maturities pass.
1195
+ const lastAliveOffset = Math.max(0, ladderLastAliveYear - ls.anchorYear);
1196
+ ladderValueTotal += ladderRemainingFace(ls.rungs, lastAliveOffset) * ls.scale * inflFactor;
1197
+ }
1198
+ }
1199
+ incomes.total =
1200
+ incomes.wages +
1201
+ incomes.socialSecurity +
1202
+ incomes.pension +
1203
+ incomes.annuity +
1204
+ incomes.tipsLadder +
1205
+ incomes.recurring +
1206
+ incomes.oneTime +
1207
+ incomes.taxableYield;
1208
+ // --- expenses ---------------------------------------------------------
1209
+ const primaryAge = stateOf(primary.id).ageAttained;
1210
+ let phaseMultiplier = 1;
1211
+ for (const phase of [...plan.expenses.phases].sort((a, b) => a.fromAge - b.fromAge)) {
1212
+ if (primaryAge >= phase.fromAge)
1213
+ phaseMultiplier = phase.multiplier;
1214
+ }
1215
+ // Survivor years (exactly one member of a multi-person household alive)
1216
+ // scale base + phase spending by the plan's survivor percentage. One-time
1217
+ // goals and the separately-modeled healthcare/debt/property costs are not
1218
+ // scaled — they carry their own person- or account-level lifecycles.
1219
+ const survivorSpendingFactor = peopleStates.length > 1 && aliveCount === 1 ? (plan.expenses.survivorSpendingPct ?? 100) / 100 : 1;
1220
+ // Split the annual lifestyle target into required, target, ideal, and excess
1221
+ // layers. Absent optional fields keep older plans on the exact old shape:
1222
+ // baseAnnual is the target lifestyle, with no annual upside layers.
1223
+ const lifestyleScale = anyAlive ? inflFactor * phaseMultiplier * survivorSpendingFactor : 0;
1224
+ let scaledTargetLifestyle = plan.expenses.baseAnnual * lifestyleScale;
1225
+ const requiredAnnualToday = Math.min(plan.expenses.requiredAnnual ?? plan.expenses.baseAnnual, plan.expenses.baseAnnual);
1226
+ let requiredLifestyleNominal = requiredAnnualToday * lifestyleScale;
1227
+ let idealLifestyleNominal = (plan.expenses.idealAnnual ?? 0) * lifestyleScale;
1228
+ let excessLifestyleNominal = (plan.expenses.excessAnnual ?? 0) * lifestyleScale;
1229
+ if (abwActive) {
1230
+ // ABW replaces the whole recurring lifestyle stack: baseAnnual, phases,
1231
+ // survivor scaling, and the required/ideal/excess layers are ignored and
1232
+ // the target is the amortized payment from the actual start-of-year
1233
+ // portfolio (nominal — the payment ratio is inflation-invariant, see
1234
+ // engine/spending/abw.ts). Healthcare, debt, property, insurance, and
1235
+ // one-time goals stay separately modeled on top.
1236
+ let startPortfolio = 0;
1237
+ for (const b of balances)
1238
+ startPortfolio += startOfYearBalance.get(b.account.id) ?? 0;
1239
+ scaledTargetLifestyle = anyAlive
1240
+ ? abwAnnualPayment(startPortfolio, abwRealReturnPct, abwTiltPct, abwHorizonYear - year + 1)
1241
+ : 0;
1242
+ requiredLifestyleNominal = 0;
1243
+ idealLifestyleNominal = 0;
1244
+ excessLifestyleNominal = 0;
1245
+ }
1246
+ const { requiredLifestyle, targetLifestyle, idealLifestyle, excessLifestyle, } = splitAnnualSpendingLayers({
1247
+ baseAnnualNominal: scaledTargetLifestyle,
1248
+ requiredAnnualNominal: requiredLifestyleNominal,
1249
+ idealAnnualNominal: idealLifestyleNominal,
1250
+ excessAnnualNominal: excessLifestyleNominal,
1251
+ });
1252
+ let debtService = 0;
1253
+ for (const account of plan.accounts) {
1254
+ if (account.type !== 'debt')
1255
+ continue;
1256
+ let bal = debtBalances.get(account.id) ?? 0;
1257
+ if (bal <= 0)
1258
+ continue;
1259
+ bal *= 1 + account.interestPct / 100;
1260
+ // A scheduled payoff year clears the whole remaining balance at once
1261
+ // (funded by the withdrawal waterfall below); otherwise pay the level
1262
+ // annual amount, capped at the balance so the loan self-terminates.
1263
+ const payoff = typeof account.payoffYear === 'number' && year >= account.payoffYear;
1264
+ const payment = payoff ? bal : Math.min(bal, account.monthlyPayment * 12);
1265
+ bal -= payment;
1266
+ debtBalances.set(account.id, bal);
1267
+ debtService += payment;
1268
+ }
1269
+ // Healthcare: ACA-credited marketplace pre-65, Medicare + IRMAA from 65.
1270
+ // Medicare eligibility begins in the birth month of the year a member
1271
+ // turns 65 (planning-grade: the born-on-the-1st prior-month rule is not
1272
+ // modeled), so the transition year splits into birthMonth − 1 months of
1273
+ // marketplace coverage and the remainder on Medicare instead of flipping
1274
+ // the whole year at once.
1275
+ const hc = plan.expenses.healthcare;
1276
+ const healthInflFactor = healthInflFactorFrom(startYear, year);
1277
+ let healthcare = 0;
1278
+ // The ACA credit is a household calculation and a MONTHLY one: covered
1279
+ // members' premiums pool per calendar month, and each covered month earns
1280
+ // max(0, premium − expectedContribution/12) — so a transition-year member
1281
+ // covered five months owes 5/12 of the household expected contribution,
1282
+ // not all of it, and the contribution is never subtracted per person.
1283
+ const acaMonthlyPremiums = new Array(12).fill(0);
1284
+ // SSA-44 (see setup above): in the two years after a qualifying event, the
1285
+ // premium MAGI is the lower of the lookback and the prior-year stand-in.
1286
+ const irmaaMagi = ssa44ActiveInYear(year)
1287
+ ? Math.min(magiFor(year - 2), magiFor(year - 1))
1288
+ : magiFor(year - 2);
1289
+ // IRMAA's filing categories differ from the income-tax tables: SSA groups
1290
+ // qualifying-surviving-spouse filers with single/HOH on the individual
1291
+ // threshold table (POMS HI 01101.020), so QSS years price premiums at the
1292
+ // single thresholds even though their income tax uses the joint tables.
1293
+ const irmaaFilingStatus = filingStatusForYear === 'qualifyingSurvivingSpouse' ? 'single' : taxFilingStatusForYear;
1294
+ let medicarePremiums = 0;
1295
+ let irmaaTier = 0;
1296
+ for (const s of peopleStates) {
1297
+ if (!s.alive)
1298
+ continue;
1299
+ const acaMonths = s.ageAttained < 65 ? 12 : s.ageAttained === 65 ? (birthMonthByPerson.get(s.personId) ?? 1) - 1 : 0;
1300
+ const medicareMonths = 12 - acaMonths;
1301
+ if (acaMonths > 0 && hc.pre65MonthlyPremiumPerPerson > 0) {
1302
+ if (hc.applyAcaCredit) {
1303
+ for (let m = 0; m < acaMonths; m++)
1304
+ acaMonthlyPremiums[m] += hc.pre65MonthlyPremiumPerPerson * healthInflFactor;
1305
+ }
1306
+ else {
1307
+ healthcare += hc.pre65MonthlyPremiumPerPerson * acaMonths * healthInflFactor;
1308
+ }
1309
+ }
1310
+ if (medicareMonths > 0) {
1311
+ const med = medicareAnnualPremiumPerPerson(pack, irmaaMagi, irmaaFilingStatus, inflFactorFrom(pack.year, year), healthInflFactorFrom(pack.year, year));
1312
+ if (med.partDSurchargeUnverified) {
1313
+ warnings.add('An IRMAA tier with an unverified Part D surcharge was hit; Part D surcharge omitted for that tier.');
1314
+ }
1315
+ const premium = (med.partBAnnual + med.partDSurchargeAnnual) * (medicareMonths / 12);
1316
+ medicarePremiums += premium;
1317
+ irmaaTier = med.irmaaTier;
1318
+ healthcare += premium + hc.medicareExtrasMonthlyPerPerson * medicareMonths * healthInflFactor;
1319
+ }
1320
+ }
1321
+ if (acaMonthlyPremiums.some((premium) => premium > 0)) {
1322
+ // Credit estimated against last year's MAGI (current-year MAGI would
1323
+ // be circular with withdrawals; reconciliation differences ignored).
1324
+ const fplScale = inflFactorFrom(pack.year, year);
1325
+ const aca = acaNetAnnualPremiumByMonth(pack, aliveCount, magiFor(year - 1), acaMonthlyPremiums, fplScale);
1326
+ if (aca.overCliff) {
1327
+ warnings.add('Some pre-65 years exceed 400% of the federal poverty line: no ACA credit (the cliff).');
1328
+ }
1329
+ healthcare += aca.netAnnualPremium;
1330
+ }
1331
+ // Insurance premiums: level (fixed nominal), charged while the insured/owner
1332
+ // is alive. paidUp charges nothing; untilAge stops at premiumEndAge.
1333
+ let insurancePremiums = 0;
1334
+ for (const policy of plan.insurance) {
1335
+ if (policy.premiumMode === 'paidUp')
1336
+ continue;
1337
+ const subjectId = policy.kind === 'ltc' ? policy.owner : policy.insured;
1338
+ const s = stateOf(subjectId);
1339
+ if (!s.alive)
1340
+ continue;
1341
+ if (policy.premiumMode === 'untilAge' && policy.premiumEndAge !== undefined && s.ageAttained >= policy.premiumEndAge) {
1342
+ continue;
1343
+ }
1344
+ insurancePremiums += policy.annualPremium;
1345
+ }
1346
+ // LTC care episodes: a deterministic late-life cost spike, additive to
1347
+ // baseline spending. An owned LTC policy offsets it up to its monthly cap
1348
+ // (grown by the inflation rider) after the elimination period, for at most
1349
+ // benefitPeriodYears. The net (careCost − ltcBenefit) is what hits spending.
1350
+ let careCost = 0;
1351
+ let ltcBenefit = 0;
1352
+ for (const event of plan.careEvents) {
1353
+ const s = stateOf(event.personId);
1354
+ if (!s.alive)
1355
+ continue;
1356
+ const yearsIntoEpisode = s.ageAttained - event.startAge;
1357
+ if (yearsIntoEpisode < 0 || yearsIntoEpisode >= event.durationYears)
1358
+ continue;
1359
+ const gross = event.annualCost * healthInflFactor;
1360
+ careCost += gross;
1361
+ let remaining = gross;
1362
+ for (const policy of plan.insurance) {
1363
+ if (policy.kind !== 'ltc' || policy.owner !== event.personId || remaining <= 0)
1364
+ continue;
1365
+ const used = ltcBenefitYearsUsed.get(policy.id) ?? 0;
1366
+ if (policy.benefitPeriodYears !== 'lifetime' && used >= policy.benefitPeriodYears)
1367
+ continue;
1368
+ const rider = (policy.inflationRiderPct ?? 0) / 100;
1369
+ let cap = policy.benefitMonthly * 12 * Math.pow(1 + rider, year - startYear);
1370
+ // Elimination period: the first eliminationPeriodDays of the episode are
1371
+ // out of pocket, so the episode's first year is prorated.
1372
+ if (yearsIntoEpisode === 0)
1373
+ cap *= Math.max(0, 1 - policy.eliminationPeriodDays / 365);
1374
+ const pay = Math.min(remaining, cap);
1375
+ if (pay > 0) {
1376
+ ltcBenefit += pay;
1377
+ remaining -= pay;
1378
+ ltcBenefitYearsUsed.set(policy.id, used + 1);
1379
+ }
1380
+ }
1381
+ }
1382
+ // Property carrying costs: tax + insurance charged while the property is
1383
+ // owned, continuing after any mortgage is paid off — the part of a PITI
1384
+ // payment the debt account deliberately excludes. Today's dollars, inflated;
1385
+ // skipped from the sale year on, and (like base spending) once nobody is alive.
1386
+ let propertyCosts = 0;
1387
+ if (anyAlive) {
1388
+ for (const account of plan.accounts) {
1389
+ if (account.type !== 'property')
1390
+ continue;
1391
+ if (account.plannedSaleYear !== null && year >= account.plannedSaleYear)
1392
+ continue;
1393
+ propertyCosts += ((account.propertyTaxAnnual ?? 0) + (account.insuranceAnnual ?? 0)) * inflFactor;
1394
+ }
1395
+ }
1396
+ // System-computed costs are required by default: a plan must never report
1397
+ // "floor success" after silently cutting healthcare, housing, debt, or care.
1398
+ const netCare = careCost - ltcBenefit; // ltcBenefit is capped at careCost above
1399
+ const systemRequired = debtService + propertyCosts + healthcare + insurancePremiums + netCare;
1400
+ // HSA qualified-withdrawal cap (steps 2–3): the household's modeled medical
1401
+ // costs this year (healthcare premiums + net care costs), plus the
1402
+ // accumulated reimburse-later pool when any HSA opts in. Cap-mode HSA
1403
+ // withdrawals are tax- and penalty-free only up to this.
1404
+ const qualifiedMedicalThisYear = healthcare + netCare;
1405
+ const hsaQualifiedCap = qualifiedMedicalThisYear + (hsaReimburseLaterActive ? hsaReimbursablePool : 0);
1406
+ // Withdrawal-rate guardrail decision (before funding). The signal is this
1407
+ // year's recurring target spending over the start-of-year portfolio, compared
1408
+ // to the same ratio in the first solvent year. Cutting/raising moves the
1409
+ // discretionary multiplier; the required floor is never touched.
1410
+ let guardrailAction = 'hold';
1411
+ const earlyPullGoalBudget = guardrailsActive
1412
+ ? plan.expenses.oneTimeGoals.reduce((sum, goal) => {
1413
+ if (goalScheduler?.isResolved(goal.id))
1414
+ return sum;
1415
+ const flexibility = goal.flexibility ?? 'fixed';
1416
+ if (flexibility === 'fixed')
1417
+ return sum;
1418
+ const earliestYear = Math.min(goal.earliestYear ?? goal.year, goal.year);
1419
+ if (year >= earliestYear && year < goal.year)
1420
+ return sum + goal.amount * inflFactor;
1421
+ return sum;
1422
+ }, 0)
1423
+ : 0;
1424
+ const annualUpsideLifestyle = idealLifestyle + excessLifestyle;
1425
+ const guardrailStepBasis = Math.max(targetLifestyle, annualUpsideLifestyle, 1);
1426
+ const allowRaisesAboveTarget = spendingPolicy?.allowRaisesAboveTarget ?? annualUpsideLifestyle + earlyPullGoalBudget > 0;
1427
+ const maxGuardrailMultiplier = guardrailsActive && allowRaisesAboveTarget
1428
+ ? 1 + (annualUpsideLifestyle + earlyPullGoalBudget) / guardrailStepBasis
1429
+ : 1;
1430
+ if (guardrailsActive && anyAlive) {
1431
+ let startPortfolio = 0;
1432
+ for (const b of balances)
1433
+ startPortfolio += startOfYearBalance.get(b.account.id) ?? 0;
1434
+ if (riskBasedGuardrails) {
1435
+ // Risk-based signal: the real (deflated) balance against dollar
1436
+ // thresholds expressed as a percent of the starting portfolio. The
1437
+ // thresholds come from the shared-path probability solver; when they
1438
+ // have not been solved the decision holds every year (mode is inert).
1439
+ const realBalance = startPortfolio / inflFactor;
1440
+ if (startingRealPortfolio === null && startPortfolio > 0)
1441
+ startingRealPortfolio = realBalance;
1442
+ if (startingRealPortfolio !== null) {
1443
+ const decision = nextBalanceGuardrailMultiplier(discretionaryMultiplier, realBalance, startingRealPortfolio, guardrailPolicy, maxGuardrailMultiplier);
1444
+ discretionaryMultiplier = decision.multiplier;
1445
+ guardrailAction = decision.action;
1446
+ }
1447
+ }
1448
+ else {
1449
+ const targetRecurring = systemRequired + requiredLifestyle + targetLifestyle;
1450
+ const currentRate = startPortfolio > 0 ? targetRecurring / startPortfolio : NaN;
1451
+ if (startingWithdrawalRate === null && Number.isFinite(currentRate))
1452
+ startingWithdrawalRate = currentRate;
1453
+ if (startingWithdrawalRate !== null) {
1454
+ const decision = nextGuardrailMultiplier(discretionaryMultiplier, currentRate, startingWithdrawalRate, guardrailPolicy, maxGuardrailMultiplier);
1455
+ discretionaryMultiplier = decision.multiplier;
1456
+ guardrailAction = decision.action;
1457
+ }
1458
+ }
1459
+ }
1460
+ const targetLifestyleFunded = guardrailsActive
1461
+ ? targetLifestyle * Math.min(1, discretionaryMultiplier)
1462
+ : targetLifestyle;
1463
+ const upsideBudget = guardrailsActive
1464
+ ? Math.max(0, discretionaryMultiplier - 1) * guardrailStepBasis
1465
+ : annualUpsideLifestyle;
1466
+ const idealLifestyleFunded = Math.min(idealLifestyle, upsideBudget);
1467
+ const excessLifestyleFunded = Math.min(excessLifestyle, Math.max(0, upsideBudget - idealLifestyleFunded));
1468
+ const remainingUpsideBudget = Math.max(0, upsideBudget - idealLifestyleFunded - excessLifestyleFunded);
1469
+ const cutting = guardrailsActive && discretionaryMultiplier < 1 - 1e-9;
1470
+ const canPullForwardGoals = guardrailsActive && !cutting && (guardrailAction === 'raise' || discretionaryMultiplier > 1 + 1e-9);
1471
+ // One-time goals. Under guardrails they route through the scheduler (which
1472
+ // may delay/skip flexible goals when cutting); otherwise every goal funds in
1473
+ // its target year exactly, as it always has. A *skipped* goal is intended
1474
+ // spending that never happens, so its amount is tracked as a target miss (a
1475
+ // required-classified skip is also a required miss) rather than silently
1476
+ // vanishing from both sides of the ledger.
1477
+ let oneTimeGoalsFunded = 0;
1478
+ let requiredGoalsFunded = 0;
1479
+ let targetGoalsFunded = 0;
1480
+ let idealGoalsFunded = 0;
1481
+ let excessGoalsFunded = 0;
1482
+ let skippedTargetNominal = 0;
1483
+ let skippedIdealNominal = 0;
1484
+ let skippedExcessNominal = 0;
1485
+ let skippedRequiredNominal = 0;
1486
+ const goalOutcomeCounts = { funded: 0, partiallyFunded: 0, deferred: 0, skipped: 0, fundedAmount: 0, unfundedAmount: 0 };
1487
+ if (anyAlive) {
1488
+ if (goalScheduler) {
1489
+ const plannedGoals = goalScheduler.planYear(year, {
1490
+ inflFactor,
1491
+ cutting,
1492
+ canPullForward: canPullForwardGoals,
1493
+ availableBudget: cutting ? 0 : canPullForwardGoals ? remainingUpsideBudget : null,
1494
+ });
1495
+ for (const r of plannedGoals.results) {
1496
+ if (r.outcome === 'funded' || r.outcome === 'partiallyFunded') {
1497
+ oneTimeGoalsFunded += r.fundedNominal;
1498
+ if (r.classification === 'required')
1499
+ requiredGoalsFunded += r.fundedNominal;
1500
+ else if (r.classification === 'target')
1501
+ targetGoalsFunded += r.fundedNominal;
1502
+ else if (r.classification === 'ideal')
1503
+ idealGoalsFunded += r.fundedNominal;
1504
+ else
1505
+ excessGoalsFunded += r.fundedNominal;
1506
+ if (r.outcome === 'funded')
1507
+ goalOutcomeCounts.funded++;
1508
+ else
1509
+ goalOutcomeCounts.partiallyFunded++;
1510
+ goalOutcomeCounts.fundedAmount += r.fundedNominal;
1511
+ goalOutcomeCounts.unfundedAmount += r.unfundedNominal;
1512
+ if (r.unfundedNominal > 0) {
1513
+ if (r.classification === 'required')
1514
+ skippedRequiredNominal += r.unfundedNominal;
1515
+ else if (r.classification === 'target')
1516
+ skippedTargetNominal += r.unfundedNominal;
1517
+ else if (r.classification === 'ideal')
1518
+ skippedIdealNominal += r.unfundedNominal;
1519
+ else
1520
+ skippedExcessNominal += r.unfundedNominal;
1521
+ }
1522
+ }
1523
+ else if (r.outcome === 'deferred') {
1524
+ goalOutcomeCounts.deferred++;
1525
+ }
1526
+ else {
1527
+ if (r.classification === 'required')
1528
+ skippedRequiredNominal += r.amountNominal;
1529
+ else if (r.classification === 'target')
1530
+ skippedTargetNominal += r.amountNominal;
1531
+ else if (r.classification === 'ideal')
1532
+ skippedIdealNominal += r.amountNominal;
1533
+ else
1534
+ skippedExcessNominal += r.amountNominal;
1535
+ goalOutcomeCounts.unfundedAmount += r.amountNominal;
1536
+ goalOutcomeCounts.skipped++;
1537
+ }
1538
+ }
1539
+ }
1540
+ else {
1541
+ for (const goal of plan.expenses.oneTimeGoals) {
1542
+ if (goal.year !== year)
1543
+ continue;
1544
+ const amount = goal.amount * inflFactor;
1545
+ oneTimeGoalsFunded += amount;
1546
+ const classification = goal.classification ?? 'target';
1547
+ if (classification === 'required')
1548
+ requiredGoalsFunded += amount;
1549
+ else if (classification === 'target')
1550
+ targetGoalsFunded += amount;
1551
+ else if (classification === 'ideal')
1552
+ idealGoalsFunded += amount;
1553
+ else
1554
+ excessGoalsFunded += amount;
1555
+ }
1556
+ }
1557
+ }
1558
+ const baseSpending = requiredLifestyle + targetLifestyleFunded + idealLifestyleFunded + excessLifestyleFunded;
1559
+ // Base layers are funding-consistent (they exclude skipped goals) so the
1560
+ // shortfall attribution below stays clean; skipped goals are folded back into
1561
+ // the *reported* required/target totals and the shortfalls as explicit deltas.
1562
+ const requiredSpendingBase = systemRequired + requiredLifestyle + requiredGoalsFunded;
1563
+ const targetSpendingBase = systemRequired + requiredLifestyle + targetLifestyle + targetGoalsFunded + requiredGoalsFunded;
1564
+ const idealSpendingBase = idealLifestyle + idealGoalsFunded;
1565
+ const excessSpendingBase = excessLifestyle + excessGoalsFunded;
1566
+ const expenses = {
1567
+ baseSpending,
1568
+ oneTimeGoals: oneTimeGoalsFunded,
1569
+ debtService,
1570
+ propertyCosts,
1571
+ healthcare,
1572
+ insurancePremiums,
1573
+ careCost,
1574
+ ltcBenefit,
1575
+ requiredSpending: requiredSpendingBase + skippedRequiredNominal,
1576
+ targetSpending: targetSpendingBase + skippedTargetNominal + skippedRequiredNominal,
1577
+ idealSpending: idealSpendingBase + skippedIdealNominal,
1578
+ excessSpending: excessSpendingBase + skippedExcessNominal,
1579
+ intendedSpending: targetSpendingBase +
1580
+ idealSpendingBase +
1581
+ excessSpendingBase +
1582
+ skippedTargetNominal +
1583
+ skippedRequiredNominal +
1584
+ skippedIdealNominal +
1585
+ skippedExcessNominal,
1586
+ guardrailFactor: discretionaryMultiplier,
1587
+ total: baseSpending + oneTimeGoalsFunded + debtService + propertyCosts + healthcare + insurancePremiums + careCost - ltcBenefit,
1588
+ };
1589
+ // --- fixed-asset dispositions (step 6) ----------------------------------
1590
+ // With a cost basis on a property account, this year's planned sale is
1591
+ // priced exactly — selling costs, §121 primary-residence exclusion, and
1592
+ // depreciation recapture — and its gains join the year's tax base up
1593
+ // front. Net proceeds enter the cash flow (so the sale can fund its own
1594
+ // tax), and the property-events block below zeroes the value without the
1595
+ // legacy tax-free deposit. Without a cost basis the legacy
1596
+ // expectedNetProceeds path is untouched.
1597
+ let propertySaleProceedsTotal = 0;
1598
+ for (const account of plan.accounts) {
1599
+ if (account.type !== 'property' || account.plannedSaleYear !== year || account.costBasis === undefined)
1600
+ continue;
1601
+ const value = propertyValues.get(account.id) ?? 0;
1602
+ if (value <= 0)
1603
+ continue;
1604
+ // Match the property-events block: the sale year's inflation growth
1605
+ // accrues before the sale.
1606
+ const sale = propertySaleTax({
1607
+ salePrice: value * (1 + inflRateAt(year)),
1608
+ costBasis: account.costBasis,
1609
+ sellingCostPct: account.sellingCostPct,
1610
+ primaryResidence: account.primaryResidence,
1611
+ depreciationRecapture: account.depreciationRecapture,
1612
+ filingStatus: taxFilingStatusForYear,
1613
+ pack,
1614
+ });
1615
+ ordinaryIncome += sale.ordinaryGain;
1616
+ oneTimeGains += sale.capitalGain;
1617
+ // A HECM on the sold home is repaid from the proceeds, non-recourse:
1618
+ // the payoff never exceeds what the sale nets, and the line closes.
1619
+ // (Loan repayment does not change the taxable gain computed above.)
1620
+ const hecmState = hecmStates.get(account.id);
1621
+ let hecmPayoff = 0;
1622
+ if (hecmState) {
1623
+ hecmPayoff = Math.min(hecmState.loanBalance, Math.max(0, sale.netProceeds));
1624
+ hecmStates.delete(account.id);
1625
+ }
1626
+ propertySaleProceedsTotal += sale.netProceeds - hecmPayoff;
1627
+ }
1628
+ // --- contributions & employer match --------------------
1629
+ let contributions = 0;
1630
+ let employerMatch = 0;
1631
+ let preTaxContributions = 0;
1632
+ // Deposit destinations for the optimizer probe: the LP models balances as
1633
+ // owner-traditional vs everything-else buckets, so contributions and match
1634
+ // must arrive in the matching compressed bucket, not vanish as spending.
1635
+ let traditionalInflow = 0;
1636
+ let otherInflow = 0;
1637
+ let taxableInflow = 0;
1638
+ const groupUsed = new Map();
1639
+ const addition415cUsed = new Map();
1640
+ for (const state of balances) {
1641
+ const account = state.account;
1642
+ const hasSchedule = 'contributionSchedule' in account && account.contributionSchedule && account.contributionSchedule.length > 0;
1643
+ if (account.annualContribution <= 0 && !hasSchedule)
1644
+ continue;
1645
+ if (!acceptsContributions(account))
1646
+ continue; // inherited accounts can't receive contributions
1647
+ const ownerId = account.ownerPersonId ?? primary.id;
1648
+ const ownerState = stateOf(ownerId);
1649
+ if (!ownerState.alive)
1650
+ continue;
1651
+ let desired = 0;
1652
+ if (hasSchedule) {
1653
+ const owner = personById.get(ownerId);
1654
+ const ownerBirthYear = dobYear(owner);
1655
+ const ownerAgeAtStartYear = startYear - ownerBirthYear;
1656
+ for (const phase of account.contributionSchedule) {
1657
+ const fromAge = phase.fromAge ?? 0;
1658
+ const toAge = phase.toAge ?? 120;
1659
+ const age = ownerState.ageAttained;
1660
+ if (age >= fromAge && age <= toAge) {
1661
+ const phaseStartYear = phase.fromAge !== null
1662
+ ? startYear + (phase.fromAge - ownerAgeAtStartYear)
1663
+ : startYear;
1664
+ const yearsElapsed = Math.max(0, year - phaseStartYear);
1665
+ desired += phase.annualAmount * Math.pow(1 + phase.escalationPct / 100, yearsElapsed) * inflFactor;
1666
+ }
1667
+ }
1668
+ // Employer accounts require wages even with a schedule
1669
+ const isEmployer = (account.type === 'traditional' || account.type === 'roth') && account.kind === 'employer';
1670
+ if (isEmployer && (wagesByPerson.get(ownerId) ?? 0) <= 0) {
1671
+ desired = 0;
1672
+ }
1673
+ }
1674
+ else {
1675
+ // Legacy behavior: must have wages
1676
+ if ((wagesByPerson.get(ownerId) ?? 0) <= 0) {
1677
+ desired = 0;
1678
+ }
1679
+ else {
1680
+ desired = account.annualContribution * inflFactor;
1681
+ }
1682
+ }
1683
+ if (desired <= 0)
1684
+ continue;
1685
+ let allowed = desired;
1686
+ let groupKey = null;
1687
+ let limit = Infinity;
1688
+ const age = ownerState.ageAttained;
1689
+ if ((account.type === 'traditional' || account.type === 'roth') && account.kind === 'employer') {
1690
+ groupKey = `${ownerId}:employer`;
1691
+ const catchUp = age >= 60 && age <= 63
1692
+ ? pack.contributionLimits.superCatchUp60to63
1693
+ : age >= 50
1694
+ ? pack.contributionLimits.catchUp50
1695
+ : 0;
1696
+ limit = (pack.contributionLimits.employee401k + catchUp) * limitGrowth;
1697
+ }
1698
+ else if ((account.type === 'traditional' || account.type === 'roth') && account.kind === 'ira') {
1699
+ groupKey = `${ownerId}:ira`;
1700
+ const catchUp = age >= 50 ? pack.contributionLimits.iraCatchUp50 : 0;
1701
+ limit = (pack.contributionLimits.ira + catchUp) * limitGrowth;
1702
+ }
1703
+ else if (account.type === 'hsa') {
1704
+ groupKey = `${ownerId}:hsa`;
1705
+ const base = people.length === 2 ? pack.contributionLimits.hsaFamily : pack.contributionLimits.hsaSelfOnly;
1706
+ const catchUp = age >= 55 ? pack.contributionLimits.hsaCatchUp55 : 0;
1707
+ limit = (base + catchUp) * limitGrowth;
1708
+ }
1709
+ if (groupKey !== null) {
1710
+ const used = groupUsed.get(groupKey) ?? 0;
1711
+ allowed = Math.max(0, Math.min(desired, limit - used));
1712
+ if (allowed < desired - EPSILON) {
1713
+ warnings.add('Some contributions were reduced to stay within IRS annual limits.');
1714
+ }
1715
+ groupUsed.set(groupKey, used + allowed);
1716
+ }
1717
+ if (allowed <= 0)
1718
+ continue;
1719
+ // Update employee contribution inside 415(c) tracker
1720
+ const isEmployerAccount = (account.type === 'traditional' || account.type === 'roth') && account.kind === 'employer';
1721
+ if (isEmployerAccount) {
1722
+ addition415cUsed.set(ownerId, (addition415cUsed.get(ownerId) ?? 0) + allowed);
1723
+ }
1724
+ state.balance += allowed;
1725
+ if (account.type === 'taxable' || account.type === 'equityComp')
1726
+ state.costBasis += allowed;
1727
+ // Direct Roth contributions add to the always-accessible basis (employer
1728
+ // Roth contributions are treated the same here, a planning simplification).
1729
+ if (account.type === 'roth') {
1730
+ const rb = rothBasis.get(rothPoolKey(account));
1731
+ if (rb)
1732
+ rb.contributionBasis += allowed;
1733
+ }
1734
+ contributions += allowed;
1735
+ if (account.type === 'traditional' || account.type === 'hsa')
1736
+ preTaxContributions += allowed;
1737
+ if (account.type === 'traditional')
1738
+ traditionalInflow += allowed;
1739
+ else
1740
+ otherInflow += allowed;
1741
+ if (account.type === 'taxable' || account.type === 'equityComp')
1742
+ taxableInflow += allowed;
1743
+ // Employer match calculation
1744
+ if (isEmployerAccount && 'employerMatch' in account && account.employerMatch) {
1745
+ const matchInfo = account.employerMatch;
1746
+ const ownerWages = wagesByPerson.get(ownerId) ?? 0;
1747
+ if (ownerWages > 0) {
1748
+ const matchCap = (matchInfo.capPctOfPay / 100) * ownerWages;
1749
+ const baseMatch = Math.min(allowed, matchCap);
1750
+ let matchVal = baseMatch * (matchInfo.matchPct / 100);
1751
+ // Capped by §415(c) total additions limit
1752
+ const limit415c = pack.contributionLimits.section415cLimit * limitGrowth;
1753
+ const usedSoFar = addition415cUsed.get(ownerId) ?? 0;
1754
+ const remaining415cLimit = Math.max(0, limit415c - usedSoFar);
1755
+ matchVal = Math.min(matchVal, remaining415cLimit);
1756
+ if (matchVal > 0) {
1757
+ state.balance += matchVal;
1758
+ employerMatch += matchVal;
1759
+ // Employer match only lands in traditional or Roth employer accounts,
1760
+ // never a taxable brokerage, so taxableInflow is unaffected here.
1761
+ if (account.type === 'traditional')
1762
+ traditionalInflow += matchVal;
1763
+ else
1764
+ otherInflow += matchVal;
1765
+ addition415cUsed.set(ownerId, usedSoFar + matchVal);
1766
+ }
1767
+ }
1768
+ }
1769
+ }
1770
+ // Fix this year's Form-8606 pro-rata fraction per owner (step 5) from the
1771
+ // aggregated pre-distribution IRA balance — after contributions, before
1772
+ // any RMD/SEPP/conversion/withdrawal depletes it. Forced flows and
1773
+ // conversions commit against this state as they happen; need-based
1774
+ // withdrawal probes stay pure and commit once at the end of the year.
1775
+ const iraProRata = new Map();
1776
+ for (const [ownerId, basis] of iraBasisByOwner) {
1777
+ if (basis <= 0)
1778
+ continue;
1779
+ let aggregateBalance = 0;
1780
+ for (const state of balances) {
1781
+ if (!isAggregatedIra(state.account))
1782
+ continue;
1783
+ if ((state.account.ownerPersonId ?? primary.id) !== ownerId)
1784
+ continue;
1785
+ aggregateBalance += state.balance;
1786
+ }
1787
+ iraProRata.set(ownerId, openIraProRataYear(basis, aggregateBalance));
1788
+ }
1789
+ let rmdNontaxable = 0;
1790
+ let seppNontaxable = 0;
1791
+ let conversionNontaxable = 0;
1792
+ // --- RMDs: forced traditional distributions (SECURE 2.0) ---------------
1793
+ let rmdTotal = 0;
1794
+ for (const state of balances) {
1795
+ if (state.account.type !== 'traditional')
1796
+ continue;
1797
+ if (!followsOwnerRmds(state.account))
1798
+ continue; // inherited accounts follow the 10-year rule below, not Uniform Lifetime
1799
+ const ownerId = state.account.ownerPersonId ?? primary.id;
1800
+ const owner = personById.get(ownerId);
1801
+ const ownerState = stateOf(ownerId);
1802
+ if (!ownerState.alive)
1803
+ continue; // a deceased owner's own account stops RMDs (no estate modeling)
1804
+ // Joint Life divisor only when the user marked the spouse the account's
1805
+ // sole beneficiary (the rule's precondition; the schema can't infer it) and
1806
+ // they're alive. Otherwise the Uniform Lifetime Table applies.
1807
+ const spousePerson = state.account.spouseSoleBeneficiary ? people.find((p) => p.id !== ownerId) : undefined;
1808
+ const spouseState = spousePerson ? stateOf(spousePerson.id) : undefined;
1809
+ const spouse = spousePerson && spouseState?.alive ? { ageAttained: spouseState.ageAttained, sex: spousePerson.sex } : undefined;
1810
+ const rmd = requiredMinimumDistribution(pack, dobYear(owner), ownerState.ageAttained, startOfYearBalance.get(state.account.id) ?? 0, { ownerSex: owner.sex, spouse });
1811
+ const take = Math.min(rmd, state.balance);
1812
+ if (take <= 0)
1813
+ continue;
1814
+ state.balance -= take;
1815
+ rmdTotal += take;
1816
+ // Pro-rata return of basis on IRA RMDs (step 5); committed immediately.
1817
+ if (state.account.kind === 'ira') {
1818
+ const proRata = iraProRata.get(ownerId);
1819
+ if (proRata) {
1820
+ const split = splitIraDistribution(proRata, take);
1821
+ iraProRata.set(ownerId, split.next);
1822
+ rmdNontaxable += split.nontaxable;
1823
+ }
1824
+ }
1825
+ }
1826
+ // --- 72(t) SEPP: forced penalty-free early distributions (roadmap V8) ----
1827
+ // A substantially-equal periodic payment is taken like an RMD — outside the
1828
+ // need-based withdrawal flow, so it never attracts the early-withdrawal
1829
+ // penalty — and is taxable ordinary income that also supplies spending cash.
1830
+ let seppTotal = 0;
1831
+ for (const state of balances) {
1832
+ if (state.account.type !== 'traditional' || !state.account.sepp || state.account.inherited)
1833
+ continue;
1834
+ const ownerState = stateOf(state.account.ownerPersonId ?? primary.id);
1835
+ if (!ownerState.alive)
1836
+ continue;
1837
+ const election = state.account.sepp;
1838
+ if (!seppActive(election.startAge, ownerState.ageAttained))
1839
+ continue;
1840
+ const ownerSex = personById.get(state.account.ownerPersonId ?? primary.id).sex;
1841
+ const startBalance = startOfYearBalance.get(state.account.id) ?? 0;
1842
+ let amount;
1843
+ if (election.method === 'amortization') {
1844
+ // Fixed for the series: compute once from the first SEPP year's balance.
1845
+ let fixed = seppAmortAmount.get(state.account.id);
1846
+ if (fixed === undefined) {
1847
+ fixed = seppAnnualAmount('amortization', startBalance, ownerState.ageAttained, ownerSex);
1848
+ seppAmortAmount.set(state.account.id, fixed);
1849
+ }
1850
+ amount = fixed;
1851
+ }
1852
+ else {
1853
+ amount = seppAnnualAmount('rmd', startBalance, ownerState.ageAttained, ownerSex);
1854
+ }
1855
+ const take = Math.min(amount, state.balance);
1856
+ if (take <= 0)
1857
+ continue;
1858
+ state.balance -= take;
1859
+ seppTotal += take;
1860
+ // Pro-rata return of basis on IRA SEPP distributions (step 5).
1861
+ if (state.account.kind === 'ira') {
1862
+ const ownerId = state.account.ownerPersonId ?? primary.id;
1863
+ const proRata = iraProRata.get(ownerId);
1864
+ if (proRata) {
1865
+ const split = splitIraDistribution(proRata, take);
1866
+ iraProRata.set(ownerId, split.next);
1867
+ seppNontaxable += split.nontaxable;
1868
+ }
1869
+ }
1870
+ }
1871
+ // --- Inherited IRA: SECURE Act 10-year rule (roadmap V8) ----------------
1872
+ // A beneficiary takes an annual single-life RMD during the window (only if
1873
+ // the decedent had started RMDs) and must empty the account by year 10. Like
1874
+ // RMDs/SEPP it's a forced, taxable, penalty-free distribution that supplies
1875
+ // spending cash.
1876
+ let inheritedTotal = 0;
1877
+ for (const state of balances) {
1878
+ if (state.account.type !== 'traditional' || !state.account.inherited)
1879
+ continue;
1880
+ const beneficiary = personById.get(state.account.ownerPersonId ?? primary.id);
1881
+ const beneficiaryState = stateOf(beneficiary.id);
1882
+ if (!beneficiaryState.alive)
1883
+ continue;
1884
+ const take = inheritedForcedAmount({
1885
+ year,
1886
+ ownerDeathYear: state.account.inherited.ownerDeathYear,
1887
+ decedentHadStartedRmds: state.account.inherited.decedentHadStartedRmds,
1888
+ balance: state.balance,
1889
+ startBalance: startOfYearBalance.get(state.account.id) ?? 0,
1890
+ beneficiaryAge: beneficiaryState.ageAttained,
1891
+ beneficiarySex: beneficiary.sex,
1892
+ });
1893
+ if (take <= 0)
1894
+ continue;
1895
+ state.balance -= take;
1896
+ inheritedTotal += take;
1897
+ }
1898
+ // QCD: charitable dollars routed out of the RMD, excluded from income.
1899
+ // Age 70½ eligibility approximated as age attained ≥ 71.
1900
+ let qcd = 0;
1901
+ if (plan.strategies.qcdAnnual > 0 && rmdTotal > 0) {
1902
+ const anyEligible = peopleStates.some((s) => s.alive && s.ageAttained >= 71);
1903
+ if (anyEligible) {
1904
+ qcd = Math.min(plan.strategies.qcdAnnual * inflFactor, rmdTotal, pack.rmd.qcdAnnualLimit * limitGrowth);
1905
+ }
1906
+ }
1907
+ // --- Roth conversions (after RMDs — RMDs must be satisfied first) -------
1908
+ const peopleAged65Plus = peopleStates.filter((s) => s.alive && s.ageAttained >= 65).length;
1909
+ // Forced IRA distributions count only their taxable (post-pro-rata) part
1910
+ // as ordinary income; QCD stays a full subtraction (planning-grade — the
1911
+ // IRS actually sources QCDs from pre-tax dollars first).
1912
+ const incomeBeforeConversion = ordinaryIncome - preTaxContributions + rmdTotal - rmdNontaxable - qcd + seppTotal - seppNontaxable + inheritedTotal;
1913
+ // Itemized deductions (today's $ → nominal). The user's SALT estimate grows
1914
+ // with general inflation, like spending; federal tax takes the greater of
1915
+ // this and the standard deduction. Built here so the conversion/bracket
1916
+ // sizers below target the same deduction the tax engine will use.
1917
+ const itm = plan.strategies.itemizedDeductions;
1918
+ const itemizedDeductions = itm
1919
+ ? {
1920
+ stateAndLocalTaxes: itm.stateAndLocalTaxes * inflFactor,
1921
+ mortgageInterest: itm.mortgageInterest * inflFactor,
1922
+ charitable: itm.charitable * inflFactor,
1923
+ }
1924
+ : undefined;
1925
+ // State-tax inputs (resolved once per year, before conversions so the
1926
+ // safety-net trim below can price a conversion's full tax bill).
1927
+ // Retirement-income base = pension/annuity + taxable RMD/SEPP/inherited −
1928
+ // QCD; traditional spending withdrawals are added per iteration below.
1929
+ // Roth conversions are excluded (not exclusion-eligible).
1930
+ const residenceState = stateForYear(plan.household, year);
1931
+ const stateResidency = stateResidencySegmentsForYear(plan.household, year);
1932
+ const agesAlive = peopleStates.filter((s) => s.alive).map((s) => s.ageAttained);
1933
+ const privateRetirementBase = Math.max(0, privateRetirementOrdinary + rmdTotal - rmdNontaxable - qcd + seppTotal - seppNontaxable + inheritedTotal);
1934
+ const publicPensionBase = Math.max(0, publicPensionOrdinary);
1935
+ if (plan.assumptions.stateEffectiveTaxPct <= 0) {
1936
+ for (const segment of stateResidency) {
1937
+ if (stateParamsFor(segment.state, year))
1938
+ continue;
1939
+ warnings.add(`State "${segment.state}" isn't modeled for per-state tax yet, so state income tax was treated as $0. ` +
1940
+ 'If it taxes income, set a flat effective rate under Assumptions to approximate it.');
1941
+ }
1942
+ }
1943
+ // Taxable safety-net floor, conversion side (step 7): trim a fill-to-target
1944
+ // conversion so its estimated tax bill stays payable from liquid dollars
1945
+ // above the floor after this year's pre-tax cash need. Manual/optimized
1946
+ // schedules are executed as requested (the user typed them); generated
1947
+ // fill-to-target candidates — including every decision-engine conversion
1948
+ // candidate — respect the floor here.
1949
+ const trimConversionForFloor = (desired) => {
1950
+ const floorNominal = safetyNetFloorToday * inflFactor;
1951
+ let liquid = 0;
1952
+ for (const b of balances) {
1953
+ if (b.account.type === 'cash' || b.account.type === 'taxable' || b.account.type === 'equityComp') {
1954
+ liquid += spendableBalance(b, year);
1955
+ }
1956
+ }
1957
+ const preConversionInflows = incomes.total - taxableYieldReinvested + rmdTotal - qcd + seppTotal + inheritedTotal + propertySaleProceedsTotal;
1958
+ // Liquid dollars available above the floor to pay a conversion's tax:
1959
+ // existing spendable liquid plus this year's surplus inflows, net of the
1960
+ // pre-tax cash need. Surplus inflows (inflows above expenses+contributions)
1961
+ // are real available cash — they land in liquid accounts at year end — so
1962
+ // they raise the headroom rather than being clamped away.
1963
+ const netLiquid = liquid + preConversionInflows - expenses.total - contributions;
1964
+ const headroom = Math.max(0, netLiquid - floorNominal);
1965
+ const taxOf = (extraOrdinary) => {
1966
+ const netted = applyCapitalLossCarryforward(capitalLossPool, Math.max(0, incomeBeforeConversion + extraOrdinary), oneTimeGains, pack.federalTax.capitalLossOrdinaryOffsetLimit);
1967
+ return taxCalculator.compute({
1968
+ year,
1969
+ filingStatus: filingStatusForYear,
1970
+ ordinaryIncome: netted.ordinaryAfter,
1971
+ capitalGains: netted.netCapitalGain,
1972
+ realizedCapitalGainsBeforeCarryforward: oneTimeGains,
1973
+ taxableInterestIncome: incomes.taxableInterest + ladderTaxableInterest,
1974
+ usGovernmentInterest: ladderTaxableInterest,
1975
+ ordinaryDividends: incomes.ordinaryDividends,
1976
+ qualifiedDividends: incomes.qualifiedDividends,
1977
+ ssBenefits: incomes.socialSecurity,
1978
+ peopleAged65Plus,
1979
+ state: residenceState,
1980
+ stateResidency,
1981
+ privateRetirementIncome: privateRetirementBase,
1982
+ publicPensionIncome: publicPensionBase,
1983
+ agesAlive,
1984
+ itemizedDeductions,
1985
+ });
1986
+ };
1987
+ const baseTax = taxOf(0);
1988
+ let trimmed = desired;
1989
+ for (let i = 0; i < 3; i++) {
1990
+ const conversionTax = Math.max(0, taxOf(trimmed) - baseTax);
1991
+ if (conversionTax <= headroom + EPSILON)
1992
+ break;
1993
+ trimmed = conversionTax > 0 ? Math.max(0, trimmed * (headroom / conversionTax)) : 0;
1994
+ if (trimmed <= 0.01) {
1995
+ trimmed = 0;
1996
+ break;
1997
+ }
1998
+ }
1999
+ if (trimmed < desired - 0.01) {
2000
+ warnings.add('Roth conversions were trimmed so their tax bill stays payable without breaching the taxable safety-net floor.');
2001
+ }
2002
+ return trimmed;
2003
+ };
2004
+ let rothConversion = 0;
2005
+ const rc = plan.strategies.rothConversion;
2006
+ if (rc.mode !== 'none' && anyAlive) {
2007
+ let desired = 0;
2008
+ if (rc.mode === 'manual' || rc.mode === 'optimized') {
2009
+ // `optimized` is an optimizer-produced schedule; identical to manual in
2010
+ // the ledger (the distinct mode only preserves provenance for the UI).
2011
+ for (const c of rc.conversions)
2012
+ if (c.year === year)
2013
+ desired += c.amount;
2014
+ }
2015
+ else if (year >= rc.startYear && year <= rc.endYear) {
2016
+ const sized = sizeRothConversion(rc, {
2017
+ year,
2018
+ pack,
2019
+ filingStatus: taxFilingStatusForYear,
2020
+ ordinaryIncomeBase: incomeBeforeConversion,
2021
+ capitalGains: oneTimeGains + rebalanceRealizedGains,
2022
+ qualifiedDividends: incomes.qualifiedDividends,
2023
+ ssBenefits: incomes.socialSecurity,
2024
+ peopleAged65Plus,
2025
+ householdSize: aliveCount,
2026
+ inflationScale: inflFactorFrom(pack.year, year),
2027
+ itemizedDeductions,
2028
+ });
2029
+ if (sized.ok) {
2030
+ desired = sized.amount;
2031
+ if (desired > 0.01 && safetyNetFloorToday > 0)
2032
+ desired = trimConversionForFloor(desired);
2033
+ }
2034
+ else if (sized.reason === 'bad_target') {
2035
+ warnings.add('The Roth-conversion target is invalid for this plan (unknown bracket or tier); no conversion made.');
2036
+ }
2037
+ }
2038
+ if (desired > 0.01) {
2039
+ const rothTarget = balances.find((b) => b.account.type === 'roth');
2040
+ if (!rothTarget) {
2041
+ warnings.add('Roth conversions were requested but the plan has no Roth account; conversions skipped.');
2042
+ }
2043
+ else {
2044
+ let remaining = desired;
2045
+ for (const state of balances) {
2046
+ // Inherited accounts follow the 10-year rule and can't be converted.
2047
+ if (!isConvertibleToRoth(state.account) || remaining <= 0)
2048
+ continue;
2049
+ const take = Math.min(state.balance, remaining);
2050
+ state.balance -= take;
2051
+ remaining -= take;
2052
+ // Pro-rata return of basis on converted IRA dollars (step 5): the
2053
+ // basis portion moves to Roth without creating ordinary income.
2054
+ if (take > 0 && state.account.kind === 'ira') {
2055
+ const ownerId = state.account.ownerPersonId ?? primary.id;
2056
+ const proRata = iraProRata.get(ownerId);
2057
+ if (proRata) {
2058
+ const split = splitIraDistribution(proRata, take);
2059
+ iraProRata.set(ownerId, split.next);
2060
+ conversionNontaxable += split.nontaxable;
2061
+ }
2062
+ }
2063
+ }
2064
+ rothConversion = desired - remaining;
2065
+ rothTarget.balance += rothConversion;
2066
+ // Converted principal starts its own 5-year recapture clock (the rule
2067
+ // that gates an early-retirement conversion ladder). The full amount
2068
+ // returns tax-free before earnings, but only the taxable portion is
2069
+ // subject to the 10% recapture penalty — nondeductible basis rolled in
2070
+ // was never included in income (IRS Pub 590-B). `conversionNontaxable`
2071
+ // accumulated only this conversion's basis above (it starts at 0).
2072
+ if (rothConversion > 0.01 && rothTarget.account.type === 'roth') {
2073
+ const rb = rothBasis.get(rothPoolKey(rothTarget.account));
2074
+ const conversionTaxable = Math.max(0, rothConversion - conversionNontaxable);
2075
+ if (rb)
2076
+ rb.conversionLayers.push({ year, amount: rothConversion, taxableAmount: conversionTaxable });
2077
+ }
2078
+ if (rothConversion < desired - 0.01) {
2079
+ warnings.add('A requested Roth conversion exceeded the available traditional balance and was reduced.');
2080
+ }
2081
+ }
2082
+ }
2083
+ }
2084
+ // --- fixed-point tax / withdrawal iteration ----------------------------
2085
+ // Only the taxable (post-pro-rata) part of a conversion is ordinary income.
2086
+ const ordinaryBase = incomeBeforeConversion + rothConversion - conversionNontaxable;
2087
+ // --- HECM coordinated draws (annuity-pension-and-home-equity, step 4) ---
2088
+ // Pfau's coordinated strategy: in the year after the portfolio actually
2089
+ // lost money (the realized wealth-weighted return the growth pass applied,
2090
+ // covering allocated and single-return accounts alike — not the raw
2091
+ // additive shock, which can be negative in a year the portfolio still
2092
+ // gained), fund spending from the line's tax-free loan proceeds instead of
2093
+ // selling depressed assets. The draw covers this year's pre-tax cash need
2094
+ // up to available credit; the year's taxes still ride the normal
2095
+ // withdrawal flow. Deterministic runs (no market series) never have a
2096
+ // losing year, so coordinated draws are Monte Carlo / scenario behavior;
2097
+ // the last-resort backstop below works everywhere.
2098
+ let hecmDraw = 0;
2099
+ {
2100
+ const baseInflows = incomes.total - taxableYieldReinvested + rmdTotal - qcd + seppTotal + inheritedTotal + propertySaleProceedsTotal;
2101
+ let coordinatedNeed = Math.max(0, expenses.total + contributions - baseInflows);
2102
+ if (anyAlive && year > startYear && priorYearPortfolioReturnPct < 0 && coordinatedNeed > 0) {
2103
+ for (const account of plan.accounts) {
2104
+ if (account.type !== 'property' || account.hecm?.drawPolicy !== 'coordinated')
2105
+ continue;
2106
+ const line = hecmStates.get(account.id);
2107
+ if (!line)
2108
+ continue;
2109
+ const draw = Math.min(coordinatedNeed, Math.max(0, line.principalLimit - line.loanBalance));
2110
+ if (draw <= 0)
2111
+ continue;
2112
+ line.loanBalance += draw;
2113
+ hecmDraw += draw;
2114
+ coordinatedNeed -= draw;
2115
+ }
2116
+ }
2117
+ }
2118
+ // Exact-taxed property sale proceeds enter the cash flow here (their gains
2119
+ // are already in the tax base above), so a sale can fund its own tax bill.
2120
+ // HECM draws are loan proceeds — cash in, never income.
2121
+ const cashInflows = incomes.total - taxableYieldReinvested + rmdTotal - qcd + seppTotal + inheritedTotal + propertySaleProceedsTotal + hecmDraw;
2122
+ // Resolve the year's withdrawal strategy. Bracket targeting reuses the
2123
+ // conversion solver to size remaining ordinary-income headroom.
2124
+ let withdrawalStrategy = { mode: 'sequential' };
2125
+ const ws = plan.strategies.withdrawalOrder;
2126
+ if (ws.mode === 'proportional') {
2127
+ withdrawalStrategy = { mode: 'proportional' };
2128
+ }
2129
+ else if (ws.mode === 'bracketTargeted') {
2130
+ const sized = sizeRothConversion({ mode: 'fillToTarget', target: 'topOfBracket', targetValue: ws.bracketPct, startYear: year, endYear: year }, {
2131
+ year,
2132
+ pack,
2133
+ filingStatus: taxFilingStatusForYear,
2134
+ ordinaryIncomeBase: ordinaryBase,
2135
+ capitalGains: oneTimeGains + rebalanceRealizedGains,
2136
+ qualifiedDividends: incomes.qualifiedDividends,
2137
+ ssBenefits: incomes.socialSecurity,
2138
+ peopleAged65Plus,
2139
+ householdSize: aliveCount,
2140
+ inflationScale: inflFactorFrom(pack.year, year),
2141
+ itemizedDeductions,
2142
+ });
2143
+ if (!sized.ok && sized.reason === 'bad_target') {
2144
+ warnings.add('The bracket-targeted withdrawal strategy names an unknown bracket; sequential order was used.');
2145
+ }
2146
+ else {
2147
+ withdrawalStrategy = { mode: 'bracketTargeted', traditionalCap: sized.ok ? sized.amount : 0 };
2148
+ }
2149
+ }
2150
+ // Early-withdrawal penalties: 10% traditional pre-59½ (approximated as
2151
+ // age < 60), 20% HSA non-medical pre-65 (v1 treats HSA spending as
2152
+ // non-medical; HSA sits last in the drain order). The Rule of 55 waives the
2153
+ // traditional penalty for an EMPLOYER plan the owner separated from in/after
2154
+ // the year they turned 55 (IRAs never qualify); "separation" is approximated
2155
+ // by the owner's retirement age. 72(t) SEPP distributions are taken outside
2156
+ // this need-based flow (above), so they're already penalty-free.
2157
+ const penaltiesFor = (byAccountId) => {
2158
+ let total = 0;
2159
+ // Per-owner taxable fraction of aggregated-IRA draws (step 5). The 10%
2160
+ // additional tax applies only to the portion included in gross income
2161
+ // (IRS Topic 557), so nondeductible basis returned pro-rata is not
2162
+ // penalized. Computed against the same current pro-rata state as
2163
+ // `iraBasisEffect`, so the two stay consistent within an iteration.
2164
+ const iraTaxableFraction = new Map();
2165
+ for (const [ownerId, proRata] of iraProRata) {
2166
+ let taken = 0;
2167
+ for (const state of balances) {
2168
+ if (!isAggregatedIra(state.account))
2169
+ continue;
2170
+ if ((state.account.ownerPersonId ?? primary.id) !== ownerId)
2171
+ continue;
2172
+ taken += byAccountId.get(state.account.id) ?? 0;
2173
+ }
2174
+ if (taken <= 0)
2175
+ continue;
2176
+ iraTaxableFraction.set(ownerId, splitIraDistribution(proRata, taken).taxable / taken);
2177
+ }
2178
+ for (const state of balances) {
2179
+ const taken = byAccountId.get(state.account.id) ?? 0;
2180
+ if (taken <= 0)
2181
+ continue;
2182
+ const ownerId = state.account.ownerPersonId ?? primary.id;
2183
+ const ownerAge = stateOf(ownerId).ageAttained;
2184
+ if (state.account.type === 'traditional') {
2185
+ // Return-of-basis is excluded from gross income, so penalize only the
2186
+ // taxable portion of an IRA with nondeductible basis; other
2187
+ // traditional accounts (employer plans, no basis) penalize in full.
2188
+ const penalizable = isAggregatedIra(state.account)
2189
+ ? taken * (iraTaxableFraction.get(ownerId) ?? 1)
2190
+ : taken;
2191
+ total +=
2192
+ penalizable *
2193
+ traditionalWithdrawalPenaltyRate(state.account, {
2194
+ ownerAgeAttained: ownerAge,
2195
+ ownerRetirementAge: personById.get(ownerId)?.retirementAge ?? null,
2196
+ });
2197
+ }
2198
+ // HSA penalties are computed by the subledger probe (hsaEffect below),
2199
+ // which knows how much of a withdrawal is qualified.
2200
+ }
2201
+ if (total > 0)
2202
+ warnings.add('Early-withdrawal penalties were charged (pre-59½ traditional or pre-65 HSA).');
2203
+ return total;
2204
+ };
2205
+ // Roth withdrawals aggregated into their basis pools (an owner's Roth IRAs
2206
+ // share one pool per IRS aggregation; employer Roth stays per-account), so a
2207
+ // draw is ordered against the owner's whole Roth-IRA basis, not one account's.
2208
+ const rothPoolWithdrawals = (byAccountId) => {
2209
+ const byPool = new Map();
2210
+ for (const state of balances) {
2211
+ if (state.account.type !== 'roth')
2212
+ continue;
2213
+ const taken = byAccountId.get(state.account.id) ?? 0;
2214
+ if (taken <= 0)
2215
+ continue;
2216
+ const key = rothPoolKey(state.account);
2217
+ const age = stateOf(state.account.ownerPersonId ?? primary.id).ageAttained;
2218
+ const entry = byPool.get(key);
2219
+ if (entry)
2220
+ entry.taken += taken;
2221
+ else
2222
+ byPool.set(key, { taken, age });
2223
+ }
2224
+ return byPool;
2225
+ };
2226
+ // Roth ordering effect of a candidate Roth draw: the 10% penalty on pre-59½
2227
+ // earnings and unseasoned conversions, plus the earnings taxed as ordinary
2228
+ // income. Pure (probed against the uncommitted basis pools every iteration);
2229
+ // the pools are only mutated once, when the final plan is applied.
2230
+ const rothEarlyEffect = (byAccountId) => {
2231
+ let penalty = 0;
2232
+ let taxableOrdinary = 0;
2233
+ for (const [key, { taken, age }] of rothPoolWithdrawals(byAccountId)) {
2234
+ const rb = rothBasis.get(key);
2235
+ if (!rb)
2236
+ continue;
2237
+ const split = splitRothWithdrawal(rb, taken, year, age);
2238
+ penalty += split.penalty;
2239
+ taxableOrdinary += split.taxableOrdinary;
2240
+ }
2241
+ return { penalty, taxableOrdinary };
2242
+ };
2243
+ // HSA subledger effect of a candidate HSA draw (steps 2–3): how much is a
2244
+ // qualified medical reimbursement (tax- and penalty-free) vs. non-qualified
2245
+ // (ordinary income, 20% penalty pre-65). Pure — probed against the year's
2246
+ // fixed qualified cap every iteration; the reimburse-later pool commits
2247
+ // once, after the final plan. Cap consumption runs in balances order.
2248
+ const hsaEffect = (byAccountId) => {
2249
+ let taxableOrdinary = 0;
2250
+ let penalty = 0;
2251
+ let qualified = 0;
2252
+ let nonQualified = 0;
2253
+ let capLeft = hsaQualifiedCap;
2254
+ for (const state of balances) {
2255
+ if (state.account.type !== 'hsa')
2256
+ continue;
2257
+ const taken = byAccountId.get(state.account.id) ?? 0;
2258
+ if (taken <= 0)
2259
+ continue;
2260
+ const ownerAge = stateOf(state.account.ownerPersonId ?? primary.id).ageAttained;
2261
+ const treatment = state.account.withdrawalTreatment;
2262
+ if (treatment === 'capByMedicalExpenses') {
2263
+ const q = Math.min(taken, capLeft);
2264
+ capLeft -= q;
2265
+ qualified += q;
2266
+ const nq = taken - q;
2267
+ nonQualified += nq;
2268
+ taxableOrdinary += nq;
2269
+ penalty += nq * hsaNonQualifiedPenaltyRate(ownerAge);
2270
+ }
2271
+ else if (treatment === 'assumeAllQualified') {
2272
+ // Explicit simplification: every withdrawal is qualified.
2273
+ qualified += taken;
2274
+ }
2275
+ else {
2276
+ // Legacy v1 treatment: tax-free but conservatively penalized pre-65.
2277
+ qualified += taken;
2278
+ penalty += taken * hsaNonQualifiedPenaltyRate(ownerAge);
2279
+ }
2280
+ }
2281
+ return { taxableOrdinary, penalty, qualified, nonQualified, capConsumed: hsaQualifiedCap - capLeft };
2282
+ };
2283
+ // Pro-rata (Form 8606) return-of-basis in a candidate's need-based IRA
2284
+ // draws (step 5). Pure — probed against the uncommitted per-owner year
2285
+ // state; the pools commit once, after the final plan.
2286
+ const iraBasisEffect = (byAccountId) => {
2287
+ if (iraProRata.size === 0)
2288
+ return 0;
2289
+ let nontaxable = 0;
2290
+ for (const [ownerId, proRata] of iraProRata) {
2291
+ let taken = 0;
2292
+ for (const state of balances) {
2293
+ if (!isAggregatedIra(state.account))
2294
+ continue;
2295
+ if ((state.account.ownerPersonId ?? primary.id) !== ownerId)
2296
+ continue;
2297
+ taken += byAccountId.get(state.account.id) ?? 0;
2298
+ }
2299
+ nontaxable += splitIraDistribution(proRata, taken).nontaxable;
2300
+ }
2301
+ return nontaxable;
2302
+ };
2303
+ // Withdrawals hold the safety-net floor (nominal) back from liquid accounts.
2304
+ const floorReserveNominal = safetyNetFloorToday > 0 ? safetyNetFloorToday * inflFactor : 0;
2305
+ // Capital-loss carryforward (today's start-of-year pool, constant across the
2306
+ // iteration); netting reduces ordinary + gains before both federal and state
2307
+ // tax so the AGI cascade (taxable SS, IRMAA, ACA, state) falls out for free.
2308
+ const lossOffsetLimit = pack.federalTax.capitalLossOrdinaryOffsetLimit;
2309
+ let tax = 0;
2310
+ let penalties = 0;
2311
+ for (let i = 0; i < MAX_TAX_ITERATIONS; i++) {
2312
+ const need = Math.max(0, expenses.total + contributions + tax + penalties - cashInflows);
2313
+ const candidate = planWithdrawals(need, balances, withdrawalStrategy, year, floorReserveNominal);
2314
+ const rothEffect = rothEarlyEffect(candidate.byAccountId);
2315
+ const hsaProbe = hsaEffect(candidate.byAccountId);
2316
+ const iraNontaxableProbe = iraBasisEffect(candidate.byAccountId);
2317
+ const nettedProbe = applyCapitalLossCarryforward(capitalLossPool, ordinaryBase + candidate.byCategory.traditional - iraNontaxableProbe + rothEffect.taxableOrdinary + hsaProbe.taxableOrdinary, oneTimeGains + rebalanceRealizedGains + candidate.realizedGains, lossOffsetLimit);
2318
+ const newTax = taxCalculator.compute({
2319
+ year,
2320
+ filingStatus: filingStatusForYear,
2321
+ ordinaryIncome: nettedProbe.ordinaryAfter,
2322
+ capitalGains: nettedProbe.netCapitalGain,
2323
+ realizedCapitalGainsBeforeCarryforward: oneTimeGains + rebalanceRealizedGains + candidate.realizedGains,
2324
+ taxableInterestIncome: incomes.taxableInterest + ladderTaxableInterest,
2325
+ usGovernmentInterest: ladderTaxableInterest,
2326
+ ordinaryDividends: incomes.ordinaryDividends,
2327
+ qualifiedDividends: incomes.qualifiedDividends,
2328
+ ssBenefits: incomes.socialSecurity,
2329
+ peopleAged65Plus,
2330
+ state: residenceState,
2331
+ stateResidency,
2332
+ privateRetirementIncome: privateRetirementBase + candidate.byCategory.traditional - iraNontaxableProbe,
2333
+ publicPensionIncome: publicPensionBase,
2334
+ agesAlive,
2335
+ itemizedDeductions,
2336
+ });
2337
+ const newPenalties = penaltiesFor(candidate.byAccountId) + rothEffect.penalty + hsaProbe.penalty;
2338
+ const converged = Math.abs(newTax - tax) + Math.abs(newPenalties - penalties) < EPSILON;
2339
+ tax = newTax;
2340
+ penalties = newPenalties;
2341
+ if (converged)
2342
+ break;
2343
+ }
2344
+ const finalNeed = Math.max(0, expenses.total + contributions + tax + penalties - cashInflows);
2345
+ const withdrawalPlan = planWithdrawals(finalNeed, balances, withdrawalStrategy, year, floorReserveNominal);
2346
+ // Any open HECM line backstops a true portfolio shortfall regardless of
2347
+ // draw policy — no borrower defaults on spending with credit available.
2348
+ // The policy only controls proactive (coordinated) draws above.
2349
+ let hecmShortfallDraw = 0;
2350
+ if (withdrawalPlan.shortfall > EPSILON && anyAlive) {
2351
+ let remaining = withdrawalPlan.shortfall;
2352
+ for (const account of plan.accounts) {
2353
+ if (account.type !== 'property' || !account.hecm)
2354
+ continue;
2355
+ const line = hecmStates.get(account.id);
2356
+ if (!line)
2357
+ continue;
2358
+ const draw = Math.min(remaining, Math.max(0, line.principalLimit - line.loanBalance));
2359
+ if (draw <= 0)
2360
+ continue;
2361
+ line.loanBalance += draw;
2362
+ hecmShortfallDraw += draw;
2363
+ remaining -= draw;
2364
+ if (remaining <= EPSILON)
2365
+ break;
2366
+ }
2367
+ hecmDraw += hecmShortfallDraw;
2368
+ }
2369
+ const shortfallAfterHecm = Math.max(0, withdrawalPlan.shortfall - hecmShortfallDraw);
2370
+ const surplus = Math.max(0, cashInflows - expenses.total - contributions - tax - penalties);
2371
+ const rothEffectFinal = rothEarlyEffect(withdrawalPlan.byAccountId);
2372
+ const hsaEffectFinal = hsaEffect(withdrawalPlan.byAccountId);
2373
+ const iraNontaxableFinal = iraBasisEffect(withdrawalPlan.byAccountId);
2374
+ if (withdrawalPlan.reserveUsed > EPSILON) {
2375
+ warnings.add('Spending needs dipped into the taxable safety-net floor after all other accounts were exhausted.');
2376
+ }
2377
+ if (hsaEffectFinal.taxableOrdinary > EPSILON) {
2378
+ warnings.add('Some HSA withdrawals exceeded modeled qualified medical expenses; the excess was taxed as ordinary income (and penalized before 65).');
2379
+ }
2380
+ if (rothEffectFinal.penalty > 0) {
2381
+ warnings.add('Early Roth distributions were penalized: earnings before 59½, or converted amounts tapped within 5 years (the conversion-ladder seasoning rule).');
2382
+ }
2383
+ if (rc.mode === 'fillToTarget' && rothConversion > 0 && withdrawalPlan.byCategory.traditional > 0.01) {
2384
+ warnings.add('Spending withdrawals from traditional accounts pushed income above the Roth-conversion target in some years.');
2385
+ }
2386
+ // Apply the carryforward to the final realized figures, then commit the
2387
+ // depleted pool to next year. Netted ordinary/gains feed MAGI, taxable SS,
2388
+ // and the gain-harvesting headroom below, so the AGI cascade is consistent.
2389
+ // IRA pro-rata basis reduces the taxable traditional draw; non-qualified
2390
+ // HSA withdrawals add ordinary income.
2391
+ const lossNetting = applyCapitalLossCarryforward(capitalLossPool, Math.max(0, ordinaryBase +
2392
+ withdrawalPlan.byCategory.traditional -
2393
+ iraNontaxableFinal +
2394
+ rothEffectFinal.taxableOrdinary +
2395
+ hsaEffectFinal.taxableOrdinary), oneTimeGains + rebalanceRealizedGains + withdrawalPlan.realizedGains, lossOffsetLimit);
2396
+ capitalLossPool = lossNetting.remaining;
2397
+ // Record realized MAGI (≈ AGI) for IRMAA's 2-year lookback and ACA. Non-
2398
+ // qualified Roth earnings are ordinary income, so they lift MAGI too.
2399
+ // gainsRealized is signed (a net capital loss is negative); floor MAGI at 0.
2400
+ const ordinaryRealized = lossNetting.ordinaryAfter;
2401
+ const gainsRealized = lossNetting.netCapitalGain;
2402
+ const realizedCapitalGainsBeforeCarryforward = oneTimeGains + rebalanceRealizedGains + withdrawalPlan.realizedGains;
2403
+ const taxableSs = taxableSocialSecurity(pack, taxFilingStatusForYear, ordinaryRealized + gainsRealized + incomes.qualifiedDividends, incomes.socialSecurity);
2404
+ magiHistory.set(year, Math.max(0, ordinaryRealized + gainsRealized + incomes.qualifiedDividends + taxableSs));
2405
+ // Gain-harvesting advisory: room left in the 0% LTCG bracket this year, given
2406
+ // the realized income and deductions (roadmap V8 §4). Advisory only — the
2407
+ // engine doesn't auto-harvest. Federal-law boundary, so computed federally.
2408
+ const federalDetail = computeFederalTax({
2409
+ year,
2410
+ filingStatus: filingStatusForYear,
2411
+ ordinaryIncome: ordinaryRealized,
2412
+ capitalGains: gainsRealized,
2413
+ realizedCapitalGainsBeforeCarryforward,
2414
+ taxableInterestIncome: incomes.taxableInterest + ladderTaxableInterest,
2415
+ usGovernmentInterest: ladderTaxableInterest,
2416
+ ordinaryDividends: incomes.ordinaryDividends,
2417
+ qualifiedDividends: incomes.qualifiedDividends,
2418
+ ssBenefits: incomes.socialSecurity,
2419
+ peopleAged65Plus,
2420
+ itemizedDeductions,
2421
+ });
2422
+ const ltcgZeroHeadroom = federalDetail.zeroRateLtcgHeadroom;
2423
+ if (federalDetail.alternativeMinimumTax > EPSILON) {
2424
+ warnings.add('The planning-grade AMT screen bound in at least one year; tax includes the AMT excess.');
2425
+ }
2426
+ // V8 optimizer linearization probe (no-op unless a sink is supplied). The
2427
+ // ordinary base excludes all traditional distributions and conversions —
2428
+ // `incomeBeforeConversion` already nets out preTaxContributions and QCD and
2429
+ // includes RMD, so subtracting RMD leaves the non-traditional ordinary
2430
+ // income; the baseline taxable-SS portion is folded in as a constant.
2431
+ if (opts.captureOptimizerInputs) {
2432
+ let startTraditional = 0;
2433
+ let startInheritedTraditional = 0;
2434
+ for (const state of balances) {
2435
+ if (state.account.type === 'traditional' && !state.account.inherited) {
2436
+ startTraditional += startOfYearBalance.get(state.account.id) ?? 0;
2437
+ }
2438
+ else if (state.account.type === 'traditional' && state.account.inherited) {
2439
+ startInheritedTraditional += startOfYearBalance.get(state.account.id) ?? 0;
2440
+ }
2441
+ }
2442
+ opts.captureOptimizerInputs({
2443
+ year,
2444
+ ordinaryIncomeBase: Math.max(0, incomeBeforeConversion - rmdTotal - inheritedTotal) + taxableSs,
2445
+ spendingNeed: expenses.total + contributions,
2446
+ exogenousCash: incomes.total - taxableYieldReinvested,
2447
+ traditionalInflow,
2448
+ otherInflow,
2449
+ taxableInflow,
2450
+ ssBenefits: incomes.socialSecurity,
2451
+ taxableSsBase: taxableSs,
2452
+ // Gains EXCLUDING taxable-withdrawal realizations: the optimizer
2453
+ // re-decides taxable draws as its own `wtax` variable and adds their
2454
+ // gain share to provisional income / MAGI itself, so including the
2455
+ // baseline's withdrawal-driven gains here would double-count them.
2456
+ // (Pre-netting components; capital-loss carryforward refinement is
2457
+ // left to the exact ledger.)
2458
+ capitalGainsBase: Math.max(0, oneTimeGains + rebalanceRealizedGains) + incomes.qualifiedDividends,
2459
+ rmd: rmdTotal,
2460
+ startTraditional,
2461
+ inheritedDistribution: inheritedTotal,
2462
+ startInheritedTraditional,
2463
+ peopleAged65Plus,
2464
+ ssa44IrmaaRedetermination: ssa44ActiveInYear(year),
2465
+ });
2466
+ }
2467
+ // --- apply flows -------------------------------------------------------
2468
+ for (const state of balances) {
2469
+ const taken = withdrawalPlan.byAccountId.get(state.account.id) ?? 0;
2470
+ if (taken <= 0)
2471
+ continue;
2472
+ if ((state.account.type === 'taxable' || state.account.type === 'equityComp') && state.balance > 0) {
2473
+ const basisRatio = Math.min(1, state.costBasis / state.balance);
2474
+ state.costBasis = Math.max(0, state.costBasis - taken * basisRatio);
2475
+ }
2476
+ state.balance -= taken;
2477
+ }
2478
+ // Commit the Roth basis ordering (contributions → conversions → earnings) once
2479
+ // per pool, so next year's seasoning + earnings are correct across the owner's
2480
+ // aggregated Roth IRAs.
2481
+ for (const [key, { taken, age }] of rothPoolWithdrawals(withdrawalPlan.byAccountId)) {
2482
+ const rb = rothBasis.get(key);
2483
+ if (rb)
2484
+ rothBasis.set(key, splitRothWithdrawal(rb, taken, year, age).next);
2485
+ }
2486
+ // Commit the year's Form-8606 IRA basis depletion from need-based draws
2487
+ // (RMD/SEPP/conversion basis already committed above as they happened).
2488
+ if (iraProRata.size > 0) {
2489
+ for (const [ownerId, proRata] of iraProRata) {
2490
+ let taken = 0;
2491
+ for (const state of balances) {
2492
+ if (!isAggregatedIra(state.account))
2493
+ continue;
2494
+ if ((state.account.ownerPersonId ?? primary.id) !== ownerId)
2495
+ continue;
2496
+ taken += withdrawalPlan.byAccountId.get(state.account.id) ?? 0;
2497
+ }
2498
+ const next = splitIraDistribution(proRata, taken).next;
2499
+ iraBasisByOwner.set(ownerId, next.basis);
2500
+ }
2501
+ }
2502
+ // Reimburse-later accumulation (step 3): out-of-pocket qualified medical
2503
+ // expenses this year (modeled costs the cap-mode HSAs did NOT reimburse)
2504
+ // grow the pool; qualified HSA reimbursements draw it down. Grows in
2505
+ // nominal dollars alongside the expenses it defers. Only cap-mode
2506
+ // consumption (`capConsumed`) touches the pool — qualified draws from
2507
+ // `assumeAllQualified`/legacy HSAs are not measured against modeled
2508
+ // expenses and must not draw the pool down.
2509
+ if (hsaReimburseLaterActive) {
2510
+ const qualifiedDrawn = hsaEffectFinal.capConsumed;
2511
+ const reimbursedFromCurrentYear = Math.min(qualifiedDrawn, qualifiedMedicalThisYear);
2512
+ const drawnFromPool = qualifiedDrawn - reimbursedFromCurrentYear;
2513
+ const outOfPocketThisYear = Math.max(0, qualifiedMedicalThisYear - reimbursedFromCurrentYear);
2514
+ hsaReimbursablePool = Math.max(0, hsaReimbursablePool - drawnFromPool) + outOfPocketThisYear;
2515
+ }
2516
+ deposit(surplus);
2517
+ if (shortfallAfterHecm > EPSILON && depletionYear === null)
2518
+ depletionYear = year;
2519
+ // --- property events + growth ------------------------------------------
2520
+ for (const account of plan.accounts) {
2521
+ if (account.type !== 'property')
2522
+ continue;
2523
+ let value = propertyValues.get(account.id) ?? 0;
2524
+ value *= 1 + inflRateAt(year);
2525
+ if (account.plannedSaleYear === year && value > 0) {
2526
+ // Exact-taxed sales (costBasis set) already deposited their net
2527
+ // proceeds through the year's cash flow above; the legacy tax-free
2528
+ // expectedNetProceeds path deposits here — net of any HECM payoff,
2529
+ // which is non-recourse (never more than the sale nets).
2530
+ if (account.costBasis === undefined) {
2531
+ const proceeds = account.expectedNetProceeds ?? value;
2532
+ const line = hecmStates.get(account.id);
2533
+ const hecmPayoff = line ? Math.min(line.loanBalance, Math.max(0, proceeds)) : 0;
2534
+ if (line)
2535
+ hecmStates.delete(account.id);
2536
+ deposit(proceeds - hecmPayoff);
2537
+ }
2538
+ value = 0;
2539
+ }
2540
+ propertyValues.set(account.id, value);
2541
+ // An open line compounds at the line's growth rate on both sides: the
2542
+ // unused principal limit grows regardless of home value (the buffer-
2543
+ // asset property), and the loan balance accrues rate + MIP.
2544
+ const line = hecmStates.get(account.id);
2545
+ if (line && account.hecm) {
2546
+ const growth = 1 + account.hecm.growthRatePct / 100;
2547
+ line.principalLimit *= growth;
2548
+ line.loanBalance *= growth;
2549
+ }
2550
+ }
2551
+ // --- insurance: permanent-life cash value + death benefit --------------
2552
+ let deathBenefitPaid = 0;
2553
+ for (const policy of plan.insurance) {
2554
+ if (policy.kind !== 'permanentLife')
2555
+ continue;
2556
+ const insured = personById.get(policy.insured);
2557
+ const deathAge = insured ? lifeAgeOf(insured) : Infinity;
2558
+ const ageAttained = insured ? stateOf(policy.insured).ageAttained : -Infinity;
2559
+ if (ageAttained < deathAge) {
2560
+ // Alive, before the settlement year: cash value tracks the illustration
2561
+ // (schedule) or compounds (flatRate).
2562
+ if (policy.cashValueMode === 'schedule' && policy.cashValueSchedule) {
2563
+ insuranceCashValues.set(policy.id, interpolateByAge(policy.cashValueSchedule, ageAttained));
2564
+ }
2565
+ else {
2566
+ const prev = insuranceCashValues.get(policy.id) ?? 0;
2567
+ insuranceCashValues.set(policy.id, prev * (1 + (policy.cashValueGrowthPct ?? 0) / 100));
2568
+ }
2569
+ }
2570
+ else if (ageAttained === deathAge) {
2571
+ // Final alive year = death settlement. Pay here (not at deathAge + 1,
2572
+ // which is past endYear for the last survivor — exactly the estate case
2573
+ // the policy models) so the benefit always lands in the projection. The
2574
+ // cash value rolls into the benefit and is zeroed so it isn't double-
2575
+ // counted in net worth; a real death benefit is never less than the cash
2576
+ // value, so max() also guards the flat-rate model drifting above face.
2577
+ const cashValue = insuranceCashValues.get(policy.id) ?? 0;
2578
+ const payout = Math.max(policy.deathBenefit, cashValue);
2579
+ deposit(payout);
2580
+ deathBenefitPaid += payout;
2581
+ insuranceCashValues.set(policy.id, 0);
2582
+ }
2583
+ else {
2584
+ insuranceCashValues.set(policy.id, 0);
2585
+ }
2586
+ }
2587
+ const shockPct = returnShockAt(year);
2588
+ // Wealth-weighted total return the ledger actually applies this year
2589
+ // (including distributed taxable yield — a distribution, not a loss).
2590
+ // Next year's coordinated HECM check reads it, so the down-market signal
2591
+ // is the realized portfolio return, not the raw additive shock.
2592
+ let returnWeightedSum = 0;
2593
+ let returnWeightBase = 0;
2594
+ for (const state of balances) {
2595
+ const taxableYieldPct = state.account.type === 'taxable' ? (taxableYieldByAccountId.get(state.account.id)?.totalYieldPct ?? 0) : 0;
2596
+ const track = allocationTrack.get(state.account.id);
2597
+ if (track) {
2598
+ // Allocated account: growth is the class blend at this year's weights
2599
+ // (superseding annualReturnPct); distributed taxable yield is carved
2600
+ // out of price growth exactly like the single-return path. Weights
2601
+ // then drift with the differential class returns until the next
2602
+ // rebalance (or forever, when rebalancing is 'none').
2603
+ const classRates = ASSET_CLASS_IDS.map((id, i) => classParams[id].returnPct + classShockAt(year, i));
2604
+ const blendedPct = classRates.reduce((sum, r, i) => sum + r * (track.weights[i] ?? 0), 0);
2605
+ returnWeightedSum += state.balance * blendedPct;
2606
+ returnWeightBase += state.balance;
2607
+ state.balance *= Math.max(0, 1 + (blendedPct - taxableYieldPct) / 100);
2608
+ track.weights = driftWeights(track.weights, classRates);
2609
+ continue;
2610
+ }
2611
+ const expectedPct = state.account.annualReturnPct ?? plan.assumptions.defaultReturnPct;
2612
+ // Cash is a stable-value bucket: the market shock hits invested accounts only.
2613
+ const ratePct = state.account.type === 'cash' ? expectedPct : expectedPct + shockPct - taxableYieldPct;
2614
+ returnWeightedSum += state.balance * (state.account.type === 'cash' ? expectedPct : expectedPct + shockPct);
2615
+ returnWeightBase += state.balance;
2616
+ state.balance *= Math.max(0, 1 + ratePct / 100);
2617
+ }
2618
+ priorYearPortfolioReturnPct = returnWeightBase > 0 ? returnWeightedSum / returnWeightBase : 0;
2619
+ for (const state of balances) {
2620
+ const taxableYield = taxableYieldByAccountId.get(state.account.id);
2621
+ if (!taxableYield?.reinvest || taxableYield.gross <= 0)
2622
+ continue;
2623
+ state.balance += taxableYield.gross;
2624
+ if (state.account.type === 'taxable')
2625
+ state.costBasis += taxableYield.gross;
2626
+ }
2627
+ // --- snapshot ------------------------------------------------------------
2628
+ const balanceRecord = {};
2629
+ let investableTotal = unassignedCash;
2630
+ for (const state of balances) {
2631
+ balanceRecord[state.account.id] = state.balance;
2632
+ investableTotal += state.balance;
2633
+ }
2634
+ let propertyTotal = 0;
2635
+ for (const [id, value] of propertyValues) {
2636
+ balanceRecord[id] = value;
2637
+ propertyTotal += value;
2638
+ }
2639
+ let debtTotal = 0;
2640
+ for (const [id, value] of debtBalances) {
2641
+ balanceRecord[id] = value;
2642
+ debtTotal += value;
2643
+ }
2644
+ // HECM loans net against net worth with the non-recourse floor honored:
2645
+ // the lender's claim never exceeds the home's value, so heirs are never
2646
+ // charged for a loan that outgrew the house.
2647
+ let hecmLoanTotal = 0;
2648
+ let hecmEffectiveDebt = 0;
2649
+ for (const [id, line] of hecmStates) {
2650
+ hecmLoanTotal += line.loanBalance;
2651
+ hecmEffectiveDebt += Math.min(line.loanBalance, propertyValues.get(id) ?? 0);
2652
+ }
2653
+ let insuranceCashValueTotal = 0;
2654
+ for (const [id, value] of insuranceCashValues) {
2655
+ balanceRecord[id] = value;
2656
+ insuranceCashValueTotal += value;
2657
+ }
2658
+ const reportedWithdrawals = {
2659
+ ...withdrawalPlan.byCategory,
2660
+ traditional: withdrawalPlan.byCategory.traditional + rmdTotal + seppTotal + inheritedTotal,
2661
+ total: withdrawalPlan.byCategory.total + rmdTotal + seppTotal + inheritedTotal,
2662
+ };
2663
+ // Attribute any portfolio shortfall across the spending layers: a deliberate
2664
+ // guardrail cut is a target-lifestyle miss, a genuine shortfall reaches the
2665
+ // required floor only after exhausting discretionary. Skipped goals are added
2666
+ // on top (a skipped goal is spending that never happened). Legacy `shortfall`
2667
+ // (and depletion-year logic) are left exactly as they were.
2668
+ const shortfallAttribution = attributeShortfall({
2669
+ requiredSpending: requiredSpendingBase,
2670
+ targetSpending: targetSpendingBase,
2671
+ idealSpending: idealSpendingBase,
2672
+ excessSpending: excessSpendingBase,
2673
+ fundedSpending: expenses.total,
2674
+ withdrawalShortfall: shortfallAfterHecm,
2675
+ });
2676
+ const requiredShortfall = shortfallAttribution.requiredShortfall + skippedRequiredNominal;
2677
+ const targetShortfall = shortfallAttribution.targetShortfall + skippedTargetNominal + skippedRequiredNominal;
2678
+ const idealShortfall = shortfallAttribution.idealShortfall + skippedIdealNominal;
2679
+ const excessShortfall = shortfallAttribution.excessShortfall + skippedExcessNominal;
2680
+ years.push({
2681
+ year,
2682
+ people: peopleStates,
2683
+ filingStatus: filingStatusForYear,
2684
+ incomes,
2685
+ expenses,
2686
+ contributions,
2687
+ employerMatch,
2688
+ rmd: rmdTotal,
2689
+ sepp: seppTotal,
2690
+ inheritedDistribution: inheritedTotal,
2691
+ qcd,
2692
+ rothConversion,
2693
+ penalties,
2694
+ magi: magiHistory.get(year),
2695
+ medicarePremiums,
2696
+ irmaaTier,
2697
+ amt: federalDetail.alternativeMinimumTax,
2698
+ ltcgZeroHeadroom,
2699
+ ssEarningsTestWithheld,
2700
+ ssdiPaid,
2701
+ tax,
2702
+ withdrawals: reportedWithdrawals,
2703
+ realizedGains: withdrawalPlan.realizedGains + rebalanceRealizedGains,
2704
+ taxableYield: incomes.taxableYield,
2705
+ capitalLossUsedAgainstGains: lossNetting.usedAgainstGains,
2706
+ capitalLossUsedAgainstOrdinary: lossNetting.usedAgainstOrdinary,
2707
+ capitalLossCarryforwardRemaining: lossNetting.remaining,
2708
+ surplusInvested: surplus,
2709
+ shortfall: shortfallAfterHecm,
2710
+ requiredShortfall,
2711
+ targetShortfall,
2712
+ idealShortfall,
2713
+ excessShortfall,
2714
+ guardrailAction,
2715
+ flexibleGoals: goalOutcomeCounts,
2716
+ balances: balanceRecord,
2717
+ investableTotal,
2718
+ insuranceCashValue: insuranceCashValueTotal,
2719
+ ladderValue: ladderValueTotal,
2720
+ deathBenefit: deathBenefitPaid,
2721
+ hecmDraw,
2722
+ hecmLoanBalance: hecmLoanTotal,
2723
+ netWorth: investableTotal + propertyTotal - debtTotal + insuranceCashValueTotal + ladderValueTotal - hecmEffectiveDebt,
2724
+ });
2725
+ }
2726
+ const last = years[years.length - 1];
2727
+ // Remaining nondeductible IRA basis at the horizon, capped per owner at their
2728
+ // ending aggregated-IRA balance (basis can exceed the balance after market
2729
+ // losses, but only balance-worth of dollars actually pass to the heir).
2730
+ let endingNondeductibleIraBasis = 0;
2731
+ for (const [ownerId, basis] of iraBasisByOwner) {
2732
+ if (basis <= 0)
2733
+ continue;
2734
+ let ownerIraBalance = 0;
2735
+ for (const state of balances) {
2736
+ if (!isAggregatedIra(state.account))
2737
+ continue;
2738
+ if ((state.account.ownerPersonId ?? primary.id) !== ownerId)
2739
+ continue;
2740
+ ownerIraBalance += state.balance;
2741
+ }
2742
+ endingNondeductibleIraBasis += Math.min(basis, ownerIraBalance);
2743
+ }
2744
+ return {
2745
+ startYear,
2746
+ endYear,
2747
+ years,
2748
+ depletionYear,
2749
+ endingInvestable: last?.investableTotal ?? 0,
2750
+ endingNetWorth: last?.netWorth ?? 0,
2751
+ endingNondeductibleIraBasis,
2752
+ warnings: [...warnings],
2753
+ };
2754
+ }