@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,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social Security claiming analysis for the planner.
|
|
3
|
+
*
|
|
4
|
+
* Two layers, mirroring the V5 plan:
|
|
5
|
+
* - whole-plan sweep: for every combination of claim ages, run the full
|
|
6
|
+
* deterministic projection and rank by ending after-tax estate (the choice
|
|
7
|
+
* interacts with taxes, Roth conversions, IRMAA, ACA, RMDs);
|
|
8
|
+
* - benefits-only: mortality-weighted expected present value of the benefits
|
|
9
|
+
* alone (socialSecurity/expectedPv), the actuarial lens.
|
|
10
|
+
*
|
|
11
|
+
* @see DOCS/features/social-security.md
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { IncomeStream, Person, Plan } from '@retiregolden/engine/model/plan'
|
|
15
|
+
import { summarizeProjection, type ProjectionSummary } from '@retiregolden/engine/projection/compare'
|
|
16
|
+
import { simulatePlan } from '@retiregolden/engine/projection/simulate'
|
|
17
|
+
import {
|
|
18
|
+
createDecisionContext,
|
|
19
|
+
evaluateCandidate,
|
|
20
|
+
objectivePolicyForPlan,
|
|
21
|
+
rankEvaluations,
|
|
22
|
+
socialSecurityClaimGridGenerator,
|
|
23
|
+
type ObjectivePolicyId,
|
|
24
|
+
} from '@retiregolden/engine/decisions'
|
|
25
|
+
import {
|
|
26
|
+
expectedPvCouple,
|
|
27
|
+
expectedPvSingle,
|
|
28
|
+
type ClaimantInput,
|
|
29
|
+
} from '../socialSecurity/expectedPv'
|
|
30
|
+
import { spousalBenefitFactor } from '@retiregolden/engine/socialSecurity/claimFactor'
|
|
31
|
+
import { DIVORCED_MIN_MARRIAGE_YEARS } from '@retiregolden/engine/socialSecurity/maritalBenefits'
|
|
32
|
+
import {
|
|
33
|
+
computePiaFromEarnings,
|
|
34
|
+
isPiaFromEarningsError,
|
|
35
|
+
piaInputFromEarnings,
|
|
36
|
+
resolveEarningsProjection,
|
|
37
|
+
type PiaFromEarningsResult,
|
|
38
|
+
} from '@retiregolden/engine/socialSecurity/piaFromEarnings'
|
|
39
|
+
import { currentStartYear, taxCalculatorFor } from './useProjection'
|
|
40
|
+
|
|
41
|
+
type SsStream = Extract<IncomeStream, { type: 'socialSecurity' }>
|
|
42
|
+
|
|
43
|
+
export const CLAIM_AGES = [62, 63, 64, 65, 66, 67, 68, 69, 70] as const
|
|
44
|
+
|
|
45
|
+
export function dobParts(person: Person): { y: number; m: number; d: number } {
|
|
46
|
+
return { y: Number(person.dob.slice(0, 4)), m: Number(person.dob.slice(5, 7)), d: Number(person.dob.slice(8, 10)) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Claim ages worth considering for a person: 62–70, but never earlier than the
|
|
51
|
+
* age they have already reached (you can't claim in the past). Someone already
|
|
52
|
+
* past 70 is left with 70.
|
|
53
|
+
*/
|
|
54
|
+
export function candidateClaimAges(person: Person, startYear: number): number[] {
|
|
55
|
+
const currentAge = startYear - dobParts(person).y
|
|
56
|
+
const ages = CLAIM_AGES.filter((a) => a >= currentAge)
|
|
57
|
+
return ages.length > 0 ? ages : [70]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function ssStreamFor(plan: Plan, personId: string): SsStream | undefined {
|
|
61
|
+
return plan.incomes.find((s): s is SsStream => s.type === 'socialSecurity' && s.personId === personId)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ResolvedPia {
|
|
65
|
+
/** Monthly PIA at FRA in today's dollars, or null if it can't be resolved. */
|
|
66
|
+
piaMonthly: number | null
|
|
67
|
+
warning: string | null
|
|
68
|
+
/** Full earnings-mode computation detail (indexed years, projection, AIME), when derived from earnings. */
|
|
69
|
+
detail: PiaFromEarningsResult | null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Resolve a stream's PIA the same way the engine does: entered, or derived from earnings. */
|
|
73
|
+
export function resolvePia(person: Person, stream: SsStream): ResolvedPia {
|
|
74
|
+
if (stream.piaMonthly !== null) return { piaMonthly: stream.piaMonthly, warning: null, detail: null }
|
|
75
|
+
if (!stream.earnings || stream.earnings.length === 0) {
|
|
76
|
+
return { piaMonthly: null, warning: 'No PIA entered and no earnings history.', detail: null }
|
|
77
|
+
}
|
|
78
|
+
const { y, m, d } = dobParts(person)
|
|
79
|
+
const projection = resolveEarningsProjection(stream.earningsProjection, person.retirementAge)
|
|
80
|
+
const result = computePiaFromEarnings(piaInputFromEarnings(y, m, d, stream.earnings, projection))
|
|
81
|
+
if (isPiaFromEarningsError(result)) {
|
|
82
|
+
return { piaMonthly: null, warning: `Earnings history could not be used (${result.code}).`, detail: null }
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
piaMonthly: result.piaMonthly,
|
|
86
|
+
warning: result.usesStandInForFutureTables ? 'PIA uses stand-in SSA tables for years beyond published data.' : null,
|
|
87
|
+
detail: result,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** People who have a Social Security stream with a resolvable benefit. */
|
|
92
|
+
export function claimingPeople(plan: Plan): { person: Person; stream: SsStream; pia: number }[] {
|
|
93
|
+
const out: { person: Person; stream: SsStream; pia: number }[] = []
|
|
94
|
+
for (const person of plan.household.people) {
|
|
95
|
+
const stream = ssStreamFor(plan, person.id)
|
|
96
|
+
if (!stream) continue
|
|
97
|
+
const { piaMonthly } = resolvePia(person, stream)
|
|
98
|
+
if (piaMonthly !== null && piaMonthly > 0) out.push({ person, stream, pia: piaMonthly })
|
|
99
|
+
}
|
|
100
|
+
return out
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Whole-plan sweep
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
export interface SweepRow {
|
|
108
|
+
/** Claim age (years) per claiming person, keyed by personId. */
|
|
109
|
+
claimByPersonId: Record<string, number>
|
|
110
|
+
summary: ProjectionSummary
|
|
111
|
+
/** Objective-policy metric delta versus the current plan; higher is better. */
|
|
112
|
+
primaryValue: number
|
|
113
|
+
eligible: boolean
|
|
114
|
+
lossReason: string | null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface SweepResult {
|
|
118
|
+
/** personId order the grid axes follow (1 entry = single, 2 = couple). */
|
|
119
|
+
personIds: string[]
|
|
120
|
+
objectivePolicyId: ObjectivePolicyId
|
|
121
|
+
primaryMetricLabel: string
|
|
122
|
+
rows: SweepRow[]
|
|
123
|
+
/** Rows sorted by the selected objective policy, descending. */
|
|
124
|
+
ranked: SweepRow[]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function planWithClaimAges(plan: Plan, claimByPersonId: Record<string, number>): Plan {
|
|
128
|
+
const next = structuredClone(plan)
|
|
129
|
+
for (const stream of next.incomes) {
|
|
130
|
+
if (stream.type === 'socialSecurity' && claimByPersonId[stream.personId] !== undefined) {
|
|
131
|
+
stream.claimAge = { years: claimByPersonId[stream.personId]!, months: 0 }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return next
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Run the full projection for every claim-age combination (62–70 per claiming
|
|
139
|
+
* person) and rank by ending after-tax estate. 9 runs single, 81 couple —
|
|
140
|
+
* each ~a few ms, so this stays well under a frame budget on the main thread.
|
|
141
|
+
*/
|
|
142
|
+
export function sweepClaimingStrategies(
|
|
143
|
+
plan: Plan,
|
|
144
|
+
startYear = currentStartYear(),
|
|
145
|
+
objectivePolicyId: ObjectivePolicyId = 'max-after-tax-estate',
|
|
146
|
+
): SweepResult {
|
|
147
|
+
const people = claimingPeople(plan)
|
|
148
|
+
const personIds = people.map((p) => p.person.id)
|
|
149
|
+
const taxCalculator = taxCalculatorFor(plan)
|
|
150
|
+
if (personIds.length === 0) {
|
|
151
|
+
const policy = objectivePolicyForPlan(objectivePolicyId, plan)
|
|
152
|
+
return { personIds, objectivePolicyId, primaryMetricLabel: policy.primaryMetricLabel, rows: [], ranked: [] }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const ctx = createDecisionContext(plan, { startYear, taxCalculator })
|
|
156
|
+
const policy = objectivePolicyForPlan(objectivePolicyId, plan)
|
|
157
|
+
const candidates = socialSecurityClaimGridGenerator.generate(ctx)
|
|
158
|
+
const evaluations = candidates.map((candidate) => evaluateCandidate(ctx, candidate))
|
|
159
|
+
const { ranked: rankedDecisions } = rankEvaluations(evaluations, ctx, policy, 0)
|
|
160
|
+
|
|
161
|
+
const rowByCandidateId = new Map<string, SweepRow>()
|
|
162
|
+
for (const row of rankedDecisions) {
|
|
163
|
+
const claimByPersonId = row.evaluation.candidate.metadata?.['claimByPersonId']
|
|
164
|
+
if (!claimByPersonId || typeof claimByPersonId !== 'object') continue
|
|
165
|
+
const claim = claimByPersonId as Record<string, number>
|
|
166
|
+
if (!personIds.every((id) => typeof claim[id] === 'number')) continue
|
|
167
|
+
rowByCandidateId.set(row.evaluation.candidate.id, {
|
|
168
|
+
claimByPersonId: Object.fromEntries(personIds.map((id) => [id, claim[id]!])),
|
|
169
|
+
summary: row.evaluation.candidateSummary,
|
|
170
|
+
primaryValue: row.primaryValue,
|
|
171
|
+
eligible: row.eligible,
|
|
172
|
+
lossReason: row.lossReason,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const rows = candidates
|
|
177
|
+
.map((candidate) => rowByCandidateId.get(candidate.id))
|
|
178
|
+
.filter((row): row is SweepRow => row !== undefined)
|
|
179
|
+
const ranked = rankedDecisions
|
|
180
|
+
.map((row) => rowByCandidateId.get(row.evaluation.candidate.id))
|
|
181
|
+
.filter((row): row is SweepRow => row !== undefined)
|
|
182
|
+
return { personIds, objectivePolicyId, primaryMetricLabel: policy.primaryMetricLabel, rows, ranked }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Monthly refinement around the best whole-year strategy
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
export interface MonthlyClaim {
|
|
190
|
+
years: number
|
|
191
|
+
months: number
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface MonthlyRefinement {
|
|
195
|
+
claimByPersonId: Record<string, MonthlyClaim>
|
|
196
|
+
summary: ProjectionSummary
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function planWithClaimAgesMonthly(plan: Plan, claimByPersonId: Record<string, MonthlyClaim>): Plan {
|
|
200
|
+
const next = structuredClone(plan)
|
|
201
|
+
for (const stream of next.incomes) {
|
|
202
|
+
if (stream.type === 'socialSecurity' && claimByPersonId[stream.personId] !== undefined) {
|
|
203
|
+
stream.claimAge = { ...claimByPersonId[stream.personId]! }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return next
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Second-pass monthly refinement: starting from the best whole-year strategy,
|
|
211
|
+
* sweep each claiming person's claim age to the month within ±1 year (clamped to
|
|
212
|
+
* 62y0m–70y0m and the current age) by coordinate ascent, keeping the best
|
|
213
|
+
* after-tax estate. ~25 runs per person, so it stays on the main thread.
|
|
214
|
+
*/
|
|
215
|
+
export function refineClaimingMonthly(
|
|
216
|
+
plan: Plan,
|
|
217
|
+
baseClaimYears: Record<string, number>,
|
|
218
|
+
startYear = currentStartYear(),
|
|
219
|
+
): MonthlyRefinement {
|
|
220
|
+
const people = claimingPeople(plan)
|
|
221
|
+
const taxCalculator = taxCalculatorFor(plan)
|
|
222
|
+
const evaluate = (claim: Record<string, MonthlyClaim>): ProjectionSummary => {
|
|
223
|
+
const candidate = planWithClaimAgesMonthly(plan, claim)
|
|
224
|
+
return summarizeProjection(candidate, simulatePlan(candidate, { startYear, taxCalculator }))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let best: Record<string, MonthlyClaim> = {}
|
|
228
|
+
for (const id of Object.keys(baseClaimYears)) best[id] = { years: baseClaimYears[id]!, months: 0 }
|
|
229
|
+
let bestSummary = evaluate(best)
|
|
230
|
+
|
|
231
|
+
for (const { person } of people) {
|
|
232
|
+
const baseYear = best[person.id]!.years
|
|
233
|
+
const currentAge = startYear - dobParts(person).y
|
|
234
|
+
let localBest = best[person.id]!
|
|
235
|
+
for (let yy = baseYear - 1; yy <= baseYear + 1; yy++) {
|
|
236
|
+
if (yy < 62 || yy > 70 || yy < currentAge) continue
|
|
237
|
+
const maxMonth = yy === 70 ? 0 : 11 // engine caps claim at 70y0m
|
|
238
|
+
for (let mm = 0; mm <= maxMonth; mm++) {
|
|
239
|
+
const summary = evaluate({ ...best, [person.id]: { years: yy, months: mm } })
|
|
240
|
+
if (summary.endingAfterTaxEstate > bestSummary.endingAfterTaxEstate) {
|
|
241
|
+
bestSummary = summary
|
|
242
|
+
localBest = { years: yy, months: mm }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
best = { ...best, [person.id]: localBest }
|
|
247
|
+
}
|
|
248
|
+
return { claimByPersonId: best, summary: bestSummary }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Benefits-only (actuarial) ranking
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
export interface BenefitsPvRow {
|
|
256
|
+
claimByPersonId: Record<string, number>
|
|
257
|
+
expectedPv: number
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function claimantInput(person: Person, pia: number, claimYears: number, startYear: number): ClaimantInput {
|
|
261
|
+
const { y, m, d } = dobParts(person)
|
|
262
|
+
return {
|
|
263
|
+
currentAge: Math.max(0, startYear - y),
|
|
264
|
+
dob: { year: y, month: m, day: d },
|
|
265
|
+
sex: person.sex,
|
|
266
|
+
piaMonthly: pia,
|
|
267
|
+
claimAge: { years: claimYears, months: 0 },
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Best divorced-spousal monthly benefit (0.5 × ex PIA, reduced for the claim age)
|
|
273
|
+
* across eligible ex-spouses, for a currently-unmarried claimant. The actuarial
|
|
274
|
+
* view assumes the ex is eligible; survivor benefits are handled separately by
|
|
275
|
+
* the survivor-switching analysis.
|
|
276
|
+
*/
|
|
277
|
+
function divorcedSpousalFloorMonthly(person: Person, stream: SsStream, claimYears: number, householdSingle: boolean): number {
|
|
278
|
+
if (!householdSingle) return 0
|
|
279
|
+
const { y, m, d } = dobParts(person)
|
|
280
|
+
let best = 0
|
|
281
|
+
for (const r of stream.formerSpouses ?? []) {
|
|
282
|
+
if (r.relationship !== 'divorced' || r.marriageYears < DIVORCED_MIN_MARRIAGE_YEARS) continue
|
|
283
|
+
best = Math.max(best, 0.5 * r.piaMonthly * spousalBenefitFactor(y, m, d, { years: claimYears, months: 0 }))
|
|
284
|
+
}
|
|
285
|
+
return best
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Mortality-weighted expected PV of benefits for every claim-age combination,
|
|
290
|
+
* ranked descending. Ignores the portfolio and taxes — the pure insurance view.
|
|
291
|
+
*/
|
|
292
|
+
export function benefitsOnlyRanking(plan: Plan, discountRate: number, startYear = currentStartYear()): {
|
|
293
|
+
personIds: string[]
|
|
294
|
+
rows: BenefitsPvRow[]
|
|
295
|
+
ranked: BenefitsPvRow[]
|
|
296
|
+
} {
|
|
297
|
+
const people = claimingPeople(plan)
|
|
298
|
+
const personIds = people.map((p) => p.person.id)
|
|
299
|
+
const householdSingle = plan.household.people.length === 1
|
|
300
|
+
const rows: BenefitsPvRow[] = []
|
|
301
|
+
|
|
302
|
+
if (people.length === 1) {
|
|
303
|
+
const { person, pia, stream } = people[0]!
|
|
304
|
+
for (const age of candidateClaimAges(person, startYear)) {
|
|
305
|
+
const benefitFloorMonthly = divorcedSpousalFloorMonthly(person, stream, age, householdSingle)
|
|
306
|
+
const pv = expectedPvSingle({ ...claimantInput(person, pia, age, startYear), benefitFloorMonthly }, { discountRate })
|
|
307
|
+
rows.push({ claimByPersonId: { [person.id]: age }, expectedPv: pv })
|
|
308
|
+
}
|
|
309
|
+
} else if (people.length === 2) {
|
|
310
|
+
const [a, b] = people
|
|
311
|
+
for (const ageA of candidateClaimAges(a!.person, startYear)) {
|
|
312
|
+
for (const ageB of candidateClaimAges(b!.person, startYear)) {
|
|
313
|
+
const pv = expectedPvCouple(
|
|
314
|
+
claimantInput(a!.person, a!.pia, ageA, startYear),
|
|
315
|
+
claimantInput(b!.person, b!.pia, ageB, startYear),
|
|
316
|
+
{ discountRate },
|
|
317
|
+
)
|
|
318
|
+
rows.push({ claimByPersonId: { [a!.person.id]: ageA, [b!.person.id]: ageB }, expectedPv: pv })
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const ranked = [...rows].sort((x, y) => y.expectedPv - x.expectedPv)
|
|
324
|
+
return { personIds, rows, ranked }
|
|
325
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-only tone banding for the Monte Carlo success rate (UI/UX round 2,
|
|
3
|
+
* Step 3). Red is reserved for genuinely failing plans so a high-60s/70s plan no
|
|
4
|
+
* longer reads as failure; the number and every computed metric are untouched.
|
|
5
|
+
* Bands agree with the "Understanding Monte Carlo success rate" Learning Center
|
|
6
|
+
* article.
|
|
7
|
+
*/
|
|
8
|
+
export function successBand(rate: number): { color: string; severity: string; verdict: string } {
|
|
9
|
+
const pct = rate * 100
|
|
10
|
+
if (pct >= 90) return { color: 'var(--good)', severity: 'on track', verdict: 'On track in nearly all markets.' }
|
|
11
|
+
if (pct >= 75)
|
|
12
|
+
return {
|
|
13
|
+
color: 'var(--good)',
|
|
14
|
+
severity: 'on track in most markets',
|
|
15
|
+
verdict: 'On track in most markets — some sequence risk remains.',
|
|
16
|
+
}
|
|
17
|
+
if (pct >= 60)
|
|
18
|
+
return { color: 'var(--warn)', severity: 'needs attention', verdict: 'Workable, but sequence risk is real here.' }
|
|
19
|
+
return { color: 'var(--bad)', severity: 'at risk', verdict: 'This plan depletes in many markets.' }
|
|
20
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Survivor-transition analysis for two-adult plans (survivor-widowhood-and-
|
|
3
|
+
* irmaa-relief, step 2).
|
|
4
|
+
*
|
|
5
|
+
* For each death-timing scenario (either spouse first, at a bounded set of
|
|
6
|
+
* ages), run the SAME deterministic ledger the Results page uses with a
|
|
7
|
+
* `deathAgeByPersonId` override and read the transition facts straight off the
|
|
8
|
+
* projection years: the filing-status timeline, the tax/MAGI shift around the
|
|
9
|
+
* death, the survivor's Social Security outcome, survivor spending coverage,
|
|
10
|
+
* the IRMAA difference with and without SSA-44 relief, and the pre-death
|
|
11
|
+
* Roth-conversion lever priced as an ordinary scenario patch. Nothing here is
|
|
12
|
+
* a separate model — every number must agree exactly with running the
|
|
13
|
+
* equivalent scenario by hand (guarded by tests).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Plan } from '@retiregolden/engine/model/plan'
|
|
17
|
+
import { applyScenarioPatch } from '@retiregolden/engine/scenarios/scenarios'
|
|
18
|
+
import { summarizeProjection } from '@retiregolden/engine/projection/compare'
|
|
19
|
+
import { simulatePlan } from '@retiregolden/engine/projection/simulate'
|
|
20
|
+
import type { ProjectedFilingStatus, ProjectionResult, TaxCalculator } from '@retiregolden/engine/projection/types'
|
|
21
|
+
|
|
22
|
+
export const SURVIVOR_DEATH_AGES = [70, 75, 80, 85, 90] as const
|
|
23
|
+
|
|
24
|
+
export interface SurvivorAnalysisOptions {
|
|
25
|
+
startYear: number
|
|
26
|
+
taxCalculator: TaxCalculator
|
|
27
|
+
/** Death-age grid per first-to-die person; defaults to {@link SURVIVOR_DEATH_AGES}, clamped to plausible ages. */
|
|
28
|
+
deathAges?: number[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One contiguous run of a filing status in the projection. */
|
|
32
|
+
export interface FilingSegment {
|
|
33
|
+
fromYear: number
|
|
34
|
+
toYear: number
|
|
35
|
+
status: ProjectedFilingStatus
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SurvivorYearFacts {
|
|
39
|
+
year: number
|
|
40
|
+
magi: number
|
|
41
|
+
tax: number
|
|
42
|
+
filingStatus: ProjectedFilingStatus
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SurvivorIrmaaYear {
|
|
46
|
+
year: number
|
|
47
|
+
/** IRMAA tier / annual Medicare premiums under the plain two-year lookback. */
|
|
48
|
+
tierWithoutSsa44: number
|
|
49
|
+
premiumsWithoutSsa44: number
|
|
50
|
+
/** … and with SSA-44 survivor-year redetermination modeled. */
|
|
51
|
+
tierWithSsa44: number
|
|
52
|
+
premiumsWithSsa44: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SurvivorScenarioRow {
|
|
56
|
+
/** The first-to-die person this row models. */
|
|
57
|
+
deceasedPersonId: string
|
|
58
|
+
deathAge: number
|
|
59
|
+
deathYear: number
|
|
60
|
+
/** Compressed filing-status timeline of the whole projection. */
|
|
61
|
+
filingTimeline: FilingSegment[]
|
|
62
|
+
/** The last joint-filing year (the death year) vs the first survivor-filed year. */
|
|
63
|
+
lastJointYear: SurvivorYearFacts
|
|
64
|
+
firstSurvivorYear: SurvivorYearFacts
|
|
65
|
+
/** Household Social Security in the death year vs the first survivor year (nominal). */
|
|
66
|
+
ssBeforeDeath: number
|
|
67
|
+
ssAfterDeath: number
|
|
68
|
+
/** SSA-44 window years (death year + 1, + 2), premiums from two otherwise-identical runs. */
|
|
69
|
+
irmaaYears: SurvivorIrmaaYear[]
|
|
70
|
+
/** Total nominal Medicare premium saved by SSA-44 across all survivor years. */
|
|
71
|
+
ssa44PremiumSavings: number
|
|
72
|
+
/** Survivor years (after the death year) with any required-spending shortfall. */
|
|
73
|
+
survivorShortfallYears: number
|
|
74
|
+
/** Lowest end-of-year investable balance across survivor years (nominal). */
|
|
75
|
+
minSurvivorInvestable: number
|
|
76
|
+
/** Ending after-tax estate / lifetime tax for this death timing, at the plan's own settings (nominal). */
|
|
77
|
+
baseEndingAfterTaxEstate: number
|
|
78
|
+
baseLifetimeTax: number
|
|
79
|
+
/**
|
|
80
|
+
* The pre-death conversion lever: the same death timing with conversions
|
|
81
|
+
* filling the 12% bracket through the last joint year (the widows-penalty
|
|
82
|
+
* detector's preview patch), priced on the same ledger. Null when the patch
|
|
83
|
+
* fails validation (never expected).
|
|
84
|
+
*/
|
|
85
|
+
conversionLever: {
|
|
86
|
+
endingAfterTaxEstate: number
|
|
87
|
+
lifetimeTax: number
|
|
88
|
+
estateDelta: number
|
|
89
|
+
lifetimeTaxDelta: number
|
|
90
|
+
} | null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface SurvivorAnalysis {
|
|
94
|
+
/** False for plans that are not a two-adult married-filing-jointly household. */
|
|
95
|
+
eligible: boolean
|
|
96
|
+
/** Whether the plan itself already models SSA-44 survivor relief. */
|
|
97
|
+
planUsesSsa44: boolean
|
|
98
|
+
rows: SurvivorScenarioRow[]
|
|
99
|
+
/** Death timings whose ledger runs threw and were skipped (0 = clean sweep). */
|
|
100
|
+
failedTimings: number
|
|
101
|
+
/** Set by the page when the whole sweep threw — render an error state, not tables. */
|
|
102
|
+
error?: boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function dobYearOf(plan: Plan, personId: string): number {
|
|
106
|
+
const p = plan.household.people.find((x) => x.id === personId)!
|
|
107
|
+
return Number(p.dob.slice(0, 4))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The plan with SSA-44 survivor relief forced on/off (other settings untouched). */
|
|
111
|
+
function withSurvivorSsa44(plan: Plan, on: boolean): Plan {
|
|
112
|
+
return {
|
|
113
|
+
...plan,
|
|
114
|
+
expenses: {
|
|
115
|
+
...plan.expenses,
|
|
116
|
+
healthcare: {
|
|
117
|
+
...plan.expenses.healthcare,
|
|
118
|
+
ssa44: { survivorYears: on, retirementYears: plan.expenses.healthcare.ssa44?.retirementYears ?? false },
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function filingTimeline(result: ProjectionResult): FilingSegment[] {
|
|
125
|
+
const segments: FilingSegment[] = []
|
|
126
|
+
for (const y of result.years) {
|
|
127
|
+
const last = segments[segments.length - 1]
|
|
128
|
+
if (last && last.status === y.filingStatus) last.toYear = y.year
|
|
129
|
+
else segments.push({ fromYear: y.year, toYear: y.year, status: y.filingStatus })
|
|
130
|
+
}
|
|
131
|
+
return segments
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The widows-penalty detector's conversion-acceleration patch for a given last joint year. */
|
|
135
|
+
export function conversionLeverPatch(startYear: number, lastJointYear: number): Record<string, unknown> {
|
|
136
|
+
return {
|
|
137
|
+
strategies: {
|
|
138
|
+
rothConversion: {
|
|
139
|
+
mode: 'fillToTarget',
|
|
140
|
+
target: 'topOfBracket',
|
|
141
|
+
targetValue: 12,
|
|
142
|
+
startYear,
|
|
143
|
+
endYear: lastJointYear,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Death ages worth sweeping for a person: the default grid clamped to
|
|
151
|
+
* [current age, planning age), so every row is a genuine "dies earlier than
|
|
152
|
+
* planned" scenario. The current attained age is included — dying at the end
|
|
153
|
+
* of this year is a valid earlier-than-planned timing (`lastAliveYear =
|
|
154
|
+
* dobYear + deathAge ≥ startYear`). Empty when no grid age fits.
|
|
155
|
+
*/
|
|
156
|
+
export function candidateDeathAges(plan: Plan, personId: string, startYear: number, grid: number[]): number[] {
|
|
157
|
+
const person = plan.household.people.find((p) => p.id === personId)!
|
|
158
|
+
const currentAge = startYear - dobYearOf(plan, personId)
|
|
159
|
+
return grid.filter((a) => a >= currentAge && a < person.longevity.planningAge)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* One death timing = three full ledger runs: the SSA-44 off/on pair (whichever
|
|
164
|
+
* matches the plan's own setting doubles as the base run — the field is
|
|
165
|
+
* engine-identical to absent when off, and summarize ignores it) plus the
|
|
166
|
+
* conversion-lever run. Returns null when the timing yields no survivor phase.
|
|
167
|
+
*/
|
|
168
|
+
function buildTimingRow(
|
|
169
|
+
plan: Plan,
|
|
170
|
+
opts: SurvivorAnalysisOptions,
|
|
171
|
+
personId: string,
|
|
172
|
+
deathAge: number,
|
|
173
|
+
planUsesSsa44: boolean,
|
|
174
|
+
): SurvivorScenarioRow | null {
|
|
175
|
+
const deathYear = dobYearOf(plan, personId) + deathAge
|
|
176
|
+
const simOpts = { startYear: opts.startYear, taxCalculator: opts.taxCalculator, deathAgeByPersonId: { [personId]: deathAge } }
|
|
177
|
+
|
|
178
|
+
const without = simulatePlan(withSurvivorSsa44(plan, false), simOpts)
|
|
179
|
+
const withRelief = simulatePlan(withSurvivorSsa44(plan, true), simOpts)
|
|
180
|
+
const base = planUsesSsa44 ? withRelief : without
|
|
181
|
+
const baseSummary = summarizeProjection(plan, base)
|
|
182
|
+
|
|
183
|
+
const lastJoint = base.years.find((y) => y.year === deathYear)
|
|
184
|
+
const firstSurvivor = base.years.find((y) => y.year === deathYear + 1)
|
|
185
|
+
if (!lastJoint || !firstSurvivor) return null
|
|
186
|
+
|
|
187
|
+
const irmaaYears: SurvivorIrmaaYear[] = [deathYear + 1, deathYear + 2]
|
|
188
|
+
.map((year) => {
|
|
189
|
+
const off = without.years.find((y) => y.year === year)
|
|
190
|
+
const on = withRelief.years.find((y) => y.year === year)
|
|
191
|
+
if (!off || !on) return null
|
|
192
|
+
return {
|
|
193
|
+
year,
|
|
194
|
+
tierWithoutSsa44: off.irmaaTier,
|
|
195
|
+
premiumsWithoutSsa44: off.medicarePremiums,
|
|
196
|
+
tierWithSsa44: on.irmaaTier,
|
|
197
|
+
premiumsWithSsa44: on.medicarePremiums,
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
.filter((x): x is SurvivorIrmaaYear => x !== null)
|
|
201
|
+
let ssa44PremiumSavings = 0
|
|
202
|
+
for (const y of without.years) {
|
|
203
|
+
const on = withRelief.years.find((x) => x.year === y.year)
|
|
204
|
+
if (on) ssa44PremiumSavings += y.medicarePremiums - on.medicarePremiums
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const survivorYears = base.years.filter((y) => y.year > deathYear && y.people.some((p) => p.alive))
|
|
208
|
+
const survivorShortfallYears = survivorYears.filter((y) => y.requiredShortfall > 0.005).length
|
|
209
|
+
const minSurvivorInvestable = survivorYears.length > 0 ? Math.min(...survivorYears.map((y) => y.investableTotal)) : 0
|
|
210
|
+
|
|
211
|
+
// The conversion lever, priced as an ordinary scenario patch on the same
|
|
212
|
+
// death timing (agrees exactly with adding the scenario by hand).
|
|
213
|
+
let conversionLever: SurvivorScenarioRow['conversionLever'] = null
|
|
214
|
+
const patched = applyScenarioPatch(plan, conversionLeverPatch(opts.startYear, deathYear))
|
|
215
|
+
if (patched.ok) {
|
|
216
|
+
const leverSummary = summarizeProjection(patched.plan, simulatePlan(patched.plan, simOpts))
|
|
217
|
+
conversionLever = {
|
|
218
|
+
endingAfterTaxEstate: leverSummary.endingAfterTaxEstate,
|
|
219
|
+
lifetimeTax: leverSummary.lifetimeTaxesAndPenalties,
|
|
220
|
+
estateDelta: leverSummary.endingAfterTaxEstate - baseSummary.endingAfterTaxEstate,
|
|
221
|
+
lifetimeTaxDelta: leverSummary.lifetimeTaxesAndPenalties - baseSummary.lifetimeTaxesAndPenalties,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
deceasedPersonId: personId,
|
|
227
|
+
deathAge,
|
|
228
|
+
deathYear,
|
|
229
|
+
filingTimeline: filingTimeline(base),
|
|
230
|
+
lastJointYear: { year: lastJoint.year, magi: lastJoint.magi, tax: lastJoint.tax, filingStatus: lastJoint.filingStatus },
|
|
231
|
+
firstSurvivorYear: {
|
|
232
|
+
year: firstSurvivor.year,
|
|
233
|
+
magi: firstSurvivor.magi,
|
|
234
|
+
tax: firstSurvivor.tax,
|
|
235
|
+
filingStatus: firstSurvivor.filingStatus,
|
|
236
|
+
},
|
|
237
|
+
ssBeforeDeath: lastJoint.incomes.socialSecurity,
|
|
238
|
+
ssAfterDeath: firstSurvivor.incomes.socialSecurity,
|
|
239
|
+
irmaaYears,
|
|
240
|
+
ssa44PremiumSavings,
|
|
241
|
+
survivorShortfallYears,
|
|
242
|
+
minSurvivorInvestable,
|
|
243
|
+
baseEndingAfterTaxEstate: baseSummary.endingAfterTaxEstate,
|
|
244
|
+
baseLifetimeTax: baseSummary.lifetimeTaxesAndPenalties,
|
|
245
|
+
conversionLever,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function buildSurvivorAnalysis(plan: Plan, opts: SurvivorAnalysisOptions): SurvivorAnalysis {
|
|
250
|
+
const eligible = plan.household.filingStatus === 'marriedFilingJointly' && plan.household.people.length === 2
|
|
251
|
+
const planUsesSsa44 = plan.expenses.healthcare.ssa44?.survivorYears === true
|
|
252
|
+
if (!eligible) return { eligible, planUsesSsa44, rows: [], failedTimings: 0 }
|
|
253
|
+
|
|
254
|
+
const grid = opts.deathAges ?? [...SURVIVOR_DEATH_AGES]
|
|
255
|
+
const rows: SurvivorScenarioRow[] = []
|
|
256
|
+
let failedTimings = 0
|
|
257
|
+
|
|
258
|
+
for (const person of plan.household.people) {
|
|
259
|
+
const survivor = plan.household.people.find((p) => p.id !== person.id)!
|
|
260
|
+
const survivorLastYear = dobYearOf(plan, survivor.id) + survivor.longevity.planningAge
|
|
261
|
+
for (const deathAge of candidateDeathAges(plan, person.id, opts.startYear, grid)) {
|
|
262
|
+
const deathYear = dobYearOf(plan, person.id) + deathAge
|
|
263
|
+
// Only genuine first deaths: the survivor must outlive this timing.
|
|
264
|
+
if (deathYear >= survivorLastYear) continue
|
|
265
|
+
// A throwing timing is skipped (and counted) rather than sinking the
|
|
266
|
+
// whole sweep — the page reports partial results honestly.
|
|
267
|
+
try {
|
|
268
|
+
const row = buildTimingRow(plan, opts, person.id, deathAge, planUsesSsa44)
|
|
269
|
+
if (row) rows.push(row)
|
|
270
|
+
} catch {
|
|
271
|
+
failedTimings += 1
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { eligible, planUsesSsa44, rows, failedTimings }
|
|
277
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const US_STATES: ReadonlyArray<{ value: string; label: string }> = [
|
|
2
|
+
{ value: 'AL', label: 'Alabama' }, { value: 'AK', label: 'Alaska' }, { value: 'AZ', label: 'Arizona' },
|
|
3
|
+
{ value: 'AR', label: 'Arkansas' }, { value: 'CA', label: 'California' }, { value: 'CO', label: 'Colorado' },
|
|
4
|
+
{ value: 'CT', label: 'Connecticut' }, { value: 'DE', label: 'Delaware' }, { value: 'DC', label: 'District of Columbia' },
|
|
5
|
+
{ value: 'FL', label: 'Florida' }, { value: 'GA', label: 'Georgia' }, { value: 'HI', label: 'Hawaii' },
|
|
6
|
+
{ value: 'ID', label: 'Idaho' }, { value: 'IL', label: 'Illinois' }, { value: 'IN', label: 'Indiana' },
|
|
7
|
+
{ value: 'IA', label: 'Iowa' }, { value: 'KS', label: 'Kansas' }, { value: 'KY', label: 'Kentucky' },
|
|
8
|
+
{ value: 'LA', label: 'Louisiana' }, { value: 'ME', label: 'Maine' }, { value: 'MD', label: 'Maryland' },
|
|
9
|
+
{ value: 'MA', label: 'Massachusetts' }, { value: 'MI', label: 'Michigan' }, { value: 'MN', label: 'Minnesota' },
|
|
10
|
+
{ value: 'MS', label: 'Mississippi' }, { value: 'MO', label: 'Missouri' }, { value: 'MT', label: 'Montana' },
|
|
11
|
+
{ value: 'NE', label: 'Nebraska' }, { value: 'NV', label: 'Nevada' }, { value: 'NH', label: 'New Hampshire' },
|
|
12
|
+
{ value: 'NJ', label: 'New Jersey' }, { value: 'NM', label: 'New Mexico' }, { value: 'NY', label: 'New York' },
|
|
13
|
+
{ value: 'NC', label: 'North Carolina' }, { value: 'ND', label: 'North Dakota' }, { value: 'OH', label: 'Ohio' },
|
|
14
|
+
{ value: 'OK', label: 'Oklahoma' }, { value: 'OR', label: 'Oregon' }, { value: 'PA', label: 'Pennsylvania' },
|
|
15
|
+
{ value: 'RI', label: 'Rhode Island' }, { value: 'SC', label: 'South Carolina' }, { value: 'SD', label: 'South Dakota' },
|
|
16
|
+
{ value: 'TN', label: 'Tennessee' }, { value: 'TX', label: 'Texas' }, { value: 'UT', label: 'Utah' },
|
|
17
|
+
{ value: 'VT', label: 'Vermont' }, { value: 'VA', label: 'Virginia' }, { value: 'WA', label: 'Washington' },
|
|
18
|
+
{ value: 'WV', label: 'West Virginia' }, { value: 'WI', label: 'Wisconsin' }, { value: 'WY', label: 'Wyoming' },
|
|
19
|
+
]
|