@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,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social Security break-even education (V7 phase 2). Pure cumulative-benefit math
|
|
3
|
+
* for a single person's own retirement benefit across a few claim ages, with COLA
|
|
4
|
+
* and an optional investment-growth assumption. This is the simple pedagogical
|
|
5
|
+
* lens; the whole-plan claiming sweep (ssAnalysis) is the more complete answer.
|
|
6
|
+
*
|
|
7
|
+
* Models one person's benefit only — spousal/survivor interactions are out of
|
|
8
|
+
* scope here (the sweep handles those). COLA accrues from age 62 for every claim
|
|
9
|
+
* age (SSA applies COLAs from 62 even before you claim). With a growth rate, each
|
|
10
|
+
* year's benefit compounds to the comparison age, so a higher return lets the
|
|
11
|
+
* early claimant's head start hold longer and pushes break-even later.
|
|
12
|
+
*
|
|
13
|
+
* @see DOCS/features/social-security.md §3.2
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { claimFactor } from '@retiregolden/engine/socialSecurity/claimFactor'
|
|
17
|
+
|
|
18
|
+
export interface BreakEvenInput {
|
|
19
|
+
dob: { year: number; month: number; day: number }
|
|
20
|
+
/** Monthly PIA at full retirement age, today's dollars. */
|
|
21
|
+
piaMonthly: number
|
|
22
|
+
/** Whole-year claim ages to compare (e.g. [62, fra, 70]). */
|
|
23
|
+
claimAges: number[]
|
|
24
|
+
/** Annual cost-of-living adjustment, percent. */
|
|
25
|
+
colaPct: number
|
|
26
|
+
/** Annual investment return on benefits received, percent. 0 = straight cumulative. */
|
|
27
|
+
growthPct: number
|
|
28
|
+
/** Highest age to chart (e.g. planning age). */
|
|
29
|
+
throughAge: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BreakEvenPoint {
|
|
33
|
+
age: number
|
|
34
|
+
/** Cumulative value at this age, keyed by claim age. */
|
|
35
|
+
cumulative: Record<number, number>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BreakEvenCrossing {
|
|
39
|
+
early: number
|
|
40
|
+
late: number
|
|
41
|
+
/** Age (to 0.1yr) the later claim's cumulative overtakes the earlier; null if not within throughAge. */
|
|
42
|
+
age: number | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface BreakEvenResult {
|
|
46
|
+
series: BreakEvenPoint[]
|
|
47
|
+
crossings: BreakEvenCrossing[]
|
|
48
|
+
/** Benefit as a fraction of PIA, by claim age. */
|
|
49
|
+
factors: Record<number, number>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const FIRST_AGE = 62
|
|
53
|
+
|
|
54
|
+
export function computeBreakEven(input: BreakEvenInput): BreakEvenResult {
|
|
55
|
+
const { dob, piaMonthly, claimAges, colaPct, growthPct, throughAge } = input
|
|
56
|
+
const cola = colaPct / 100
|
|
57
|
+
const g = growthPct / 100
|
|
58
|
+
|
|
59
|
+
const factors: Record<number, number> = {}
|
|
60
|
+
for (const a of claimAges) {
|
|
61
|
+
factors[a] = claimFactor(dob.year, dob.month, dob.day, { years: a, months: 0 })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Annual benefit received at `age` for a person who claimed at `a` (0 before claim). */
|
|
65
|
+
const annualBenefit = (a: number, age: number): number =>
|
|
66
|
+
age < a ? 0 : piaMonthly * factors[a]! * 12 * Math.pow(1 + cola, age - FIRST_AGE)
|
|
67
|
+
|
|
68
|
+
const series: BreakEvenPoint[] = []
|
|
69
|
+
const balance: Record<number, number> = {}
|
|
70
|
+
for (const a of claimAges) balance[a] = 0
|
|
71
|
+
|
|
72
|
+
for (let age = FIRST_AGE; age <= throughAge; age++) {
|
|
73
|
+
const cumulative: Record<number, number> = {}
|
|
74
|
+
for (const a of claimAges) {
|
|
75
|
+
balance[a] = balance[a]! * (1 + g) + annualBenefit(a, age)
|
|
76
|
+
cumulative[a] = Math.round(balance[a]!)
|
|
77
|
+
}
|
|
78
|
+
series.push({ age, cumulative })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const sorted = [...claimAges].sort((x, y) => x - y)
|
|
82
|
+
const crossings: BreakEvenCrossing[] = []
|
|
83
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
84
|
+
for (let j = i + 1; j < sorted.length; j++) {
|
|
85
|
+
const early = sorted[i]!
|
|
86
|
+
const late = sorted[j]!
|
|
87
|
+
let crossAge: number | null = null
|
|
88
|
+
let prevDiff: number | null = null
|
|
89
|
+
for (const pt of series) {
|
|
90
|
+
// Before the earlier claim begins, both are $0 — nothing to compare yet.
|
|
91
|
+
if (pt.cumulative[early]! <= 0) continue
|
|
92
|
+
const diff = pt.cumulative[late]! - pt.cumulative[early]!
|
|
93
|
+
if (diff >= 0) {
|
|
94
|
+
crossAge =
|
|
95
|
+
prevDiff !== null && prevDiff < 0
|
|
96
|
+
? pt.age - 1 + -prevDiff / (diff - prevDiff)
|
|
97
|
+
: pt.age
|
|
98
|
+
break
|
|
99
|
+
}
|
|
100
|
+
prevDiff = diff
|
|
101
|
+
}
|
|
102
|
+
crossings.push({ early, late, age: crossAge === null ? null : Math.round(crossAge * 10) / 10 })
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { series, crossings, factors }
|
|
107
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mortality-weighted expected present value of a Social Security claiming
|
|
3
|
+
* strategy — the "benefits only" actuarial view: weight each future year's
|
|
4
|
+
* benefit by the probability of being
|
|
5
|
+
* alive to receive it, and discount at a real rate (benefits are CPI-indexed,
|
|
6
|
+
* so working in today's dollars with a real rate makes COLA and inflation
|
|
7
|
+
* cancel).
|
|
8
|
+
*
|
|
9
|
+
* Survival curve: the repo's SSA period table stores remaining-life-expectancy
|
|
10
|
+
* e(x), not death probabilities, so we recover one-year survival from the
|
|
11
|
+
* standard identity p(x) = l(x+1)/l(x) = (e(x) − 0.5) / (e(x+1) + 0.5)
|
|
12
|
+
* (half-year-of-life convention). This avoids transcribing a second large
|
|
13
|
+
* q(x) table and matches the SSA e(x) data already used by the longevity
|
|
14
|
+
* model. An optional multiplier scales e(x) to reflect a person's longevity
|
|
15
|
+
* questionnaire result (approximate).
|
|
16
|
+
*
|
|
17
|
+
* Simplifications: annual (not monthly) cashflows; claim months ignored
|
|
18
|
+
* (whole-year claim ages, matching the analysis grid); couples assume
|
|
19
|
+
* independent lifetimes; spousal top-up and survivor step-up mirror the
|
|
20
|
+
* projection engine. See DOCS/features/social-security.md.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { FEMALE, MALE } from '@retiregolden/engine/longevity/ssaPeriod2022'
|
|
24
|
+
import type { Sex } from '@retiregolden/engine/longevity/types'
|
|
25
|
+
import { claimFactor, spousalBenefitFactor, type ClaimAge } from '@retiregolden/engine/socialSecurity/claimFactor'
|
|
26
|
+
|
|
27
|
+
function table(sex: Sex): readonly number[] {
|
|
28
|
+
return sex === 'male' ? MALE : sex === 'female' ? FEMALE : MALE // 'average' handled in remainingYears
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Remaining life expectancy e(x) at integer age, optionally scaled by a longevity multiplier. */
|
|
32
|
+
function remainingYears(age: number, sex: Sex, multiplier: number): number {
|
|
33
|
+
const i = Math.max(0, Math.min(Math.round(age), MALE.length - 1))
|
|
34
|
+
const raw = sex === 'average' ? (MALE[i]! + FEMALE[i]!) / 2 : table(sex)[i]!
|
|
35
|
+
return raw * multiplier
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One-year survival probability p(age) = P(alive at age+1 | alive at age). */
|
|
39
|
+
function oneYearSurvival(age: number, sex: Sex, multiplier: number): number {
|
|
40
|
+
const ex = remainingYears(age, sex, multiplier)
|
|
41
|
+
const exNext = remainingYears(age + 1, sex, multiplier)
|
|
42
|
+
const p = (ex - 0.5) / (exNext + 0.5)
|
|
43
|
+
return Math.max(0, Math.min(1, p))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SurvivalCurve {
|
|
47
|
+
/** P(alive at integer `toAge` | alive at integer `fromAge`). 1 when toAge ≤ fromAge. */
|
|
48
|
+
survival(fromAge: number, toAge: number): number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function survivalCurve(sex: Sex, longevityMultiplier = 1): SurvivalCurve {
|
|
52
|
+
const m = longevityMultiplier > 0 ? longevityMultiplier : 1
|
|
53
|
+
// Precompute cumulative survival from age 0 so survival(a,b) = S(b)/S(a).
|
|
54
|
+
const cum: number[] = [1]
|
|
55
|
+
for (let age = 0; age < MALE.length; age++) {
|
|
56
|
+
cum.push(cum[age]! * oneYearSurvival(age, sex, m))
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
survival(fromAge: number, toAge: number): number {
|
|
60
|
+
if (toAge <= fromAge) return 1
|
|
61
|
+
const a = Math.max(0, Math.min(Math.round(fromAge), cum.length - 1))
|
|
62
|
+
const b = Math.max(0, Math.min(Math.round(toAge), cum.length - 1))
|
|
63
|
+
if (cum[a]! <= 0) return 0
|
|
64
|
+
return cum[b]! / cum[a]!
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ClaimantInput {
|
|
70
|
+
/** Whole age today (the present value's t=0 reference). */
|
|
71
|
+
currentAge: number
|
|
72
|
+
dob: { year: number; month: number; day: number }
|
|
73
|
+
sex: Sex
|
|
74
|
+
piaMonthly: number
|
|
75
|
+
claimAge: ClaimAge
|
|
76
|
+
/** Scales SSA remaining-years (e.g. from the longevity questionnaire); default 1. */
|
|
77
|
+
longevityMultiplier?: number
|
|
78
|
+
/**
|
|
79
|
+
* Optional monthly benefit floor the claimant receives instead of their own
|
|
80
|
+
* once claimed, if larger — e.g. a divorced-spousal benefit on an ex's record.
|
|
81
|
+
* Already reduced for the claim age by the caller.
|
|
82
|
+
*/
|
|
83
|
+
benefitFloorMonthly?: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ExpectedPvOptions {
|
|
87
|
+
/** Real annual discount rate, e.g. 0.02 for ~2% (≈ long TIPS yield). */
|
|
88
|
+
discountRate: number
|
|
89
|
+
/** Highest age to sum benefits through; defaults to the table's top. */
|
|
90
|
+
maxAge?: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Annual benefit (today's dollars) once claimed, ignoring claim months. */
|
|
94
|
+
function annualBenefit(c: ClaimantInput): number {
|
|
95
|
+
const factor = claimFactor(c.dob.year, c.dob.month, c.dob.day, { years: c.claimAge.years, months: 0 })
|
|
96
|
+
const own = c.piaMonthly * factor
|
|
97
|
+
return Math.max(own, c.benefitFloorMonthly ?? 0) * 12
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Expected PV of one person's own retirement benefit. */
|
|
101
|
+
export function expectedPvSingle(c: ClaimantInput, opts: ExpectedPvOptions): number {
|
|
102
|
+
const maxAge = opts.maxAge ?? MALE.length - 1
|
|
103
|
+
const curve = survivalCurve(c.sex, c.longevityMultiplier)
|
|
104
|
+
const benefit = annualBenefit(c)
|
|
105
|
+
const startAge = Math.max(c.currentAge, c.claimAge.years)
|
|
106
|
+
let pv = 0
|
|
107
|
+
for (let age = startAge; age <= maxAge; age++) {
|
|
108
|
+
const t = age - c.currentAge
|
|
109
|
+
const discount = Math.pow(1 + opts.discountRate, -t)
|
|
110
|
+
pv += curve.survival(c.currentAge, age) * benefit * discount
|
|
111
|
+
}
|
|
112
|
+
return pv
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Expected PV of a couple's combined benefits under a claiming strategy,
|
|
117
|
+
* including the spousal top-up (lower earner lifted to 50% of the higher PIA
|
|
118
|
+
* while both alive and both claimed) and the survivor step-up (survivor keeps
|
|
119
|
+
* the larger of the two benefits). Lifetimes assumed independent.
|
|
120
|
+
*/
|
|
121
|
+
export function expectedPvCouple(a: ClaimantInput, b: ClaimantInput, opts: ExpectedPvOptions): number {
|
|
122
|
+
const maxAge = opts.maxAge ?? MALE.length - 1
|
|
123
|
+
const curveA = survivalCurve(a.sex, a.longevityMultiplier)
|
|
124
|
+
const curveB = survivalCurve(b.sex, b.longevityMultiplier)
|
|
125
|
+
const benefitA = annualBenefit(a)
|
|
126
|
+
const benefitB = annualBenefit(b)
|
|
127
|
+
const lowerIsA = a.piaMonthly < b.piaMonthly
|
|
128
|
+
const higher = lowerIsA ? b : a
|
|
129
|
+
const lower = lowerIsA ? a : b
|
|
130
|
+
// Spousal benefit (50% of higher PIA) reduced for the lower earner's own claim age.
|
|
131
|
+
const spousalAnnual =
|
|
132
|
+
0.5 * higher.piaMonthly * spousalBenefitFactor(lower.dob.year, lower.dob.month, lower.dob.day, lower.claimAge) * 12
|
|
133
|
+
|
|
134
|
+
let pv = 0
|
|
135
|
+
// Step over calendar years from now until both spouses are past the table.
|
|
136
|
+
for (let t = 0; ; t++) {
|
|
137
|
+
const ageA = a.currentAge + t
|
|
138
|
+
const ageB = b.currentAge + t
|
|
139
|
+
if (ageA > maxAge && ageB > maxAge) break
|
|
140
|
+
const sA = curveA.survival(a.currentAge, ageA)
|
|
141
|
+
const sB = curveB.survival(b.currentAge, ageB)
|
|
142
|
+
|
|
143
|
+
const aClaimed = ageA >= a.claimAge.years
|
|
144
|
+
const bClaimed = ageB >= b.claimAge.years
|
|
145
|
+
const ownA = aClaimed ? benefitA : 0
|
|
146
|
+
const ownB = bClaimed ? benefitB : 0
|
|
147
|
+
|
|
148
|
+
// Both alive: spousal top-up lifts the lower earner once both have claimed.
|
|
149
|
+
let bothAlive = ownA + ownB
|
|
150
|
+
if (aClaimed && bClaimed) {
|
|
151
|
+
const lowerOwn = lowerIsA ? ownA : ownB
|
|
152
|
+
const otherOwn = lowerIsA ? ownB : ownA
|
|
153
|
+
bothAlive = otherOwn + Math.max(lowerOwn, spousalAnnual)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// One survivor: keeps the larger of the two claimed benefits.
|
|
157
|
+
const survivorBenefit = Math.max(ownA, ownB)
|
|
158
|
+
|
|
159
|
+
const expected =
|
|
160
|
+
sA * sB * bothAlive + sA * (1 - sB) * survivorBenefit + sB * (1 - sA) * survivorBenefit
|
|
161
|
+
pv += expected * Math.pow(1 + opts.discountRate, -t)
|
|
162
|
+
}
|
|
163
|
+
return pv
|
|
164
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social Security explainer helpers (V7 phase 1). Pure functions that turn the
|
|
3
|
+
* earnings-mode PIA computation into teaching points: which bend-point tier the
|
|
4
|
+
* next dollar lands in, how many zero years drag down the average, the marginal
|
|
5
|
+
* value of replacing a zero year, and a rough credits-of-coverage eligibility
|
|
6
|
+
* estimate. No engine behavior depends on these — they only feed copy.
|
|
7
|
+
*
|
|
8
|
+
* @see DOCS/features/social-security.md §3.1
|
|
9
|
+
* @see DOCS/features/social-security.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { PiaFromEarningsResult, YearEarning } from '@retiregolden/engine/socialSecurity/piaFromEarnings'
|
|
13
|
+
import { bendPointsForEligibilityYearOrLatest } from '@retiregolden/engine/socialSecurity/ssaWageData'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* SSA quarter-of-coverage ("credit") earnings amount — 2025 figure. One credit
|
|
17
|
+
* is earned per this much in covered wages, up to 4 per year. Used only to
|
|
18
|
+
* estimate eligibility for the warning; the user can override the credit count.
|
|
19
|
+
* @see https://www.ssa.gov/oact/cola/QC.html
|
|
20
|
+
*/
|
|
21
|
+
export const CREDIT_EARNINGS = 1_810
|
|
22
|
+
export const CREDITS_FOR_ELIGIBILITY = 40
|
|
23
|
+
|
|
24
|
+
export interface BendTier {
|
|
25
|
+
/** Replacement rate applied to the next dollar of AIME. */
|
|
26
|
+
label: '90%' | '32%' | '15%'
|
|
27
|
+
marginalRate: number
|
|
28
|
+
first: number
|
|
29
|
+
second: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Which bend-point tier the next dollar of AIME falls into for this eligibility year. */
|
|
33
|
+
export function bendTierForAime(aime: number, eligibilityYear: number): BendTier {
|
|
34
|
+
const bp = bendPointsForEligibilityYearOrLatest(eligibilityYear)
|
|
35
|
+
if (aime < bp.first) return { label: '90%', marginalRate: 0.9, first: bp.first, second: bp.second }
|
|
36
|
+
if (aime < bp.second) return { label: '32%', marginalRate: 0.32, first: bp.first, second: bp.second }
|
|
37
|
+
return { label: '15%', marginalRate: 0.15, first: bp.first, second: bp.second }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ComputationSummary {
|
|
41
|
+
/** Years averaged into AIME (the top 35, or fewer if the history is short). */
|
|
42
|
+
computationYearCount: number
|
|
43
|
+
/** How many of those averaged years are $0 — each is a "replace a zero year" opportunity. */
|
|
44
|
+
zeroYearsInAime: number
|
|
45
|
+
/** AIME divisor in months (12 × computationYearCount). */
|
|
46
|
+
divisorMonths: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function summarizeComputation(result: PiaFromEarningsResult): ComputationSummary {
|
|
50
|
+
const zeroYearsInAime = result.yearsUsedInAime.filter((v) => v === 0).length
|
|
51
|
+
return {
|
|
52
|
+
computationYearCount: result.computationYearCount,
|
|
53
|
+
zeroYearsInAime,
|
|
54
|
+
divisorMonths: 12 * result.computationYearCount,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Marginal monthly-PIA gain from replacing one $0 computation year with a year
|
|
60
|
+
* of `indexedAnnual` covered earnings: AIME rises by indexedAnnual / divisor,
|
|
61
|
+
* then the next-dollar bend rate applies. A rough teaching estimate, not the
|
|
62
|
+
* exact recomputation (which would re-sort the top 35).
|
|
63
|
+
*/
|
|
64
|
+
export function replaceZeroYearGain(result: PiaFromEarningsResult, indexedAnnual: number): number {
|
|
65
|
+
if (indexedAnnual <= 0 || result.computationYearCount <= 0) return 0
|
|
66
|
+
const aimeDelta = indexedAnnual / (12 * result.computationYearCount)
|
|
67
|
+
return aimeDelta * bendTierForAime(result.aime, result.eligibilityYear).marginalRate
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CreditEstimate {
|
|
71
|
+
credits: number
|
|
72
|
+
eligible: boolean
|
|
73
|
+
/** True when derived from earnings rather than a user-entered count. */
|
|
74
|
+
estimated: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Estimate covered-work credits (max 40). An explicit `override` wins; otherwise
|
|
79
|
+
* count up to 4 credits per year with covered earnings, using the current credit
|
|
80
|
+
* threshold (conservative for low-wage older years — the user can override).
|
|
81
|
+
*/
|
|
82
|
+
export function estimateCredits(earnings: YearEarning[], override: number | null | undefined): CreditEstimate {
|
|
83
|
+
if (override != null) {
|
|
84
|
+
return { credits: override, eligible: override >= CREDITS_FOR_ELIGIBILITY, estimated: false }
|
|
85
|
+
}
|
|
86
|
+
let credits = 0
|
|
87
|
+
for (const e of earnings) {
|
|
88
|
+
if (e.amount > 0) credits += Math.min(4, Math.floor(e.amount / CREDIT_EARNINGS))
|
|
89
|
+
}
|
|
90
|
+
credits = Math.min(CREDITS_FOR_ELIGIBILITY, credits)
|
|
91
|
+
return { credits, eligible: credits >= CREDITS_FOR_ELIGIBILITY, estimated: true }
|
|
92
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "What you paid in vs. what you get back" — a pure education/context readout
|
|
3
|
+
* for Social Security, NOT a working-years tax inside the projection (the engine
|
|
4
|
+
* never taxes pre-retirement wages; this is an analysis helper called from the SS
|
|
5
|
+
* analysis panel). Cited in DOCS/domain/domain-rules-reference.md §4.
|
|
6
|
+
*
|
|
7
|
+
* - **Paid in** = the OASDI (Social Security portion) payroll tax over the
|
|
8
|
+
* entered earnings history, capped at each year's taxable wage base. Employee
|
|
9
|
+
* share = 6.2%; self-employed = 12.4% (employee + employer combined). The 1.45%
|
|
10
|
+
* Medicare HI is intentionally excluded (the "get back" side is the SS
|
|
11
|
+
* benefit, not Medicare). The employer's 6.2% is returned as optional context.
|
|
12
|
+
* - **Get back** reuses the tested `expectedPvSingle` path (mortality-weighted
|
|
13
|
+
* expected PV at the chosen claim age), so the "return" is the survival-
|
|
14
|
+
* weighted PV of lifetime benefits in today's dollars.
|
|
15
|
+
*
|
|
16
|
+
* Documented simplifications: the OASDI rate is applied uniformly over the
|
|
17
|
+
* career (historical rates were slightly lower pre-1990); the self-employed
|
|
18
|
+
* half-deductibility of SE tax is ignored; the figure is an individual-level
|
|
19
|
+
* illustration, not the program's actuarial return, and excludes disability and
|
|
20
|
+
* survivor insurance value, spousal benefits, and the Medicare portion.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { WAGE_BASE_BY_YEAR } from '@retiregolden/engine/socialSecurity/ssaWageData'
|
|
24
|
+
|
|
25
|
+
export interface FicaPaidInResult {
|
|
26
|
+
/** Employee (or self-employed total) OASDI paid over the earnings history, today's $. */
|
|
27
|
+
paidIn: number
|
|
28
|
+
/** Employer-side OASDI (context only; 0 for self-employed since it's in paidIn). */
|
|
29
|
+
employerPaid: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FicaPaidInOptions {
|
|
33
|
+
/** Employee-side OASDI rate as a percent (e.g. 6.2). From the parameter pack. */
|
|
34
|
+
oasdiEmployeeRatePct: number
|
|
35
|
+
/** Self-employed ⇒ paidIn doubles (employee + employer combined = 12.4%). */
|
|
36
|
+
selfEmployed: boolean
|
|
37
|
+
/**
|
|
38
|
+
* Per-year OASDI taxable wage base. Defaults to the SSA historical series
|
|
39
|
+
* (`WAGE_BASE_BY_YEAR`); the caller may override (e.g., a grown future cap).
|
|
40
|
+
*/
|
|
41
|
+
wageBaseByYear?: Readonly<Record<number, number>>
|
|
42
|
+
/** Wage base for earnings years beyond the table (defaults to the latest known). */
|
|
43
|
+
wageBaseFallback?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Sum the OASDI payroll tax over an earnings history (employee or self-employed),
|
|
48
|
+
* capped at each year's taxable wage base. Today's dollars (nominal history; no
|
|
49
|
+
* indexing — this is "what you literally paid," not wage-indexed like AIME).
|
|
50
|
+
*/
|
|
51
|
+
export function ficaOasdiPaidIn(
|
|
52
|
+
earnings: ReadonlyArray<{ year: number; amount: number }>,
|
|
53
|
+
opts: FicaPaidInOptions,
|
|
54
|
+
): FicaPaidInResult {
|
|
55
|
+
const table = opts.wageBaseByYear ?? WAGE_BASE_BY_YEAR
|
|
56
|
+
const fallback = opts.wageBaseFallback ?? latestWageBase(table)
|
|
57
|
+
const rate = opts.oasdiEmployeeRatePct / 100
|
|
58
|
+
let employee = 0
|
|
59
|
+
let employer = 0
|
|
60
|
+
for (const { year, amount } of earnings) {
|
|
61
|
+
if (amount <= 0) continue
|
|
62
|
+
const cap = table[year] ?? fallback
|
|
63
|
+
const capped = cap > 0 ? Math.min(amount, cap) : amount
|
|
64
|
+
if (opts.selfEmployed) {
|
|
65
|
+
employee += capped * rate * 2 // 12.4% total (employee + employer)
|
|
66
|
+
} else {
|
|
67
|
+
employee += capped * rate // 6.2% employee
|
|
68
|
+
employer += capped * rate // 6.2% employer (context)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { paidIn: employee, employerPaid: opts.selfEmployed ? 0 : employer }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function latestWageBase(table: Readonly<Record<number, number>>): number {
|
|
75
|
+
let latest = 0
|
|
76
|
+
for (const y of Object.keys(table)) {
|
|
77
|
+
const year = Number(y)
|
|
78
|
+
if (Number.isFinite(year) && table[year]! > latest) latest = table[year]!
|
|
79
|
+
}
|
|
80
|
+
return latest
|
|
81
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { HouseholdMode, PiaSourceMode, QuickPiaKind, SsFormSnapshot, SsPersisted } from './storage'
|
|
2
|
+
|
|
3
|
+
function isRecord(x: unknown): x is Record<string, unknown> {
|
|
4
|
+
return typeof x === 'object' && x !== null && !Array.isArray(x)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function num(x: unknown): number | null {
|
|
8
|
+
return typeof x === 'number' && Number.isFinite(x) ? x : null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function str(x: unknown): string | null {
|
|
12
|
+
return typeof x === 'string' ? x : null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function oneOf<T extends string>(x: unknown, allowed: readonly T[]): T | undefined {
|
|
16
|
+
if (x === undefined) return undefined
|
|
17
|
+
return typeof x === 'string' && (allowed as readonly string[]).includes(x) ? (x as T) : undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function claimAgesList(x: unknown): number[] | null {
|
|
21
|
+
if (!Array.isArray(x)) return null
|
|
22
|
+
const out: number[] = []
|
|
23
|
+
for (const a of x) {
|
|
24
|
+
const n = num(a)
|
|
25
|
+
if (n == null || n < 62 || n > 70) return null
|
|
26
|
+
out.push(n)
|
|
27
|
+
}
|
|
28
|
+
return out.length >= 2 ? out : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function partnerClaimAgesList(x: unknown): number[] | null {
|
|
32
|
+
if (!Array.isArray(x)) return null
|
|
33
|
+
const out: number[] = []
|
|
34
|
+
for (const a of x) {
|
|
35
|
+
const n = num(a)
|
|
36
|
+
if (n == null || n < 62 || n > 70) return null
|
|
37
|
+
out.push(n)
|
|
38
|
+
}
|
|
39
|
+
return out.length >= 1 ? out : null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Returns null if JSON shape does not match v1 SS persisted form (schema drift / corruption). */
|
|
43
|
+
export function parseSsPersistedLoose(raw: unknown): SsPersisted | null {
|
|
44
|
+
if (!isRecord(raw)) return null
|
|
45
|
+
if (raw.version !== 1) return null
|
|
46
|
+
const formRaw = raw.form
|
|
47
|
+
if (!isRecord(formRaw)) return null
|
|
48
|
+
|
|
49
|
+
const dob = str(formRaw.dob)
|
|
50
|
+
const piaMonthly = num(formRaw.piaMonthly)
|
|
51
|
+
const claimAges = claimAgesList(formRaw.claimAges)
|
|
52
|
+
const endAge = num(formRaw.endAge)
|
|
53
|
+
const colaPercent = num(formRaw.colaPercent)
|
|
54
|
+
const discountPercent = num(formRaw.discountPercent)
|
|
55
|
+
if (!dob || piaMonthly == null || !claimAges || endAge == null || colaPercent == null || discountPercent == null) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
if (endAge < 63 || endAge > 110) return null
|
|
59
|
+
|
|
60
|
+
let partnerClaimAges: number[] | undefined
|
|
61
|
+
if (formRaw.partnerClaimAges !== undefined) {
|
|
62
|
+
const pa = partnerClaimAgesList(formRaw.partnerClaimAges)
|
|
63
|
+
if (pa == null) return null
|
|
64
|
+
partnerClaimAges = pa
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const householdMode = oneOf<HouseholdMode>(formRaw.householdMode, ['single', 'couple'])
|
|
68
|
+
const piaSource = oneOf<PiaSourceMode>(formRaw.piaSource, ['quick', 'earnings'])
|
|
69
|
+
const partnerPiaSource = oneOf<PiaSourceMode>(formRaw.partnerPiaSource, ['quick', 'earnings'])
|
|
70
|
+
const quickPiaKind = oneOf<QuickPiaKind>(formRaw.quickPiaKind, ['authoritative', 'ssa_estimate'])
|
|
71
|
+
const partnerQuickPiaKind = oneOf<QuickPiaKind>(formRaw.partnerQuickPiaKind, [
|
|
72
|
+
'authoritative',
|
|
73
|
+
'ssa_estimate',
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
const form: SsFormSnapshot = {
|
|
77
|
+
householdMode,
|
|
78
|
+
dob,
|
|
79
|
+
piaSource,
|
|
80
|
+
piaMonthly,
|
|
81
|
+
quickPiaKind,
|
|
82
|
+
ssaEstimateWorkThroughAge:
|
|
83
|
+
formRaw.ssaEstimateWorkThroughAge === undefined
|
|
84
|
+
? undefined
|
|
85
|
+
: formRaw.ssaEstimateWorkThroughAge === null
|
|
86
|
+
? null
|
|
87
|
+
: num(formRaw.ssaEstimateWorkThroughAge),
|
|
88
|
+
earningsPaste: typeof formRaw.earningsPaste === 'string' ? formRaw.earningsPaste : undefined,
|
|
89
|
+
lastEarningsYear:
|
|
90
|
+
formRaw.lastEarningsYear === undefined
|
|
91
|
+
? undefined
|
|
92
|
+
: formRaw.lastEarningsYear === null
|
|
93
|
+
? null
|
|
94
|
+
: num(formRaw.lastEarningsYear),
|
|
95
|
+
claimAges,
|
|
96
|
+
partnerDob: typeof formRaw.partnerDob === 'string' ? formRaw.partnerDob : undefined,
|
|
97
|
+
partnerPiaSource,
|
|
98
|
+
partnerPiaMonthly:
|
|
99
|
+
formRaw.partnerPiaMonthly === undefined ? undefined : num(formRaw.partnerPiaMonthly) ?? undefined,
|
|
100
|
+
partnerQuickPiaKind,
|
|
101
|
+
partnerSsaEstimateWorkThroughAge:
|
|
102
|
+
formRaw.partnerSsaEstimateWorkThroughAge === undefined
|
|
103
|
+
? undefined
|
|
104
|
+
: formRaw.partnerSsaEstimateWorkThroughAge === null
|
|
105
|
+
? null
|
|
106
|
+
: num(formRaw.partnerSsaEstimateWorkThroughAge),
|
|
107
|
+
partnerEarningsPaste:
|
|
108
|
+
typeof formRaw.partnerEarningsPaste === 'string' ? formRaw.partnerEarningsPaste : undefined,
|
|
109
|
+
partnerLastEarningsYear:
|
|
110
|
+
formRaw.partnerLastEarningsYear === undefined
|
|
111
|
+
? undefined
|
|
112
|
+
: formRaw.partnerLastEarningsYear === null
|
|
113
|
+
? null
|
|
114
|
+
: num(formRaw.partnerLastEarningsYear),
|
|
115
|
+
partnerClaimAges,
|
|
116
|
+
survivorOverlayEnabled:
|
|
117
|
+
typeof formRaw.survivorOverlayEnabled === 'boolean' ? formRaw.survivorOverlayEnabled : undefined,
|
|
118
|
+
survivorPrimaryDeathAge:
|
|
119
|
+
formRaw.survivorPrimaryDeathAge === undefined
|
|
120
|
+
? undefined
|
|
121
|
+
: formRaw.survivorPrimaryDeathAge === null
|
|
122
|
+
? null
|
|
123
|
+
: num(formRaw.survivorPrimaryDeathAge),
|
|
124
|
+
survivorPartnerDeathAge:
|
|
125
|
+
formRaw.survivorPartnerDeathAge === undefined
|
|
126
|
+
? undefined
|
|
127
|
+
: formRaw.survivorPartnerDeathAge === null
|
|
128
|
+
? null
|
|
129
|
+
: num(formRaw.survivorPartnerDeathAge),
|
|
130
|
+
endAge,
|
|
131
|
+
colaPercent,
|
|
132
|
+
discountPercent,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const updatedAt = str(raw.updatedAt)
|
|
136
|
+
if (!updatedAt) return null
|
|
137
|
+
return { version: 1, form, updatedAt }
|
|
138
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { loadLongevity } from '../longevity/storage'
|
|
2
|
+
import { loadSs, type SsFormSnapshot } from './storage'
|
|
3
|
+
|
|
4
|
+
export const CLAIM_OPTIONS = [62, 63, 64, 65, 66, 67, 68, 69, 70] as const
|
|
5
|
+
|
|
6
|
+
export function parseDob(iso: string): { y: number; m: number; d: number } | null {
|
|
7
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso.trim())
|
|
8
|
+
if (!m) return null
|
|
9
|
+
const y = Number(m[1])
|
|
10
|
+
const mo = Number(m[2])
|
|
11
|
+
const d = Number(m[3])
|
|
12
|
+
if (!y || mo < 1 || mo > 12 || d < 1 || d > 31) return null
|
|
13
|
+
return { y, m: mo, d }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const SS_CHART_LINE_COLORS = [
|
|
17
|
+
'#0d9488',
|
|
18
|
+
'#6366f1',
|
|
19
|
+
'#a855f7',
|
|
20
|
+
'#ea580c',
|
|
21
|
+
'#0891b2',
|
|
22
|
+
'#16a34a',
|
|
23
|
+
'#ca8a04',
|
|
24
|
+
'#dc2626',
|
|
25
|
+
'#db2777',
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
export function defaultEndAge(): number {
|
|
29
|
+
const L = loadLongevity()
|
|
30
|
+
if (L?.result?.illustrativePlanningAge) return L.result.illustrativePlanningAge
|
|
31
|
+
return 90
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function initialSsForm(): SsFormSnapshot {
|
|
35
|
+
const saved = loadSs()
|
|
36
|
+
if (saved?.form) return saved.form
|
|
37
|
+
return {
|
|
38
|
+
householdMode: 'single',
|
|
39
|
+
dob: '1962-06-15',
|
|
40
|
+
piaSource: 'quick',
|
|
41
|
+
quickPiaKind: 'authoritative',
|
|
42
|
+
piaMonthly: 3200,
|
|
43
|
+
claimAges: [62, 67, 70],
|
|
44
|
+
endAge: defaultEndAge(),
|
|
45
|
+
colaPercent: 0,
|
|
46
|
+
discountPercent: 0,
|
|
47
|
+
}
|
|
48
|
+
}
|