@nestpilot/mcp-app 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +350 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +214 -0
- package/dist/cli/export-import.d.ts +6 -0
- package/dist/cli/export-import.js +132 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +168 -0
- package/dist/cli/init.d.ts +1 -0
- package/dist/cli/init.js +171 -0
- package/dist/host-configs/cowork.json +11 -0
- package/dist/host-configs/goose.yaml +22 -0
- package/dist/host-configs/openclaw-manifest.json +16 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +128 -0
- package/dist/mcp-app.html +155 -0
- package/dist/nestpilot-client.d.ts +44 -0
- package/dist/nestpilot-client.js +160 -0
- package/dist/planner.html +222 -0
- package/dist/server.d.ts +19 -0
- package/dist/server.js +245 -0
- package/dist/skills/SKILL.md +162 -0
- package/dist/skills/manifest.json +51 -0
- package/dist/skills/tools/activate_plan.md +36 -0
- package/dist/skills/tools/coach.md +59 -0
- package/dist/skills/tools/comprehensive_plan.md +65 -0
- package/dist/skills/tools/create_plan.md +59 -0
- package/dist/skills/tools/create_saved_plan.md +49 -0
- package/dist/skills/tools/delete_plan.md +42 -0
- package/dist/skills/tools/delete_scenario.md +38 -0
- package/dist/skills/tools/generate_proposal.md +63 -0
- package/dist/skills/tools/generate_retirement_report.md +50 -0
- package/dist/skills/tools/get_active_plan.md +44 -0
- package/dist/skills/tools/get_baseline_forecast.md +47 -0
- package/dist/skills/tools/get_plan.md +44 -0
- package/dist/skills/tools/get_plan_components.md +50 -0
- package/dist/skills/tools/get_scenario.md +46 -0
- package/dist/skills/tools/list_plans.md +44 -0
- package/dist/skills/tools/list_scenarios.md +42 -0
- package/dist/skills/tools/medicare-guardian.md +59 -0
- package/dist/skills/tools/nestpilot_run_plan.md +61 -0
- package/dist/skills/tools/optimize_roth_conversion.md +107 -0
- package/dist/skills/tools/optimize_ss_claiming.md +30 -0
- package/dist/skills/tools/rename_plan.md +34 -0
- package/dist/skills/tools/retirement-planner.md +55 -0
- package/dist/skills/tools/run_forecast.md +65 -0
- package/dist/skills/tools/run_saved_forecast.md +52 -0
- package/dist/skills/tools/run_scenario.md +66 -0
- package/dist/skills/tools/save_plan.md +48 -0
- package/dist/skills/tools/save_scenario.md +50 -0
- package/dist/skills/tools/verify_forecast.md +43 -0
- package/dist/src/config.d.ts +20 -0
- package/dist/src/config.js +44 -0
- package/dist/src/contracts/provenance.d.ts +37 -0
- package/dist/src/contracts/provenance.js +71 -0
- package/dist/src/contracts/tool-contract-registry.d.ts +43 -0
- package/dist/src/contracts/tool-contract-registry.js +282 -0
- package/dist/src/local/cloud-compute-client.d.ts +55 -0
- package/dist/src/local/cloud-compute-client.js +135 -0
- package/dist/src/local/encryption.d.ts +24 -0
- package/dist/src/local/encryption.js +105 -0
- package/dist/src/local/keychain.d.ts +41 -0
- package/dist/src/local/keychain.js +236 -0
- package/dist/src/local/local-config.d.ts +34 -0
- package/dist/src/local/local-config.js +61 -0
- package/dist/src/local/local-data-layer.d.ts +20 -0
- package/dist/src/local/local-data-layer.js +15 -0
- package/dist/src/local/local-plan-store.d.ts +66 -0
- package/dist/src/local/local-plan-store.js +195 -0
- package/dist/src/local/pii-scrubber.d.ts +26 -0
- package/dist/src/local/pii-scrubber.js +219 -0
- package/dist/src/policy/policy-engine.d.ts +44 -0
- package/dist/src/policy/policy-engine.js +119 -0
- package/dist/src/rate-limit.d.ts +17 -0
- package/dist/src/rate-limit.js +41 -0
- package/dist/src/security.d.ts +19 -0
- package/dist/src/security.js +118 -0
- package/dist/src/skills/index.d.ts +12 -0
- package/dist/src/skills/index.js +16 -0
- package/dist/src/skills/retirement-pack-v1.d.ts +28 -0
- package/dist/src/skills/retirement-pack-v1.js +295 -0
- package/dist/src/skills/skill-executor.d.ts +65 -0
- package/dist/src/skills/skill-executor.js +174 -0
- package/dist/src/skills/skill-manifest-schema.d.ts +337 -0
- package/dist/src/skills/skill-manifest-schema.js +94 -0
- package/dist/src/skills/skill-registry.d.ts +71 -0
- package/dist/src/skills/skill-registry.js +116 -0
- package/dist/src/telemetry.d.ts +12 -0
- package/dist/src/telemetry.js +59 -0
- package/dist/src/types.d.ts +46 -0
- package/dist/src/types.js +4 -0
- package/dist/tools/agent-tools.d.ts +12 -0
- package/dist/tools/agent-tools.js +141 -0
- package/dist/tools/forecast-management-tools.d.ts +9 -0
- package/dist/tools/forecast-management-tools.js +133 -0
- package/dist/tools/local-plan-tools.d.ts +8 -0
- package/dist/tools/local-plan-tools.js +357 -0
- package/dist/tools/mcp-helpers.d.ts +52 -0
- package/dist/tools/mcp-helpers.js +177 -0
- package/dist/tools/medicare-tools.d.ts +3 -0
- package/dist/tools/medicare-tools.js +162 -0
- package/dist/tools/optimize-roth-tools-test.d.ts +2 -0
- package/dist/tools/optimize-roth-tools-test.js +36 -0
- package/dist/tools/optimize-roth-tools.d.ts +3 -0
- package/dist/tools/optimize-roth-tools.js +818 -0
- package/dist/tools/plan-management-tools.d.ts +3 -0
- package/dist/tools/plan-management-tools.js +196 -0
- package/dist/tools/planning-tools.d.ts +3 -0
- package/dist/tools/planning-tools.js +290 -0
- package/dist/tools/proposal-tools.d.ts +3 -0
- package/dist/tools/proposal-tools.js +428 -0
- package/dist/tools/report-tools.d.ts +3 -0
- package/dist/tools/report-tools.js +245 -0
- package/dist/tools/scenario-management-tools.d.ts +3 -0
- package/dist/tools/scenario-management-tools.js +136 -0
- package/dist/views/verification-packet.html +211 -0
- package/host-configs/cowork.json +11 -0
- package/host-configs/goose.yaml +22 -0
- package/host-configs/openclaw-manifest.json +16 -0
- package/package.json +66 -0
- package/skills/SKILL.md +162 -0
- package/skills/manifest.json +51 -0
- package/skills/tools/activate_plan.md +36 -0
- package/skills/tools/coach.md +59 -0
- package/skills/tools/comprehensive_plan.md +65 -0
- package/skills/tools/create_plan.md +59 -0
- package/skills/tools/create_saved_plan.md +49 -0
- package/skills/tools/delete_plan.md +42 -0
- package/skills/tools/delete_scenario.md +38 -0
- package/skills/tools/generate_proposal.md +63 -0
- package/skills/tools/generate_retirement_report.md +50 -0
- package/skills/tools/get_active_plan.md +44 -0
- package/skills/tools/get_baseline_forecast.md +47 -0
- package/skills/tools/get_plan.md +44 -0
- package/skills/tools/get_plan_components.md +50 -0
- package/skills/tools/get_scenario.md +46 -0
- package/skills/tools/list_plans.md +44 -0
- package/skills/tools/list_scenarios.md +42 -0
- package/skills/tools/medicare-guardian.md +59 -0
- package/skills/tools/nestpilot_run_plan.md +61 -0
- package/skills/tools/optimize_roth_conversion.md +107 -0
- package/skills/tools/optimize_ss_claiming.md +30 -0
- package/skills/tools/rename_plan.md +34 -0
- package/skills/tools/retirement-planner.md +55 -0
- package/skills/tools/run_forecast.md +65 -0
- package/skills/tools/run_saved_forecast.md +52 -0
- package/skills/tools/run_scenario.md +66 -0
- package/skills/tools/save_plan.md +48 -0
- package/skills/tools/save_scenario.md +50 -0
- package/skills/tools/verify_forecast.md +43 -0
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roth conversion optimization MCP tool — FEAT-0066
|
|
3
|
+
*
|
|
4
|
+
* Composes existing backend APIs to produce a multi-year Roth conversion
|
|
5
|
+
* strategy with tax impact analysis, IRMAA warnings, and break-even estimates.
|
|
6
|
+
*
|
|
7
|
+
* Pipeline:
|
|
8
|
+
* 1. POST /api/tax/roth/max-conversion — bracket headroom per year
|
|
9
|
+
* 2. POST /api/plan/forecast (with Roth conversions) — conversion forecast
|
|
10
|
+
* 3. POST /api/plan/forecast (baseline, no conversion) — baseline forecast
|
|
11
|
+
* 4. Compute deltas, break-even age, evidence packet
|
|
12
|
+
*
|
|
13
|
+
* @feature FEAT-0066
|
|
14
|
+
*/
|
|
15
|
+
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { nestpilotClient } from "../nestpilot-client.js";
|
|
18
|
+
import { toCallToolResult } from "./mcp-helpers.js";
|
|
19
|
+
// ── IRMAA thresholds (2025) ─────────────────────────────────────────────
|
|
20
|
+
const IRMAA_THRESHOLDS = {
|
|
21
|
+
SINGLE: 103_000,
|
|
22
|
+
MFJ: 206_000,
|
|
23
|
+
MFS: 103_000,
|
|
24
|
+
HOH: 103_000,
|
|
25
|
+
};
|
|
26
|
+
// ── Conversion policy defaults ──────────────────────────────────────────
|
|
27
|
+
const POLICY_TARGET_BRACKETS = {
|
|
28
|
+
conservative: 0.12,
|
|
29
|
+
moderate: 0.22,
|
|
30
|
+
aggressive: 0.24,
|
|
31
|
+
integrated: 0.24,
|
|
32
|
+
};
|
|
33
|
+
// ── Tool registration ───────────────────────────────────────────────────
|
|
34
|
+
export function registerRothTools(server, authCtx) {
|
|
35
|
+
console.log("registerRothTools: Starting registration...");
|
|
36
|
+
try {
|
|
37
|
+
registerAppTool(server, "optimize_roth_conversion", {
|
|
38
|
+
title: "Optimize Roth Conversion",
|
|
39
|
+
description: `Analyzes and optimizes a multi-year Roth conversion strategy. Returns a year-by-year conversion schedule with tax costs, bracket headroom usage, IRMAA threshold warnings, RMD impact comparison, break-even age estimate, and lifetime tax savings.
|
|
40
|
+
|
|
41
|
+
USE THIS TOOL WHEN THE USER:
|
|
42
|
+
- Wants to optimize Roth conversions across multiple years
|
|
43
|
+
- Asks "How much should I convert to Roth each year?"
|
|
44
|
+
- Wants to minimize lifetime taxes through strategic conversions
|
|
45
|
+
- Asks about filling tax brackets before RMDs begin
|
|
46
|
+
- Wants to evaluate the tax impact of Roth conversion laddering
|
|
47
|
+
|
|
48
|
+
DO NOT USE for general tax questions — use coach instead.
|
|
49
|
+
DO NOT USE for basic forecasting — use run_forecast instead.`,
|
|
50
|
+
inputSchema: {
|
|
51
|
+
filingStatus: z
|
|
52
|
+
.enum(["SINGLE", "MFJ", "MFS", "HOH"])
|
|
53
|
+
.describe("Tax filing status"),
|
|
54
|
+
currentAge: z
|
|
55
|
+
.number()
|
|
56
|
+
.int()
|
|
57
|
+
.min(18)
|
|
58
|
+
.max(100)
|
|
59
|
+
.describe("Current age of the user"),
|
|
60
|
+
retirementAge: z
|
|
61
|
+
.number()
|
|
62
|
+
.int()
|
|
63
|
+
.min(50)
|
|
64
|
+
.max(100)
|
|
65
|
+
.describe("Target retirement age"),
|
|
66
|
+
traditionalIraBalance: z
|
|
67
|
+
.number()
|
|
68
|
+
.min(0)
|
|
69
|
+
.describe("Current balance of Traditional IRA / 401k (pre-tax) in dollars"),
|
|
70
|
+
rothIraBalance: z
|
|
71
|
+
.number()
|
|
72
|
+
.min(0)
|
|
73
|
+
.optional()
|
|
74
|
+
.describe("Current Roth IRA balance in dollars"),
|
|
75
|
+
annualIncome: z
|
|
76
|
+
.number()
|
|
77
|
+
.min(0)
|
|
78
|
+
.describe("Current annual gross income in dollars"),
|
|
79
|
+
otherIncome: z
|
|
80
|
+
.number()
|
|
81
|
+
.min(0)
|
|
82
|
+
.optional()
|
|
83
|
+
.describe("Other annual income (pensions, rental, etc.) in dollars"),
|
|
84
|
+
socialSecurityAnnual: z
|
|
85
|
+
.number()
|
|
86
|
+
.min(0)
|
|
87
|
+
.optional()
|
|
88
|
+
.describe("Expected annual Social Security benefit in dollars (if currently receiving or projected)"),
|
|
89
|
+
conversionPolicy: z
|
|
90
|
+
.enum(["conservative", "moderate", "aggressive", "integrated"])
|
|
91
|
+
.optional()
|
|
92
|
+
.describe("Conversion aggressiveness: conservative (fill 12% bracket), moderate (fill 22%), aggressive (fill 24%), integrated (all-in externality-aware)"),
|
|
93
|
+
guardrails: z
|
|
94
|
+
.object({
|
|
95
|
+
avoidIrmaaTierJump: z.boolean().optional(),
|
|
96
|
+
maxAcaFplPct: z.number().min(0).optional(),
|
|
97
|
+
maxAllInMarginalRate: z.number().min(0).optional(),
|
|
98
|
+
annualCap: z.number().min(0).optional(),
|
|
99
|
+
lookaheadYears: z.number().int().min(1).max(40).optional(),
|
|
100
|
+
})
|
|
101
|
+
.optional()
|
|
102
|
+
.describe("Optional integrated-policy guardrails"),
|
|
103
|
+
planId: z
|
|
104
|
+
.string()
|
|
105
|
+
.optional()
|
|
106
|
+
.describe("Plan ID for context linking"),
|
|
107
|
+
},
|
|
108
|
+
_meta: {
|
|
109
|
+
ui: {
|
|
110
|
+
visibility: ["model", "app"],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
}, async (args) => {
|
|
114
|
+
const { filingStatus, currentAge, retirementAge, traditionalIraBalance, rothIraBalance = 0, annualIncome, otherIncome = 0, socialSecurityAnnual = 0, conversionPolicy = "moderate", guardrails, planId, } = args;
|
|
115
|
+
try {
|
|
116
|
+
const reqHeaders = buildRequestHeaders(authCtx);
|
|
117
|
+
if (conversionPolicy === "integrated") {
|
|
118
|
+
const integratedResult = await runIntegratedPolicyOptimization({
|
|
119
|
+
filingStatus,
|
|
120
|
+
currentAge,
|
|
121
|
+
retirementAge,
|
|
122
|
+
traditionalIraBalance,
|
|
123
|
+
rothIraBalance,
|
|
124
|
+
annualIncome,
|
|
125
|
+
otherIncome,
|
|
126
|
+
socialSecurityAnnual,
|
|
127
|
+
guardrails,
|
|
128
|
+
planId,
|
|
129
|
+
reqHeaders,
|
|
130
|
+
});
|
|
131
|
+
if ("error" in integratedResult) {
|
|
132
|
+
return toCallToolResult({
|
|
133
|
+
error: true,
|
|
134
|
+
code: integratedResult.code,
|
|
135
|
+
message: integratedResult.message,
|
|
136
|
+
}, true);
|
|
137
|
+
}
|
|
138
|
+
return toCallToolResult(integratedResult);
|
|
139
|
+
}
|
|
140
|
+
const targetBracket = POLICY_TARGET_BRACKETS[conversionPolicy];
|
|
141
|
+
const rmdStartAge = 73; // SECURE Act 2.0
|
|
142
|
+
const horizonAge = 95;
|
|
143
|
+
const currentYear = new Date().getFullYear();
|
|
144
|
+
// ── Step 1: Get max conversion per bracket from tax API ────────
|
|
145
|
+
const maxConversionResult = await nestpilotClient.post("/api/tax/roth/max-conversion", {
|
|
146
|
+
year: 2025,
|
|
147
|
+
filingStatus,
|
|
148
|
+
wages: annualIncome,
|
|
149
|
+
interest: 0,
|
|
150
|
+
iraDistributions: 0,
|
|
151
|
+
rothConversion: 0,
|
|
152
|
+
socialSecurityAnnual,
|
|
153
|
+
qualifiedDividends: 0,
|
|
154
|
+
ltCapitalGains: 0,
|
|
155
|
+
taxExemptInterest: 0,
|
|
156
|
+
adjustments: 0,
|
|
157
|
+
itemizedDeductions: null,
|
|
158
|
+
age65OrOlderCount: currentAge >= 65 ? 1 : 0,
|
|
159
|
+
livedWithSpouse: null,
|
|
160
|
+
targetBracket,
|
|
161
|
+
guardrailMode: "TI_TOP",
|
|
162
|
+
}, { headers: reqHeaders });
|
|
163
|
+
if (maxConversionResult.error) {
|
|
164
|
+
return toCallToolResult({
|
|
165
|
+
error: true,
|
|
166
|
+
code: maxConversionResult.code,
|
|
167
|
+
message: maxConversionResult.message ??
|
|
168
|
+
"Failed to calculate max Roth conversion",
|
|
169
|
+
}, true);
|
|
170
|
+
}
|
|
171
|
+
const maxConversion = maxConversionResult;
|
|
172
|
+
// ── Step 2: Build year-by-year conversion schedule ─────────────
|
|
173
|
+
const conversionSchedule = [];
|
|
174
|
+
const irmaaWarnings = [];
|
|
175
|
+
let remainingTraditional = traditionalIraBalance;
|
|
176
|
+
let cumulativeConverted = 0;
|
|
177
|
+
let totalTaxCost = 0;
|
|
178
|
+
// Conversion window: from now until RMD start age (or retirement, whichever is later)
|
|
179
|
+
const conversionEndAge = Math.max(retirementAge, rmdStartAge);
|
|
180
|
+
const yearsOfConversions = conversionEndAge - currentAge;
|
|
181
|
+
for (let yearOffset = 0; yearOffset < yearsOfConversions; yearOffset++) {
|
|
182
|
+
const age = currentAge + yearOffset;
|
|
183
|
+
const year = currentYear + yearOffset;
|
|
184
|
+
// Income changes at retirement
|
|
185
|
+
const incomeForYear = age >= retirementAge ? otherIncome : annualIncome + otherIncome;
|
|
186
|
+
const ssForYear = age >= 62 ? socialSecurityAnnual : 0;
|
|
187
|
+
// Scale conversion room by income changes relative to baseline
|
|
188
|
+
const baseIncome = annualIncome + otherIncome;
|
|
189
|
+
const incomeRatio = baseIncome > 0 ? (incomeForYear + ssForYear) / baseIncome : 1;
|
|
190
|
+
// Conversion amount = bracket headroom adjusted for income changes
|
|
191
|
+
let conversionAmount = Math.max(0, maxConversion.maxConversion * (1 - incomeRatio + 1));
|
|
192
|
+
// In retirement years with lower income, there's more room to convert
|
|
193
|
+
if (age >= retirementAge && incomeForYear < annualIncome) {
|
|
194
|
+
const incomeGap = annualIncome - incomeForYear;
|
|
195
|
+
conversionAmount = Math.min(maxConversion.maxConversion + incomeGap, remainingTraditional);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
conversionAmount = Math.min(conversionAmount, remainingTraditional);
|
|
199
|
+
}
|
|
200
|
+
if (guardrails?.annualCap != null && guardrails.annualCap > 0) {
|
|
201
|
+
conversionAmount = Math.min(conversionAmount, guardrails.annualCap);
|
|
202
|
+
}
|
|
203
|
+
if (conversionAmount <= 0)
|
|
204
|
+
break;
|
|
205
|
+
// Check IRMAA thresholds
|
|
206
|
+
const totalIncomeWithConversion = incomeForYear + ssForYear + conversionAmount;
|
|
207
|
+
const irmaaThreshold = IRMAA_THRESHOLDS[filingStatus];
|
|
208
|
+
let irmaaWarning = null;
|
|
209
|
+
const guardrailsTriggered = [];
|
|
210
|
+
if (guardrails?.avoidIrmaaTierJump &&
|
|
211
|
+
totalIncomeWithConversion > irmaaThreshold) {
|
|
212
|
+
conversionAmount = Math.max(0, irmaaThreshold - incomeForYear - ssForYear);
|
|
213
|
+
guardrailsTriggered.push("irmaa_tier_jump");
|
|
214
|
+
}
|
|
215
|
+
if (conversionAmount <= 0)
|
|
216
|
+
break;
|
|
217
|
+
// Estimate tax + externality cost decomposition
|
|
218
|
+
const estimatedTaxRate = targetBracket;
|
|
219
|
+
const taxCost = conversionAmount * estimatedTaxRate;
|
|
220
|
+
const deltaFederalTax = Math.round(taxCost);
|
|
221
|
+
const deltaStateTax = 0;
|
|
222
|
+
const deltaAcaPremium = 0;
|
|
223
|
+
const deltaMedicarePremium = totalIncomeWithConversion > irmaaThreshold ? 1200 : 0;
|
|
224
|
+
const deltaTaxableSocialSecurity = age >= 62 && ssForYear > 0 ? Math.round(conversionAmount * 0.15) : 0;
|
|
225
|
+
const allInCost = deltaFederalTax +
|
|
226
|
+
deltaStateTax +
|
|
227
|
+
deltaAcaPremium +
|
|
228
|
+
deltaMedicarePremium;
|
|
229
|
+
const lookaheadYears = guardrails?.lookaheadYears ?? 15;
|
|
230
|
+
const futureBenefitPv = Math.round(conversionAmount * 0.08 * Math.min(1, lookaheadYears / 15));
|
|
231
|
+
const allInMarginalRate = conversionAmount > 0 ? allInCost / conversionAmount : 0;
|
|
232
|
+
const maxAllIn = guardrails?.maxAllInMarginalRate != null
|
|
233
|
+
? guardrails.maxAllInMarginalRate > 1
|
|
234
|
+
? guardrails.maxAllInMarginalRate / 100
|
|
235
|
+
: guardrails.maxAllInMarginalRate
|
|
236
|
+
: null;
|
|
237
|
+
if (maxAllIn != null && allInMarginalRate > maxAllIn) {
|
|
238
|
+
guardrailsTriggered.push("all_in_marginal_cap");
|
|
239
|
+
}
|
|
240
|
+
const primaryDriver = deltaMedicarePremium > deltaFederalTax ? "medicare" : "tax";
|
|
241
|
+
totalTaxCost += taxCost;
|
|
242
|
+
cumulativeConverted += conversionAmount;
|
|
243
|
+
remainingTraditional -= conversionAmount;
|
|
244
|
+
if (totalIncomeWithConversion > irmaaThreshold) {
|
|
245
|
+
irmaaWarning = `Year ${year}: Income with conversion ($${Math.round(totalIncomeWithConversion).toLocaleString()}) exceeds IRMAA threshold ($${irmaaThreshold.toLocaleString()}). Medicare Part B/D premiums will increase.`;
|
|
246
|
+
irmaaWarnings.push(irmaaWarning);
|
|
247
|
+
}
|
|
248
|
+
else if (totalIncomeWithConversion > irmaaThreshold * 0.9) {
|
|
249
|
+
irmaaWarning = `Year ${year}: Income with conversion ($${Math.round(totalIncomeWithConversion).toLocaleString()}) is within 10% of IRMAA threshold ($${irmaaThreshold.toLocaleString()}). Consider reducing conversion.`;
|
|
250
|
+
irmaaWarnings.push(irmaaWarning);
|
|
251
|
+
}
|
|
252
|
+
const bracketPercent = `${Math.round(targetBracket * 100)}%`;
|
|
253
|
+
conversionSchedule.push({
|
|
254
|
+
year,
|
|
255
|
+
age,
|
|
256
|
+
conversionAmount: Math.round(conversionAmount),
|
|
257
|
+
taxCost: Math.round(taxCost),
|
|
258
|
+
bracketFilled: bracketPercent,
|
|
259
|
+
bracketHeadroomUsed: Math.round(maxConversion.maxConversion > 0
|
|
260
|
+
? (conversionAmount / maxConversion.maxConversion) * 100
|
|
261
|
+
: 0),
|
|
262
|
+
cumulativeConverted: Math.round(cumulativeConverted),
|
|
263
|
+
irmaaWarning,
|
|
264
|
+
deltaFederalTax,
|
|
265
|
+
deltaStateTax,
|
|
266
|
+
deltaAcaPremium,
|
|
267
|
+
deltaMedicarePremium,
|
|
268
|
+
deltaTaxableSocialSecurity,
|
|
269
|
+
allInCost,
|
|
270
|
+
futureBenefitPv,
|
|
271
|
+
primaryDriver,
|
|
272
|
+
guardrailsTriggered,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
// ── Step 3: Estimate RMD impact ────────────────────────────────
|
|
276
|
+
const rmdFactor = 26.5; // Uniform Lifetime Table factor at age 73
|
|
277
|
+
const growthRate = 1.06; // Assumed 6% real return
|
|
278
|
+
const yearsToRmd = rmdStartAge - currentAge;
|
|
279
|
+
const traditionalAtRmdNoConversion = traditionalIraBalance * Math.pow(growthRate, yearsToRmd);
|
|
280
|
+
const traditionalAtRmdWithConversion = remainingTraditional * Math.pow(growthRate, yearsToRmd);
|
|
281
|
+
const rmdWithoutConversions = traditionalAtRmdNoConversion / rmdFactor;
|
|
282
|
+
const rmdWithConversions = traditionalAtRmdWithConversion / rmdFactor;
|
|
283
|
+
// ── Step 4: Estimate break-even age ────────────────────────────
|
|
284
|
+
// Break-even: when Roth tax-free growth exceeds the upfront tax cost
|
|
285
|
+
let breakEvenAge = null;
|
|
286
|
+
if (totalTaxCost > 0 && cumulativeConverted > 0) {
|
|
287
|
+
// Tax savings per year in retirement from tax-free Roth withdrawals
|
|
288
|
+
const annualTaxSavings = (cumulativeConverted * 0.04 * targetBracket); // 4% withdrawal rate
|
|
289
|
+
if (annualTaxSavings > 0) {
|
|
290
|
+
const yearsToBreakEven = totalTaxCost / annualTaxSavings;
|
|
291
|
+
breakEvenAge = Math.round(retirementAge + yearsToBreakEven);
|
|
292
|
+
if (breakEvenAge > horizonAge)
|
|
293
|
+
breakEvenAge = null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// ── Step 5: Estimate lifetime tax savings ──────────────────────
|
|
297
|
+
const yearsInRetirement = horizonAge - retirementAge;
|
|
298
|
+
const annualWithdrawalFromConverted = cumulativeConverted * 0.04;
|
|
299
|
+
const lifetimeTaxSavings = annualWithdrawalFromConverted * targetBracket * yearsInRetirement -
|
|
300
|
+
totalTaxCost;
|
|
301
|
+
// ── Step 6: Package evidence packet ────────────────────────────
|
|
302
|
+
const result = {
|
|
303
|
+
conversionSchedule,
|
|
304
|
+
summary: {
|
|
305
|
+
totalConverted: Math.round(cumulativeConverted),
|
|
306
|
+
totalTaxCost: Math.round(totalTaxCost),
|
|
307
|
+
estimatedLifetimeTaxSavings: Math.round(Math.max(0, lifetimeTaxSavings)),
|
|
308
|
+
breakEvenAge,
|
|
309
|
+
yearsOfConversions: conversionSchedule.length,
|
|
310
|
+
},
|
|
311
|
+
irmaaWarnings,
|
|
312
|
+
rmdImpact: {
|
|
313
|
+
withConversions: {
|
|
314
|
+
estimatedAnnualRmd: Math.round(rmdWithConversions),
|
|
315
|
+
},
|
|
316
|
+
withoutConversions: {
|
|
317
|
+
estimatedAnnualRmd: Math.round(rmdWithoutConversions),
|
|
318
|
+
},
|
|
319
|
+
rmdReduction: Math.round(rmdWithoutConversions - rmdWithConversions),
|
|
320
|
+
},
|
|
321
|
+
evidencePacket: {
|
|
322
|
+
assumptions: [
|
|
323
|
+
`Filing status: ${filingStatus}`,
|
|
324
|
+
`Current age: ${currentAge}, Retirement age: ${retirementAge}`,
|
|
325
|
+
`Conversion policy: ${conversionPolicy} (fill ${Math.round(targetBracket * 100)}% bracket)`,
|
|
326
|
+
"Legacy mode uses bracket-fill tax optimization.",
|
|
327
|
+
`Traditional IRA balance: $${traditionalIraBalance.toLocaleString()}`,
|
|
328
|
+
`Annual income: $${annualIncome.toLocaleString()}`,
|
|
329
|
+
`Assumed real return: 6%`,
|
|
330
|
+
`RMD start age: ${rmdStartAge} (SECURE Act 2.0)`,
|
|
331
|
+
`Withdrawal rate: 4%`,
|
|
332
|
+
`Planning horizon: age ${horizonAge}`,
|
|
333
|
+
`Tax year: 2025 brackets`,
|
|
334
|
+
`IRMAA thresholds: $${irmaaThreshold(filingStatus).toLocaleString()} (${filingStatus})`,
|
|
335
|
+
],
|
|
336
|
+
calculationSteps: [
|
|
337
|
+
"1. Called POST /api/tax/roth/max-conversion to determine bracket headroom",
|
|
338
|
+
`2. Max conversion in ${Math.round(targetBracket * 100)}% bracket: $${Math.round(maxConversion.maxConversion).toLocaleString()}`,
|
|
339
|
+
`3. Built ${conversionSchedule.length}-year conversion schedule from age ${currentAge} to ${currentAge + conversionSchedule.length - 1}`,
|
|
340
|
+
`4. Total converted: $${Math.round(cumulativeConverted).toLocaleString()} at tax cost of $${Math.round(totalTaxCost).toLocaleString()}`,
|
|
341
|
+
`5. RMD at ${rmdStartAge}: $${Math.round(rmdWithConversions).toLocaleString()} (vs. $${Math.round(rmdWithoutConversions).toLocaleString()} without conversions)`,
|
|
342
|
+
breakEvenAge
|
|
343
|
+
? `6. Break-even age: ${breakEvenAge}`
|
|
344
|
+
: "6. Break-even age: beyond planning horizon or not applicable",
|
|
345
|
+
],
|
|
346
|
+
disclaimers: [
|
|
347
|
+
"This analysis is for educational purposes only and does not constitute tax advice.",
|
|
348
|
+
"Consult a qualified tax professional before making Roth conversion decisions.",
|
|
349
|
+
"Tax brackets and IRMAA thresholds are based on 2025 values and may change.",
|
|
350
|
+
"Actual results will vary based on future tax law changes, investment returns, and personal circumstances.",
|
|
351
|
+
"State income taxes are not included in this analysis.",
|
|
352
|
+
],
|
|
353
|
+
inputs: {
|
|
354
|
+
filingStatus,
|
|
355
|
+
currentAge,
|
|
356
|
+
retirementAge,
|
|
357
|
+
traditionalIraBalance,
|
|
358
|
+
rothIraBalance,
|
|
359
|
+
annualIncome,
|
|
360
|
+
otherIncome,
|
|
361
|
+
socialSecurityAnnual,
|
|
362
|
+
conversionPolicy,
|
|
363
|
+
guardrails: guardrails ?? null,
|
|
364
|
+
planId: planId ?? null,
|
|
365
|
+
},
|
|
366
|
+
generatedAt: new Date().toISOString(),
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
return toCallToolResult(result);
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
return toCallToolResult({
|
|
373
|
+
error: true,
|
|
374
|
+
message: err instanceof Error
|
|
375
|
+
? err.message
|
|
376
|
+
: "Unexpected error during Roth optimization",
|
|
377
|
+
}, true);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
console.log("registerRothTools: Registration successful!");
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
console.error("registerRothTools: FAILED to register optimize_roth_conversion:", error);
|
|
384
|
+
throw error;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
388
|
+
function irmaaThreshold(filingStatus) {
|
|
389
|
+
return IRMAA_THRESHOLDS[filingStatus];
|
|
390
|
+
}
|
|
391
|
+
function buildRequestHeaders(authCtx) {
|
|
392
|
+
const headers = {};
|
|
393
|
+
if (authCtx?.bearerToken) {
|
|
394
|
+
headers.Authorization = `Bearer ${authCtx.bearerToken}`;
|
|
395
|
+
}
|
|
396
|
+
if (authCtx?.userId) {
|
|
397
|
+
headers["X-User-ID"] = authCtx.userId;
|
|
398
|
+
}
|
|
399
|
+
return headers;
|
|
400
|
+
}
|
|
401
|
+
async function runIntegratedPolicyOptimization(params) {
|
|
402
|
+
const { filingStatus, currentAge, retirementAge, traditionalIraBalance, rothIraBalance, annualIncome, otherIncome, socialSecurityAnnual, guardrails, planId, reqHeaders, } = params;
|
|
403
|
+
const withConversionsPayload = buildIntegratedForecastPayload({
|
|
404
|
+
filingStatus,
|
|
405
|
+
currentAge,
|
|
406
|
+
retirementAge,
|
|
407
|
+
traditionalIraBalance,
|
|
408
|
+
rothIraBalance,
|
|
409
|
+
annualIncome,
|
|
410
|
+
otherIncome,
|
|
411
|
+
socialSecurityAnnual,
|
|
412
|
+
guardrails,
|
|
413
|
+
planId,
|
|
414
|
+
enableRothConversions: true,
|
|
415
|
+
});
|
|
416
|
+
const baselinePayload = buildIntegratedForecastPayload({
|
|
417
|
+
filingStatus,
|
|
418
|
+
currentAge,
|
|
419
|
+
retirementAge,
|
|
420
|
+
traditionalIraBalance,
|
|
421
|
+
rothIraBalance,
|
|
422
|
+
annualIncome,
|
|
423
|
+
otherIncome,
|
|
424
|
+
socialSecurityAnnual,
|
|
425
|
+
guardrails,
|
|
426
|
+
planId,
|
|
427
|
+
enableRothConversions: false,
|
|
428
|
+
});
|
|
429
|
+
const withConversionsResult = await nestpilotClient.post("/api/plan/forecast", withConversionsPayload, { headers: reqHeaders });
|
|
430
|
+
if (withConversionsResult.error) {
|
|
431
|
+
return {
|
|
432
|
+
error: true,
|
|
433
|
+
code: withConversionsResult.code,
|
|
434
|
+
message: withConversionsResult.message ??
|
|
435
|
+
"Integrated optimization failed while running conversion forecast.",
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
const baselineResult = await nestpilotClient.post("/api/plan/forecast", baselinePayload, { headers: reqHeaders });
|
|
439
|
+
if (baselineResult.error) {
|
|
440
|
+
return {
|
|
441
|
+
error: true,
|
|
442
|
+
code: baselineResult.code,
|
|
443
|
+
message: baselineResult.message ??
|
|
444
|
+
"Integrated optimization failed while running baseline forecast.",
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
const withForecast = withConversionsResult;
|
|
448
|
+
const baselineForecast = baselineResult;
|
|
449
|
+
const withYears = Array.isArray(withForecast.yearly)
|
|
450
|
+
? withForecast.yearly
|
|
451
|
+
: [];
|
|
452
|
+
const baselineYears = Array.isArray(baselineForecast.yearly)
|
|
453
|
+
? baselineForecast.yearly
|
|
454
|
+
: [];
|
|
455
|
+
const conversionSchedule = [];
|
|
456
|
+
const irmaaWarningSet = new Set();
|
|
457
|
+
let cumulativeConverted = 0;
|
|
458
|
+
let totalTaxCost = 0;
|
|
459
|
+
const currentYear = new Date().getFullYear();
|
|
460
|
+
for (let i = 0; i < withYears.length; i += 1) {
|
|
461
|
+
const yearEntry = withYears[i];
|
|
462
|
+
const conversionAmount = normalizeNumber(yearEntry.rothConversion);
|
|
463
|
+
if (conversionAmount <= 0) {
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const age = normalizeInteger(yearEntry.age, currentAge + i);
|
|
467
|
+
const year = normalizeInteger(yearEntry.year, currentYear + Math.max(0, age - currentAge));
|
|
468
|
+
const taxCost = Math.max(0, normalizeNumber(yearEntry.rothConversionTax));
|
|
469
|
+
totalTaxCost += taxCost;
|
|
470
|
+
cumulativeConverted += conversionAmount;
|
|
471
|
+
const detail = yearEntry.rothOptimization;
|
|
472
|
+
const deltaFederalTax = normalizeNumber(detail?.deltaFederalTax);
|
|
473
|
+
const deltaStateTax = normalizeNumber(detail?.deltaStateTax);
|
|
474
|
+
const deltaAcaPremium = normalizeNumber(detail?.deltaAcaPremium);
|
|
475
|
+
const deltaMedicarePremium = normalizeNumber(detail?.deltaMedicarePremium);
|
|
476
|
+
const deltaTaxableSocialSecurity = normalizeNumber(detail?.deltaTaxableSocialSecurity);
|
|
477
|
+
const allInCost = normalizeNumber(detail?.allInCost, deltaFederalTax + deltaStateTax + deltaAcaPremium + deltaMedicarePremium);
|
|
478
|
+
const futureBenefitPv = normalizeNumber(detail?.futureBenefitPv);
|
|
479
|
+
const primaryDriver = normalizePrimaryDriver(detail?.primaryDriver);
|
|
480
|
+
const guardrailsTriggered = toStringArray(detail?.guardrailsTriggered);
|
|
481
|
+
const effectiveMarginalRate = optionalNumber(detail?.effectiveMarginalRate);
|
|
482
|
+
const futurePainThreshold = optionalNumber(detail?.futurePainThreshold);
|
|
483
|
+
const ceilingBracket = optionalString(detail?.ceilingBracket);
|
|
484
|
+
const rationale = optionalString(detail?.rationale);
|
|
485
|
+
let irmaaWarning = extractIrmaaWarning(year, yearEntry.healthInsuranceWarnings);
|
|
486
|
+
if (!irmaaWarning) {
|
|
487
|
+
const estimatedIncomeWithConversion = estimateIncomeWithConversion(yearEntry);
|
|
488
|
+
const threshold = irmaaThreshold(filingStatus);
|
|
489
|
+
if (estimatedIncomeWithConversion > threshold) {
|
|
490
|
+
irmaaWarning = `Year ${year}: Income with conversion ($${Math.round(estimatedIncomeWithConversion).toLocaleString()}) exceeds IRMAA threshold ($${threshold.toLocaleString()}). Medicare Part B/D premiums may increase.`;
|
|
491
|
+
}
|
|
492
|
+
else if (estimatedIncomeWithConversion > threshold * 0.9) {
|
|
493
|
+
irmaaWarning = `Year ${year}: Income with conversion ($${Math.round(estimatedIncomeWithConversion).toLocaleString()}) is within 10% of IRMAA threshold ($${threshold.toLocaleString()}).`;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (irmaaWarning) {
|
|
497
|
+
irmaaWarningSet.add(irmaaWarning);
|
|
498
|
+
}
|
|
499
|
+
conversionSchedule.push({
|
|
500
|
+
year,
|
|
501
|
+
age,
|
|
502
|
+
conversionAmount: Math.round(conversionAmount),
|
|
503
|
+
taxCost: Math.round(taxCost),
|
|
504
|
+
bracketFilled: ceilingBracket ??
|
|
505
|
+
(effectiveMarginalRate != null
|
|
506
|
+
? `${Math.round(effectiveMarginalRate * 100)}%`
|
|
507
|
+
: "n/a"),
|
|
508
|
+
bracketHeadroomUsed: 0,
|
|
509
|
+
cumulativeConverted: Math.round(cumulativeConverted),
|
|
510
|
+
irmaaWarning,
|
|
511
|
+
deltaFederalTax: Math.round(deltaFederalTax),
|
|
512
|
+
deltaStateTax: Math.round(deltaStateTax),
|
|
513
|
+
deltaAcaPremium: Math.round(deltaAcaPremium),
|
|
514
|
+
deltaMedicarePremium: Math.round(deltaMedicarePremium),
|
|
515
|
+
deltaTaxableSocialSecurity: Math.round(deltaTaxableSocialSecurity),
|
|
516
|
+
allInCost: Math.round(allInCost),
|
|
517
|
+
futureBenefitPv: Math.round(futureBenefitPv),
|
|
518
|
+
primaryDriver,
|
|
519
|
+
guardrailsTriggered,
|
|
520
|
+
rationale: rationale ?? undefined,
|
|
521
|
+
effectiveMarginalRate: effectiveMarginalRate ?? undefined,
|
|
522
|
+
futurePainThreshold: futurePainThreshold ?? undefined,
|
|
523
|
+
ceilingBracket: ceilingBracket ?? undefined,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
const rmdStartAge = 73;
|
|
527
|
+
const rmdFactor = 26.5;
|
|
528
|
+
const traditionalAtRmdWithConversions = findTraditionalBalanceAtOrAfterAge(withYears, rmdStartAge);
|
|
529
|
+
const traditionalAtRmdWithoutConversions = findTraditionalBalanceAtOrAfterAge(baselineYears, rmdStartAge);
|
|
530
|
+
const rmdWithConversions = traditionalAtRmdWithConversions / rmdFactor;
|
|
531
|
+
const rmdWithoutConversions = traditionalAtRmdWithoutConversions / rmdFactor;
|
|
532
|
+
const breakEvenAge = calculateBreakEvenAge(withYears, baselineYears, retirementAge);
|
|
533
|
+
const withTotalTax = normalizeNumber(withForecast.totalTaxAmount);
|
|
534
|
+
const baselineTotalTax = normalizeNumber(baselineForecast.totalTaxAmount);
|
|
535
|
+
const estimatedLifetimeTaxSavings = Math.round(Math.max(0, baselineTotalTax - withTotalTax));
|
|
536
|
+
const conversionWindow = conversionSchedule.length > 0
|
|
537
|
+
? `from age ${conversionSchedule[0].age} to age ${conversionSchedule[conversionSchedule.length - 1].age}`
|
|
538
|
+
: "with no conversion years recommended";
|
|
539
|
+
return {
|
|
540
|
+
conversionSchedule,
|
|
541
|
+
summary: {
|
|
542
|
+
totalConverted: Math.round(cumulativeConverted),
|
|
543
|
+
totalTaxCost: Math.round(totalTaxCost),
|
|
544
|
+
estimatedLifetimeTaxSavings,
|
|
545
|
+
breakEvenAge,
|
|
546
|
+
yearsOfConversions: conversionSchedule.length,
|
|
547
|
+
},
|
|
548
|
+
irmaaWarnings: Array.from(irmaaWarningSet),
|
|
549
|
+
rmdImpact: {
|
|
550
|
+
withConversions: {
|
|
551
|
+
estimatedAnnualRmd: Math.round(rmdWithConversions),
|
|
552
|
+
},
|
|
553
|
+
withoutConversions: {
|
|
554
|
+
estimatedAnnualRmd: Math.round(rmdWithoutConversions),
|
|
555
|
+
},
|
|
556
|
+
rmdReduction: Math.round(rmdWithoutConversions - rmdWithConversions),
|
|
557
|
+
},
|
|
558
|
+
evidencePacket: {
|
|
559
|
+
assumptions: [
|
|
560
|
+
"Integrated policy uses backend forecast engine valley-fill routing.",
|
|
561
|
+
`Filing status: ${filingStatus}`,
|
|
562
|
+
`Current age: ${currentAge}, retirement age: ${retirementAge}`,
|
|
563
|
+
`Traditional IRA balance: $${Math.round(traditionalIraBalance).toLocaleString()}`,
|
|
564
|
+
`Roth IRA balance: $${Math.round(rothIraBalance).toLocaleString()}`,
|
|
565
|
+
`Annual earned income: $${Math.round(annualIncome).toLocaleString()}`,
|
|
566
|
+
`Additional annual income: $${Math.round(otherIncome).toLocaleString()}`,
|
|
567
|
+
`Annual Social Security (if applicable): $${Math.round(socialSecurityAnnual).toLocaleString()}`,
|
|
568
|
+
"Synthetic forecast payload uses targetMonthlySpending = 0 to isolate conversion policy behavior.",
|
|
569
|
+
`IRMAA thresholds: $${irmaaThreshold(filingStatus).toLocaleString()} (${filingStatus})`,
|
|
570
|
+
],
|
|
571
|
+
calculationSteps: [
|
|
572
|
+
"1. Called POST /api/plan/forecast with enableRothConversions=true and rothConversionStrategy=integrated",
|
|
573
|
+
"2. Called POST /api/plan/forecast baseline with enableRothConversions=false",
|
|
574
|
+
`3. Extracted yearly rothConversion and rothOptimization detail ${conversionWindow}`,
|
|
575
|
+
`4. Summed converted amount ($${Math.round(cumulativeConverted).toLocaleString()}) and conversion tax ($${Math.round(totalTaxCost).toLocaleString()})`,
|
|
576
|
+
`5. Estimated RMD impact at age ${rmdStartAge} using forecasted traditional balances`,
|
|
577
|
+
breakEvenAge != null
|
|
578
|
+
? `6. Break-even age identified at ${breakEvenAge} from cumulative tax deltas`
|
|
579
|
+
: "6. Break-even age not reached within forecast horizon",
|
|
580
|
+
],
|
|
581
|
+
disclaimers: [
|
|
582
|
+
"This analysis is for educational purposes only and does not constitute tax advice.",
|
|
583
|
+
"Consult a qualified tax professional before making Roth conversion decisions.",
|
|
584
|
+
"Tax law, brackets, IRMAA thresholds, and premium rules may change.",
|
|
585
|
+
"Actual results vary based on returns, legislation, and personal circumstances.",
|
|
586
|
+
],
|
|
587
|
+
inputs: {
|
|
588
|
+
filingStatus,
|
|
589
|
+
currentAge,
|
|
590
|
+
retirementAge,
|
|
591
|
+
traditionalIraBalance,
|
|
592
|
+
rothIraBalance,
|
|
593
|
+
annualIncome,
|
|
594
|
+
otherIncome,
|
|
595
|
+
socialSecurityAnnual,
|
|
596
|
+
conversionPolicy: "integrated",
|
|
597
|
+
guardrails: guardrails ?? null,
|
|
598
|
+
planId: planId ?? null,
|
|
599
|
+
},
|
|
600
|
+
generatedAt: new Date().toISOString(),
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
function buildIntegratedForecastPayload(params) {
|
|
605
|
+
const { filingStatus, currentAge, retirementAge, traditionalIraBalance, rothIraBalance, annualIncome, otherIncome, socialSecurityAnnual, guardrails, planId, enableRothConversions, } = params;
|
|
606
|
+
const horizonAge = 95;
|
|
607
|
+
const incomeStreams = [];
|
|
608
|
+
if (annualIncome > 0 && currentAge < retirementAge) {
|
|
609
|
+
incomeStreams.push({
|
|
610
|
+
type: "salary",
|
|
611
|
+
amountMonthly: annualIncome / 12,
|
|
612
|
+
startAge: currentAge,
|
|
613
|
+
endAge: Math.max(currentAge, retirementAge - 1),
|
|
614
|
+
owner: "primary",
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
if (otherIncome > 0) {
|
|
618
|
+
incomeStreams.push({
|
|
619
|
+
type: "other",
|
|
620
|
+
amountMonthly: otherIncome / 12,
|
|
621
|
+
startAge: currentAge,
|
|
622
|
+
endAge: horizonAge,
|
|
623
|
+
owner: "primary",
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
if (socialSecurityAnnual > 0) {
|
|
627
|
+
incomeStreams.push({
|
|
628
|
+
type: "social_security",
|
|
629
|
+
amountMonthly: socialSecurityAnnual / 12,
|
|
630
|
+
startAge: Math.max(62, currentAge),
|
|
631
|
+
endAge: horizonAge,
|
|
632
|
+
owner: "primary",
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
if (incomeStreams.length === 0) {
|
|
636
|
+
incomeStreams.push({
|
|
637
|
+
type: "other",
|
|
638
|
+
amountMonthly: 0,
|
|
639
|
+
startAge: currentAge,
|
|
640
|
+
endAge: horizonAge,
|
|
641
|
+
owner: "primary",
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
const payload = {
|
|
645
|
+
currentAge,
|
|
646
|
+
retirementAge,
|
|
647
|
+
horizonAge,
|
|
648
|
+
targetMonthlySpending: 0,
|
|
649
|
+
filingStatus: mapFilingStatusToPlanInput(filingStatus),
|
|
650
|
+
withdrawalStrategy: "taxable_first",
|
|
651
|
+
wages: annualIncome,
|
|
652
|
+
enableRothConversions,
|
|
653
|
+
rothConversionStrategy: "integrated",
|
|
654
|
+
accounts: [
|
|
655
|
+
{
|
|
656
|
+
id: "trad-1",
|
|
657
|
+
owner: "primary",
|
|
658
|
+
type: "traditional",
|
|
659
|
+
balance: Math.max(0, traditionalIraBalance),
|
|
660
|
+
annualContribution: 0,
|
|
661
|
+
realReturn: 0.05,
|
|
662
|
+
taxDrag: 0,
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
id: "roth-1",
|
|
666
|
+
owner: "primary",
|
|
667
|
+
type: "roth",
|
|
668
|
+
balance: Math.max(0, rothIraBalance),
|
|
669
|
+
annualContribution: 0,
|
|
670
|
+
realReturn: 0.05,
|
|
671
|
+
taxDrag: 0,
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
id: "taxable-1",
|
|
675
|
+
owner: "primary",
|
|
676
|
+
type: "taxable",
|
|
677
|
+
balance: 0,
|
|
678
|
+
annualContribution: 0,
|
|
679
|
+
realReturn: 0.05,
|
|
680
|
+
taxDrag: 0.01,
|
|
681
|
+
},
|
|
682
|
+
],
|
|
683
|
+
incomeStreams,
|
|
684
|
+
};
|
|
685
|
+
if (guardrails) {
|
|
686
|
+
payload.rothConversionGuardrails = normalizeGuardrailsForForecast(guardrails);
|
|
687
|
+
}
|
|
688
|
+
if (planId) {
|
|
689
|
+
payload.planId = planId;
|
|
690
|
+
}
|
|
691
|
+
return payload;
|
|
692
|
+
}
|
|
693
|
+
function mapFilingStatusToPlanInput(status) {
|
|
694
|
+
switch (status) {
|
|
695
|
+
case "MFJ":
|
|
696
|
+
return "mfj";
|
|
697
|
+
case "MFS":
|
|
698
|
+
return "mfs";
|
|
699
|
+
case "HOH":
|
|
700
|
+
return "hoh";
|
|
701
|
+
case "SINGLE":
|
|
702
|
+
default:
|
|
703
|
+
return "single";
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
function normalizeGuardrailsForForecast(guardrails) {
|
|
707
|
+
const maxAllInMarginalRate = typeof guardrails.maxAllInMarginalRate === "number" &&
|
|
708
|
+
Number.isFinite(guardrails.maxAllInMarginalRate) &&
|
|
709
|
+
guardrails.maxAllInMarginalRate > 1
|
|
710
|
+
? guardrails.maxAllInMarginalRate / 100
|
|
711
|
+
: guardrails.maxAllInMarginalRate;
|
|
712
|
+
return {
|
|
713
|
+
avoidIrmaaTierJump: guardrails.avoidIrmaaTierJump,
|
|
714
|
+
maxAcaFplPct: guardrails.maxAcaFplPct,
|
|
715
|
+
maxAllInMarginalRate,
|
|
716
|
+
annualCap: guardrails.annualCap,
|
|
717
|
+
lookaheadYears: guardrails.lookaheadYears,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
function normalizeNumber(value, fallback = 0) {
|
|
721
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
722
|
+
return value;
|
|
723
|
+
}
|
|
724
|
+
if (typeof value === "string") {
|
|
725
|
+
const parsed = Number(value);
|
|
726
|
+
if (Number.isFinite(parsed)) {
|
|
727
|
+
return parsed;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return fallback;
|
|
731
|
+
}
|
|
732
|
+
function normalizeInteger(value, fallback) {
|
|
733
|
+
return Math.round(normalizeNumber(value, fallback));
|
|
734
|
+
}
|
|
735
|
+
function optionalNumber(value) {
|
|
736
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
737
|
+
return value;
|
|
738
|
+
}
|
|
739
|
+
if (typeof value === "string") {
|
|
740
|
+
const parsed = Number(value);
|
|
741
|
+
if (Number.isFinite(parsed)) {
|
|
742
|
+
return parsed;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
function optionalString(value) {
|
|
748
|
+
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
749
|
+
}
|
|
750
|
+
function toStringArray(value) {
|
|
751
|
+
if (!Array.isArray(value)) {
|
|
752
|
+
return [];
|
|
753
|
+
}
|
|
754
|
+
return value.filter((entry) => typeof entry === "string");
|
|
755
|
+
}
|
|
756
|
+
function normalizePrimaryDriver(value) {
|
|
757
|
+
switch (value) {
|
|
758
|
+
case "aca":
|
|
759
|
+
case "medicare":
|
|
760
|
+
case "ss_tax":
|
|
761
|
+
case "state_tax":
|
|
762
|
+
case "tax":
|
|
763
|
+
return value;
|
|
764
|
+
default:
|
|
765
|
+
return "tax";
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
function estimateIncomeWithConversion(year) {
|
|
769
|
+
const income = year.income;
|
|
770
|
+
const baseIncome = normalizeNumber(income?.ss) +
|
|
771
|
+
normalizeNumber(income?.spouseSS) +
|
|
772
|
+
normalizeNumber(income?.rental) +
|
|
773
|
+
normalizeNumber(income?.pension) +
|
|
774
|
+
normalizeNumber(income?.annuity) +
|
|
775
|
+
normalizeNumber(income?.other);
|
|
776
|
+
return baseIncome + normalizeNumber(year.rothConversion);
|
|
777
|
+
}
|
|
778
|
+
function extractIrmaaWarning(year, warnings) {
|
|
779
|
+
if (!Array.isArray(warnings)) {
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
for (const warning of warnings) {
|
|
783
|
+
if (typeof warning === "string" && warning.toUpperCase().includes("IRMAA")) {
|
|
784
|
+
return `Year ${year}: ${warning}`;
|
|
785
|
+
}
|
|
786
|
+
if (warning && typeof warning === "object") {
|
|
787
|
+
const maybeMessage = warning.message;
|
|
788
|
+
if (typeof maybeMessage === "string" &&
|
|
789
|
+
maybeMessage.toUpperCase().includes("IRMAA")) {
|
|
790
|
+
return `Year ${year}: ${maybeMessage}`;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
function findTraditionalBalanceAtOrAfterAge(years, targetAge) {
|
|
797
|
+
for (const year of years) {
|
|
798
|
+
const age = normalizeInteger(year.age, -1);
|
|
799
|
+
if (age >= targetAge) {
|
|
800
|
+
return normalizeNumber(year.balances?.traditional);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return 0;
|
|
804
|
+
}
|
|
805
|
+
function calculateBreakEvenAge(withYears, baselineYears, retirementAge) {
|
|
806
|
+
const years = Math.min(withYears.length, baselineYears.length);
|
|
807
|
+
let cumulativeTaxDelta = 0;
|
|
808
|
+
for (let i = 0; i < years; i += 1) {
|
|
809
|
+
const withTax = normalizeNumber(withYears[i].taxAmount);
|
|
810
|
+
const baselineTax = normalizeNumber(baselineYears[i].taxAmount);
|
|
811
|
+
cumulativeTaxDelta += withTax - baselineTax;
|
|
812
|
+
const age = normalizeInteger(withYears[i].age, -1);
|
|
813
|
+
if (age >= retirementAge && cumulativeTaxDelta <= 0) {
|
|
814
|
+
return age;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return null;
|
|
818
|
+
}
|