@retiregolden/engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +79 -0
- package/dist/allocation/assetClasses.d.ts +94 -0
- package/dist/allocation/assetClasses.js +215 -0
- package/dist/decisions/annuitization.d.ts +72 -0
- package/dist/decisions/annuitization.js +184 -0
- package/dist/decisions/decisionFixtures.d.ts +48 -0
- package/dist/decisions/decisionFixtures.js +253 -0
- package/dist/decisions/evaluateCandidate.d.ts +61 -0
- package/dist/decisions/evaluateCandidate.js +193 -0
- package/dist/decisions/generators.d.ts +110 -0
- package/dist/decisions/generators.js +667 -0
- package/dist/decisions/index.d.ts +17 -0
- package/dist/decisions/index.js +17 -0
- package/dist/decisions/insightsAdapter.d.ts +27 -0
- package/dist/decisions/insightsAdapter.js +51 -0
- package/dist/decisions/objectives.d.ts +96 -0
- package/dist/decisions/objectives.js +319 -0
- package/dist/decisions/pensionElection.d.ts +112 -0
- package/dist/decisions/pensionElection.js +239 -0
- package/dist/decisions/search.d.ts +51 -0
- package/dist/decisions/search.js +110 -0
- package/dist/decisions/spendingSolver.d.ts +70 -0
- package/dist/decisions/spendingSolver.js +180 -0
- package/dist/decisions/spiaQuotes.d.ts +21 -0
- package/dist/decisions/spiaQuotes.js +43 -0
- package/dist/decisions/stochastic.d.ts +11 -0
- package/dist/decisions/stochastic.js +66 -0
- package/dist/decisions/swrComparator.d.ts +54 -0
- package/dist/decisions/swrComparator.js +89 -0
- package/dist/decisions/tournament.d.ts +53 -0
- package/dist/decisions/tournament.js +104 -0
- package/dist/decisions/types.d.ts +125 -0
- package/dist/decisions/types.js +12 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/insights/detectors/annuitizationHeadroom.d.ts +11 -0
- package/dist/insights/detectors/annuitizationHeadroom.js +77 -0
- package/dist/insights/detectors/assetLocation.d.ts +9 -0
- package/dist/insights/detectors/assetLocation.js +86 -0
- package/dist/insights/detectors/hecmBufferCandidate.d.ts +12 -0
- package/dist/insights/detectors/hecmBufferCandidate.js +76 -0
- package/dist/insights/detectors/incomeFloorFunded.d.ts +8 -0
- package/dist/insights/detectors/incomeFloorFunded.js +52 -0
- package/dist/insights/detectors/irmaaTierEdge.d.ts +2 -0
- package/dist/insights/detectors/irmaaTierEdge.js +102 -0
- package/dist/insights/detectors/pensionElectionPending.d.ts +11 -0
- package/dist/insights/detectors/pensionElectionPending.js +62 -0
- package/dist/insights/detectors/qcdEfficiency.d.ts +2 -0
- package/dist/insights/detectors/qcdEfficiency.js +62 -0
- package/dist/insights/detectors/rothBridgeHeadroom.d.ts +2 -0
- package/dist/insights/detectors/rothBridgeHeadroom.js +77 -0
- package/dist/insights/detectors/spendingGuardrails.d.ts +2 -0
- package/dist/insights/detectors/spendingGuardrails.js +77 -0
- package/dist/insights/detectors/spendingHeadroom.d.ts +12 -0
- package/dist/insights/detectors/spendingHeadroom.js +92 -0
- package/dist/insights/detectors/ssBridgeGap.d.ts +9 -0
- package/dist/insights/detectors/ssBridgeGap.js +92 -0
- package/dist/insights/detectors/stateRelocation.d.ts +12 -0
- package/dist/insights/detectors/stateRelocation.js +99 -0
- package/dist/insights/detectors/widowsPenalty.d.ts +12 -0
- package/dist/insights/detectors/widowsPenalty.js +145 -0
- package/dist/insights/registry.d.ts +4 -0
- package/dist/insights/registry.js +62 -0
- package/dist/insights/runInsights.d.ts +6 -0
- package/dist/insights/runInsights.js +15 -0
- package/dist/insights/types.d.ts +70 -0
- package/dist/insights/types.js +1 -0
- package/dist/ladder/bridge.d.ts +72 -0
- package/dist/ladder/bridge.js +59 -0
- package/dist/ladder/fedInvest.d.ts +55 -0
- package/dist/ladder/fedInvest.js +77 -0
- package/dist/ladder/fundedRatio.d.ts +41 -0
- package/dist/ladder/fundedRatio.js +46 -0
- package/dist/ladder/ladderMath.d.ts +88 -0
- package/dist/ladder/ladderMath.js +140 -0
- package/dist/longevity/ssaPeriod2022.d.ts +10 -0
- package/dist/longevity/ssaPeriod2022.js +51 -0
- package/dist/longevity/types.d.ts +39 -0
- package/dist/longevity/types.js +1 -0
- package/dist/model/migrations.d.ts +20 -0
- package/dist/model/migrations.js +67 -0
- package/dist/model/plan.d.ts +3108 -0
- package/dist/model/plan.js +1514 -0
- package/dist/montecarlo/frontiers.d.ts +22 -0
- package/dist/montecarlo/frontiers.js +61 -0
- package/dist/montecarlo/historicalReturns.d.ts +27 -0
- package/dist/montecarlo/historicalReturns.js +124 -0
- package/dist/montecarlo/historicalSuites.d.ts +45 -0
- package/dist/montecarlo/historicalSuites.js +102 -0
- package/dist/montecarlo/ltcShock.d.ts +42 -0
- package/dist/montecarlo/ltcShock.js +62 -0
- package/dist/montecarlo/marketModels.d.ts +248 -0
- package/dist/montecarlo/marketModels.js +672 -0
- package/dist/montecarlo/mortality.d.ts +31 -0
- package/dist/montecarlo/mortality.js +61 -0
- package/dist/montecarlo/riskBasedGuardrails.d.ts +93 -0
- package/dist/montecarlo/riskBasedGuardrails.js +200 -0
- package/dist/montecarlo/rng.d.ts +18 -0
- package/dist/montecarlo/rng.js +44 -0
- package/dist/montecarlo/run.d.ts +210 -0
- package/dist/montecarlo/run.js +353 -0
- package/dist/montecarlo/sharedPaths.d.ts +43 -0
- package/dist/montecarlo/sharedPaths.js +28 -0
- package/dist/montecarlo/survival.d.ts +49 -0
- package/dist/montecarlo/survival.js +126 -0
- package/dist/params/data/realYieldCurve2026.d.ts +17 -0
- package/dist/params/data/realYieldCurve2026.js +26 -0
- package/dist/params/data/year2026.d.ts +14 -0
- package/dist/params/data/year2026.js +199 -0
- package/dist/params/index.d.ts +63 -0
- package/dist/params/index.js +134 -0
- package/dist/params/provenance.d.ts +29 -0
- package/dist/params/provenance.js +123 -0
- package/dist/params/state/data/year2026.d.ts +18 -0
- package/dist/params/state/data/year2026.js +581 -0
- package/dist/params/state/index.d.ts +13 -0
- package/dist/params/state/index.js +26 -0
- package/dist/params/state/types.d.ts +73 -0
- package/dist/params/state/types.js +10 -0
- package/dist/params/types.d.ts +201 -0
- package/dist/params/types.js +7 -0
- package/dist/projection/annuityForms.d.ts +62 -0
- package/dist/projection/annuityForms.js +87 -0
- package/dist/projection/compare.d.ts +96 -0
- package/dist/projection/compare.js +216 -0
- package/dist/projection/flatTax.d.ts +7 -0
- package/dist/projection/flatTax.js +14 -0
- package/dist/projection/optimizePlan.d.ts +328 -0
- package/dist/projection/optimizePlan.js +1039 -0
- package/dist/projection/relocation.d.ts +149 -0
- package/dist/projection/relocation.js +317 -0
- package/dist/projection/simulate.d.ts +61 -0
- package/dist/projection/simulate.js +2754 -0
- package/dist/projection/types.d.ts +381 -0
- package/dist/projection/types.js +9 -0
- package/dist/rmd/jointLifeTable.d.ts +8 -0
- package/dist/rmd/jointLifeTable.js +663 -0
- package/dist/rmd/rmd.d.ts +29 -0
- package/dist/rmd/rmd.js +34 -0
- package/dist/scenarios/scenarios.d.ts +70 -0
- package/dist/scenarios/scenarios.js +130 -0
- package/dist/socialSecurity/benefitFactor.d.ts +13 -0
- package/dist/socialSecurity/benefitFactor.js +40 -0
- package/dist/socialSecurity/claimFactor.d.ts +33 -0
- package/dist/socialSecurity/claimFactor.js +58 -0
- package/dist/socialSecurity/disability.d.ts +36 -0
- package/dist/socialSecurity/disability.js +42 -0
- package/dist/socialSecurity/familyMaximum.d.ts +38 -0
- package/dist/socialSecurity/familyMaximum.js +48 -0
- package/dist/socialSecurity/maritalBenefits.d.ts +57 -0
- package/dist/socialSecurity/maritalBenefits.js +87 -0
- package/dist/socialSecurity/nra.d.ts +30 -0
- package/dist/socialSecurity/nra.js +83 -0
- package/dist/socialSecurity/piaFromEarnings.d.ts +100 -0
- package/dist/socialSecurity/piaFromEarnings.js +202 -0
- package/dist/socialSecurity/ssaWageData.d.ts +68 -0
- package/dist/socialSecurity/ssaWageData.js +306 -0
- package/dist/socialSecurity/survivorBenefit.d.ts +61 -0
- package/dist/socialSecurity/survivorBenefit.js +61 -0
- package/dist/spending/abw.d.ts +57 -0
- package/dist/spending/abw.js +80 -0
- package/dist/spending/flexibleGoals.d.ts +62 -0
- package/dist/spending/flexibleGoals.js +136 -0
- package/dist/spending/guardrails.d.ts +78 -0
- package/dist/spending/guardrails.js +100 -0
- package/dist/spending/layers.d.ts +81 -0
- package/dist/spending/layers.js +67 -0
- package/dist/spending/shapePresets.d.ts +42 -0
- package/dist/spending/shapePresets.js +77 -0
- package/dist/strategies/accountEligibility.d.ts +81 -0
- package/dist/strategies/accountEligibility.js +98 -0
- package/dist/strategies/inheritedIra.d.ts +45 -0
- package/dist/strategies/inheritedIra.js +43 -0
- package/dist/strategies/iraBasis.d.ts +48 -0
- package/dist/strategies/iraBasis.js +46 -0
- package/dist/strategies/optimizer.d.ts +307 -0
- package/dist/strategies/optimizer.js +551 -0
- package/dist/strategies/rothBasis.d.ts +67 -0
- package/dist/strategies/rothBasis.js +83 -0
- package/dist/strategies/rothConversion.d.ts +55 -0
- package/dist/strategies/rothConversion.js +83 -0
- package/dist/strategies/sepp.d.ts +38 -0
- package/dist/strategies/sepp.js +53 -0
- package/dist/tax/aca.d.ts +40 -0
- package/dist/tax/aca.js +82 -0
- package/dist/tax/federalTax.d.ts +110 -0
- package/dist/tax/federalTax.js +249 -0
- package/dist/tax/medicare.d.ts +20 -0
- package/dist/tax/medicare.js +36 -0
- package/dist/tax/propertySale.d.ts +46 -0
- package/dist/tax/propertySale.js +35 -0
- package/dist/tax/stateTax.d.ts +66 -0
- package/dist/tax/stateTax.js +198 -0
- package/dist/testing/money.d.ts +7 -0
- package/dist/testing/money.js +17 -0
- package/dist/testing/planFixtures.d.ts +29 -0
- package/dist/testing/planFixtures.js +141 -0
- package/package.json +75 -0
|
@@ -0,0 +1,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
|
+
}
|