@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,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse mySSA "Online Social Security Statement" XML (OSSS schema 2.0).
|
|
3
|
+
* @see http://ssa.gov/osss/schemas/2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { YearEarning } from '@retiregolden/engine/socialSecurity/piaFromEarnings'
|
|
7
|
+
|
|
8
|
+
export const OSSS_NS = 'http://ssa.gov/osss/schemas/2.0'
|
|
9
|
+
|
|
10
|
+
export type SsaStatementXmlOk = {
|
|
11
|
+
ok: true
|
|
12
|
+
dob: string
|
|
13
|
+
rows: YearEarning[]
|
|
14
|
+
/** SSA statement name field (PII); optional display only. */
|
|
15
|
+
statementName?: string
|
|
16
|
+
/** XML FileCreationDate text if present; display only. */
|
|
17
|
+
fileCreationDate?: string
|
|
18
|
+
/** When endYear > startYear on an Earnings element, only startYear was applied. */
|
|
19
|
+
yearRangeWarnings: string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type SsaStatementXmlFail = {
|
|
23
|
+
ok: false
|
|
24
|
+
message: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type SsaStatementXmlResult = SsaStatementXmlOk | SsaStatementXmlFail
|
|
28
|
+
|
|
29
|
+
function textContentNs(parent: Element, localName: string): string {
|
|
30
|
+
const NS = OSSS_NS
|
|
31
|
+
const kids = parent.getElementsByTagNameNS(NS, localName)
|
|
32
|
+
return kids[0]?.textContent?.trim() ?? ''
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseNonNegInt(s: string | null | undefined): number | null {
|
|
36
|
+
if (s == null || s === '') return null
|
|
37
|
+
const n = Number.parseInt(String(s).trim(), 10)
|
|
38
|
+
if (!Number.isFinite(n) || n < 0) return null
|
|
39
|
+
return n
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseNonNegAmount(s: string | null | undefined): number {
|
|
43
|
+
if (s == null || s === '') return 0
|
|
44
|
+
const n = Number.parseFloat(String(s).trim())
|
|
45
|
+
if (!Number.isFinite(n) || n < 0) return 0
|
|
46
|
+
return n
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isValidDobIso(s: string): boolean {
|
|
50
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s.trim())
|
|
51
|
+
if (!m) return false
|
|
52
|
+
const y = Number(m[1])
|
|
53
|
+
const mo = Number(m[2])
|
|
54
|
+
const d = Number(m[3])
|
|
55
|
+
return !!(y && mo >= 1 && mo <= 12 && d >= 1 && d <= 31)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Serialize earnings rows for `earningsPaste` / `parseEarningsLines`.
|
|
60
|
+
*/
|
|
61
|
+
export function yearEarningsToPaste(rows: YearEarning[]): string {
|
|
62
|
+
const sorted = [...rows].sort((a, b) => a.year - b.year)
|
|
63
|
+
return sorted.map((r) => `${r.year} ${r.amount}`).join('\n')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse OSSS 2.0 statement XML (browser or Vitest jsdom).
|
|
68
|
+
*/
|
|
69
|
+
export function parseSsaStatementXml(xmlText: string): SsaStatementXmlResult {
|
|
70
|
+
if (typeof DOMParser === 'undefined') {
|
|
71
|
+
return { ok: false, message: 'XML parsing is not available in this environment.' }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const doc = new DOMParser().parseFromString(xmlText, 'application/xml')
|
|
75
|
+
const root = doc.documentElement
|
|
76
|
+
if (!root) {
|
|
77
|
+
return { ok: false, message: 'Empty or invalid XML document.' }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const pe = doc.getElementsByTagName('parsererror')
|
|
81
|
+
if (pe.length > 0) {
|
|
82
|
+
const hint = pe[0]?.textContent?.trim().slice(0, 200)
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
message: hint ? `XML parse error: ${hint}` : 'XML parse error.',
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (root.namespaceURI !== OSSS_NS || root.localName !== 'OnlineSocialSecurityStatementData') {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
message:
|
|
93
|
+
'Not a recognized SSA online statement export (expected OnlineSocialSecurityStatementData in the official SSA namespace).',
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const dobRaw = textContentNs(root, 'DateOfBirth')
|
|
98
|
+
if (!isValidDobIso(dobRaw)) {
|
|
99
|
+
return { ok: false, message: 'Statement XML is missing a valid DateOfBirth (YYYY-MM-DD).' }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const nameRaw = textContentNs(root, 'Name')
|
|
103
|
+
const fileDateRaw = textContentNs(root, 'FileCreationDate')
|
|
104
|
+
|
|
105
|
+
const yearRangeWarnings: string[] = []
|
|
106
|
+
const byYear = new Map<number, number>()
|
|
107
|
+
|
|
108
|
+
const earningsNodes = root.getElementsByTagNameNS(OSSS_NS, 'Earnings')
|
|
109
|
+
for (let i = 0; i < earningsNodes.length; i++) {
|
|
110
|
+
const el = earningsNodes[i]!
|
|
111
|
+
const startY = parseNonNegInt(el.getAttribute('startYear'))
|
|
112
|
+
if (startY === null || startY < 1951 || startY > 2100) continue
|
|
113
|
+
|
|
114
|
+
const endYRaw = el.getAttribute('endYear')
|
|
115
|
+
const endY = endYRaw != null && endYRaw !== '' ? parseNonNegInt(endYRaw) : startY
|
|
116
|
+
const end = endY === null ? startY : endY
|
|
117
|
+
|
|
118
|
+
if (end > startY) {
|
|
119
|
+
yearRangeWarnings.push(
|
|
120
|
+
`Earnings element with startYear=${startY} and endYear=${end}: using start year only (same as SSA row granularity in exports).`,
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const fica = parseNonNegAmount(textContentNs(el, 'FicaEarnings'))
|
|
125
|
+
const y = startY
|
|
126
|
+
const prev = byYear.get(y) ?? 0
|
|
127
|
+
byYear.set(y, prev + fica)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const rows: YearEarning[] = []
|
|
131
|
+
for (const [year, amount] of byYear) {
|
|
132
|
+
rows.push({ year, amount })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
ok: true,
|
|
137
|
+
dob: dobRaw.trim(),
|
|
138
|
+
rows,
|
|
139
|
+
statementName: nameRaw || undefined,
|
|
140
|
+
fileCreationDate: fileDateRaw || undefined,
|
|
141
|
+
yearRangeWarnings,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Latest year with positive FICA earnings; if none, latest year in rows. */
|
|
146
|
+
export function defaultLastEarningsYearFromRows(rows: YearEarning[]): number | null {
|
|
147
|
+
if (rows.length === 0) return null
|
|
148
|
+
let maxAny = rows[0]!.year
|
|
149
|
+
let maxPositive = -Infinity
|
|
150
|
+
for (const r of rows) {
|
|
151
|
+
if (r.year > maxAny) maxAny = r.year
|
|
152
|
+
if (r.amount > 0 && r.year > maxPositive) maxPositive = r.year
|
|
153
|
+
}
|
|
154
|
+
if (Number.isFinite(maxPositive)) return maxPositive
|
|
155
|
+
return maxAny
|
|
156
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readLocal, STORAGE_KEYS, writeLocal } from '../data/localStore'
|
|
2
|
+
import { parseSsPersistedLoose } from './persistedSsGuard'
|
|
3
|
+
|
|
4
|
+
export const SS_STORAGE_KEY = STORAGE_KEYS.socialSecurityForm
|
|
5
|
+
|
|
6
|
+
export type PiaSourceMode = 'quick' | 'earnings'
|
|
7
|
+
|
|
8
|
+
/** Household scope for Social Security workbench (M6). */
|
|
9
|
+
export type HouseholdMode = 'single' | 'couple'
|
|
10
|
+
|
|
11
|
+
/** How the quick-mode PIA number should be interpreted (for disclosure only). */
|
|
12
|
+
export type QuickPiaKind = 'authoritative' | 'ssa_estimate'
|
|
13
|
+
|
|
14
|
+
export interface SsFormSnapshot {
|
|
15
|
+
/** When `couple`, partner fields apply and analysis uses household strategies. */
|
|
16
|
+
householdMode?: HouseholdMode
|
|
17
|
+
dob: string
|
|
18
|
+
/** Default `quick`: enter PIA manually. `earnings`: derive PIA from pasted earnings. */
|
|
19
|
+
piaSource?: PiaSourceMode
|
|
20
|
+
piaMonthly: number
|
|
21
|
+
/** Meaning of the quick-mode PIA figure; optional for backward compatibility. */
|
|
22
|
+
quickPiaKind?: QuickPiaKind
|
|
23
|
+
/** When `quickPiaKind` is `ssa_estimate`, optional age through which SSA assumed work. */
|
|
24
|
+
ssaEstimateWorkThroughAge?: number | null
|
|
25
|
+
/** Lines of `year amount` for earnings mode; see `parseEarningsLines`. */
|
|
26
|
+
earningsPaste?: string
|
|
27
|
+
/** Last calendar year with covered earnings (earnings mode); omit to use last base year. */
|
|
28
|
+
lastEarningsYear?: number | null
|
|
29
|
+
claimAges: number[]
|
|
30
|
+
/** Partner (second earner) — used when `householdMode === 'couple'`. */
|
|
31
|
+
partnerDob?: string
|
|
32
|
+
partnerPiaSource?: PiaSourceMode
|
|
33
|
+
partnerPiaMonthly?: number
|
|
34
|
+
partnerQuickPiaKind?: QuickPiaKind
|
|
35
|
+
partnerSsaEstimateWorkThroughAge?: number | null
|
|
36
|
+
partnerEarningsPaste?: string
|
|
37
|
+
partnerLastEarningsYear?: number | null
|
|
38
|
+
partnerClaimAges?: number[]
|
|
39
|
+
/** When true, household cumulative uses illustrative death ages (see below). */
|
|
40
|
+
survivorOverlayEnabled?: boolean
|
|
41
|
+
/** Completed age through which primary receives benefits (inclusive). */
|
|
42
|
+
survivorPrimaryDeathAge?: number | null
|
|
43
|
+
/** Completed age through which partner receives benefits (inclusive). */
|
|
44
|
+
survivorPartnerDeathAge?: number | null
|
|
45
|
+
endAge: number
|
|
46
|
+
colaPercent: number
|
|
47
|
+
discountPercent: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SsPersisted {
|
|
51
|
+
version: 1
|
|
52
|
+
form: SsFormSnapshot
|
|
53
|
+
updatedAt: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function loadSs(): SsPersisted | null {
|
|
57
|
+
try {
|
|
58
|
+
const raw = readLocal(SS_STORAGE_KEY)
|
|
59
|
+
if (!raw) return null
|
|
60
|
+
return parseSsPersistedLoose(JSON.parse(raw) as unknown)
|
|
61
|
+
} catch {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function saveSs(form: SsFormSnapshot): void {
|
|
67
|
+
const data: SsPersisted = { version: 1, form, updatedAt: new Date().toISOString() }
|
|
68
|
+
writeLocal(SS_STORAGE_KEY, JSON.stringify(data))
|
|
69
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Survivor ↔ personal switching (V7 phase 3). A widow(er) can hold two benefits
|
|
3
|
+
* on different records and switch between them: take the survivor benefit early
|
|
4
|
+
* and let their own retirement benefit grow to 70, or take their own early and
|
|
5
|
+
* switch to survivor later. Only one benefit is paid at a time (the larger of
|
|
6
|
+
* those claimed), so the value is in the *sequencing*.
|
|
7
|
+
*
|
|
8
|
+
* Why it matters: survivor benefits earn no delayed credits (they top out at the
|
|
9
|
+
* survivor's FRA), while own benefits grow ~8%/yr to 70. So a common pattern is
|
|
10
|
+
* survivor-first then own-at-70 when the deceased's benefit is the smaller of the
|
|
11
|
+
* two, or own-first then survivor when own is smaller.
|
|
12
|
+
*
|
|
13
|
+
* Survivor precision (cited in DOCS/domain/domain-rules-reference.md §4): the
|
|
14
|
+
* early-claim widow(er) reduction (up to 28.5% at 60, measured against the
|
|
15
|
+
* **survivor** FRA — a separate, earlier schedule than the worker FRA) and the
|
|
16
|
+
* **RIB-LIM / widow's-limit** cap (`max(deceased's actual benefit, 82.5% × PIA)`)
|
|
17
|
+
* are computed by the shared `survivorBenefitMonthly` helper, identical to the
|
|
18
|
+
* projection ledger so the PV view and the ledger can't drift. Illustrative, not
|
|
19
|
+
* a filing tool.
|
|
20
|
+
*
|
|
21
|
+
* @see DOCS/features/social-security.md
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { claimFactor } from '@retiregolden/engine/socialSecurity/claimFactor'
|
|
25
|
+
import { survivalCurve } from './expectedPv'
|
|
26
|
+
import type { Sex } from '@retiregolden/engine/longevity/types'
|
|
27
|
+
import { effectiveBirthYear, fraForBirthYear, fraTotalMonths, survivorFraForBirthYear } from '@retiregolden/engine/socialSecurity/nra'
|
|
28
|
+
import { survivorBenefitMonthly, survivorReductionFactor, SURVIVOR_EARLIEST_AGE } from '@retiregolden/engine/socialSecurity/survivorBenefit'
|
|
29
|
+
|
|
30
|
+
// Re-exported for back-compat (callers that previously imported them from here).
|
|
31
|
+
export { survivorReductionFactor, SURVIVOR_EARLIEST_AGE }
|
|
32
|
+
|
|
33
|
+
export interface SwitchingInput {
|
|
34
|
+
dob: { year: number; month: number; day: number }
|
|
35
|
+
sex: Sex
|
|
36
|
+
/** Whole age today (PV reference, t=0). */
|
|
37
|
+
currentAge: number
|
|
38
|
+
/** The claimant's own monthly PIA. */
|
|
39
|
+
ownPiaMonthly: number
|
|
40
|
+
/**
|
|
41
|
+
* The deceased spouse's actual monthly benefit (claim-age-adjusted, including
|
|
42
|
+
* delayed credits) — the survivor base before RIB-LIM.
|
|
43
|
+
*/
|
|
44
|
+
survivorMonthly: number
|
|
45
|
+
/**
|
|
46
|
+
* The deceased's monthly PIA. When omitted, defaults to `survivorMonthly`
|
|
47
|
+
* (i.e. the deceased claimed at FRA: actual = PIA), so the RIB-LIM floor
|
|
48
|
+
* (82.5% × PIA) never binds and behavior matches the pre-precision model.
|
|
49
|
+
* Provide it separately when the deceased claimed early (actual < PIA) so the
|
|
50
|
+
* widow's-limit cap is applied.
|
|
51
|
+
*/
|
|
52
|
+
deceasedPiaMonthly?: number
|
|
53
|
+
/** Scales SSA remaining-years (longevity questionnaire); default 1. */
|
|
54
|
+
longevityMultiplier?: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SwitchStrategy {
|
|
58
|
+
/** Age the survivor benefit starts (≥60), or null to never take survivor. */
|
|
59
|
+
survivorClaimAge: number | null
|
|
60
|
+
/** Age the own retirement benefit starts (62–70), or null to never take own. */
|
|
61
|
+
ownClaimAge: number | null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SwitchResult {
|
|
65
|
+
strategy: SwitchStrategy
|
|
66
|
+
expectedPv: number
|
|
67
|
+
/** Plain-language description, e.g. "Survivor at 60, switch to own at 70". */
|
|
68
|
+
label: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function strategyLabel(s: SwitchStrategy): string {
|
|
72
|
+
if (s.survivorClaimAge !== null && s.ownClaimAge !== null) {
|
|
73
|
+
if (s.survivorClaimAge <= s.ownClaimAge) return `Survivor at ${s.survivorClaimAge}, switch to own at ${s.ownClaimAge}`
|
|
74
|
+
return `Own at ${s.ownClaimAge}, switch to survivor at ${s.survivorClaimAge}`
|
|
75
|
+
}
|
|
76
|
+
if (s.survivorClaimAge !== null) return `Survivor only, at ${s.survivorClaimAge}`
|
|
77
|
+
if (s.ownClaimAge !== null) return `Own only, at ${s.ownClaimAge}`
|
|
78
|
+
return 'Claim nothing'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface SwitchingOptions {
|
|
82
|
+
discountRate: number
|
|
83
|
+
maxAge?: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Expected PV of a single switching strategy: survival-weighted, discounted to today. */
|
|
87
|
+
export function expectedPvSwitch(input: SwitchingInput, strategy: SwitchStrategy, opts: SwitchingOptions): number {
|
|
88
|
+
const maxAge = opts.maxAge ?? 119
|
|
89
|
+
const curve = survivalCurve(input.sex, input.longevityMultiplier)
|
|
90
|
+
const survivorFraMonths = fraTotalMonths(
|
|
91
|
+
survivorFraForBirthYear(effectiveBirthYear(input.dob.year, input.dob.month, input.dob.day)),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
const deceasedPia = input.deceasedPiaMonthly ?? input.survivorMonthly
|
|
95
|
+
const survivorAnnual =
|
|
96
|
+
strategy.survivorClaimAge !== null
|
|
97
|
+
? survivorBenefitMonthly({
|
|
98
|
+
deceasedPiaMonthly: deceasedPia,
|
|
99
|
+
deceasedActualMonthly: input.survivorMonthly,
|
|
100
|
+
survivorClaimAge: { years: strategy.survivorClaimAge, months: 0 },
|
|
101
|
+
survivorFraMonths,
|
|
102
|
+
}) * 12
|
|
103
|
+
: 0
|
|
104
|
+
const ownAnnual =
|
|
105
|
+
strategy.ownClaimAge !== null
|
|
106
|
+
? input.ownPiaMonthly * 12 * claimFactor(input.dob.year, input.dob.month, input.dob.day, { years: strategy.ownClaimAge, months: 0 })
|
|
107
|
+
: 0
|
|
108
|
+
|
|
109
|
+
let pv = 0
|
|
110
|
+
for (let age = input.currentAge; age <= maxAge; age++) {
|
|
111
|
+
const survivorOn = strategy.survivorClaimAge !== null && age >= strategy.survivorClaimAge
|
|
112
|
+
const ownOn = strategy.ownClaimAge !== null && age >= strategy.ownClaimAge
|
|
113
|
+
const benefit = Math.max(survivorOn ? survivorAnnual : 0, ownOn ? ownAnnual : 0)
|
|
114
|
+
if (benefit > 0) {
|
|
115
|
+
const t = age - input.currentAge
|
|
116
|
+
pv += curve.survival(input.currentAge, age) * benefit * Math.pow(1 + opts.discountRate, -t)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return pv
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Rank candidate switching strategies by expected PV (descending). Sweeps
|
|
124
|
+
* survivor claim ages 60→FRA and own claim ages 62→70, plus the single-benefit
|
|
125
|
+
* options, never claiming before the claimant's current age.
|
|
126
|
+
*/
|
|
127
|
+
export function rankSwitchStrategies(input: SwitchingInput, opts: SwitchingOptions): SwitchResult[] {
|
|
128
|
+
const effY = effectiveBirthYear(input.dob.year, input.dob.month, input.dob.day)
|
|
129
|
+
const survivorFraYears = survivorFraForBirthYear(effY).years
|
|
130
|
+
const ownFraYears = fraForBirthYear(effY).years
|
|
131
|
+
// Always include "claim now" so users already past the earliest age can still
|
|
132
|
+
// take a benefit immediately (e.g. a 63-year-old widow bridging to own at 70).
|
|
133
|
+
const survivorNow = Math.max(input.currentAge, SURVIVOR_EARLIEST_AGE)
|
|
134
|
+
const survivorAges = [...new Set([survivorNow, Math.max(survivorNow, survivorFraYears)])]
|
|
135
|
+
const ownNow = Math.min(70, Math.max(input.currentAge, 62))
|
|
136
|
+
const ownAges = [...new Set([ownNow, ownFraYears, 70].filter((a) => a >= ownNow && a <= 70))]
|
|
137
|
+
|
|
138
|
+
const strategies: SwitchStrategy[] = []
|
|
139
|
+
for (const s of survivorAges) for (const o of ownAges) strategies.push({ survivorClaimAge: s, ownClaimAge: o })
|
|
140
|
+
for (const s of survivorAges) strategies.push({ survivorClaimAge: s, ownClaimAge: null })
|
|
141
|
+
for (const o of ownAges) strategies.push({ survivorClaimAge: null, ownClaimAge: o })
|
|
142
|
+
|
|
143
|
+
// De-dupe identical (survivor/own age) pairs that survive the filters.
|
|
144
|
+
const seen = new Set<string>()
|
|
145
|
+
const results: SwitchResult[] = []
|
|
146
|
+
for (const strategy of strategies) {
|
|
147
|
+
const key = `${strategy.survivorClaimAge ?? 'x'}-${strategy.ownClaimAge ?? 'x'}`
|
|
148
|
+
if (seen.has(key)) continue
|
|
149
|
+
seen.add(key)
|
|
150
|
+
results.push({ strategy, expectedPv: expectedPvSwitch(input, strategy, opts), label: strategyLabel(strategy) })
|
|
151
|
+
}
|
|
152
|
+
return results.sort((a, b) => b.expectedPv - a.expectedPv)
|
|
153
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic Web Worker request/response runner shared by the Monte Carlo pool
|
|
3
|
+
* (src/mc/pool.ts) and the optimizer/spending solvers (src/optimize/). Spawns
|
|
4
|
+
* a worker, posts one request, resolves on the interpreted done-message,
|
|
5
|
+
* rejects on an error message or worker error, and always terminates the
|
|
6
|
+
* worker once settled.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type WorkerMessageOutcome<TResult> =
|
|
10
|
+
| { kind: 'done'; result: TResult }
|
|
11
|
+
| { kind: 'error'; message: string }
|
|
12
|
+
/** Keep listening (e.g. progress messages). */
|
|
13
|
+
| { kind: 'progress' }
|
|
14
|
+
|
|
15
|
+
export function runWorkerRequest<TReq, TMsg, TResult>(options: {
|
|
16
|
+
request: TReq
|
|
17
|
+
/**
|
|
18
|
+
* Call sites keep the literal `new Worker(new URL('./x.worker.ts',
|
|
19
|
+
* import.meta.url), ...)` so the bundler can still see and split the chunk.
|
|
20
|
+
*/
|
|
21
|
+
createWorker: () => Worker
|
|
22
|
+
interpret: (msg: TMsg) => WorkerMessageOutcome<TResult>
|
|
23
|
+
/** Fallback error text when the worker fails without a message. */
|
|
24
|
+
errorLabel: string
|
|
25
|
+
/** Observe the spawned worker (e.g. so a pool can terminate siblings). */
|
|
26
|
+
onSpawn?: (worker: Worker) => void
|
|
27
|
+
}): Promise<TResult> {
|
|
28
|
+
const { request, createWorker, interpret, errorLabel, onSpawn } = options
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const worker = createWorker()
|
|
31
|
+
onSpawn?.(worker)
|
|
32
|
+
worker.onmessage = (event: MessageEvent<TMsg>) => {
|
|
33
|
+
const outcome = interpret(event.data)
|
|
34
|
+
if (outcome.kind === 'progress') return
|
|
35
|
+
worker.terminate()
|
|
36
|
+
if (outcome.kind === 'done') resolve(outcome.result)
|
|
37
|
+
else reject(new Error(outcome.message))
|
|
38
|
+
}
|
|
39
|
+
worker.onerror = (event) => {
|
|
40
|
+
worker.terminate()
|
|
41
|
+
reject(new Error(event.message || errorLabel))
|
|
42
|
+
}
|
|
43
|
+
worker.postMessage(request)
|
|
44
|
+
})
|
|
45
|
+
}
|