@retiregolden/planner-ui 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 +181 -0
- package/package.json +77 -0
- package/src/App.tsx +246 -0
- package/src/RouteErrorBoundary.tsx +45 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/vite.svg +1 -0
- package/src/data/fedInvestClient.ts +113 -0
- package/src/data/localStore.ts +42 -0
- package/src/data/planOrigin.ts +24 -0
- package/src/data/planStore.ts +165 -0
- package/src/data/v2Backup.ts +101 -0
- package/src/import/ImportPage.tsx +347 -0
- package/src/import/ReviewChecklistView.tsx +38 -0
- package/src/import/brokerCsv.ts +395 -0
- package/src/import/csv.ts +133 -0
- package/src/import/genericCsv.ts +224 -0
- package/src/import/projectionLab.ts +350 -0
- package/src/import/reviewChecklist.ts +33 -0
- package/src/import/tenForty.ts +275 -0
- package/src/index.css +630 -0
- package/src/index.ts +16 -0
- package/src/learn/ArticleBody.tsx +78 -0
- package/src/learn/ArticlePage.tsx +57 -0
- package/src/learn/GlossaryPage.tsx +33 -0
- package/src/learn/LearnAboutScreen.tsx +41 -0
- package/src/learn/LearnCards.tsx +41 -0
- package/src/learn/LearnLink.tsx +91 -0
- package/src/learn/LearningCenterPage.tsx +114 -0
- package/src/learn/SourcesPage.tsx +98 -0
- package/src/learn/components/ArticleFigure.tsx +34 -0
- package/src/learn/components/ArticleShell.tsx +86 -0
- package/src/learn/components/ComparisonTable.tsx +42 -0
- package/src/learn/components/FormulaBlock.tsx +34 -0
- package/src/learn/components/PurchasingPowerChart.tsx +41 -0
- package/src/learn/components/RelatedArticles.tsx +27 -0
- package/src/learn/components/ScenarioCard.tsx +24 -0
- package/src/learn/components/SourceList.tsx +23 -0
- package/src/learn/components/charts.tsx +21 -0
- package/src/learn/content/about-retiregolden.ts +100 -0
- package/src/learn/content/aca-premium-tax-credits-and-magi.ts +103 -0
- package/src/learn/content/account-types-overview.ts +106 -0
- package/src/learn/content/after-tax-estate.ts +111 -0
- package/src/learn/content/agi-magi-and-taxable-income.ts +112 -0
- package/src/learn/content/appealing-irmaa-ssa-44.ts +95 -0
- package/src/learn/content/assumption-general-inflation.ts +82 -0
- package/src/learn/content/assumption-healthcare-inflation.ts +85 -0
- package/src/learn/content/assumption-heir-tax-rate.ts +79 -0
- package/src/learn/content/assumption-investment-returns.ts +90 -0
- package/src/learn/content/assumption-longevity-planning-age.ts +78 -0
- package/src/learn/content/assumption-recent-magi.ts +83 -0
- package/src/learn/content/assumption-social-security-cola.ts +89 -0
- package/src/learn/content/assumption-social-security-trust-fund.ts +83 -0
- package/src/learn/content/assumption-state-tax-override.ts +79 -0
- package/src/learn/content/beneficiaries-and-account-titling.ts +99 -0
- package/src/learn/content/break-even-useful-lens.ts +94 -0
- package/src/learn/content/building-a-retirement-spending-budget.ts +100 -0
- package/src/learn/content/cola-and-inflation-protection.ts +102 -0
- package/src/learn/content/divorced-spousal-and-survivor-records.ts +104 -0
- package/src/learn/content/dynamic-spending-guardrails.ts +90 -0
- package/src/learn/content/earnings-test-before-fra.ts +100 -0
- package/src/learn/content/employer-match-and-contribution-order.ts +104 -0
- package/src/learn/content/examplePlanArticles.ts +525 -0
- package/src/learn/content/fees-expense-ratios-and-compounding-drag.ts +98 -0
- package/src/learn/content/fi-number-and-four-percent-rule.ts +64 -0
- package/src/learn/content/filling-a-tax-bracket-with-roth-conversions.ts +98 -0
- package/src/learn/content/funded-ratio.ts +70 -0
- package/src/learn/content/healthcare-after-65.ts +103 -0
- package/src/learn/content/healthcare-before-65.ts +104 -0
- package/src/learn/content/historical-vs-random-return-models.ts +101 -0
- package/src/learn/content/how-assumptions-change-the-answer.ts +105 -0
- package/src/learn/content/how-much-can-i-spend.ts +105 -0
- package/src/learn/content/how-social-security-is-taxed.ts +95 -0
- package/src/learn/content/how-the-optimizer-thinks.ts +102 -0
- package/src/learn/content/how-the-optimizer-values-after-tax-estate.ts +97 -0
- package/src/learn/content/how-to-model-accumulation.ts +67 -0
- package/src/learn/content/how-to-read-a-retirement-projection.ts +115 -0
- package/src/learn/content/hsas-and-qualified-medical-expenses.ts +108 -0
- package/src/learn/content/hsas-as-retirement-accounts.ts +101 -0
- package/src/learn/content/inflation-risk.ts +98 -0
- package/src/learn/content/inherited-ira-10-year-rule.ts +105 -0
- package/src/learn/content/insurance-in-your-retirement-plan.ts +103 -0
- package/src/learn/content/irmaa-two-year-lookback.ts +99 -0
- package/src/learn/content/long-term-care-costs-and-insurance.ts +103 -0
- package/src/learn/content/long-term-care-insurance-as-risk-transfer.ts +98 -0
- package/src/learn/content/longevity-risk.ts +99 -0
- package/src/learn/content/marginal-vs-effective-tax-rate.ts +98 -0
- package/src/learn/content/medicare-part-b-vs-part-d-irmaa.ts +102 -0
- package/src/learn/content/mortality-weighted-social-security.ts +113 -0
- package/src/learn/content/moving-to-retiregolden.ts +86 -0
- package/src/learn/content/niit-high-income-investment-tax.ts +98 -0
- package/src/learn/content/ordinary-income-vs-capital-gains.ts +103 -0
- package/src/learn/content/paying-conversion-taxes-taxable-vs-ira.ts +102 -0
- package/src/learn/content/pensions-and-annuities.ts +101 -0
- package/src/learn/content/permanent-life-insurance-in-a-plan.ts +106 -0
- package/src/learn/content/pia-aime-and-bend-points.ts +103 -0
- package/src/learn/content/planner-overview.ts +106 -0
- package/src/learn/content/planning-for-couples-and-survivor-years.ts +108 -0
- package/src/learn/content/privacy-what-stays-in-your-browser.ts +99 -0
- package/src/learn/content/qcds-qualified-charitable-distributions.ts +101 -0
- package/src/learn/content/reading-the-results-page.ts +96 -0
- package/src/learn/content/reading-the-social-security-analysis-page.ts +106 -0
- package/src/learn/content/real-estate-home-equity-and-debt.ts +100 -0
- package/src/learn/content/reports-csv-exports-and-sharing.ts +101 -0
- package/src/learn/content/risk-based-guardrails.ts +100 -0
- package/src/learn/content/rmds-required-minimum-distributions.ts +100 -0
- package/src/learn/content/roth-conversion-basics.ts +104 -0
- package/src/learn/content/rsus-and-espp.ts +101 -0
- package/src/learn/content/rule-of-55-and-72t.ts +107 -0
- package/src/learn/content/savings-rate-biggest-lever.ts +66 -0
- package/src/learn/content/seed-your-plan-from-your-tax-return.ts +93 -0
- package/src/learn/content/sensitivity-testing-what-changes-the-answer.ts +104 -0
- package/src/learn/content/sequence-of-returns-risk.ts +98 -0
- package/src/learn/content/social-security-bridge.ts +67 -0
- package/src/learn/content/social-security-claiming-age-basics.ts +113 -0
- package/src/learn/content/social-security-taxes-vs-benefits.ts +76 -0
- package/src/learn/content/spending-profiles-and-the-retirement-smile.ts +92 -0
- package/src/learn/content/spousal-and-survivor-benefits.ts +120 -0
- package/src/learn/content/ssdi-and-retirement-planning.ts +72 -0
- package/src/learn/content/standard-deduction-senior-deduction-and-itemizing.ts +97 -0
- package/src/learn/content/state-income-taxes-in-retirement.ts +97 -0
- package/src/learn/content/step-up-in-basis.ts +102 -0
- package/src/learn/content/survivor-planning-for-couples.ts +110 -0
- package/src/learn/content/survivor-spending-in-couple-plans.ts +98 -0
- package/src/learn/content/tax-cliffs-and-bracket-edges.ts +105 -0
- package/src/learn/content/tax-loss-and-gain-harvesting.ts +99 -0
- package/src/learn/content/taxable-brokerage-basis-and-capital-gains.ts +99 -0
- package/src/learn/content/three-big-questions-spending-time-risk.ts +103 -0
- package/src/learn/content/tips-ladders.ts +92 -0
- package/src/learn/content/todays-dollars-vs-future-dollars.ts +107 -0
- package/src/learn/content/traditional-vs-roth-contributions.ts +113 -0
- package/src/learn/content/troubleshooting-surprising-results.ts +105 -0
- package/src/learn/content/trust-fund-haircut-scenarios.ts +101 -0
- package/src/learn/content/understanding-monte-carlo-success-rate.ts +118 -0
- package/src/learn/content/understanding-your-plan-assumptions.ts +134 -0
- package/src/learn/content/using-assumptions-and-provenance.ts +98 -0
- package/src/learn/content/using-scenarios-to-compare-choices.ts +99 -0
- package/src/learn/content/what-changes-when-you-move-states.ts +141 -0
- package/src/learn/content/what-is-fire.ts +65 -0
- package/src/learn/content/what-monte-carlo-proves.ts +98 -0
- package/src/learn/content/what-retiregolden-models.ts +103 -0
- package/src/learn/content/what-retirement-healthcare-really-costs.ts +117 -0
- package/src/learn/content/why-95-percent-is-not-a-guarantee.ts +98 -0
- package/src/learn/content/why-roth-conversions-raise-other-costs.ts +106 -0
- package/src/learn/content/why-small-tax-cliffs-can-matter.ts +109 -0
- package/src/learn/content/widows-penalty-and-survivor-brackets.ts +106 -0
- package/src/learn/content/withdrawal-order-basics.ts +105 -0
- package/src/learn/glossary.ts +191 -0
- package/src/learn/inlineMarkdown.tsx +54 -0
- package/src/learn/learn.css +537 -0
- package/src/learn/learningRegistry.ts +502 -0
- package/src/longevity/LongevityResults.tsx +85 -0
- package/src/longevity/LongevityWizard.tsx +305 -0
- package/src/longevity/constants.ts +15 -0
- package/src/longevity/factors.ts +125 -0
- package/src/longevity/model.ts +31 -0
- package/src/longevity/persistedGuard.ts +129 -0
- package/src/longevity/storage.ts +40 -0
- package/src/mc/messages.ts +118 -0
- package/src/mc/monteCarlo.worker.ts +44 -0
- package/src/mc/pool.ts +267 -0
- package/src/mc/runRequest.ts +125 -0
- package/src/optimize/messages.ts +84 -0
- package/src/optimize/optimize.worker.ts +29 -0
- package/src/optimize/runOptimize.ts +92 -0
- package/src/optimize/runSpendingSolve.ts +47 -0
- package/src/optimize/runner.ts +21 -0
- package/src/optimize/spendingMessages.ts +44 -0
- package/src/optimize/spendingRunner.ts +21 -0
- package/src/optimize/spendingSolve.worker.ts +18 -0
- package/src/planner/AssumptionsCardPage.tsx +136 -0
- package/src/planner/BucketLensCard.tsx +114 -0
- package/src/planner/ComparePlansPage.tsx +219 -0
- package/src/planner/DisclaimerPage.tsx +88 -0
- package/src/planner/HowTestedPage.tsx +159 -0
- package/src/planner/LiveStatus.tsx +15 -0
- package/src/planner/LongevityModal.tsx +55 -0
- package/src/planner/Modal.tsx +97 -0
- package/src/planner/MonteCarloPage.tsx +907 -0
- package/src/planner/OptimizePage.tsx +611 -0
- package/src/planner/PlanContext.tsx +198 -0
- package/src/planner/PlanPickerPage.tsx +124 -0
- package/src/planner/PlanWorkspace.tsx +290 -0
- package/src/planner/ProvenancePanel.tsx +45 -0
- package/src/planner/RelocationComparePage.tsx +485 -0
- package/src/planner/ReportPage.tsx +375 -0
- package/src/planner/ResultsPage.tsx +817 -0
- package/src/planner/ScenariosPage.tsx +285 -0
- package/src/planner/SocialSecuritySection.tsx +556 -0
- package/src/planner/SpendingSolverPage.tsx +512 -0
- package/src/planner/SsAnalysisPage.tsx +1134 -0
- package/src/planner/SurvivalPercentileModal.tsx +161 -0
- package/src/planner/SurvivorTransitionPage.tsx +286 -0
- package/src/planner/assumptionsExport.ts +371 -0
- package/src/planner/bucketLens.ts +89 -0
- package/src/planner/chartFrame.ts +8 -0
- package/src/planner/chartStyle.ts +11 -0
- package/src/planner/dialogViews.tsx +184 -0
- package/src/planner/dialogs.tsx +133 -0
- package/src/planner/examples/ExampleLibrary.tsx +189 -0
- package/src/planner/examples/ExamplePreviewBanner.tsx +55 -0
- package/src/planner/examples/ExamplesPage.tsx +25 -0
- package/src/planner/examples/OpenExampleButton.tsx +61 -0
- package/src/planner/examples/buildAggressiveSaver.ts +102 -0
- package/src/planner/examples/buildAnnuityEstate.ts +137 -0
- package/src/planner/examples/buildBaristaFire.ts +115 -0
- package/src/planner/examples/buildBracketFillRoth.ts +65 -0
- package/src/planner/examples/buildBridgeEarlyRetirement.ts +94 -0
- package/src/planner/examples/buildBrokerageNoHsa.ts +109 -0
- package/src/planner/examples/buildCoastFire.ts +88 -0
- package/src/planner/examples/buildContext.ts +20 -0
- package/src/planner/examples/buildEarlyCareerMatch.ts +93 -0
- package/src/planner/examples/buildEarlyRetireeAca.ts +61 -0
- package/src/planner/examples/buildExampleCouple.ts +103 -0
- package/src/planner/examples/buildFixedTargetSpending.ts +74 -0
- package/src/planner/examples/buildGlidepathAllocation.ts +131 -0
- package/src/planner/examples/buildGuardrailsFlex.ts +120 -0
- package/src/planner/examples/buildHsaPropertyDepth.ts +109 -0
- package/src/planner/examples/buildHsaStealthRetirement.ts +97 -0
- package/src/planner/examples/buildLeanFatFire.ts +109 -0
- package/src/planner/examples/buildLtcShock.ts +62 -0
- package/src/planner/examples/buildMovingStateTax.ts +53 -0
- package/src/planner/examples/buildNoAnnuityBrokerage.ts +92 -0
- package/src/planner/examples/buildRmdIrmaa.ts +55 -0
- package/src/planner/examples/buildSalaryGrowthEscalation.ts +96 -0
- package/src/planner/examples/buildStaticAllocationControl.ts +96 -0
- package/src/planner/examples/buildSurvivorYears.ts +62 -0
- package/src/planner/examples/buildUnderSavedSingle.ts +51 -0
- package/src/planner/examples/exampleCopy.ts +23 -0
- package/src/planner/examples/loadExample.ts +90 -0
- package/src/planner/examples/registry.ts +313 -0
- package/src/planner/explainPanels.tsx +233 -0
- package/src/planner/fields.tsx +381 -0
- package/src/planner/format.ts +33 -0
- package/src/planner/home/DataAndPrivacyCard.tsx +56 -0
- package/src/planner/home/GettingStartedPaths.tsx +46 -0
- package/src/planner/home/GettingStartedReopener.tsx +32 -0
- package/src/planner/home/StartHereLinks.tsx +22 -0
- package/src/planner/home/WelcomeHero.tsx +39 -0
- package/src/planner/home/YourPlans.tsx +72 -0
- package/src/planner/home/importErrorMessage.ts +22 -0
- package/src/planner/home/startHereSlugs.ts +7 -0
- package/src/planner/home/useHomeData.ts +190 -0
- package/src/planner/home/useHomeMode.ts +47 -0
- package/src/planner/householdActions.ts +22 -0
- package/src/planner/insights/InsightCardView.tsx +340 -0
- package/src/planner/insights/InsightsPage.tsx +204 -0
- package/src/planner/insights/categoryLabels.ts +11 -0
- package/src/planner/learnLinks.ts +85 -0
- package/src/planner/marketModelPicker.ts +172 -0
- package/src/planner/optimizePageChart.ts +40 -0
- package/src/planner/optimizePageClaim.ts +64 -0
- package/src/planner/planCompleteness.ts +27 -0
- package/src/planner/planContextCore.ts +26 -0
- package/src/planner/planner.css +2304 -0
- package/src/planner/provenanceLinks.ts +25 -0
- package/src/planner/sections/AccountFields.tsx +872 -0
- package/src/planner/sections/AccountsSection.tsx +89 -0
- package/src/planner/sections/AllocationPanel.tsx +261 -0
- package/src/planner/sections/AssumptionsSection.tsx +256 -0
- package/src/planner/sections/HouseholdSection.tsx +243 -0
- package/src/planner/sections/IncomeFloorSection.tsx +418 -0
- package/src/planner/sections/IncomeSection.tsx +170 -0
- package/src/planner/sections/InsuranceSection.tsx +362 -0
- package/src/planner/sections/SpendingSection.tsx +904 -0
- package/src/planner/sections/StrategySection.tsx +349 -0
- package/src/planner/sections/UpdateBalancesPanel.tsx +182 -0
- package/src/planner/sections/sectionHelpers.ts +48 -0
- package/src/planner/sections/shared.tsx +15 -0
- package/src/planner/sections.tsx +15 -0
- package/src/planner/ssAnalysis.ts +325 -0
- package/src/planner/successBand.ts +20 -0
- package/src/planner/survivorAnalysis.ts +277 -0
- package/src/planner/usStates.ts +19 -0
- package/src/planner/useMcSuccessRate.ts +77 -0
- package/src/planner/useProjection.ts +63 -0
- package/src/relocation/messages.ts +21 -0
- package/src/relocation/relocation.worker.ts +18 -0
- package/src/relocation/runRelocation.ts +17 -0
- package/src/relocation/runner.ts +22 -0
- package/src/report/brandingContext.ts +15 -0
- package/src/report/downloadReport.ts +34 -0
- package/src/report/reportHtml.ts +547 -0
- package/src/routes/LearnRoutes.tsx +46 -0
- package/src/routes/PlanRoutes.tsx +55 -0
- package/src/routes/RouteFallback.tsx +9 -0
- package/src/socialSecurity/breakEven.ts +107 -0
- package/src/socialSecurity/expectedPv.ts +164 -0
- package/src/socialSecurity/explain.ts +92 -0
- package/src/socialSecurity/ficaReturn.ts +81 -0
- package/src/socialSecurity/persistedSsGuard.ts +138 -0
- package/src/socialSecurity/ssFormUtils.ts +48 -0
- package/src/socialSecurity/ssaStatementXml.ts +156 -0
- package/src/socialSecurity/storage.ts +69 -0
- package/src/socialSecurity/survivorSwitching.ts +153 -0
- package/src/testSupport/samplePlan.ts +2 -0
- package/src/workers/run.ts +45 -0
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
/** Per-account form fields. */
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import type { Account, Plan } from '@retiregolden/engine/model/plan'
|
|
6
|
+
import { analyzePensionElections } from '@retiregolden/engine/decisions/pensionElection'
|
|
7
|
+
import { packForYear } from '@retiregolden/engine/params'
|
|
8
|
+
import { AllocationPanel, ReturnEstimatorModal } from './AllocationPanel'
|
|
9
|
+
import { ACCOUNT_LABEL, EVEN_START_WEIGHTS, isAllocatable, isIndividuallyOwnedAccount, type AllocatableAccount } from './sectionHelpers'
|
|
10
|
+
import { usePlan } from '../planContextCore'
|
|
11
|
+
import { CheckboxField, MoneyField, NumberField, PercentField, ReadonlyField, SelectField, TextField } from '../fields'
|
|
12
|
+
import { fmtMoney } from '../format'
|
|
13
|
+
import { currentStartYear } from '../useProjection'
|
|
14
|
+
import { LEARN } from '../learnLinks'
|
|
15
|
+
|
|
16
|
+
function ownerOptions(plan: Plan, type: Account['type']) {
|
|
17
|
+
const peopleOptions = plan.household.people.map((p) => ({ value: p.id, label: p.name }))
|
|
18
|
+
return isIndividuallyOwnedAccount(type) ? peopleOptions : [{ value: 'joint', label: 'Joint' }, ...peopleOptions]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function AccountFields({ account, index }: { account: Account; index: number }) {
|
|
22
|
+
const { plan, update } = usePlan()
|
|
23
|
+
const [estimating, setEstimating] = useState(false)
|
|
24
|
+
const set = <K extends string>(key: K, value: unknown) =>
|
|
25
|
+
update((d) => {
|
|
26
|
+
;(d.accounts[index] as unknown as Record<string, unknown>)[key] = value
|
|
27
|
+
})
|
|
28
|
+
return (
|
|
29
|
+
<div className="form-grid">
|
|
30
|
+
<TextField label="Name" value={account.name} onCommit={(v) => set('name', v || ACCOUNT_LABEL[account.type])} />
|
|
31
|
+
<SelectField
|
|
32
|
+
label="Owner"
|
|
33
|
+
value={account.ownerPersonId ?? 'joint'}
|
|
34
|
+
options={ownerOptions(plan, account.type)}
|
|
35
|
+
onCommit={(v) => set('ownerPersonId', v === 'joint' ? null : v)}
|
|
36
|
+
/>
|
|
37
|
+
{'balance' in account ? <MoneyField label={account.type === 'debt' ? 'Balance owed' : 'Balance'} value={account.balance} onCommit={(v) => set('balance', v ?? 0)} /> : null}
|
|
38
|
+
{account.type === 'taxable' || account.type === 'equityComp' ? <MoneyField label="Cost basis" hint="Aggregate basis; gains realize pro-rata." value={account.costBasis} onCommit={(v) => set('costBasis', v ?? 0)} /> : null}
|
|
39
|
+
{account.type === 'taxable' && account.allocation === undefined ? (
|
|
40
|
+
<>
|
|
41
|
+
<PercentField
|
|
42
|
+
label="Interest yield"
|
|
43
|
+
help="Annual taxable interest yield generated from this brokerage account before market-price growth."
|
|
44
|
+
value={account.interestYieldPct ?? 0}
|
|
45
|
+
onCommit={(v) => set('interestYieldPct', v ?? 0)}
|
|
46
|
+
/>
|
|
47
|
+
<PercentField
|
|
48
|
+
label="Dividend yield"
|
|
49
|
+
help="Annual dividend yield generated from this brokerage account before market-price growth."
|
|
50
|
+
value={account.dividendYieldPct ?? 0}
|
|
51
|
+
onCommit={(v) => set('dividendYieldPct', v ?? 0)}
|
|
52
|
+
/>
|
|
53
|
+
<PercentField
|
|
54
|
+
label="Qualified dividends"
|
|
55
|
+
help="Share of dividends taxed at long-term capital-gain rates federally. The rest is taxed as ordinary dividends."
|
|
56
|
+
value={(account.qualifiedRatio ?? 0.85) * 100}
|
|
57
|
+
onCommit={(v) => set('qualifiedRatio', Math.min(1, Math.max(0, (v ?? 85) / 100)))}
|
|
58
|
+
/>
|
|
59
|
+
<CheckboxField
|
|
60
|
+
label="Reinvest yield"
|
|
61
|
+
help="When checked, interest and dividends stay in the brokerage account and add to basis. When unchecked, they flow into annual cash surplus."
|
|
62
|
+
value={account.reinvestDividends ?? true}
|
|
63
|
+
onCommit={(v) => set('reinvestDividends', v)}
|
|
64
|
+
/>
|
|
65
|
+
</>
|
|
66
|
+
) : null}
|
|
67
|
+
{account.type === 'taxable' && account.allocation !== undefined ? (
|
|
68
|
+
<>
|
|
69
|
+
<PercentField
|
|
70
|
+
label="Interest yield override"
|
|
71
|
+
help="Optional. Leave blank to use the blended interest yield from the class mix (shown as 'This year's blend' above). Enter a value to override it for this account."
|
|
72
|
+
hint="Blank = use blended yield."
|
|
73
|
+
value={account.interestYieldPct ?? null}
|
|
74
|
+
allowNull
|
|
75
|
+
onCommit={(v) => set('interestYieldPct', v ?? undefined)}
|
|
76
|
+
/>
|
|
77
|
+
<PercentField
|
|
78
|
+
label="Dividend yield override"
|
|
79
|
+
help="Optional. Leave blank to use the blended dividend yield from the class mix. Enter a value to override it for this account."
|
|
80
|
+
hint="Blank = use blended yield."
|
|
81
|
+
value={account.dividendYieldPct ?? null}
|
|
82
|
+
allowNull
|
|
83
|
+
onCommit={(v) => set('dividendYieldPct', v ?? undefined)}
|
|
84
|
+
/>
|
|
85
|
+
<PercentField
|
|
86
|
+
label="Qualified dividends override"
|
|
87
|
+
help="Optional. Leave blank to use the blended qualified share from the class mix. Enter a value to override the share of dividends taxed at long-term capital-gain rates."
|
|
88
|
+
hint="Blank = use blended share."
|
|
89
|
+
value={account.qualifiedRatio === undefined ? null : account.qualifiedRatio * 100}
|
|
90
|
+
allowNull
|
|
91
|
+
onCommit={(v) => set('qualifiedRatio', v === null || v === undefined ? undefined : Math.min(1, Math.max(0, v / 100)))}
|
|
92
|
+
/>
|
|
93
|
+
<CheckboxField
|
|
94
|
+
label="Reinvest yield"
|
|
95
|
+
help="When checked, interest and dividends stay in the brokerage account and add to basis. When unchecked, they flow into annual cash surplus."
|
|
96
|
+
value={account.reinvestDividends ?? true}
|
|
97
|
+
onCommit={(v) => set('reinvestDividends', v)}
|
|
98
|
+
/>
|
|
99
|
+
</>
|
|
100
|
+
) : null}
|
|
101
|
+
{account.type === 'equityComp' ? (
|
|
102
|
+
<>
|
|
103
|
+
<SelectField
|
|
104
|
+
label="Availability"
|
|
105
|
+
value={account.vestingMode}
|
|
106
|
+
options={[
|
|
107
|
+
{ value: 'cliff', label: 'Locked until vest date' },
|
|
108
|
+
{ value: 'final', label: 'Available now' },
|
|
109
|
+
]}
|
|
110
|
+
onCommit={(v) => set('vestingMode', v)}
|
|
111
|
+
/>
|
|
112
|
+
{account.vestingMode === 'cliff' ? (
|
|
113
|
+
<TextField label="Vest date" hint="YYYY-MM-DD; balance counts in net worth but is unavailable for withdrawals before this year." value={account.vestDate ?? ''} onCommit={(v) => set('vestDate', v || null)} />
|
|
114
|
+
) : null}
|
|
115
|
+
</>
|
|
116
|
+
) : null}
|
|
117
|
+
{account.type === 'traditional' || account.type === 'roth' ? (
|
|
118
|
+
<SelectField
|
|
119
|
+
label="Kind"
|
|
120
|
+
value={account.kind}
|
|
121
|
+
options={[
|
|
122
|
+
{ value: 'employer', label: '401(k)/403(b)' },
|
|
123
|
+
{ value: 'ira', label: 'IRA' },
|
|
124
|
+
]}
|
|
125
|
+
onCommit={(v) => set('kind', v)}
|
|
126
|
+
/>
|
|
127
|
+
) : null}
|
|
128
|
+
{account.type === 'roth' ? (
|
|
129
|
+
<MoneyField
|
|
130
|
+
label="Contribution basis"
|
|
131
|
+
help="Your total direct Roth contributions (today's dollars). Contributions come out tax- and penalty-free at any age — before conversions and earnings — so this is what you can tap penalty-free in early retirement. Leave blank to treat the whole current balance as contributions (the safe default). Roth conversions made inside this app automatically start their own 5-year clocks."
|
|
132
|
+
hint="Blank = treat whole balance as contributions."
|
|
133
|
+
value={account.contributionBasis ?? null}
|
|
134
|
+
allowNull
|
|
135
|
+
onCommit={(v) => set('contributionBasis', v ?? undefined)}
|
|
136
|
+
/>
|
|
137
|
+
) : null}
|
|
138
|
+
{account.type === 'traditional' && account.kind === 'ira' && !account.inherited ? (
|
|
139
|
+
<MoneyField
|
|
140
|
+
label="Nondeductible basis (Form 8606)"
|
|
141
|
+
help="After-tax money already inside this traditional IRA — nondeductible contributions you've reported on IRS Form 8606. When set, every withdrawal and Roth conversion from your IRAs is part tax-free basis and part taxable, in proportion to the basis across all your IRAs (the pro-rata rule). Leave blank if all your IRA money was pre-tax."
|
|
142
|
+
hint="Blank = fully pre-tax IRA."
|
|
143
|
+
value={account.nondeductibleBasis ?? null}
|
|
144
|
+
allowNull
|
|
145
|
+
onCommit={(v) => set('nondeductibleBasis', v ?? undefined)}
|
|
146
|
+
/>
|
|
147
|
+
) : null}
|
|
148
|
+
{account.type === 'hsa' ? (
|
|
149
|
+
<>
|
|
150
|
+
<SelectField
|
|
151
|
+
label="Withdrawal treatment"
|
|
152
|
+
help="How HSA withdrawals are taxed. 'Assume all qualified' treats every withdrawal as a tax- and penalty-free medical reimbursement (simplest; use if you track receipts). 'Cap at modeled medical costs' only lets withdrawals up to your modeled healthcare premiums and care costs come out tax-free — the excess is taxed as ordinary income and, before 65, penalized 20%. Leave on the default to keep the conservative legacy behavior (tax-free but penalized before 65)."
|
|
153
|
+
value={account.withdrawalTreatment ?? 'legacy'}
|
|
154
|
+
options={[
|
|
155
|
+
{ value: 'legacy', label: 'Default (tax-free, penalized before 65)' },
|
|
156
|
+
{ value: 'assumeAllQualified', label: 'Assume all withdrawals qualified' },
|
|
157
|
+
{ value: 'capByMedicalExpenses', label: 'Cap at modeled medical costs' },
|
|
158
|
+
]}
|
|
159
|
+
onCommit={(v) => {
|
|
160
|
+
const next = v === 'legacy' ? undefined : (v as 'assumeAllQualified' | 'capByMedicalExpenses')
|
|
161
|
+
set('withdrawalTreatment', next)
|
|
162
|
+
if (next !== 'capByMedicalExpenses' && account.reimburseLater) set('reimburseLater', undefined)
|
|
163
|
+
}}
|
|
164
|
+
/>
|
|
165
|
+
{account.withdrawalTreatment === 'capByMedicalExpenses' ? (
|
|
166
|
+
<CheckboxField
|
|
167
|
+
label="Accumulate unreimbursed expenses (reimburse later)"
|
|
168
|
+
help="Model the 'pay medical costs out of pocket now, reimburse yourself from the HSA later' strategy. Modeled medical costs you don't withdraw for in a given year accumulate as a carryover that future withdrawals can draw against tax-free — letting the HSA keep growing while the reimbursable balance grows with it."
|
|
169
|
+
value={account.reimburseLater === true}
|
|
170
|
+
onCommit={(v) => set('reimburseLater', v ? true : undefined)}
|
|
171
|
+
/>
|
|
172
|
+
) : null}
|
|
173
|
+
<SelectField
|
|
174
|
+
label="Beneficiary"
|
|
175
|
+
help="Who inherits this HSA. A spouse inherits it as their own HSA and it passes untaxed. Any other beneficiary (child, estate, single-person plans) receives a fully taxable distribution of the balance in the year of death — so the after-tax estate metric taxes the remaining HSA at your assumed heir tax rate, like a traditional account."
|
|
176
|
+
value={account.beneficiary ?? 'spouse'}
|
|
177
|
+
options={[
|
|
178
|
+
{ value: 'spouse', label: 'Spouse (inherits as HSA, untaxed)' },
|
|
179
|
+
{ value: 'nonSpouse', label: 'Non-spouse (fully taxable to heir)' },
|
|
180
|
+
]}
|
|
181
|
+
onCommit={(v) => set('beneficiary', v === 'spouse' ? undefined : 'nonSpouse')}
|
|
182
|
+
/>
|
|
183
|
+
</>
|
|
184
|
+
) : null}
|
|
185
|
+
{account.type === 'traditional' && plan.household.people.length === 2 ? (
|
|
186
|
+
<CheckboxField
|
|
187
|
+
label="Spouse is sole beneficiary"
|
|
188
|
+
help="If checked and your spouse is more than 10 years younger, RMDs use the larger IRS Joint Life divisor. Leave unchecked when the beneficiary is a child, trust, estate, or split — RMDs then use the standard Uniform Lifetime Table."
|
|
189
|
+
value={account.spouseSoleBeneficiary === true}
|
|
190
|
+
onCommit={(v) => set('spouseSoleBeneficiary', v)}
|
|
191
|
+
/>
|
|
192
|
+
) : null}
|
|
193
|
+
{account.type === 'traditional' ? (
|
|
194
|
+
<CheckboxField
|
|
195
|
+
label="Inherited account (10-year rule)"
|
|
196
|
+
help="A non-spouse beneficiary must empty an inherited IRA/401(k) by the end of the 10th year after the original owner's death. Distributions are taxable but never carry the 10% early-withdrawal penalty, and the account is exempt from your own age-based RMDs."
|
|
197
|
+
value={account.inherited !== undefined}
|
|
198
|
+
onCommit={(v) => set('inherited', v ? { ownerDeathYear: new Date().getFullYear() - 1, decedentHadStartedRmds: false } : undefined)}
|
|
199
|
+
/>
|
|
200
|
+
) : null}
|
|
201
|
+
{account.type === 'traditional' && account.inherited ? (
|
|
202
|
+
<>
|
|
203
|
+
<NumberField
|
|
204
|
+
label="Original owner's death year"
|
|
205
|
+
hint="Starts the 10-year clock; the account must be empty by the end of this year + 10."
|
|
206
|
+
value={account.inherited.ownerDeathYear}
|
|
207
|
+
min={1990}
|
|
208
|
+
max={2100}
|
|
209
|
+
onCommit={(v) => set('inherited', { ...account.inherited, ownerDeathYear: Math.round(v ?? new Date().getFullYear() - 1) })}
|
|
210
|
+
/>
|
|
211
|
+
<CheckboxField
|
|
212
|
+
label="Owner had started RMDs"
|
|
213
|
+
help="If the original owner had reached their required beginning date, you must also take an annual RMD in years 1–9 of the window (based on your single life expectancy), not just empty it by year 10."
|
|
214
|
+
value={account.inherited.decedentHadStartedRmds}
|
|
215
|
+
onCommit={(v) => set('inherited', { ...account.inherited, decedentHadStartedRmds: v })}
|
|
216
|
+
/>
|
|
217
|
+
</>
|
|
218
|
+
) : null}
|
|
219
|
+
{account.type === 'traditional' && !account.inherited ? (
|
|
220
|
+
<CheckboxField
|
|
221
|
+
label="72(t) SEPP (penalty-free early access)"
|
|
222
|
+
help="Substantially-equal periodic payments let you tap this account before 59½ without the 10% penalty, taken for the longer of 5 years or until 59½. The Rule of 55 already waives the penalty automatically on a 401(k) you separate from at 55+, so SEPP is mainly for IRAs or for access before 55."
|
|
223
|
+
value={account.sepp !== undefined}
|
|
224
|
+
onCommit={(v) => set('sepp', v ? { startAge: 55, method: 'rmd' } : undefined)}
|
|
225
|
+
/>
|
|
226
|
+
) : null}
|
|
227
|
+
{account.type === 'traditional' && account.sepp && !account.inherited ? (
|
|
228
|
+
<>
|
|
229
|
+
<NumberField
|
|
230
|
+
label="SEPP start age"
|
|
231
|
+
hint="Under 59½. Payments run for the longer of 5 years or until 59½."
|
|
232
|
+
value={account.sepp.startAge}
|
|
233
|
+
min={40}
|
|
234
|
+
max={59}
|
|
235
|
+
onCommit={(v) => set('sepp', { ...account.sepp, startAge: Math.round(v ?? 55) })}
|
|
236
|
+
/>
|
|
237
|
+
<SelectField
|
|
238
|
+
label="SEPP method"
|
|
239
|
+
value={account.sepp.method}
|
|
240
|
+
options={[
|
|
241
|
+
{ value: 'rmd', label: 'RMD — recomputed yearly (smaller, flexible)' },
|
|
242
|
+
{ value: 'amortization', label: 'Amortization — level payment (larger)' },
|
|
243
|
+
]}
|
|
244
|
+
onCommit={(v) => set('sepp', { ...account.sepp, method: v })}
|
|
245
|
+
/>
|
|
246
|
+
</>
|
|
247
|
+
) : null}
|
|
248
|
+
{(account.type === 'traditional' || account.type === 'roth') && account.kind === 'employer' ? (
|
|
249
|
+
account.employerMatch !== undefined ? (
|
|
250
|
+
<div className="nested-form-section field-span-full" data-testid="employer-match-panel">
|
|
251
|
+
<div className="form-grid nested-control-grid">
|
|
252
|
+
<CheckboxField
|
|
253
|
+
label="Employer match"
|
|
254
|
+
help="Configure a first-class employer matching program for this payroll account. The match does not count against your employee elective contribution limit, but is constrained by the IRS Section 415(c) annual additions limit."
|
|
255
|
+
learn={LEARN.employerMatch}
|
|
256
|
+
value
|
|
257
|
+
onCommit={(v) => {
|
|
258
|
+
set('employerMatch', v ? { matchPct: 100, capPctOfPay: 4 } : undefined)
|
|
259
|
+
}}
|
|
260
|
+
/>
|
|
261
|
+
<PercentField
|
|
262
|
+
label="Match percent"
|
|
263
|
+
help="The percentage of your contributions the employer matches. E.g., 100% means a dollar-for-dollar match."
|
|
264
|
+
learn={LEARN.employerMatch}
|
|
265
|
+
value={account.employerMatch.matchPct}
|
|
266
|
+
onCommit={(v) => set('employerMatch', { ...account.employerMatch, matchPct: v ?? 100 })}
|
|
267
|
+
/>
|
|
268
|
+
<PercentField
|
|
269
|
+
label="Up to % of wages"
|
|
270
|
+
help="The maximum employee pay percentage the employer will match. E.g., 4% means the employer matches contributions up to 4% of your salary."
|
|
271
|
+
learn={LEARN.employerMatch}
|
|
272
|
+
value={account.employerMatch.capPctOfPay}
|
|
273
|
+
onCommit={(v) => set('employerMatch', { ...account.employerMatch, capPctOfPay: v ?? 4 })}
|
|
274
|
+
/>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
) : (
|
|
278
|
+
<CheckboxField
|
|
279
|
+
label="Employer match"
|
|
280
|
+
help="Configure a first-class employer matching program for this payroll account. The match does not count against your employee elective contribution limit, but is constrained by the IRS Section 415(c) annual additions limit."
|
|
281
|
+
learn={LEARN.employerMatch}
|
|
282
|
+
value={false}
|
|
283
|
+
onCommit={(v) => {
|
|
284
|
+
set('employerMatch', v ? { matchPct: 100, capPctOfPay: 4 } : undefined)
|
|
285
|
+
}}
|
|
286
|
+
/>
|
|
287
|
+
)
|
|
288
|
+
) : null}
|
|
289
|
+
{account.type === 'pension' ? (
|
|
290
|
+
<SelectField
|
|
291
|
+
label="Pension source"
|
|
292
|
+
help="Used for state income tax when public civil-service or military pensions receive a different exclusion than private retirement income."
|
|
293
|
+
value={account.source ?? 'private'}
|
|
294
|
+
options={[
|
|
295
|
+
{ value: 'private', label: 'Private pension' },
|
|
296
|
+
{ value: 'public', label: 'Public / military pension' },
|
|
297
|
+
]}
|
|
298
|
+
onCommit={(v) => set('source', v)}
|
|
299
|
+
/>
|
|
300
|
+
) : null}
|
|
301
|
+
{(account.type === 'cash' || account.type === 'equityComp' || (isAllocatable(account) && account.allocation === undefined)) ? (
|
|
302
|
+
<div className="field-with-action">
|
|
303
|
+
<PercentField
|
|
304
|
+
label="Expected return"
|
|
305
|
+
help="Average annual nominal growth for this account. Leave blank to use the plan-wide default from Assumptions, or click Calculate to estimate it from how the account is invested."
|
|
306
|
+
hint="Blank = default assumption."
|
|
307
|
+
value={account.annualReturnPct}
|
|
308
|
+
allowNull
|
|
309
|
+
onCommit={(v) => set('annualReturnPct', v)}
|
|
310
|
+
/>
|
|
311
|
+
<button type="button" className="btn btn-secondary btn-small" onClick={() => setEstimating(true)}>
|
|
312
|
+
Calculate
|
|
313
|
+
</button>
|
|
314
|
+
</div>
|
|
315
|
+
) : null}
|
|
316
|
+
{isAllocatable(account) ? (
|
|
317
|
+
<CheckboxField
|
|
318
|
+
label="Model asset classes"
|
|
319
|
+
help="Instead of one expected return, describe the account as a mix of US stocks, international stocks, bonds, and cash. Growth becomes the blended class return, glidepaths and annual rebalancing become available, Monte Carlo shocks each class with realistic correlations, and a brokerage account's taxable yield follows the mix. Class assumptions are editable under Assumptions."
|
|
320
|
+
value={account.allocation !== undefined}
|
|
321
|
+
onCommit={(v) =>
|
|
322
|
+
update((d) => {
|
|
323
|
+
const target = d.accounts[index] as AllocatableAccount
|
|
324
|
+
target.allocation = v ? { mode: 'static', rebalancing: 'annual', weights: { ...EVEN_START_WEIGHTS } } : undefined
|
|
325
|
+
if (target.type === 'taxable' && v) {
|
|
326
|
+
// Let the class mix drive taxable yield unless re-entered later.
|
|
327
|
+
target.interestYieldPct = undefined
|
|
328
|
+
target.dividendYieldPct = undefined
|
|
329
|
+
target.qualifiedRatio = undefined
|
|
330
|
+
}
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
/>
|
|
334
|
+
) : null}
|
|
335
|
+
{isAllocatable(account) && account.allocation !== undefined ? (
|
|
336
|
+
<AllocationPanel account={account} plan={plan} onCommit={(a) => set('allocation', a)} />
|
|
337
|
+
) : null}
|
|
338
|
+
{'annualContribution' in account ? (
|
|
339
|
+
<>
|
|
340
|
+
<CheckboxField
|
|
341
|
+
label="Schedule contributions over time"
|
|
342
|
+
help="Toggle this to model different contribution amounts during different periods (e.g. Coast-FIRE, or saving more later in your career). This will override the flat annual contribution."
|
|
343
|
+
learn={LEARN.accumulation}
|
|
344
|
+
value={account.contributionSchedule !== undefined}
|
|
345
|
+
onCommit={(v) => {
|
|
346
|
+
set('contributionSchedule', v ? [{ annualAmount: account.annualContribution, fromAge: null, toAge: null, escalationPct: 0 }] : undefined)
|
|
347
|
+
}}
|
|
348
|
+
/>
|
|
349
|
+
{account.contributionSchedule !== undefined ? (
|
|
350
|
+
<div className="nested-form-section field-span-full" data-testid="contribution-schedule-panel">
|
|
351
|
+
<h4>Contribution Schedule</h4>
|
|
352
|
+
{account.contributionSchedule.map((phase, pIdx) => {
|
|
353
|
+
const updatePhase = (key: string, val: unknown) => {
|
|
354
|
+
const newSchedule = [...(account.contributionSchedule ?? [])]
|
|
355
|
+
newSchedule[pIdx] = { ...phase, [key]: val }
|
|
356
|
+
set('contributionSchedule', newSchedule)
|
|
357
|
+
}
|
|
358
|
+
const removePhase = () => {
|
|
359
|
+
const newSchedule = (account.contributionSchedule ?? []).filter((_, idx) => idx !== pIdx)
|
|
360
|
+
set('contributionSchedule', newSchedule.length > 0 ? newSchedule : undefined)
|
|
361
|
+
}
|
|
362
|
+
return (
|
|
363
|
+
<div key={pIdx} className="nested-phase-row">
|
|
364
|
+
<div className="form-grid nested-phase-grid">
|
|
365
|
+
<MoneyField
|
|
366
|
+
label="Amount / year"
|
|
367
|
+
value={phase.annualAmount}
|
|
368
|
+
onCommit={(v) => updatePhase('annualAmount', v ?? 0)}
|
|
369
|
+
/>
|
|
370
|
+
<PercentField
|
|
371
|
+
label="Phase escalation"
|
|
372
|
+
help="Optional. Annual rate at which this phase's contribution amount increases, on top of inflation."
|
|
373
|
+
learn={LEARN.accumulation}
|
|
374
|
+
value={phase.escalationPct}
|
|
375
|
+
onCommit={(v) => updatePhase('escalationPct', v ?? 0)}
|
|
376
|
+
/>
|
|
377
|
+
<NumberField
|
|
378
|
+
label="From age"
|
|
379
|
+
hint="Blank = start age."
|
|
380
|
+
value={phase.fromAge}
|
|
381
|
+
allowNull
|
|
382
|
+
min={0}
|
|
383
|
+
max={100}
|
|
384
|
+
onCommit={(v) => updatePhase('fromAge', v)}
|
|
385
|
+
/>
|
|
386
|
+
<NumberField
|
|
387
|
+
label="To age"
|
|
388
|
+
hint="Blank = run forever."
|
|
389
|
+
value={phase.toAge}
|
|
390
|
+
allowNull
|
|
391
|
+
min={0}
|
|
392
|
+
max={100}
|
|
393
|
+
onCommit={(v) => updatePhase('toAge', v)}
|
|
394
|
+
/>
|
|
395
|
+
</div>
|
|
396
|
+
<button
|
|
397
|
+
type="button"
|
|
398
|
+
className="btn btn-secondary btn-small"
|
|
399
|
+
onClick={removePhase}
|
|
400
|
+
>
|
|
401
|
+
Remove Phase
|
|
402
|
+
</button>
|
|
403
|
+
</div>
|
|
404
|
+
)
|
|
405
|
+
})}
|
|
406
|
+
<button
|
|
407
|
+
type="button"
|
|
408
|
+
className="btn btn-secondary btn-small"
|
|
409
|
+
onClick={() => {
|
|
410
|
+
set('contributionSchedule', [...(account.contributionSchedule ?? []), { annualAmount: 0, fromAge: null, toAge: null, escalationPct: 0 }])
|
|
411
|
+
}}
|
|
412
|
+
>
|
|
413
|
+
Add Contribution Phase
|
|
414
|
+
</button>
|
|
415
|
+
</div>
|
|
416
|
+
) : (
|
|
417
|
+
<MoneyField
|
|
418
|
+
label="Annual contribution"
|
|
419
|
+
hint="While the owner has wages; IRS caps applied."
|
|
420
|
+
value={account.annualContribution}
|
|
421
|
+
onCommit={(v) => set('annualContribution', v ?? 0)}
|
|
422
|
+
/>
|
|
423
|
+
)}
|
|
424
|
+
</>
|
|
425
|
+
) : null}
|
|
426
|
+
{account.type === 'pension' || account.type === 'annuity' ? (
|
|
427
|
+
<>
|
|
428
|
+
<NumberField label="Start age" value={account.startAge} min={40} max={95} onCommit={(v) => set('startAge', Math.round(v ?? 65))} />
|
|
429
|
+
<MoneyField label="Monthly amount" value={account.monthlyAmount} onCommit={(v) => set('monthlyAmount', v ?? 0)} />
|
|
430
|
+
<PercentField label="COLA" value={account.colaPct} onCommit={(v) => set('colaPct', v ?? 0)} />
|
|
431
|
+
</>
|
|
432
|
+
) : null}
|
|
433
|
+
{account.type === 'pension' ? <PercentField label="Survivor benefit" value={account.survivorPct} onCommit={(v) => set('survivorPct', v ?? 0)} /> : null}
|
|
434
|
+
{account.type === 'pension' ? (
|
|
435
|
+
<CheckboxField
|
|
436
|
+
label="Lump-sum offer on record"
|
|
437
|
+
help="Record a lump-sum buyout offer to unlock the decision view: the annuity's discounted present value against the offer, a discount-rate × longevity sensitivity table, and the survivor option's value. Recording the offer changes nothing in the projection until you elect it."
|
|
438
|
+
value={account.lumpSumOffer !== undefined}
|
|
439
|
+
onCommit={(v) =>
|
|
440
|
+
update((d) => {
|
|
441
|
+
const p = d.accounts[index] as Extract<Account, { type: 'pension' }>
|
|
442
|
+
if (v) {
|
|
443
|
+
p.lumpSumOffer = { amount: 0, electionYear: new Date().getFullYear() }
|
|
444
|
+
} else {
|
|
445
|
+
p.lumpSumOffer = undefined
|
|
446
|
+
p.lumpSumElection = undefined
|
|
447
|
+
}
|
|
448
|
+
})
|
|
449
|
+
}
|
|
450
|
+
/>
|
|
451
|
+
) : null}
|
|
452
|
+
{account.type === 'pension' && account.lumpSumOffer ? (
|
|
453
|
+
<>
|
|
454
|
+
<MoneyField
|
|
455
|
+
label="Lump sum offered"
|
|
456
|
+
help="The one-time payment offered instead of the lifetime annuity (from your plan administrator's election packet)."
|
|
457
|
+
value={account.lumpSumOffer.amount}
|
|
458
|
+
onCommit={(v) => set('lumpSumOffer', { ...account.lumpSumOffer!, amount: v ?? 0 })}
|
|
459
|
+
/>
|
|
460
|
+
<NumberField
|
|
461
|
+
label="Election year"
|
|
462
|
+
help="The year the election is due — and the year the lump sum would be paid if taken."
|
|
463
|
+
value={account.lumpSumOffer.electionYear}
|
|
464
|
+
min={1900}
|
|
465
|
+
max={2200}
|
|
466
|
+
onCommit={(v) => set('lumpSumOffer', { ...account.lumpSumOffer!, electionYear: Math.round(v ?? new Date().getFullYear()) })}
|
|
467
|
+
/>
|
|
468
|
+
<SelectField
|
|
469
|
+
label="Election"
|
|
470
|
+
help="Take the lump sum: in the election year the offer rolls over tax-free into the chosen traditional IRA/401(k) and the pension never pays its annuity. Keep the annuity: the offer stays on record for comparison only. Taking the lump sum requires a traditional account to receive the rollover."
|
|
471
|
+
value={account.lumpSumElection ? 'lumpSum' : 'annuity'}
|
|
472
|
+
options={[
|
|
473
|
+
{ value: 'annuity', label: 'Keep the annuity (undecided)' },
|
|
474
|
+
...(plan.accounts.some((a) => a.type === 'traditional' && !a.inherited)
|
|
475
|
+
? [{ value: 'lumpSum', label: 'Take the lump sum (rollover)' }]
|
|
476
|
+
: []),
|
|
477
|
+
]}
|
|
478
|
+
onCommit={(v) => {
|
|
479
|
+
const target = plan.accounts.find((a) => a.type === 'traditional' && !a.inherited)
|
|
480
|
+
set('lumpSumElection', v === 'lumpSum' && target ? { rolloverAccountId: target.id } : undefined)
|
|
481
|
+
}}
|
|
482
|
+
/>
|
|
483
|
+
{account.lumpSumElection ? (
|
|
484
|
+
<SelectField
|
|
485
|
+
label="Rollover account"
|
|
486
|
+
help="The traditional account receiving the tax-free direct rollover in the election year."
|
|
487
|
+
value={account.lumpSumElection.rolloverAccountId}
|
|
488
|
+
options={plan.accounts
|
|
489
|
+
.filter((a) => a.type === 'traditional' && !a.inherited)
|
|
490
|
+
.map((a) => ({ value: a.id, label: a.name }))}
|
|
491
|
+
onCommit={(v) => set('lumpSumElection', { rolloverAccountId: v })}
|
|
492
|
+
/>
|
|
493
|
+
) : null}
|
|
494
|
+
<PensionDecisionPanel plan={plan} pensionId={account.id} />
|
|
495
|
+
</>
|
|
496
|
+
) : null}
|
|
497
|
+
{account.type === 'annuity' && !account.purchase ? <PercentField label="Taxable share" hint="Simplified exclusion ratio." value={account.taxablePct} onCommit={(v) => set('taxablePct', v ?? 0)} /> : null}
|
|
498
|
+
{account.type === 'annuity' && account.purchase ? (
|
|
499
|
+
<ReadonlyField label="Taxable share" value="Determined by purchase (exclusion ratio for non-qualified; fully taxable for qualified)" />
|
|
500
|
+
) : null}
|
|
501
|
+
{account.type === 'annuity' ? (
|
|
502
|
+
<SelectField
|
|
503
|
+
label="Payout form"
|
|
504
|
+
help="Life only: payments stop at the owner's death (the default). Life with period certain: payments are guaranteed for N years from the start age — if the owner dies inside the window, the household keeps receiving them. Joint & survivor: payments continue to the other household member at the chosen share for their lifetime. Non-qualified exclusion-ratio taxation adjusts to the form."
|
|
505
|
+
value={account.payoutForm?.kind ?? 'lifeOnly'}
|
|
506
|
+
options={[
|
|
507
|
+
{ value: 'lifeOnly', label: 'Life only' },
|
|
508
|
+
{ value: 'periodCertain', label: 'Life with period certain' },
|
|
509
|
+
...(plan.household.people.length >= 2 ? [{ value: 'jointSurvivor', label: 'Joint & survivor' }] : []),
|
|
510
|
+
]}
|
|
511
|
+
onCommit={(v) =>
|
|
512
|
+
set(
|
|
513
|
+
'payoutForm',
|
|
514
|
+
v === 'periodCertain'
|
|
515
|
+
? { kind: 'periodCertain', certainYears: account.payoutForm?.kind === 'periodCertain' ? account.payoutForm.certainYears : 10 }
|
|
516
|
+
: v === 'jointSurvivor'
|
|
517
|
+
? { kind: 'jointSurvivor', survivorPct: account.payoutForm?.kind === 'jointSurvivor' ? account.payoutForm.survivorPct : 50 }
|
|
518
|
+
: undefined,
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
/>
|
|
522
|
+
) : null}
|
|
523
|
+
{account.type === 'annuity' && account.payoutForm?.kind === 'periodCertain' ? (
|
|
524
|
+
<NumberField
|
|
525
|
+
label="Guaranteed years"
|
|
526
|
+
help="Years of payments guaranteed from the start age, paid to the household even if the owner dies inside the window."
|
|
527
|
+
value={account.payoutForm.certainYears}
|
|
528
|
+
min={1}
|
|
529
|
+
max={40}
|
|
530
|
+
onCommit={(v) => set('payoutForm', { kind: 'periodCertain', certainYears: Math.round(v ?? 10) })}
|
|
531
|
+
/>
|
|
532
|
+
) : null}
|
|
533
|
+
{account.type === 'annuity' && account.payoutForm?.kind === 'jointSurvivor' ? (
|
|
534
|
+
<PercentField
|
|
535
|
+
label="Survivor share"
|
|
536
|
+
help="Percent of the payment continuing to the surviving joint annuitant for their lifetime (100% / 75% / 50% are the common contract options)."
|
|
537
|
+
value={account.payoutForm.survivorPct}
|
|
538
|
+
onCommit={(v) => set('payoutForm', { kind: 'jointSurvivor', survivorPct: Math.min(100, Math.max(1, v ?? 50)) })}
|
|
539
|
+
/>
|
|
540
|
+
) : null}
|
|
541
|
+
{account.type === 'annuity' ? (
|
|
542
|
+
<CheckboxField
|
|
543
|
+
label="Model a purchase event"
|
|
544
|
+
help="Configure an annuity purchase: the engine withdraws the premium from a funding account in the purchase year, then applies IRS exclusion-ratio taxation for non-qualified purchases or fully ordinary taxation for qualified purchases. QLAC purchases are capped at the statutory limit."
|
|
545
|
+
value={account.purchase !== undefined}
|
|
546
|
+
onCommit={(v) => {
|
|
547
|
+
// Default to the first eligible non-qualified funding source (cash /
|
|
548
|
+
// taxable / equity comp) so toggling the feature on leaves the plan
|
|
549
|
+
// valid rather than immediately failing the funding-account refinement.
|
|
550
|
+
const defaultFunding = plan.accounts.find(
|
|
551
|
+
(a) => a.id !== account.id && (a.type === 'cash' || a.type === 'taxable' || a.type === 'equityComp'),
|
|
552
|
+
)
|
|
553
|
+
set(
|
|
554
|
+
'purchase',
|
|
555
|
+
v
|
|
556
|
+
? {
|
|
557
|
+
year: new Date().getFullYear(),
|
|
558
|
+
premium: 100_000,
|
|
559
|
+
fundingAccountId: defaultFunding?.id ?? '',
|
|
560
|
+
taxQualification: 'nonQualified' as const,
|
|
561
|
+
}
|
|
562
|
+
: undefined,
|
|
563
|
+
)
|
|
564
|
+
}}
|
|
565
|
+
/>
|
|
566
|
+
) : null}
|
|
567
|
+
{account.type === 'annuity' && account.purchase ? (
|
|
568
|
+
<>
|
|
569
|
+
<NumberField
|
|
570
|
+
label="Purchase year"
|
|
571
|
+
value={account.purchase.year}
|
|
572
|
+
min={1900}
|
|
573
|
+
max={2200}
|
|
574
|
+
onCommit={(v) => set('purchase', { ...account.purchase!, year: Math.round(v ?? new Date().getFullYear()) })}
|
|
575
|
+
/>
|
|
576
|
+
<MoneyField
|
|
577
|
+
label="Premium"
|
|
578
|
+
help="The lump sum paid to purchase the annuity contract, withdrawn from the funding account in the purchase year."
|
|
579
|
+
value={account.purchase.premium}
|
|
580
|
+
onCommit={(v) => set('purchase', { ...account.purchase!, premium: v ?? 0 })}
|
|
581
|
+
/>
|
|
582
|
+
<SelectField
|
|
583
|
+
label="Funding account"
|
|
584
|
+
help="Which account the premium is withdrawn from. Non-qualified purchases must come from cash, taxable, or equity comp; qualified purchases from a traditional IRA or 401(k)."
|
|
585
|
+
value={account.purchase.fundingAccountId}
|
|
586
|
+
options={plan.accounts
|
|
587
|
+
.filter((a) =>
|
|
588
|
+
a.id !== account.id &&
|
|
589
|
+
(account.purchase!.taxQualification === 'qualified'
|
|
590
|
+
? a.type === 'traditional'
|
|
591
|
+
: a.type === 'cash' || a.type === 'taxable' || a.type === 'equityComp'),
|
|
592
|
+
)
|
|
593
|
+
.map((a) => ({ value: a.id, label: a.name }))}
|
|
594
|
+
onCommit={(v) => set('purchase', { ...account.purchase!, fundingAccountId: v })}
|
|
595
|
+
/>
|
|
596
|
+
<SelectField
|
|
597
|
+
label="Tax qualification"
|
|
598
|
+
help="Non-qualified: purchased with after-tax money; payouts use the IRS Pub 939 exclusion ratio (part tax-free, part taxable) until the investment is recovered. Qualified: purchased with pre-tax IRA/401(k) money; every payout is fully ordinary income."
|
|
599
|
+
value={account.purchase.taxQualification}
|
|
600
|
+
options={[
|
|
601
|
+
{ value: 'nonQualified', label: 'Non-qualified (after-tax money)' },
|
|
602
|
+
{ value: 'qualified', label: 'Qualified (pre-tax IRA/401k)' },
|
|
603
|
+
]}
|
|
604
|
+
onCommit={(v) => {
|
|
605
|
+
const taxQualification = v as 'nonQualified' | 'qualified'
|
|
606
|
+
// The current funding account may no longer be eligible for the new
|
|
607
|
+
// qualification; re-default to the first eligible source so the plan
|
|
608
|
+
// stays valid instead of pointing at an impossible funding type.
|
|
609
|
+
const stillEligible = plan.accounts.some(
|
|
610
|
+
(a) =>
|
|
611
|
+
a.id === account.purchase!.fundingAccountId &&
|
|
612
|
+
(taxQualification === 'qualified'
|
|
613
|
+
? a.type === 'traditional'
|
|
614
|
+
: a.type === 'cash' || a.type === 'taxable' || a.type === 'equityComp'),
|
|
615
|
+
)
|
|
616
|
+
const fundingAccountId = stillEligible
|
|
617
|
+
? account.purchase!.fundingAccountId
|
|
618
|
+
: plan.accounts.find((a) =>
|
|
619
|
+
a.id !== account.id &&
|
|
620
|
+
(taxQualification === 'qualified'
|
|
621
|
+
? a.type === 'traditional'
|
|
622
|
+
: a.type === 'cash' || a.type === 'taxable' || a.type === 'equityComp'),
|
|
623
|
+
)?.id ?? ''
|
|
624
|
+
set('purchase', {
|
|
625
|
+
...account.purchase!,
|
|
626
|
+
taxQualification,
|
|
627
|
+
fundingAccountId,
|
|
628
|
+
qlac: taxQualification === 'nonQualified' ? undefined : account.purchase!.qlac,
|
|
629
|
+
})
|
|
630
|
+
}}
|
|
631
|
+
/>
|
|
632
|
+
{account.purchase.taxQualification === 'qualified' ? (
|
|
633
|
+
<CheckboxField
|
|
634
|
+
label="QLAC (qualified longevity annuity)"
|
|
635
|
+
help="A deferred-start longevity annuity purchased inside a traditional account. The premium is capped at the SECURE 2.0 statutory limit ($210,000 for 2026) and excluded from the RMD base until payouts begin."
|
|
636
|
+
value={account.purchase.qlac === true}
|
|
637
|
+
onCommit={(v) => set('purchase', { ...account.purchase!, qlac: v || undefined })}
|
|
638
|
+
/>
|
|
639
|
+
) : null}
|
|
640
|
+
</>
|
|
641
|
+
) : null}
|
|
642
|
+
{account.type === 'property' ? (
|
|
643
|
+
<>
|
|
644
|
+
<MoneyField label="Value" value={account.value} onCommit={(v) => set('value', v ?? 0)} />
|
|
645
|
+
<NumberField label="Planned sale year" value={account.plannedSaleYear} allowNull min={1900} max={2200} onCommit={(v) => set('plannedSaleYear', v === null ? null : Math.round(v))} />
|
|
646
|
+
<MoneyField
|
|
647
|
+
label="Cost basis"
|
|
648
|
+
help="What you paid for the property plus improvements (not inflation-adjusted). Set this to have the sale taxed exactly: capital-gains tax on the gain above basis, net of selling costs, minus the primary-residence exclusion. Leave blank to fall back to the simple tax-free 'expected net proceeds' estimate below."
|
|
649
|
+
hint="Blank = use expected net proceeds (tax-free)."
|
|
650
|
+
value={account.costBasis ?? null}
|
|
651
|
+
allowNull
|
|
652
|
+
onCommit={(v) => set('costBasis', v ?? undefined)}
|
|
653
|
+
/>
|
|
654
|
+
{account.costBasis !== undefined ? (
|
|
655
|
+
<>
|
|
656
|
+
<PercentField
|
|
657
|
+
label="Selling costs"
|
|
658
|
+
help="Commissions plus closing costs as a percent of the sale price, deducted from the amount realized before computing the gain. Typical all-in cost to sell a home is 6–8%."
|
|
659
|
+
hint="% of sale price."
|
|
660
|
+
value={account.sellingCostPct ?? null}
|
|
661
|
+
allowNull
|
|
662
|
+
onCommit={(v) => set('sellingCostPct', v ?? undefined)}
|
|
663
|
+
/>
|
|
664
|
+
<CheckboxField
|
|
665
|
+
label="Primary residence (§121 exclusion)"
|
|
666
|
+
help="If this is your main home and you meet the ownership/use tests (lived there 2 of the last 5 years), the IRS §121 exclusion shields $250,000 of gain ($500,000 if married filing jointly) from tax. Only the gain above the exclusion is taxed."
|
|
667
|
+
value={account.primaryResidence === true}
|
|
668
|
+
onCommit={(v) => set('primaryResidence', v ? true : undefined)}
|
|
669
|
+
/>
|
|
670
|
+
<MoneyField
|
|
671
|
+
label="Depreciation to recapture"
|
|
672
|
+
help="Depreciation you claimed (e.g. rental years or a home office). It is recaptured as ordinary income on sale and cannot be shielded by the §121 exclusion. Leave blank if none."
|
|
673
|
+
hint="Blank = none."
|
|
674
|
+
value={account.depreciationRecapture ?? null}
|
|
675
|
+
allowNull
|
|
676
|
+
onCommit={(v) => set('depreciationRecapture', v ?? undefined)}
|
|
677
|
+
/>
|
|
678
|
+
</>
|
|
679
|
+
) : (
|
|
680
|
+
<MoneyField label="Expected net proceeds" hint="Blank = sell at projected value." value={account.expectedNetProceeds} allowNull onCommit={(v) => set('expectedNetProceeds', v)} />
|
|
681
|
+
)}
|
|
682
|
+
<MoneyField label="Property tax / year" help="Annual property tax in today's dollars. Charged as a recurring expense while you own the home — and, unlike the mortgage, it keeps going after the loan is paid off." hint="Today's $; continues after payoff." value={account.propertyTaxAnnual ?? null} allowNull onCommit={(v) => set('propertyTaxAnnual', v ?? undefined)} />
|
|
683
|
+
<MoneyField label="Insurance / year" hint="Homeowner's/hazard insurance, today's $." value={account.insuranceAnnual ?? null} allowNull onCommit={(v) => set('insuranceAnnual', v ?? undefined)} />
|
|
684
|
+
<CheckboxField
|
|
685
|
+
label="Model a HECM line of credit"
|
|
686
|
+
help="An FHA reverse-mortgage line of credit on your primary residence (borrowers 62+). The unused line grows every year regardless of home value; draws are tax-free loan proceeds; the loan is repaid from the home at sale or the end of the plan, non-recourse (never more than the home is worth). Turning this on marks the home as your primary residence."
|
|
687
|
+
value={account.hecm !== undefined}
|
|
688
|
+
onCommit={(v) =>
|
|
689
|
+
update((d) => {
|
|
690
|
+
const p = d.accounts[index] as Extract<Account, { type: 'property' }>
|
|
691
|
+
if (v) {
|
|
692
|
+
p.primaryResidence = true
|
|
693
|
+
p.hecm = {
|
|
694
|
+
openYear: new Date().getFullYear(),
|
|
695
|
+
growthRatePct: packForYear(new Date().getFullYear()).pack.hecm.defaultGrowthRatePct,
|
|
696
|
+
drawPolicy: 'lastResort',
|
|
697
|
+
}
|
|
698
|
+
} else {
|
|
699
|
+
p.hecm = undefined
|
|
700
|
+
}
|
|
701
|
+
})
|
|
702
|
+
}
|
|
703
|
+
/>
|
|
704
|
+
{account.hecm ? (
|
|
705
|
+
<>
|
|
706
|
+
<NumberField
|
|
707
|
+
label="Line opens in"
|
|
708
|
+
help="The year the line of credit is opened. Pfau's research favors opening early — the unused credit compounds from that point regardless of home value."
|
|
709
|
+
value={account.hecm.openYear}
|
|
710
|
+
min={1900}
|
|
711
|
+
max={2200}
|
|
712
|
+
onCommit={(v) => set('hecm', { ...account.hecm!, openYear: Math.round(v ?? new Date().getFullYear()) })}
|
|
713
|
+
/>
|
|
714
|
+
<PercentField
|
|
715
|
+
label="Line size (% of value)"
|
|
716
|
+
help="The initial principal limit as a percent of the home's value — enter your lender-quoted figure. Blank uses the published HUD principal-limit-factor table by the youngest borrower's age (35–61% between 62 and 90 at a 5.875% expected rate)."
|
|
717
|
+
hint="Blank = published factor table."
|
|
718
|
+
value={account.hecm.principalLimitPct ?? null}
|
|
719
|
+
allowNull
|
|
720
|
+
onCommit={(v) => set('hecm', { ...account.hecm!, principalLimitPct: v ?? undefined })}
|
|
721
|
+
/>
|
|
722
|
+
<PercentField
|
|
723
|
+
label="Line & loan growth / yr"
|
|
724
|
+
help="Annual growth applied to both the credit line and the loan balance: the note rate plus the 0.5% annual mortgage-insurance premium (roughly 7–8% at 2026 rates)."
|
|
725
|
+
value={account.hecm.growthRatePct}
|
|
726
|
+
onCommit={(v) => set('hecm', { ...account.hecm!, growthRatePct: v ?? 7.5 })}
|
|
727
|
+
/>
|
|
728
|
+
<PercentField
|
|
729
|
+
label="Upfront costs (% of value)"
|
|
730
|
+
help="Origination, closing costs, and the initial 2% FHA mortgage-insurance premium, financed into the loan balance at open (typically 3–6% of home value)."
|
|
731
|
+
hint="Blank = none."
|
|
732
|
+
value={account.hecm.upfrontCostPct ?? null}
|
|
733
|
+
allowNull
|
|
734
|
+
onCommit={(v) => set('hecm', { ...account.hecm!, upfrontCostPct: v ?? undefined })}
|
|
735
|
+
/>
|
|
736
|
+
<SelectField
|
|
737
|
+
label="Draw policy"
|
|
738
|
+
help="Coordinated (buffer asset): draw for spending in years after a negative market return so depressed holdings can recover — visible in Monte Carlo, where down years exist. Last resort: draw only once the portfolio cannot cover spending. Either way an open line backstops a true shortfall."
|
|
739
|
+
value={account.hecm.drawPolicy}
|
|
740
|
+
options={[
|
|
741
|
+
{ value: 'lastResort', label: 'Last resort (when portfolio is exhausted)' },
|
|
742
|
+
{ value: 'coordinated', label: 'Coordinated (after down market years)' },
|
|
743
|
+
]}
|
|
744
|
+
onCommit={(v) => set('hecm', { ...account.hecm!, drawPolicy: v as 'coordinated' | 'lastResort' })}
|
|
745
|
+
/>
|
|
746
|
+
</>
|
|
747
|
+
) : null}
|
|
748
|
+
</>
|
|
749
|
+
) : null}
|
|
750
|
+
{account.type === 'debt' ? (
|
|
751
|
+
<>
|
|
752
|
+
<PercentField label="Interest rate" value={account.interestPct} onCommit={(v) => set('interestPct', v ?? 0)} />
|
|
753
|
+
<MoneyField label="Monthly payment" help="Principal & interest only. Don't include escrowed property tax or homeowner's insurance here — put those on the home (property) account so they correctly continue after the loan is paid off." hint="P&I only — escrow goes on the home account." value={account.monthlyPayment} onCommit={(v) => set('monthlyPayment', v ?? 0)} />
|
|
754
|
+
<NumberField label="Lump-sum payoff year" help="Optional. In this year the entire remaining balance is paid off at once, funded from your withdrawal order (selling taxable holdings realizes gains/tax, just like any other withdrawal). Use it to compare keeping a low-rate loan vs. paying it off early or mid-retirement." hint="Blank = run to term." value={account.payoffYear ?? null} allowNull min={1900} max={2200} onCommit={(v) => set('payoffYear', v === null ? undefined : Math.round(v))} />
|
|
755
|
+
</>
|
|
756
|
+
) : null}
|
|
757
|
+
{account.type !== 'debt' && account.type !== 'property' && account.type !== 'pension' ? (
|
|
758
|
+
<>
|
|
759
|
+
<SelectField
|
|
760
|
+
label="Estate beneficiary"
|
|
761
|
+
help="Who inherits this account's balance. Spouse: rolls over untaxed (spousal IRA rollover or HSA inheritance). Non-spouse: the balance is taxed at the heir tax rate for this account class. Charity: passes untaxed and leaves the heirs' estate entirely. Default treats each account by its type — traditional (and a non-spouse HSA) pass to a non-spouse heir taxed at the heir rate; cash, taxable, and Roth pass through untaxed."
|
|
762
|
+
hint="Blank = default by account type."
|
|
763
|
+
value={account.estateBeneficiary?.destination ?? ''}
|
|
764
|
+
options={[
|
|
765
|
+
{ value: '', label: 'Default (by account type)' },
|
|
766
|
+
{ value: 'spouse', label: 'Spouse (rolls over untaxed)' },
|
|
767
|
+
{ value: 'nonSpouse', label: 'Non-spouse heir' },
|
|
768
|
+
{ value: 'charity', label: 'Charity' },
|
|
769
|
+
]}
|
|
770
|
+
onCommit={(v) =>
|
|
771
|
+
set(
|
|
772
|
+
'estateBeneficiary' as string,
|
|
773
|
+
v === '' || v === 'nonSpouse'
|
|
774
|
+
? v === 'nonSpouse' ? { destination: 'nonSpouse' } : undefined
|
|
775
|
+
: v === 'charity'
|
|
776
|
+
? { destination: 'charity', charityPct: 100 }
|
|
777
|
+
: { destination: 'spouse' },
|
|
778
|
+
)
|
|
779
|
+
}
|
|
780
|
+
/>
|
|
781
|
+
{account.estateBeneficiary?.destination === 'charity' ? (
|
|
782
|
+
<PercentField
|
|
783
|
+
label="Charity share"
|
|
784
|
+
help="What percentage of this account goes to charity. The remainder goes to a non-spouse heir and is taxed at the heir tax rate."
|
|
785
|
+
value={account.estateBeneficiary.charityPct ?? 100}
|
|
786
|
+
min={0}
|
|
787
|
+
max={100}
|
|
788
|
+
onCommit={(v) => set('estateBeneficiary' as string, { destination: 'charity', charityPct: v ?? 100 })}
|
|
789
|
+
/>
|
|
790
|
+
) : null}
|
|
791
|
+
</>
|
|
792
|
+
) : null}
|
|
793
|
+
{estimating ? (
|
|
794
|
+
<ReturnEstimatorModal
|
|
795
|
+
initialPct={account.annualReturnPct}
|
|
796
|
+
onApply={(pct) => set('annualReturnPct', pct)}
|
|
797
|
+
onClose={() => setEstimating(false)}
|
|
798
|
+
/>
|
|
799
|
+
) : null}
|
|
800
|
+
</div>
|
|
801
|
+
)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Lump-sum vs annuity decision view (annuity-pension-and-home-equity, step 3):
|
|
806
|
+
* deterministic PV math against the recorded offer — a curve-anchored
|
|
807
|
+
* discounted value, the survivor option's worth, and a discount-rate ×
|
|
808
|
+
* longevity sensitivity table. Framed as tradeoffs; the exact-ledger scenario
|
|
809
|
+
* pair (Insights preview / decision engine) prices taxes and sequence risk.
|
|
810
|
+
*/
|
|
811
|
+
function PensionDecisionPanel({ plan, pensionId }: { plan: Plan; pensionId: string }) {
|
|
812
|
+
// Captured per render (not just inside the memo) so a start-year rollover
|
|
813
|
+
// invalidates the memoized analysis even without a plan edit.
|
|
814
|
+
const startYear = currentStartYear()
|
|
815
|
+
const analysis = useMemo(
|
|
816
|
+
() => analyzePensionElections(plan, startYear).find((a) => a.pensionId === pensionId),
|
|
817
|
+
[plan, pensionId, startYear],
|
|
818
|
+
)
|
|
819
|
+
if (!analysis) return null
|
|
820
|
+
if (analysis.lumpSum <= 0) {
|
|
821
|
+
return (
|
|
822
|
+
<p className="muted field-span-full" style={{ margin: 0 }}>
|
|
823
|
+
Enter the offered amount to see the decision view: the annuity's discounted value against the lump sum across
|
|
824
|
+
discount rates and longevity.
|
|
825
|
+
</p>
|
|
826
|
+
)
|
|
827
|
+
}
|
|
828
|
+
const ratio = analysis.presentValueAtCurveRate / analysis.lumpSum
|
|
829
|
+
const survivorOptionValue = analysis.presentValueAtCurveRate - analysis.presentValueSingleLife
|
|
830
|
+
return (
|
|
831
|
+
<div className="field-span-full">
|
|
832
|
+
<h4 style={{ marginBottom: '0.25rem' }}>Lump sum vs annuity</h4>
|
|
833
|
+
<p className="card-hint" style={{ marginTop: 0 }}>
|
|
834
|
+
At the {analysis.curveRatePct.toFixed(1)}% curve-anchored discount rate (TIPS real yield + your inflation
|
|
835
|
+
assumption) to the owner's planning age, the annuity's payments are worth{' '}
|
|
836
|
+
<strong>{fmtMoney(analysis.presentValueAtCurveRate)}</strong> against the{' '}
|
|
837
|
+
<strong>{fmtMoney(analysis.lumpSum)}</strong> offer ({(ratio * 100).toFixed(0)}%).
|
|
838
|
+
{survivorOptionValue > 1 ? (
|
|
839
|
+
<> The survivor continuation accounts for {fmtMoney(survivorOptionValue)} of that value.</>
|
|
840
|
+
) : null}{' '}
|
|
841
|
+
Living longer or discounting at lower rates favors the annuity; dying earlier, higher rates, bequest goals, and
|
|
842
|
+
control over the money favor the lump sum — the table shows how the comparison moves, and the Insights page can
|
|
843
|
+
preview the rollover against your full plan. A tradeoff, not advice.
|
|
844
|
+
</p>
|
|
845
|
+
<div className="year-table-wrap" style={{ border: 'none' }}>
|
|
846
|
+
<table className="compare-table">
|
|
847
|
+
<thead>
|
|
848
|
+
<tr>
|
|
849
|
+
<th>Annuity value ÷ offer</th>
|
|
850
|
+
{analysis.sensitivity.discountRatesPct.map((r) => (
|
|
851
|
+
<th key={r}>{r.toFixed(1)}%</th>
|
|
852
|
+
))}
|
|
853
|
+
</tr>
|
|
854
|
+
</thead>
|
|
855
|
+
<tbody>
|
|
856
|
+
{analysis.sensitivity.rows.map((row) => (
|
|
857
|
+
<tr key={row.ownerDeathAge}>
|
|
858
|
+
<td>To age {row.ownerDeathAge}</td>
|
|
859
|
+
{row.cells.map((cell) => (
|
|
860
|
+
<td key={cell.discountRatePct} title={fmtMoney(cell.presentValue)}>
|
|
861
|
+
{(cell.ratioToLumpSum * 100).toFixed(0)}%
|
|
862
|
+
</td>
|
|
863
|
+
))}
|
|
864
|
+
</tr>
|
|
865
|
+
))}
|
|
866
|
+
</tbody>
|
|
867
|
+
</table>
|
|
868
|
+
</div>
|
|
869
|
+
</div>
|
|
870
|
+
)
|
|
871
|
+
}
|
|
872
|
+
|