@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,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Federal income tax (planning-grade), replacing the V1 flat placeholder.
|
|
3
|
+
*
|
|
4
|
+
* Computation order per year:
|
|
5
|
+
* 1. Taxable Social Security via provisional income (unindexed thresholds)
|
|
6
|
+
* 2. AGI = ordinary + capital gains + taxable SS; MAGI ≈ AGI (no foreign
|
|
7
|
+
* exclusions or tax-exempt interest modeled)
|
|
8
|
+
* 3. Deductions: standard + age-65 additions + OBBBA senior deduction
|
|
9
|
+
* (2025–2028, 6%-of-MAGI phase-out); itemized not modeled in v1
|
|
10
|
+
* 4. Ordinary brackets on non-preferential taxable income
|
|
11
|
+
* 5. LTCG/qualified-dividend stacking at 0/15/20% on top of ordinary
|
|
12
|
+
* 6. NIIT 3.8% on investment income over the (unindexed) MAGI threshold
|
|
13
|
+
* 7. Planning-grade AMT screen: modeled add-backs/preference items (notably
|
|
14
|
+
* standard deduction or itemized SALT), AMT exemption/phaseout, and
|
|
15
|
+
* preferential-rate-aware tentative minimum tax.
|
|
16
|
+
*
|
|
17
|
+
* Out of scope here (see DOCS/features/taxes.md): credits, full Form 6251
|
|
18
|
+
* adjustments, early-withdrawal penalties (projection-level), IRMAA
|
|
19
|
+
* (expense-side), state.
|
|
20
|
+
*
|
|
21
|
+
* @see DOCS/domain/domain-rules-reference.md §§1–3
|
|
22
|
+
*/
|
|
23
|
+
import { packForYear, standardDeduction } from '../params/index.js';
|
|
24
|
+
import { taxParameterFilingStatus } from '../projection/types.js';
|
|
25
|
+
/**
|
|
26
|
+
* Itemized-deduction total (SALT capped) from its components, or 0 when none.
|
|
27
|
+
* The OBBBA high-income SALT phase-out is not modeled (see pack `saltCap`).
|
|
28
|
+
*/
|
|
29
|
+
function itemizedTotal(pack, items) {
|
|
30
|
+
if (!items)
|
|
31
|
+
return 0;
|
|
32
|
+
const salt = Math.min(Math.max(0, items.stateAndLocalTaxes), pack.federalTax.saltCap);
|
|
33
|
+
return salt + Math.max(0, items.mortgageInterest) + Math.max(0, items.charitable);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Room left in the 0% long-term-capital-gains bracket this year: additional
|
|
37
|
+
* preferential income that could be realized and still be taxed at 0%. Gains
|
|
38
|
+
* stack on top of ordinary taxable income, so the 0% layer runs up to the 15%
|
|
39
|
+
* threshold.
|
|
40
|
+
*
|
|
41
|
+
* For retirees on Social Security this is NOT simply `threshold − taxableIncome`:
|
|
42
|
+
* realizing gains raises provisional income, which can make more of the benefit
|
|
43
|
+
* taxable, so taxable income climbs faster than $1 per gain dollar. We solve for
|
|
44
|
+
* the largest additional gain that keeps taxable income at the threshold,
|
|
45
|
+
* modeling that SS phase-in (the dominant interaction). The deduction is held
|
|
46
|
+
* fixed — the second-order senior-deduction MAGI phase-out is not modeled.
|
|
47
|
+
* @see DOCS/domain/domain-rules-reference.md §2
|
|
48
|
+
*/
|
|
49
|
+
export function zeroRateLtcgHeadroom(pack, filingStatus, ordinaryExcludingSs, currentGains, currentQualifiedDividends, ssBenefits, deduction) {
|
|
50
|
+
const threshold = pack.capitalGains.rate15StartsAbove[filingStatus];
|
|
51
|
+
const taxableIncomeAt = (extraGains) => {
|
|
52
|
+
const agiExcludingSs = ordinaryExcludingSs + currentGains + currentQualifiedDividends + extraGains;
|
|
53
|
+
const taxableSs = taxableSocialSecurity(pack, filingStatus, agiExcludingSs, ssBenefits);
|
|
54
|
+
return Math.max(0, agiExcludingSs + taxableSs - deduction);
|
|
55
|
+
};
|
|
56
|
+
if (taxableIncomeAt(0) >= threshold)
|
|
57
|
+
return 0;
|
|
58
|
+
// Monotonic increasing in extraGains (slope 1–1.85); binary-search the largest
|
|
59
|
+
// gain that keeps taxable income at the threshold. `threshold` brackets the
|
|
60
|
+
// root since the slope is ≥ 1 and taxableIncomeAt(0) ≥ 0.
|
|
61
|
+
let lo = 0;
|
|
62
|
+
let hi = threshold;
|
|
63
|
+
for (let i = 0; i < 60 && hi - lo > 0.01; i++) {
|
|
64
|
+
const mid = (lo + hi) / 2;
|
|
65
|
+
if (taxableIncomeAt(mid) <= threshold)
|
|
66
|
+
lo = mid;
|
|
67
|
+
else
|
|
68
|
+
hi = mid;
|
|
69
|
+
}
|
|
70
|
+
return lo;
|
|
71
|
+
}
|
|
72
|
+
function bracketTax(brackets, taxable) {
|
|
73
|
+
let tax = 0;
|
|
74
|
+
for (let i = 0; i < brackets.length; i++) {
|
|
75
|
+
const lower = brackets[i].lowerBound;
|
|
76
|
+
const upper = i + 1 < brackets.length ? brackets[i + 1].lowerBound : Infinity;
|
|
77
|
+
if (taxable <= lower)
|
|
78
|
+
break;
|
|
79
|
+
tax += (Math.min(taxable, upper) - lower) * (brackets[i].ratePct / 100);
|
|
80
|
+
}
|
|
81
|
+
return tax;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Taxable share of Social Security benefits (IRC §86).
|
|
85
|
+
* Provisional income = AGI excluding SS + 50% of benefits (tax-exempt interest not modeled).
|
|
86
|
+
*/
|
|
87
|
+
export function taxableSocialSecurity(pack, filingStatus, agiExcludingSs, ssBenefits) {
|
|
88
|
+
if (ssBenefits <= 0)
|
|
89
|
+
return 0;
|
|
90
|
+
const t50 = pack.ssBenefitTaxation.tier50Start[filingStatus];
|
|
91
|
+
const t85 = pack.ssBenefitTaxation.tier85Start[filingStatus];
|
|
92
|
+
const provisional = agiExcludingSs + 0.5 * ssBenefits;
|
|
93
|
+
if (provisional <= t50)
|
|
94
|
+
return 0;
|
|
95
|
+
if (provisional <= t85)
|
|
96
|
+
return Math.min(0.5 * ssBenefits, 0.5 * (provisional - t50));
|
|
97
|
+
const tier1 = Math.min(0.5 * ssBenefits, 0.5 * (t85 - t50));
|
|
98
|
+
return Math.min(0.85 * ssBenefits, 0.85 * (provisional - t85) + tier1);
|
|
99
|
+
}
|
|
100
|
+
/** OBBBA senior deduction (per person 65+, 6% MAGI phase-out, expires after lastApplicableYear). */
|
|
101
|
+
function seniorDeductionAmount(pack, year, filingStatus, peopleAged65Plus, magi) {
|
|
102
|
+
const rule = pack.federalTax.seniorDeduction;
|
|
103
|
+
if (!rule || peopleAged65Plus <= 0 || year > rule.lastApplicableYear)
|
|
104
|
+
return 0;
|
|
105
|
+
const base = rule.amountPerPerson * peopleAged65Plus;
|
|
106
|
+
const phaseOut = Math.max(0, magi - rule.magiPhaseOutStart[filingStatus]) * (rule.phaseOutRatePct / 100);
|
|
107
|
+
return Math.max(0, base - phaseOut);
|
|
108
|
+
}
|
|
109
|
+
/** LTCG/QDI stacked on top of ordinary taxable income at 0/15/20%. */
|
|
110
|
+
function capitalGainsTaxStacked(pack, filingStatus, ordinaryTaxable, preferentialIncome) {
|
|
111
|
+
if (preferentialIncome <= 0)
|
|
112
|
+
return 0;
|
|
113
|
+
const t15 = pack.capitalGains.rate15StartsAbove[filingStatus];
|
|
114
|
+
const t20 = pack.capitalGains.rate20StartsAbove[filingStatus];
|
|
115
|
+
const from = ordinaryTaxable;
|
|
116
|
+
const to = ordinaryTaxable + preferentialIncome;
|
|
117
|
+
// The layer below t15 is the 0% bracket and contributes no tax.
|
|
118
|
+
const at15 = Math.max(0, Math.min(to, t20) - Math.max(from, t15));
|
|
119
|
+
const at20 = Math.max(0, to - Math.max(from, t20));
|
|
120
|
+
return at15 * 0.15 + at20 * 0.2;
|
|
121
|
+
}
|
|
122
|
+
function amtExemptionAmount(pack, filingStatus, amti) {
|
|
123
|
+
const rule = pack.federalTax.amt;
|
|
124
|
+
const base = rule.exemption[filingStatus];
|
|
125
|
+
const phaseOut = Math.max(0, amti - rule.exemptionPhaseOutStart[filingStatus]) * (rule.exemptionPhaseOutRatePct / 100);
|
|
126
|
+
return Math.max(0, base - phaseOut);
|
|
127
|
+
}
|
|
128
|
+
function amtOrdinaryRateTax(pack, taxableExcess) {
|
|
129
|
+
if (taxableExcess <= 0)
|
|
130
|
+
return 0;
|
|
131
|
+
const rule = pack.federalTax.amt;
|
|
132
|
+
const firstLayer = Math.min(taxableExcess, rule.rate28StartsAbove) * (rule.rate26Pct / 100);
|
|
133
|
+
const secondLayer = Math.max(0, taxableExcess - rule.rate28StartsAbove) * (rule.rate28Pct / 100);
|
|
134
|
+
return firstLayer + secondLayer;
|
|
135
|
+
}
|
|
136
|
+
function tentativeMinimumTax(pack, filingStatus, taxableExcess, preferentialIncome) {
|
|
137
|
+
if (taxableExcess <= 0)
|
|
138
|
+
return 0;
|
|
139
|
+
const amtPreferentialIncome = Math.min(Math.max(0, preferentialIncome), taxableExcess);
|
|
140
|
+
const ordinaryAmtExcess = taxableExcess - amtPreferentialIncome;
|
|
141
|
+
return (amtOrdinaryRateTax(pack, ordinaryAmtExcess) +
|
|
142
|
+
capitalGainsTaxStacked(pack, filingStatus, ordinaryAmtExcess, amtPreferentialIncome));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Apply a net capital-loss carryforward to one year (IRC §1211(b)/§1212): net
|
|
146
|
+
* against this year's realized gains first, then deduct up to `ordinaryOffsetLimit`
|
|
147
|
+
* ($3,000) of the remainder as a net capital loss — a *negative* figure on the
|
|
148
|
+
* return's capital-gain line that reduces AGI (and so provisional income, taxable
|
|
149
|
+
* SS, and MAGI) regardless of how much other income there is, **not** an offset
|
|
150
|
+
* capped at ordinary income. The rest carries forward indefinitely. Pure — the
|
|
151
|
+
* projection threads the depleting pool year-to-year and feeds the netted figures
|
|
152
|
+
* to BOTH the federal and state calculators, so the AGI cascade falls out. Single
|
|
153
|
+
* pool, no short-/long-term split (a documented planning simplification); the
|
|
154
|
+
* §1212 carryover-worksheet preservation of a deduction "wasted" in a year with
|
|
155
|
+
* no taxable income to absorb it is not modeled (immaterial outside zero-income
|
|
156
|
+
* years). @see DOCS/features/taxes.md
|
|
157
|
+
*/
|
|
158
|
+
export function applyCapitalLossCarryforward(carryforward, ordinaryIncome, capitalGains, ordinaryOffsetLimit) {
|
|
159
|
+
const pool = Math.max(0, carryforward);
|
|
160
|
+
const netGains = Math.max(0, capitalGains);
|
|
161
|
+
const ordinary = Math.max(0, ordinaryIncome);
|
|
162
|
+
const usedAgainstGains = Math.min(pool, netGains);
|
|
163
|
+
let remaining = pool - usedAgainstGains;
|
|
164
|
+
const usedAgainstOrdinary = Math.min(remaining, Math.max(0, ordinaryOffsetLimit));
|
|
165
|
+
remaining -= usedAgainstOrdinary;
|
|
166
|
+
return {
|
|
167
|
+
ordinaryAfter: ordinary,
|
|
168
|
+
netCapitalGain: netGains - usedAgainstGains - usedAgainstOrdinary,
|
|
169
|
+
usedAgainstGains,
|
|
170
|
+
usedAgainstOrdinary,
|
|
171
|
+
remaining,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
export function computeFederalTax(input) {
|
|
175
|
+
const { year, filingStatus } = input;
|
|
176
|
+
const taxStatus = taxParameterFilingStatus(filingStatus);
|
|
177
|
+
const ordinary = Math.max(0, input.ordinaryIncome);
|
|
178
|
+
// `capitalGains` is signed: after a carryforward absorbs realized gains, the
|
|
179
|
+
// deductible net loss (≤ the annual limit) arrives negative — the 1040 carries
|
|
180
|
+
// it on the capital-gain line, so it reduces AGI / provisional income / taxable
|
|
181
|
+
// SS / MAGI even when there is little or no other income.
|
|
182
|
+
const netCapital = input.capitalGains;
|
|
183
|
+
const gains = Math.max(0, netCapital);
|
|
184
|
+
const qualifiedDividends = Math.max(0, input.qualifiedDividends ?? 0);
|
|
185
|
+
const ss = Math.max(0, input.ssBenefits);
|
|
186
|
+
const { pack, isStandIn } = packForYear(year);
|
|
187
|
+
const agiExcludingSs = ordinary + netCapital + qualifiedDividends; // a net capital loss can drive this below zero
|
|
188
|
+
const taxableSs = taxableSocialSecurity(pack, taxStatus, agiExcludingSs, ss);
|
|
189
|
+
const agi = Math.max(0, agiExcludingSs + taxableSs); // floor for reporting / MAGI / IRMAA / ACA
|
|
190
|
+
const magi = agi;
|
|
191
|
+
const senior = seniorDeductionAmount(pack, year, taxStatus, input.peopleAged65Plus, magi);
|
|
192
|
+
// The OBBBA senior deduction applies whether you take the standard deduction or
|
|
193
|
+
// itemize, so it rides on top of whichever base is larger.
|
|
194
|
+
const standardBase = standardDeduction(pack, taxStatus, input.peopleAged65Plus);
|
|
195
|
+
const itemized = itemizedTotal(pack, input.itemizedDeductions);
|
|
196
|
+
const useItemized = itemized > standardBase;
|
|
197
|
+
const deduction = Math.max(standardBase, itemized) + senior;
|
|
198
|
+
const taxableIncome = Math.max(0, agi - deduction);
|
|
199
|
+
const preferentialIncome = Math.min(gains + qualifiedDividends, taxableIncome);
|
|
200
|
+
const ordinaryTaxable = taxableIncome - preferentialIncome;
|
|
201
|
+
const ordinaryTax = bracketTax(pack.federalTax.brackets[taxStatus], ordinaryTaxable);
|
|
202
|
+
const capitalGainsTax = capitalGainsTaxStacked(pack, taxStatus, ordinaryTaxable, preferentialIncome);
|
|
203
|
+
const saltPreference = useItemized ? Math.min(Math.max(0, input.itemizedDeductions?.stateAndLocalTaxes ?? 0), pack.federalTax.saltCap) : 0;
|
|
204
|
+
const standardDeductionAddback = useItemized ? 0 : deduction;
|
|
205
|
+
const amtPreferenceItems = Math.max(0, input.amtPreferenceItems ?? 0) + saltPreference + standardDeductionAddback;
|
|
206
|
+
const alternativeMinimumTaxableIncome = Math.max(0, taxableIncome + amtPreferenceItems);
|
|
207
|
+
const amtExemption = amtExemptionAmount(pack, taxStatus, alternativeMinimumTaxableIncome);
|
|
208
|
+
const amtTaxableExcess = Math.max(0, alternativeMinimumTaxableIncome - amtExemption);
|
|
209
|
+
const tmt = tentativeMinimumTax(pack, taxStatus, amtTaxableExcess, gains + qualifiedDividends);
|
|
210
|
+
const regularIncomeTax = ordinaryTax + capitalGainsTax;
|
|
211
|
+
const alternativeMinimumTax = Math.max(0, tmt - regularIncomeTax);
|
|
212
|
+
const investmentIncome = gains + qualifiedDividends + Math.max(0, input.taxableInterestIncome ?? 0) + Math.max(0, input.ordinaryDividends ?? 0);
|
|
213
|
+
const niitBase = Math.min(investmentIncome, Math.max(0, magi - pack.niit.magiThreshold[taxStatus]));
|
|
214
|
+
const niit = niitBase * (pack.niit.ratePct / 100);
|
|
215
|
+
return {
|
|
216
|
+
year,
|
|
217
|
+
usesStandInPack: isStandIn,
|
|
218
|
+
taxableSocialSecurity: taxableSs,
|
|
219
|
+
agi,
|
|
220
|
+
magi,
|
|
221
|
+
deduction,
|
|
222
|
+
seniorDeduction: senior,
|
|
223
|
+
itemized: useItemized,
|
|
224
|
+
taxableIncome,
|
|
225
|
+
ordinaryTaxable,
|
|
226
|
+
preferentialIncome,
|
|
227
|
+
ordinaryTax,
|
|
228
|
+
capitalGainsTax,
|
|
229
|
+
amtPreferenceItems,
|
|
230
|
+
alternativeMinimumTaxableIncome,
|
|
231
|
+
amtExemption,
|
|
232
|
+
tentativeMinimumTax: tmt,
|
|
233
|
+
alternativeMinimumTax,
|
|
234
|
+
niit,
|
|
235
|
+
totalTax: regularIncomeTax + alternativeMinimumTax + niit,
|
|
236
|
+
zeroRateLtcgHeadroom: zeroRateLtcgHeadroom(pack, taxStatus, ordinary + Math.min(0, netCapital), gains, qualifiedDividends, ss, deduction),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/** Federal engine behind the projection's pluggable interface. */
|
|
240
|
+
export function createFederalTaxCalculator() {
|
|
241
|
+
return {
|
|
242
|
+
compute: (input) => computeFederalTax(input).totalTax,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
export function combineTaxCalculators(...calculators) {
|
|
246
|
+
return {
|
|
247
|
+
compute: (input) => calculators.reduce((sum, c) => sum + c.compute(input), 0),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Medicare premiums with IRMAA, per person 65+.
|
|
3
|
+
*
|
|
4
|
+
* IRMAA brackets are cliffs determined by MAGI from two years prior. Beyond
|
|
5
|
+
* the latest parameter pack, premiums are indexed at the healthcare inflation
|
|
6
|
+
* rate and bracket thresholds at general inflation (both are statutorily
|
|
7
|
+
* indexed; this is the projection's stand-in).
|
|
8
|
+
*
|
|
9
|
+
* @see DOCS/domain/domain-rules-reference.md §7
|
|
10
|
+
*/
|
|
11
|
+
import type { FilingStatus, ParameterPack } from '../params/types.js';
|
|
12
|
+
export interface MedicarePremiumResult {
|
|
13
|
+
partBAnnual: number;
|
|
14
|
+
partDSurchargeAnnual: number;
|
|
15
|
+
/** 0 = standard premium; 1–5 = IRMAA tier. */
|
|
16
|
+
irmaaTier: number;
|
|
17
|
+
/** True when an IRMAA tier with an unverified Part D surcharge was hit. */
|
|
18
|
+
partDSurchargeUnverified: boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare function medicareAnnualPremiumPerPerson(pack: ParameterPack, magiTwoYearsPrior: number, filingStatus: FilingStatus, thresholdScale?: number, premiumScale?: number): MedicarePremiumResult;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Medicare premiums with IRMAA, per person 65+.
|
|
3
|
+
*
|
|
4
|
+
* IRMAA brackets are cliffs determined by MAGI from two years prior. Beyond
|
|
5
|
+
* the latest parameter pack, premiums are indexed at the healthcare inflation
|
|
6
|
+
* rate and bracket thresholds at general inflation (both are statutorily
|
|
7
|
+
* indexed; this is the projection's stand-in).
|
|
8
|
+
*
|
|
9
|
+
* @see DOCS/domain/domain-rules-reference.md §7
|
|
10
|
+
*/
|
|
11
|
+
import { irmaaTierForMagi } from '../params/index.js';
|
|
12
|
+
export function medicareAnnualPremiumPerPerson(pack, magiTwoYearsPrior, filingStatus, thresholdScale = 1, premiumScale = 1) {
|
|
13
|
+
const tier = irmaaTierForMagi(pack, magiTwoYearsPrior, filingStatus, thresholdScale);
|
|
14
|
+
const base = pack.medicare.partBStandardMonthly;
|
|
15
|
+
let partDSurchargeMonthly = 0;
|
|
16
|
+
let partDSurchargeUnverified = false;
|
|
17
|
+
let applicablePct = 25;
|
|
18
|
+
if (tier > 0) {
|
|
19
|
+
const t = pack.medicare.irmaaTiers[tier - 1];
|
|
20
|
+
applicablePct = t.applicablePct;
|
|
21
|
+
if (t.partDSurchargeMonthly === null) {
|
|
22
|
+
partDSurchargeUnverified = true;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
partDSurchargeMonthly = t.partDSurchargeMonthly;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Standard premium is 25% of program cost; IRMAA tiers pay a larger share.
|
|
29
|
+
const partBMonthly = base * (applicablePct / 25) * premiumScale;
|
|
30
|
+
return {
|
|
31
|
+
partBAnnual: partBMonthly * 12,
|
|
32
|
+
partDSurchargeAnnual: partDSurchargeMonthly * 12 * premiumScale,
|
|
33
|
+
irmaaTier: tier,
|
|
34
|
+
partDSurchargeUnverified,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed-asset (property) disposition tax treatment (account/HSA/fixed-asset
|
|
3
|
+
* depth plan, step 6).
|
|
4
|
+
*
|
|
5
|
+
* With a cost basis on the property account, a sale is priced exactly instead
|
|
6
|
+
* of via the legacy tax-free `expectedNetProceeds` estimate:
|
|
7
|
+
*
|
|
8
|
+
* amount realized = sale price − selling costs
|
|
9
|
+
* gain = amount realized − adjusted basis
|
|
10
|
+
* ordinary part = min(gain, depreciation recapture) (never excludable)
|
|
11
|
+
* capital part = gain − ordinary part − §121 exclusion (primary residence)
|
|
12
|
+
*
|
|
13
|
+
* §121 exclusion: $250k single / $500k joint (parameter pack; statutory and
|
|
14
|
+
* never indexed — IRS Topic 701 / Pub 523). Depreciation attributable to
|
|
15
|
+
* post-May-1997 business use cannot be excluded under §121 and is recaptured
|
|
16
|
+
* as ordinary income (planning-grade stand-in for the §1250 25% cap). A loss
|
|
17
|
+
* on personal-use property is nondeductible, so gain floors at zero.
|
|
18
|
+
*/
|
|
19
|
+
import type { ParameterPack, FilingStatus } from '../params/types.js';
|
|
20
|
+
export interface PropertySaleInput {
|
|
21
|
+
/** Projected market value in the sale year (nominal dollars). */
|
|
22
|
+
salePrice: number;
|
|
23
|
+
/** Adjusted cost basis (purchase + improvements, historical dollars). */
|
|
24
|
+
costBasis: number;
|
|
25
|
+
/** Selling costs as a percent of the sale price (commissions, closing). */
|
|
26
|
+
sellingCostPct?: number;
|
|
27
|
+
/** Primary residence: apply the §121 exclusion to the capital gain. */
|
|
28
|
+
primaryResidence?: boolean;
|
|
29
|
+
/** Accumulated depreciation recaptured as ordinary income (≤ gain). */
|
|
30
|
+
depreciationRecapture?: number;
|
|
31
|
+
filingStatus: FilingStatus;
|
|
32
|
+
pack: ParameterPack;
|
|
33
|
+
}
|
|
34
|
+
export interface PropertySaleResult {
|
|
35
|
+
/** Cash entering the portfolio: sale price − selling costs (taxes ride the normal flow). */
|
|
36
|
+
netProceeds: number;
|
|
37
|
+
/** Gain taxed as ordinary income (depreciation recapture). */
|
|
38
|
+
ordinaryGain: number;
|
|
39
|
+
/** Gain taxed as long-term capital gain, after any §121 exclusion. */
|
|
40
|
+
capitalGain: number;
|
|
41
|
+
/** Gain shielded by the §121 exclusion. */
|
|
42
|
+
excludedGain: number;
|
|
43
|
+
/** Selling costs deducted from the amount realized. */
|
|
44
|
+
sellingCosts: number;
|
|
45
|
+
}
|
|
46
|
+
export declare function propertySaleTax(input: PropertySaleInput): PropertySaleResult;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed-asset (property) disposition tax treatment (account/HSA/fixed-asset
|
|
3
|
+
* depth plan, step 6).
|
|
4
|
+
*
|
|
5
|
+
* With a cost basis on the property account, a sale is priced exactly instead
|
|
6
|
+
* of via the legacy tax-free `expectedNetProceeds` estimate:
|
|
7
|
+
*
|
|
8
|
+
* amount realized = sale price − selling costs
|
|
9
|
+
* gain = amount realized − adjusted basis
|
|
10
|
+
* ordinary part = min(gain, depreciation recapture) (never excludable)
|
|
11
|
+
* capital part = gain − ordinary part − §121 exclusion (primary residence)
|
|
12
|
+
*
|
|
13
|
+
* §121 exclusion: $250k single / $500k joint (parameter pack; statutory and
|
|
14
|
+
* never indexed — IRS Topic 701 / Pub 523). Depreciation attributable to
|
|
15
|
+
* post-May-1997 business use cannot be excluded under §121 and is recaptured
|
|
16
|
+
* as ordinary income (planning-grade stand-in for the §1250 25% cap). A loss
|
|
17
|
+
* on personal-use property is nondeductible, so gain floors at zero.
|
|
18
|
+
*/
|
|
19
|
+
export function propertySaleTax(input) {
|
|
20
|
+
const sellingCosts = Math.max(0, input.salePrice) * (Math.max(0, input.sellingCostPct ?? 0) / 100);
|
|
21
|
+
const amountRealized = Math.max(0, input.salePrice) - sellingCosts;
|
|
22
|
+
// Personal-use losses are nondeductible; gain floors at zero.
|
|
23
|
+
const gain = Math.max(0, amountRealized - Math.max(0, input.costBasis));
|
|
24
|
+
const ordinaryGain = Math.min(gain, Math.max(0, input.depreciationRecapture ?? 0));
|
|
25
|
+
const gainAfterRecapture = gain - ordinaryGain;
|
|
26
|
+
const exclusionCap = input.primaryResidence ? input.pack.federalTax.section121Exclusion[input.filingStatus] : 0;
|
|
27
|
+
const excludedGain = Math.min(gainAfterRecapture, exclusionCap);
|
|
28
|
+
return {
|
|
29
|
+
netProceeds: amountRealized,
|
|
30
|
+
ordinaryGain,
|
|
31
|
+
capitalGain: gainAfterRecapture - excludedGain,
|
|
32
|
+
excludedGain,
|
|
33
|
+
sellingCosts,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State income tax (V5, "big levers"). State taxable income starts from
|
|
3
|
+
* ordinary income plus capital gains (most states tax gains as ordinary) plus
|
|
4
|
+
* the federally taxable Social Security amount only where the state taxes SS,
|
|
5
|
+
* minus the major retirement-income exclusion and the state standard
|
|
6
|
+
* deduction; brackets then apply.
|
|
7
|
+
*
|
|
8
|
+
* A single calculator instance resolves the right state+year from each
|
|
9
|
+
* TaxYearInput, so mid-retirement moves and year-specific packs work without
|
|
10
|
+
* rebuilding it. The plan's flat effective-rate override takes precedence when
|
|
11
|
+
* set above zero (a manual correction); otherwise a modeled pack is used, and
|
|
12
|
+
* unmodeled states contribute zero until their pack ships.
|
|
13
|
+
*
|
|
14
|
+
* @see DOCS/features/taxes.md
|
|
15
|
+
*/
|
|
16
|
+
import { type StateTaxParams } from '../params/state/index.js';
|
|
17
|
+
import { type TaxCalculator, type TaxYearInput } from '../projection/types.js';
|
|
18
|
+
export interface ComputeStateTaxOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Pre-computed federally taxable Social Security to add to the state base
|
|
21
|
+
* instead of recomputing it here. Split-year residency uses this: taxable SS
|
|
22
|
+
* must be derived once from full-year income against full-year federal
|
|
23
|
+
* thresholds, then apportioned to each state — recomputing it per partial-year
|
|
24
|
+
* slice against annual thresholds would understate it.
|
|
25
|
+
*/
|
|
26
|
+
taxableSocialSecurityOverride?: number;
|
|
27
|
+
/** Optional flat local rate (percent) applied to computed state taxable income. */
|
|
28
|
+
localRatePct?: number;
|
|
29
|
+
}
|
|
30
|
+
export interface StateTaxDetail {
|
|
31
|
+
taxableIncome: number;
|
|
32
|
+
stateTax: number;
|
|
33
|
+
localTax: number;
|
|
34
|
+
totalTax: number;
|
|
35
|
+
}
|
|
36
|
+
export declare function computeStateTaxableIncome(params: StateTaxParams, input: TaxYearInput, opts?: ComputeStateTaxOptions): number;
|
|
37
|
+
export declare function computeStateTaxDetail(params: StateTaxParams, input: TaxYearInput, opts?: ComputeStateTaxOptions): StateTaxDetail;
|
|
38
|
+
export declare function computeStateTax(params: StateTaxParams, input: TaxYearInput, opts?: ComputeStateTaxOptions): number;
|
|
39
|
+
export interface StateTaxOptions {
|
|
40
|
+
/** Flat effective rate (percent). When > 0 it overrides modeled packs. */
|
|
41
|
+
overridePct?: number;
|
|
42
|
+
/** Flat local income-tax rate (percent), applied to state taxable income. */
|
|
43
|
+
localPct?: number;
|
|
44
|
+
}
|
|
45
|
+
export interface StateTaxYearOptions extends StateTaxOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Transform the resolved state params before computing. Used by the
|
|
48
|
+
* relocation-compare driver attribution to re-price a year with one state
|
|
49
|
+
* feature neutralized (e.g. "as if this state taxed Social Security");
|
|
50
|
+
* with no transform the result is exactly what the calculator charged.
|
|
51
|
+
*/
|
|
52
|
+
mapParams?: (params: StateTaxParams) => StateTaxParams;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Full state+local tax for one TaxYearInput — the single computation behind
|
|
56
|
+
* `createStateTaxCalculator`, exported so callers can re-price a recorded
|
|
57
|
+
* ledger year (relocation-compare drivers) through the identical path,
|
|
58
|
+
* including the flat override and split-year residency proration.
|
|
59
|
+
*/
|
|
60
|
+
export declare function computeStateTaxYearTotal(input: TaxYearInput, opts?: StateTaxYearOptions): number;
|
|
61
|
+
/**
|
|
62
|
+
* State tax behind the projection's pluggable interface. Resolves residence
|
|
63
|
+
* (input.state) and year per call so it composes with the federal calculator
|
|
64
|
+
* via combineTaxCalculators and handles mid-plan relocation.
|
|
65
|
+
*/
|
|
66
|
+
export declare function createStateTaxCalculator(opts?: StateTaxOptions): TaxCalculator;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State income tax (V5, "big levers"). State taxable income starts from
|
|
3
|
+
* ordinary income plus capital gains (most states tax gains as ordinary) plus
|
|
4
|
+
* the federally taxable Social Security amount only where the state taxes SS,
|
|
5
|
+
* minus the major retirement-income exclusion and the state standard
|
|
6
|
+
* deduction; brackets then apply.
|
|
7
|
+
*
|
|
8
|
+
* A single calculator instance resolves the right state+year from each
|
|
9
|
+
* TaxYearInput, so mid-retirement moves and year-specific packs work without
|
|
10
|
+
* rebuilding it. The plan's flat effective-rate override takes precedence when
|
|
11
|
+
* set above zero (a manual correction); otherwise a modeled pack is used, and
|
|
12
|
+
* unmodeled states contribute zero until their pack ships.
|
|
13
|
+
*
|
|
14
|
+
* @see DOCS/features/taxes.md
|
|
15
|
+
*/
|
|
16
|
+
import { stateParamsFor } from '../params/state/index.js';
|
|
17
|
+
import { taxableSocialSecurity } from './federalTax.js';
|
|
18
|
+
import { packForYear } from '../params/index.js';
|
|
19
|
+
import { taxParameterFilingStatus } from '../projection/types.js';
|
|
20
|
+
function bracketTax(brackets, taxable) {
|
|
21
|
+
let tax = 0;
|
|
22
|
+
for (let i = 0; i < brackets.length; i++) {
|
|
23
|
+
const lower = brackets[i].lowerBound;
|
|
24
|
+
const upper = i + 1 < brackets.length ? brackets[i + 1].lowerBound : Infinity;
|
|
25
|
+
if (taxable <= lower)
|
|
26
|
+
break;
|
|
27
|
+
tax += (Math.min(taxable, upper) - lower) * (brackets[i].ratePct / 100);
|
|
28
|
+
}
|
|
29
|
+
return tax;
|
|
30
|
+
}
|
|
31
|
+
/** Retirement income excluded from state taxable income, per the state's rule. */
|
|
32
|
+
function retirementExclusion(rule, retirementIncome, agesAlive) {
|
|
33
|
+
const r = rule;
|
|
34
|
+
const income = Math.max(0, retirementIncome);
|
|
35
|
+
if (r.kind === 'none' || income === 0)
|
|
36
|
+
return 0;
|
|
37
|
+
const eligibleCount = r.minAge === undefined ? agesAlive.length : agesAlive.filter((a) => a >= r.minAge).length;
|
|
38
|
+
if (eligibleCount === 0)
|
|
39
|
+
return 0;
|
|
40
|
+
if (r.kind === 'full')
|
|
41
|
+
return income;
|
|
42
|
+
return Math.min(income, (r.capPerPerson ?? 0) * eligibleCount);
|
|
43
|
+
}
|
|
44
|
+
export function computeStateTaxableIncome(params, input, opts = {}) {
|
|
45
|
+
if (!params.hasIncomeTax)
|
|
46
|
+
return 0;
|
|
47
|
+
const taxStatus = taxParameterFilingStatus(input.filingStatus);
|
|
48
|
+
const ordinary = Math.max(0, input.ordinaryIncome);
|
|
49
|
+
const qualifiedDividends = Math.max(0, input.qualifiedDividends ?? 0);
|
|
50
|
+
// Signed: a capital-loss carryforward arrives as a negative net capital gain,
|
|
51
|
+
// which reduces state taxable income (and its SS base) just like it does federally.
|
|
52
|
+
const netCapital = params.capitalLossCarryforwardConformity === 'currentYearOnly'
|
|
53
|
+
? Math.max(0, input.realizedCapitalGainsBeforeCarryforward ?? input.capitalGains)
|
|
54
|
+
: input.capitalGains;
|
|
55
|
+
const taxableCapitalPct = (params.capitalGainsTaxablePct ?? (params.capitalGainsAsOrdinary ? 100 : 0)) / 100;
|
|
56
|
+
const ss = Math.max(0, input.ssBenefits);
|
|
57
|
+
// Interest on U.S. government obligations (Treasury/TIPS) is exempt from
|
|
58
|
+
// state income tax in every state (31 U.S.C. §3124). It arrives inside
|
|
59
|
+
// ordinaryIncome, so subtract it from the state base here.
|
|
60
|
+
const usGovInterest = Math.min(ordinary, Math.max(0, input.usGovernmentInterest ?? 0));
|
|
61
|
+
let taxable = ordinary - usGovInterest + qualifiedDividends;
|
|
62
|
+
if (taxableCapitalPct > 0)
|
|
63
|
+
taxable += netCapital * taxableCapitalPct;
|
|
64
|
+
if (params.taxesSocialSecurity && ss > 0) {
|
|
65
|
+
if (opts.taxableSocialSecurityOverride !== undefined) {
|
|
66
|
+
taxable += Math.max(0, opts.taxableSocialSecurityOverride);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const { pack } = packForYear(input.year);
|
|
70
|
+
taxable += taxableSocialSecurity(pack, taxStatus, ordinary + qualifiedDividends + netCapital, ss);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const privateRetirement = input.privateRetirementIncome ?? input.retirementIncome ?? 0;
|
|
74
|
+
const publicPension = input.publicPensionIncome ?? 0;
|
|
75
|
+
const agesAlive = input.agesAlive ?? [];
|
|
76
|
+
if (params.retirementRuleShared) {
|
|
77
|
+
// One all-retirement rule copied into both buckets: a capped exclusion
|
|
78
|
+
// applies once to the combined retirement income, not once per bucket.
|
|
79
|
+
taxable -= retirementExclusion(params.retirementPrivate, privateRetirement + publicPension, agesAlive);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
taxable -= retirementExclusion(params.retirementPrivate, privateRetirement, agesAlive);
|
|
83
|
+
taxable -= retirementExclusion(params.retirementPublic, publicPension, agesAlive);
|
|
84
|
+
}
|
|
85
|
+
taxable -= params.standardDeduction[taxStatus];
|
|
86
|
+
return Math.max(0, taxable);
|
|
87
|
+
}
|
|
88
|
+
export function computeStateTaxDetail(params, input, opts = {}) {
|
|
89
|
+
const taxableIncome = computeStateTaxableIncome(params, input, opts);
|
|
90
|
+
const taxStatus = taxParameterFilingStatus(input.filingStatus);
|
|
91
|
+
const stateTax = params.hasIncomeTax ? bracketTax(params.brackets[taxStatus], taxableIncome) : 0;
|
|
92
|
+
const localTax = taxableIncome * (Math.max(0, opts.localRatePct ?? 0) / 100);
|
|
93
|
+
return { taxableIncome, stateTax, localTax, totalTax: stateTax + localTax };
|
|
94
|
+
}
|
|
95
|
+
export function computeStateTax(params, input, opts = {}) {
|
|
96
|
+
return computeStateTaxDetail(params, input, opts).stateTax;
|
|
97
|
+
}
|
|
98
|
+
function scaleExclusion(rule, scale) {
|
|
99
|
+
return rule.capPerPerson === undefined ? rule : { ...rule, capPerPerson: rule.capPerPerson * scale };
|
|
100
|
+
}
|
|
101
|
+
function prorateParams(params, scale) {
|
|
102
|
+
return {
|
|
103
|
+
...params,
|
|
104
|
+
standardDeduction: {
|
|
105
|
+
single: params.standardDeduction.single * scale,
|
|
106
|
+
marriedFilingJointly: params.standardDeduction.marriedFilingJointly * scale,
|
|
107
|
+
},
|
|
108
|
+
brackets: {
|
|
109
|
+
single: params.brackets.single.map((b) => ({ ...b, lowerBound: b.lowerBound * scale })),
|
|
110
|
+
marriedFilingJointly: params.brackets.marriedFilingJointly.map((b) => ({ ...b, lowerBound: b.lowerBound * scale })),
|
|
111
|
+
},
|
|
112
|
+
retirementPrivate: scaleExclusion(params.retirementPrivate, scale),
|
|
113
|
+
retirementPublic: scaleExclusion(params.retirementPublic, scale),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function prorateInput(input, scale, state) {
|
|
117
|
+
return {
|
|
118
|
+
...input,
|
|
119
|
+
state,
|
|
120
|
+
stateResidency: undefined,
|
|
121
|
+
ordinaryIncome: input.ordinaryIncome * scale,
|
|
122
|
+
capitalGains: input.capitalGains * scale,
|
|
123
|
+
realizedCapitalGainsBeforeCarryforward: input.realizedCapitalGainsBeforeCarryforward === undefined
|
|
124
|
+
? undefined
|
|
125
|
+
: input.realizedCapitalGainsBeforeCarryforward * scale,
|
|
126
|
+
taxableInterestIncome: (input.taxableInterestIncome ?? 0) * scale,
|
|
127
|
+
usGovernmentInterest: input.usGovernmentInterest === undefined ? undefined : input.usGovernmentInterest * scale,
|
|
128
|
+
ordinaryDividends: (input.ordinaryDividends ?? 0) * scale,
|
|
129
|
+
qualifiedDividends: (input.qualifiedDividends ?? 0) * scale,
|
|
130
|
+
ssBenefits: input.ssBenefits * scale,
|
|
131
|
+
retirementIncome: input.retirementIncome === undefined ? undefined : input.retirementIncome * scale,
|
|
132
|
+
privateRetirementIncome: input.privateRetirementIncome === undefined ? undefined : input.privateRetirementIncome * scale,
|
|
133
|
+
publicPensionIncome: input.publicPensionIncome === undefined ? undefined : input.publicPensionIncome * scale,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Full state+local tax for one TaxYearInput — the single computation behind
|
|
138
|
+
* `createStateTaxCalculator`, exported so callers can re-price a recorded
|
|
139
|
+
* ledger year (relocation-compare drivers) through the identical path,
|
|
140
|
+
* including the flat override and split-year residency proration.
|
|
141
|
+
*/
|
|
142
|
+
export function computeStateTaxYearTotal(input, opts = {}) {
|
|
143
|
+
const overrideRate = Math.max(0, opts.overridePct ?? 0) / 100;
|
|
144
|
+
const localRatePct = Math.max(0, opts.localPct ?? 0);
|
|
145
|
+
const localRate = localRatePct / 100;
|
|
146
|
+
const resolveParams = (code) => {
|
|
147
|
+
const params = stateParamsFor(code, input.year);
|
|
148
|
+
return params && opts.mapParams ? opts.mapParams(params) : params;
|
|
149
|
+
};
|
|
150
|
+
if (overrideRate > 0) {
|
|
151
|
+
// The flat effective rate approximates a state return, so it still
|
|
152
|
+
// honors the universal U.S.-government-interest exemption.
|
|
153
|
+
const base = Math.max(0, Math.max(0, input.ordinaryIncome) - Math.max(0, input.usGovernmentInterest ?? 0)) +
|
|
154
|
+
Math.max(0, input.capitalGains) +
|
|
155
|
+
Math.max(0, input.qualifiedDividends ?? 0);
|
|
156
|
+
return base * (overrideRate + localRate);
|
|
157
|
+
}
|
|
158
|
+
if (input.stateResidency && input.stateResidency.length > 0) {
|
|
159
|
+
// Taxable SS is a full-year federal computation: derive it once from
|
|
160
|
+
// annual income and thresholds, then apportion it to each state by
|
|
161
|
+
// months of residency (recomputing per slice would understate it).
|
|
162
|
+
let annualTaxableSs = 0;
|
|
163
|
+
if (input.ssBenefits > 0) {
|
|
164
|
+
const { pack } = packForYear(input.year);
|
|
165
|
+
annualTaxableSs = taxableSocialSecurity(pack, taxParameterFilingStatus(input.filingStatus), Math.max(0, input.ordinaryIncome) + Math.max(0, input.qualifiedDividends ?? 0) + input.capitalGains, input.ssBenefits);
|
|
166
|
+
}
|
|
167
|
+
return input.stateResidency.reduce((sum, segment) => {
|
|
168
|
+
const months = Math.min(12, Math.max(0, segment.months));
|
|
169
|
+
if (months <= 0)
|
|
170
|
+
return sum;
|
|
171
|
+
const params = resolveParams(segment.state);
|
|
172
|
+
if (!params)
|
|
173
|
+
return sum;
|
|
174
|
+
const scale = months / 12;
|
|
175
|
+
const detail = computeStateTaxDetail(prorateParams(params, scale), prorateInput(input, scale, segment.state), {
|
|
176
|
+
taxableSocialSecurityOverride: annualTaxableSs * scale,
|
|
177
|
+
localRatePct,
|
|
178
|
+
});
|
|
179
|
+
return sum + detail.totalTax;
|
|
180
|
+
}, 0);
|
|
181
|
+
}
|
|
182
|
+
if (!input.state)
|
|
183
|
+
return 0;
|
|
184
|
+
const params = resolveParams(input.state);
|
|
185
|
+
return params ? computeStateTaxDetail(params, input, { localRatePct }).totalTax : 0;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* State tax behind the projection's pluggable interface. Resolves residence
|
|
189
|
+
* (input.state) and year per call so it composes with the federal calculator
|
|
190
|
+
* via combineTaxCalculators and handles mid-plan relocation.
|
|
191
|
+
*/
|
|
192
|
+
export function createStateTaxCalculator(opts = {}) {
|
|
193
|
+
return {
|
|
194
|
+
compute(input) {
|
|
195
|
+
return computeStateTaxYearTotal(input, opts);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tolerance assertions for money and percentages. Deliberately framework-free
|
|
3
|
+
* (a thrown Error fails a test in any runner), so `@retiregolden/engine/testing/*`
|
|
4
|
+
* carries no runtime dependency on vitest for consumers.
|
|
5
|
+
*/
|
|
6
|
+
export declare function expectMoney(actual: number, expected: number, tolerance?: number): void;
|
|
7
|
+
export declare function expectPercent(actual: number, expected: number, tolerance?: number): void;
|