@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.
Files changed (298) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +181 -0
  3. package/package.json +77 -0
  4. package/src/App.tsx +246 -0
  5. package/src/RouteErrorBoundary.tsx +45 -0
  6. package/src/assets/hero.png +0 -0
  7. package/src/assets/react.svg +1 -0
  8. package/src/assets/vite.svg +1 -0
  9. package/src/data/fedInvestClient.ts +113 -0
  10. package/src/data/localStore.ts +42 -0
  11. package/src/data/planOrigin.ts +24 -0
  12. package/src/data/planStore.ts +165 -0
  13. package/src/data/v2Backup.ts +101 -0
  14. package/src/import/ImportPage.tsx +347 -0
  15. package/src/import/ReviewChecklistView.tsx +38 -0
  16. package/src/import/brokerCsv.ts +395 -0
  17. package/src/import/csv.ts +133 -0
  18. package/src/import/genericCsv.ts +224 -0
  19. package/src/import/projectionLab.ts +350 -0
  20. package/src/import/reviewChecklist.ts +33 -0
  21. package/src/import/tenForty.ts +275 -0
  22. package/src/index.css +630 -0
  23. package/src/index.ts +16 -0
  24. package/src/learn/ArticleBody.tsx +78 -0
  25. package/src/learn/ArticlePage.tsx +57 -0
  26. package/src/learn/GlossaryPage.tsx +33 -0
  27. package/src/learn/LearnAboutScreen.tsx +41 -0
  28. package/src/learn/LearnCards.tsx +41 -0
  29. package/src/learn/LearnLink.tsx +91 -0
  30. package/src/learn/LearningCenterPage.tsx +114 -0
  31. package/src/learn/SourcesPage.tsx +98 -0
  32. package/src/learn/components/ArticleFigure.tsx +34 -0
  33. package/src/learn/components/ArticleShell.tsx +86 -0
  34. package/src/learn/components/ComparisonTable.tsx +42 -0
  35. package/src/learn/components/FormulaBlock.tsx +34 -0
  36. package/src/learn/components/PurchasingPowerChart.tsx +41 -0
  37. package/src/learn/components/RelatedArticles.tsx +27 -0
  38. package/src/learn/components/ScenarioCard.tsx +24 -0
  39. package/src/learn/components/SourceList.tsx +23 -0
  40. package/src/learn/components/charts.tsx +21 -0
  41. package/src/learn/content/about-retiregolden.ts +100 -0
  42. package/src/learn/content/aca-premium-tax-credits-and-magi.ts +103 -0
  43. package/src/learn/content/account-types-overview.ts +106 -0
  44. package/src/learn/content/after-tax-estate.ts +111 -0
  45. package/src/learn/content/agi-magi-and-taxable-income.ts +112 -0
  46. package/src/learn/content/appealing-irmaa-ssa-44.ts +95 -0
  47. package/src/learn/content/assumption-general-inflation.ts +82 -0
  48. package/src/learn/content/assumption-healthcare-inflation.ts +85 -0
  49. package/src/learn/content/assumption-heir-tax-rate.ts +79 -0
  50. package/src/learn/content/assumption-investment-returns.ts +90 -0
  51. package/src/learn/content/assumption-longevity-planning-age.ts +78 -0
  52. package/src/learn/content/assumption-recent-magi.ts +83 -0
  53. package/src/learn/content/assumption-social-security-cola.ts +89 -0
  54. package/src/learn/content/assumption-social-security-trust-fund.ts +83 -0
  55. package/src/learn/content/assumption-state-tax-override.ts +79 -0
  56. package/src/learn/content/beneficiaries-and-account-titling.ts +99 -0
  57. package/src/learn/content/break-even-useful-lens.ts +94 -0
  58. package/src/learn/content/building-a-retirement-spending-budget.ts +100 -0
  59. package/src/learn/content/cola-and-inflation-protection.ts +102 -0
  60. package/src/learn/content/divorced-spousal-and-survivor-records.ts +104 -0
  61. package/src/learn/content/dynamic-spending-guardrails.ts +90 -0
  62. package/src/learn/content/earnings-test-before-fra.ts +100 -0
  63. package/src/learn/content/employer-match-and-contribution-order.ts +104 -0
  64. package/src/learn/content/examplePlanArticles.ts +525 -0
  65. package/src/learn/content/fees-expense-ratios-and-compounding-drag.ts +98 -0
  66. package/src/learn/content/fi-number-and-four-percent-rule.ts +64 -0
  67. package/src/learn/content/filling-a-tax-bracket-with-roth-conversions.ts +98 -0
  68. package/src/learn/content/funded-ratio.ts +70 -0
  69. package/src/learn/content/healthcare-after-65.ts +103 -0
  70. package/src/learn/content/healthcare-before-65.ts +104 -0
  71. package/src/learn/content/historical-vs-random-return-models.ts +101 -0
  72. package/src/learn/content/how-assumptions-change-the-answer.ts +105 -0
  73. package/src/learn/content/how-much-can-i-spend.ts +105 -0
  74. package/src/learn/content/how-social-security-is-taxed.ts +95 -0
  75. package/src/learn/content/how-the-optimizer-thinks.ts +102 -0
  76. package/src/learn/content/how-the-optimizer-values-after-tax-estate.ts +97 -0
  77. package/src/learn/content/how-to-model-accumulation.ts +67 -0
  78. package/src/learn/content/how-to-read-a-retirement-projection.ts +115 -0
  79. package/src/learn/content/hsas-and-qualified-medical-expenses.ts +108 -0
  80. package/src/learn/content/hsas-as-retirement-accounts.ts +101 -0
  81. package/src/learn/content/inflation-risk.ts +98 -0
  82. package/src/learn/content/inherited-ira-10-year-rule.ts +105 -0
  83. package/src/learn/content/insurance-in-your-retirement-plan.ts +103 -0
  84. package/src/learn/content/irmaa-two-year-lookback.ts +99 -0
  85. package/src/learn/content/long-term-care-costs-and-insurance.ts +103 -0
  86. package/src/learn/content/long-term-care-insurance-as-risk-transfer.ts +98 -0
  87. package/src/learn/content/longevity-risk.ts +99 -0
  88. package/src/learn/content/marginal-vs-effective-tax-rate.ts +98 -0
  89. package/src/learn/content/medicare-part-b-vs-part-d-irmaa.ts +102 -0
  90. package/src/learn/content/mortality-weighted-social-security.ts +113 -0
  91. package/src/learn/content/moving-to-retiregolden.ts +86 -0
  92. package/src/learn/content/niit-high-income-investment-tax.ts +98 -0
  93. package/src/learn/content/ordinary-income-vs-capital-gains.ts +103 -0
  94. package/src/learn/content/paying-conversion-taxes-taxable-vs-ira.ts +102 -0
  95. package/src/learn/content/pensions-and-annuities.ts +101 -0
  96. package/src/learn/content/permanent-life-insurance-in-a-plan.ts +106 -0
  97. package/src/learn/content/pia-aime-and-bend-points.ts +103 -0
  98. package/src/learn/content/planner-overview.ts +106 -0
  99. package/src/learn/content/planning-for-couples-and-survivor-years.ts +108 -0
  100. package/src/learn/content/privacy-what-stays-in-your-browser.ts +99 -0
  101. package/src/learn/content/qcds-qualified-charitable-distributions.ts +101 -0
  102. package/src/learn/content/reading-the-results-page.ts +96 -0
  103. package/src/learn/content/reading-the-social-security-analysis-page.ts +106 -0
  104. package/src/learn/content/real-estate-home-equity-and-debt.ts +100 -0
  105. package/src/learn/content/reports-csv-exports-and-sharing.ts +101 -0
  106. package/src/learn/content/risk-based-guardrails.ts +100 -0
  107. package/src/learn/content/rmds-required-minimum-distributions.ts +100 -0
  108. package/src/learn/content/roth-conversion-basics.ts +104 -0
  109. package/src/learn/content/rsus-and-espp.ts +101 -0
  110. package/src/learn/content/rule-of-55-and-72t.ts +107 -0
  111. package/src/learn/content/savings-rate-biggest-lever.ts +66 -0
  112. package/src/learn/content/seed-your-plan-from-your-tax-return.ts +93 -0
  113. package/src/learn/content/sensitivity-testing-what-changes-the-answer.ts +104 -0
  114. package/src/learn/content/sequence-of-returns-risk.ts +98 -0
  115. package/src/learn/content/social-security-bridge.ts +67 -0
  116. package/src/learn/content/social-security-claiming-age-basics.ts +113 -0
  117. package/src/learn/content/social-security-taxes-vs-benefits.ts +76 -0
  118. package/src/learn/content/spending-profiles-and-the-retirement-smile.ts +92 -0
  119. package/src/learn/content/spousal-and-survivor-benefits.ts +120 -0
  120. package/src/learn/content/ssdi-and-retirement-planning.ts +72 -0
  121. package/src/learn/content/standard-deduction-senior-deduction-and-itemizing.ts +97 -0
  122. package/src/learn/content/state-income-taxes-in-retirement.ts +97 -0
  123. package/src/learn/content/step-up-in-basis.ts +102 -0
  124. package/src/learn/content/survivor-planning-for-couples.ts +110 -0
  125. package/src/learn/content/survivor-spending-in-couple-plans.ts +98 -0
  126. package/src/learn/content/tax-cliffs-and-bracket-edges.ts +105 -0
  127. package/src/learn/content/tax-loss-and-gain-harvesting.ts +99 -0
  128. package/src/learn/content/taxable-brokerage-basis-and-capital-gains.ts +99 -0
  129. package/src/learn/content/three-big-questions-spending-time-risk.ts +103 -0
  130. package/src/learn/content/tips-ladders.ts +92 -0
  131. package/src/learn/content/todays-dollars-vs-future-dollars.ts +107 -0
  132. package/src/learn/content/traditional-vs-roth-contributions.ts +113 -0
  133. package/src/learn/content/troubleshooting-surprising-results.ts +105 -0
  134. package/src/learn/content/trust-fund-haircut-scenarios.ts +101 -0
  135. package/src/learn/content/understanding-monte-carlo-success-rate.ts +118 -0
  136. package/src/learn/content/understanding-your-plan-assumptions.ts +134 -0
  137. package/src/learn/content/using-assumptions-and-provenance.ts +98 -0
  138. package/src/learn/content/using-scenarios-to-compare-choices.ts +99 -0
  139. package/src/learn/content/what-changes-when-you-move-states.ts +141 -0
  140. package/src/learn/content/what-is-fire.ts +65 -0
  141. package/src/learn/content/what-monte-carlo-proves.ts +98 -0
  142. package/src/learn/content/what-retiregolden-models.ts +103 -0
  143. package/src/learn/content/what-retirement-healthcare-really-costs.ts +117 -0
  144. package/src/learn/content/why-95-percent-is-not-a-guarantee.ts +98 -0
  145. package/src/learn/content/why-roth-conversions-raise-other-costs.ts +106 -0
  146. package/src/learn/content/why-small-tax-cliffs-can-matter.ts +109 -0
  147. package/src/learn/content/widows-penalty-and-survivor-brackets.ts +106 -0
  148. package/src/learn/content/withdrawal-order-basics.ts +105 -0
  149. package/src/learn/glossary.ts +191 -0
  150. package/src/learn/inlineMarkdown.tsx +54 -0
  151. package/src/learn/learn.css +537 -0
  152. package/src/learn/learningRegistry.ts +502 -0
  153. package/src/longevity/LongevityResults.tsx +85 -0
  154. package/src/longevity/LongevityWizard.tsx +305 -0
  155. package/src/longevity/constants.ts +15 -0
  156. package/src/longevity/factors.ts +125 -0
  157. package/src/longevity/model.ts +31 -0
  158. package/src/longevity/persistedGuard.ts +129 -0
  159. package/src/longevity/storage.ts +40 -0
  160. package/src/mc/messages.ts +118 -0
  161. package/src/mc/monteCarlo.worker.ts +44 -0
  162. package/src/mc/pool.ts +267 -0
  163. package/src/mc/runRequest.ts +125 -0
  164. package/src/optimize/messages.ts +84 -0
  165. package/src/optimize/optimize.worker.ts +29 -0
  166. package/src/optimize/runOptimize.ts +92 -0
  167. package/src/optimize/runSpendingSolve.ts +47 -0
  168. package/src/optimize/runner.ts +21 -0
  169. package/src/optimize/spendingMessages.ts +44 -0
  170. package/src/optimize/spendingRunner.ts +21 -0
  171. package/src/optimize/spendingSolve.worker.ts +18 -0
  172. package/src/planner/AssumptionsCardPage.tsx +136 -0
  173. package/src/planner/BucketLensCard.tsx +114 -0
  174. package/src/planner/ComparePlansPage.tsx +219 -0
  175. package/src/planner/DisclaimerPage.tsx +88 -0
  176. package/src/planner/HowTestedPage.tsx +159 -0
  177. package/src/planner/LiveStatus.tsx +15 -0
  178. package/src/planner/LongevityModal.tsx +55 -0
  179. package/src/planner/Modal.tsx +97 -0
  180. package/src/planner/MonteCarloPage.tsx +907 -0
  181. package/src/planner/OptimizePage.tsx +611 -0
  182. package/src/planner/PlanContext.tsx +198 -0
  183. package/src/planner/PlanPickerPage.tsx +124 -0
  184. package/src/planner/PlanWorkspace.tsx +290 -0
  185. package/src/planner/ProvenancePanel.tsx +45 -0
  186. package/src/planner/RelocationComparePage.tsx +485 -0
  187. package/src/planner/ReportPage.tsx +375 -0
  188. package/src/planner/ResultsPage.tsx +817 -0
  189. package/src/planner/ScenariosPage.tsx +285 -0
  190. package/src/planner/SocialSecuritySection.tsx +556 -0
  191. package/src/planner/SpendingSolverPage.tsx +512 -0
  192. package/src/planner/SsAnalysisPage.tsx +1134 -0
  193. package/src/planner/SurvivalPercentileModal.tsx +161 -0
  194. package/src/planner/SurvivorTransitionPage.tsx +286 -0
  195. package/src/planner/assumptionsExport.ts +371 -0
  196. package/src/planner/bucketLens.ts +89 -0
  197. package/src/planner/chartFrame.ts +8 -0
  198. package/src/planner/chartStyle.ts +11 -0
  199. package/src/planner/dialogViews.tsx +184 -0
  200. package/src/planner/dialogs.tsx +133 -0
  201. package/src/planner/examples/ExampleLibrary.tsx +189 -0
  202. package/src/planner/examples/ExamplePreviewBanner.tsx +55 -0
  203. package/src/planner/examples/ExamplesPage.tsx +25 -0
  204. package/src/planner/examples/OpenExampleButton.tsx +61 -0
  205. package/src/planner/examples/buildAggressiveSaver.ts +102 -0
  206. package/src/planner/examples/buildAnnuityEstate.ts +137 -0
  207. package/src/planner/examples/buildBaristaFire.ts +115 -0
  208. package/src/planner/examples/buildBracketFillRoth.ts +65 -0
  209. package/src/planner/examples/buildBridgeEarlyRetirement.ts +94 -0
  210. package/src/planner/examples/buildBrokerageNoHsa.ts +109 -0
  211. package/src/planner/examples/buildCoastFire.ts +88 -0
  212. package/src/planner/examples/buildContext.ts +20 -0
  213. package/src/planner/examples/buildEarlyCareerMatch.ts +93 -0
  214. package/src/planner/examples/buildEarlyRetireeAca.ts +61 -0
  215. package/src/planner/examples/buildExampleCouple.ts +103 -0
  216. package/src/planner/examples/buildFixedTargetSpending.ts +74 -0
  217. package/src/planner/examples/buildGlidepathAllocation.ts +131 -0
  218. package/src/planner/examples/buildGuardrailsFlex.ts +120 -0
  219. package/src/planner/examples/buildHsaPropertyDepth.ts +109 -0
  220. package/src/planner/examples/buildHsaStealthRetirement.ts +97 -0
  221. package/src/planner/examples/buildLeanFatFire.ts +109 -0
  222. package/src/planner/examples/buildLtcShock.ts +62 -0
  223. package/src/planner/examples/buildMovingStateTax.ts +53 -0
  224. package/src/planner/examples/buildNoAnnuityBrokerage.ts +92 -0
  225. package/src/planner/examples/buildRmdIrmaa.ts +55 -0
  226. package/src/planner/examples/buildSalaryGrowthEscalation.ts +96 -0
  227. package/src/planner/examples/buildStaticAllocationControl.ts +96 -0
  228. package/src/planner/examples/buildSurvivorYears.ts +62 -0
  229. package/src/planner/examples/buildUnderSavedSingle.ts +51 -0
  230. package/src/planner/examples/exampleCopy.ts +23 -0
  231. package/src/planner/examples/loadExample.ts +90 -0
  232. package/src/planner/examples/registry.ts +313 -0
  233. package/src/planner/explainPanels.tsx +233 -0
  234. package/src/planner/fields.tsx +381 -0
  235. package/src/planner/format.ts +33 -0
  236. package/src/planner/home/DataAndPrivacyCard.tsx +56 -0
  237. package/src/planner/home/GettingStartedPaths.tsx +46 -0
  238. package/src/planner/home/GettingStartedReopener.tsx +32 -0
  239. package/src/planner/home/StartHereLinks.tsx +22 -0
  240. package/src/planner/home/WelcomeHero.tsx +39 -0
  241. package/src/planner/home/YourPlans.tsx +72 -0
  242. package/src/planner/home/importErrorMessage.ts +22 -0
  243. package/src/planner/home/startHereSlugs.ts +7 -0
  244. package/src/planner/home/useHomeData.ts +190 -0
  245. package/src/planner/home/useHomeMode.ts +47 -0
  246. package/src/planner/householdActions.ts +22 -0
  247. package/src/planner/insights/InsightCardView.tsx +340 -0
  248. package/src/planner/insights/InsightsPage.tsx +204 -0
  249. package/src/planner/insights/categoryLabels.ts +11 -0
  250. package/src/planner/learnLinks.ts +85 -0
  251. package/src/planner/marketModelPicker.ts +172 -0
  252. package/src/planner/optimizePageChart.ts +40 -0
  253. package/src/planner/optimizePageClaim.ts +64 -0
  254. package/src/planner/planCompleteness.ts +27 -0
  255. package/src/planner/planContextCore.ts +26 -0
  256. package/src/planner/planner.css +2304 -0
  257. package/src/planner/provenanceLinks.ts +25 -0
  258. package/src/planner/sections/AccountFields.tsx +872 -0
  259. package/src/planner/sections/AccountsSection.tsx +89 -0
  260. package/src/planner/sections/AllocationPanel.tsx +261 -0
  261. package/src/planner/sections/AssumptionsSection.tsx +256 -0
  262. package/src/planner/sections/HouseholdSection.tsx +243 -0
  263. package/src/planner/sections/IncomeFloorSection.tsx +418 -0
  264. package/src/planner/sections/IncomeSection.tsx +170 -0
  265. package/src/planner/sections/InsuranceSection.tsx +362 -0
  266. package/src/planner/sections/SpendingSection.tsx +904 -0
  267. package/src/planner/sections/StrategySection.tsx +349 -0
  268. package/src/planner/sections/UpdateBalancesPanel.tsx +182 -0
  269. package/src/planner/sections/sectionHelpers.ts +48 -0
  270. package/src/planner/sections/shared.tsx +15 -0
  271. package/src/planner/sections.tsx +15 -0
  272. package/src/planner/ssAnalysis.ts +325 -0
  273. package/src/planner/successBand.ts +20 -0
  274. package/src/planner/survivorAnalysis.ts +277 -0
  275. package/src/planner/usStates.ts +19 -0
  276. package/src/planner/useMcSuccessRate.ts +77 -0
  277. package/src/planner/useProjection.ts +63 -0
  278. package/src/relocation/messages.ts +21 -0
  279. package/src/relocation/relocation.worker.ts +18 -0
  280. package/src/relocation/runRelocation.ts +17 -0
  281. package/src/relocation/runner.ts +22 -0
  282. package/src/report/brandingContext.ts +15 -0
  283. package/src/report/downloadReport.ts +34 -0
  284. package/src/report/reportHtml.ts +547 -0
  285. package/src/routes/LearnRoutes.tsx +46 -0
  286. package/src/routes/PlanRoutes.tsx +55 -0
  287. package/src/routes/RouteFallback.tsx +9 -0
  288. package/src/socialSecurity/breakEven.ts +107 -0
  289. package/src/socialSecurity/expectedPv.ts +164 -0
  290. package/src/socialSecurity/explain.ts +92 -0
  291. package/src/socialSecurity/ficaReturn.ts +81 -0
  292. package/src/socialSecurity/persistedSsGuard.ts +138 -0
  293. package/src/socialSecurity/ssFormUtils.ts +48 -0
  294. package/src/socialSecurity/ssaStatementXml.ts +156 -0
  295. package/src/socialSecurity/storage.ts +69 -0
  296. package/src/socialSecurity/survivorSwitching.ts +153 -0
  297. package/src/testSupport/samplePlan.ts +2 -0
  298. 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,2 @@
1
+ /** @deprecated Use the example library registry — kept for tests importing createSamplePlan. */
2
+ export { buildExampleCouple as createSamplePlan } from '../planner/examples/buildExampleCouple'
@@ -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
+ }