@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,395 @@
1
+ /**
2
+ * Broker positions-CSV import (onboarding-import-and-migration step 2).
3
+ *
4
+ * Parses the positions/holdings CSV files Schwab, Fidelity, and Vanguard let
5
+ * every customer download, into per-account balance (and cost basis, where the
6
+ * file carries it) aggregates. Two consumers: the account editor's "update
7
+ * balances from CSV" flow, and the import wizard's "start a plan from a broker
8
+ * CSV" path. Only documented customer-facing exports are recognized — files
9
+ * are version-sniffed by header shape and refused with a helpful message
10
+ * otherwise. All parsing is client-side on hostile input; see csv.ts.
11
+ */
12
+
13
+ import type { Account, Plan } from '@retiregolden/engine/model/plan'
14
+ import { createEmptyPlan, parsePlan } from '@retiregolden/engine/model/plan'
15
+ import { findColumn, parseCsv, parseMoney } from './csv'
16
+ import type { ImportReviewItem } from './reviewChecklist'
17
+
18
+ export type BrokerId = 'schwab' | 'fidelity' | 'vanguard'
19
+
20
+ export const BROKER_LABEL: Record<BrokerId, string> = {
21
+ schwab: 'Schwab',
22
+ fidelity: 'Fidelity',
23
+ vanguard: 'Vanguard',
24
+ }
25
+
26
+ export interface BrokerAccountBalance {
27
+ /** Account label as it appears in the file (brokers mask numbers themselves). */
28
+ accountLabel: string
29
+ /** Sum of position market values, including cash/money-market rows. */
30
+ totalValue: number
31
+ /** Sum of cost basis over rows that had one; null when the file carries none. */
32
+ costBasis: number | null
33
+ positionCount: number
34
+ }
35
+
36
+ export type BrokerCsvResult =
37
+ | { ok: true; broker: BrokerId; accounts: BrokerAccountBalance[]; review: ImportReviewItem[] }
38
+ | { ok: false; message: string }
39
+
40
+ const UNRECOGNIZED_MESSAGE =
41
+ 'This file does not look like a Schwab, Fidelity, or Vanguard positions export. ' +
42
+ 'Download the positions/holdings CSV from your broker, or use the spreadsheet import to map columns yourself.'
43
+
44
+ /** Rows that are file furniture, not positions — silently structural, never balances. */
45
+ function isFooterOrNoise(cells: string[]): boolean {
46
+ const first = (cells[0] ?? '').trim().toLowerCase()
47
+ if (first === '') return true
48
+ if (first.startsWith('the data and information')) return true // Fidelity disclaimer
49
+ if (first.startsWith('date downloaded')) return true
50
+ if (first.startsWith('brokerage services')) return true
51
+ if (first.startsWith('"disclaimer') || first.startsWith('disclaimer')) return true
52
+ return false
53
+ }
54
+
55
+ interface Aggregate {
56
+ label: string
57
+ total: number
58
+ basis: number
59
+ basisRows: number
60
+ valueRowsWithoutBasis: number
61
+ positions: number
62
+ }
63
+
64
+ function newAggregate(label: string): Aggregate {
65
+ return { label, total: 0, basis: 0, basisRows: 0, valueRowsWithoutBasis: 0, positions: 0 }
66
+ }
67
+
68
+ function finishAggregates(
69
+ broker: BrokerId,
70
+ byAccount: Map<string, Aggregate>,
71
+ review: ImportReviewItem[],
72
+ ): BrokerCsvResult {
73
+ const accounts: BrokerAccountBalance[] = []
74
+ for (const agg of byAccount.values()) {
75
+ if (agg.positions === 0) continue
76
+ accounts.push({
77
+ accountLabel: agg.label,
78
+ totalValue: Math.round(agg.total * 100) / 100,
79
+ costBasis: agg.basisRows > 0 ? Math.round(agg.basis * 100) / 100 : null,
80
+ positionCount: agg.positions,
81
+ })
82
+ review.push({
83
+ status: 'mapped',
84
+ source: agg.label,
85
+ detail: `${agg.positions} position${agg.positions === 1 ? '' : 's'} totaling $${agg.total.toLocaleString('en-US', { maximumFractionDigits: 0 })}${
86
+ agg.basisRows > 0 ? ` (cost basis $${agg.basis.toLocaleString('en-US', { maximumFractionDigits: 0 })})` : ''
87
+ }.`,
88
+ })
89
+ if (agg.basisRows > 0 && agg.valueRowsWithoutBasis > 0) {
90
+ review.push({
91
+ status: 'defaulted',
92
+ source: agg.label,
93
+ detail: `${agg.valueRowsWithoutBasis} position${agg.valueRowsWithoutBasis === 1 ? '' : 's'} (typically cash/money market) had no cost basis; the imported basis covers the rest. Adjust it if this is a taxable account.`,
94
+ })
95
+ }
96
+ }
97
+ if (accounts.length === 0) {
98
+ return { ok: false, message: 'No positions with a readable market value were found in this file.' }
99
+ }
100
+ review.push({
101
+ status: 'unmapped',
102
+ source: 'Positions detail',
103
+ detail: 'Only account balances (and cost basis where present) import — individual holdings, lots, and quantities are not modeled.',
104
+ })
105
+ return { ok: true, broker, accounts, review }
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Schwab: title row per account section, then a header row, then positions.
110
+ // ---------------------------------------------------------------------------
111
+
112
+ function parseSchwab(rows: string[][]): BrokerCsvResult {
113
+ const review: ImportReviewItem[] = []
114
+ const byAccount = new Map<string, Aggregate>()
115
+ let current: Aggregate | null = null
116
+ let valueCol = -1
117
+ let basisCol = -1
118
+
119
+ for (let r = 0; r < rows.length; r++) {
120
+ const cells = rows[r]!
121
+ const first = (cells[0] ?? '').trim()
122
+
123
+ const section = /^positions for (?:account )?(.+?) as of /i.exec(first)
124
+ if (section) {
125
+ const label = section[1]!.trim()
126
+ current = byAccount.get(label) ?? newAggregate(label)
127
+ byAccount.set(label, current)
128
+ valueCol = -1
129
+ basisCol = -1
130
+ continue
131
+ }
132
+
133
+ if (first.toLowerCase() === 'symbol') {
134
+ valueCol = findColumn(cells, 'market value', 'mkt val')
135
+ basisCol = findColumn(cells, 'cost basis')
136
+ continue
137
+ }
138
+
139
+ if (!current || valueCol === -1) continue
140
+ if (isFooterOrNoise(cells)) continue
141
+ const lower = first.toLowerCase()
142
+ if (lower === 'account total' || lower === 'total') continue
143
+
144
+ const value = parseMoney(cells[valueCol])
145
+ if (value === null) {
146
+ review.push({ status: 'skipped', source: `${current.label}: ${first}`, detail: 'Row had no readable market value.' })
147
+ continue
148
+ }
149
+ current.total += value
150
+ current.positions++
151
+ const basis = basisCol === -1 ? null : parseMoney(cells[basisCol])
152
+ if (basis === null) current.valueRowsWithoutBasis++
153
+ else {
154
+ current.basis += basis
155
+ current.basisRows++
156
+ }
157
+ }
158
+
159
+ if (byAccount.size === 0) return { ok: false, message: UNRECOGNIZED_MESSAGE }
160
+ return finishAggregates('schwab', byAccount, review)
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Fidelity / Vanguard: single header row with an account-number column.
165
+ // ---------------------------------------------------------------------------
166
+
167
+ /** Summary rows that re-state position sums — counting them doubles balances. */
168
+ const TOTAL_ROW_RE = /^(sub|account |grand )?total\b/i
169
+
170
+ function parseAccountColumnFile(
171
+ broker: BrokerId,
172
+ rows: string[][],
173
+ headerIndex: number,
174
+ cols: { account: number; accountName: number; value: number; basis: number; symbol: number; description: number },
175
+ ): BrokerCsvResult {
176
+ const review: ImportReviewItem[] = []
177
+ const byAccount = new Map<string, Aggregate>()
178
+
179
+ for (let r = headerIndex + 1; r < rows.length; r++) {
180
+ const cells = rows[r]!
181
+ if (isFooterOrNoise(cells)) continue
182
+ // Vanguard appends a transactions section with its own header; stop there.
183
+ if (broker === 'vanguard' && findColumn(cells, 'trade date') !== -1) break
184
+
185
+ const accountRaw = (cells[cols.account] ?? '').trim()
186
+ if (accountRaw === '') continue
187
+ const name = cols.accountName === -1 ? '' : (cells[cols.accountName] ?? '').trim()
188
+ const label = name !== '' ? `${name} (${accountRaw})` : accountRaw
189
+ const key = accountRaw
190
+ const symbol = cols.symbol === -1 ? '' : (cells[cols.symbol] ?? '').trim()
191
+ const description = cols.description === -1 ? '' : (cells[cols.description] ?? '').trim()
192
+
193
+ // Summary furniture, not positions: total rows re-state money already
194
+ // counted (silent double-count if kept), and pending activity is unsettled.
195
+ // A description match only counts with no symbol, so a fund actually named
196
+ // "Total Stock Market Index" is never mistaken for a summary row.
197
+ const isTotalRow =
198
+ TOTAL_ROW_RE.test(accountRaw) || TOTAL_ROW_RE.test(symbol) || (symbol === '' && TOTAL_ROW_RE.test(description))
199
+ if (isTotalRow) continue
200
+ const value = parseMoney(cells[cols.value])
201
+ if (/^pending activity$/i.test(symbol) || /^pending activity$/i.test(description)) {
202
+ review.push({
203
+ status: 'skipped',
204
+ source: `${label}: Pending Activity`,
205
+ detail:
206
+ value !== null
207
+ ? `$${value.toLocaleString('en-US')} of unsettled activity was not counted — it will appear in a position or cash on your next download.`
208
+ : 'Unsettled activity row was not counted.',
209
+ })
210
+ continue
211
+ }
212
+
213
+ if (value === null) {
214
+ review.push({
215
+ status: 'skipped',
216
+ source: `${label}: ${symbol || 'row ' + String(r + 1)}`,
217
+ detail: 'Row had no readable value.',
218
+ })
219
+ continue
220
+ }
221
+ const agg = byAccount.get(key) ?? newAggregate(label)
222
+ byAccount.set(key, agg)
223
+ agg.total += value
224
+ agg.positions++
225
+ const basis = cols.basis === -1 ? null : parseMoney(cells[cols.basis])
226
+ if (basis === null) agg.valueRowsWithoutBasis++
227
+ else {
228
+ agg.basis += basis
229
+ agg.basisRows++
230
+ }
231
+ }
232
+
233
+ if (broker === 'vanguard') {
234
+ review.push({
235
+ status: 'unmapped',
236
+ source: 'Cost basis',
237
+ detail: "Vanguard's holdings download has no cost basis column — enter basis on taxable accounts from vanguard.com's cost basis page.",
238
+ })
239
+ }
240
+ if (byAccount.size === 0) return { ok: false, message: 'No positions with a readable value were found in this file.' }
241
+ return finishAggregates(broker, byAccount, review)
242
+ }
243
+
244
+ /**
245
+ * Sniff the broker by header shape and parse. Unknown shapes are refused with
246
+ * a pointer at the generic spreadsheet import — never guessed at.
247
+ */
248
+ export function parseBrokerPositionsCsv(text: string): BrokerCsvResult {
249
+ const parsed = parseCsv(text)
250
+ if (!parsed.ok) return { ok: false, message: parsed.message }
251
+ const rows = parsed.rows
252
+
253
+ // Schwab: any section-title row wins, headers are per-section.
254
+ if (rows.some((cells) => /^positions for /i.test((cells[0] ?? '').trim()))) {
255
+ return parseSchwab(rows)
256
+ }
257
+
258
+ // Fidelity / Vanguard: locate the header row among leading junk.
259
+ for (let r = 0; r < Math.min(rows.length, 20); r++) {
260
+ const cells = rows[r]!
261
+ const account = findColumn(cells, 'account number')
262
+ if (account === -1) continue
263
+
264
+ const fidelityValue = findColumn(cells, 'current value')
265
+ if (fidelityValue !== -1) {
266
+ return parseAccountColumnFile('fidelity', rows, r, {
267
+ account,
268
+ accountName: findColumn(cells, 'account name'),
269
+ value: fidelityValue,
270
+ basis: findColumn(cells, 'cost basis total', 'cost basis'),
271
+ symbol: findColumn(cells, 'symbol'),
272
+ description: findColumn(cells, 'description'),
273
+ })
274
+ }
275
+
276
+ const vanguardValue = findColumn(cells, 'total value')
277
+ if (vanguardValue !== -1 && findColumn(cells, 'investment name') !== -1) {
278
+ return parseAccountColumnFile('vanguard', rows, r, {
279
+ account,
280
+ accountName: -1,
281
+ value: vanguardValue,
282
+ basis: -1,
283
+ symbol: findColumn(cells, 'symbol'),
284
+ description: findColumn(cells, 'investment name'),
285
+ })
286
+ }
287
+ }
288
+
289
+ return { ok: false, message: UNRECOGNIZED_MESSAGE }
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Applying parsed balances
294
+ // ---------------------------------------------------------------------------
295
+
296
+ /** Plan account types a broker balance can sensibly land on. */
297
+ export function isBalanceUpdatable(account: Account): account is Account & { balance: number } {
298
+ return (
299
+ account.type === 'cash' ||
300
+ account.type === 'taxable' ||
301
+ account.type === 'equityComp' ||
302
+ account.type === 'traditional' ||
303
+ account.type === 'roth' ||
304
+ account.type === 'hsa'
305
+ )
306
+ }
307
+
308
+ /**
309
+ * Return a copy of `account` with the parsed balance (and, on basis-tracking
310
+ * account types, cost basis when the file had one) applied.
311
+ */
312
+ export function applyBrokerBalance(account: Account, source: BrokerAccountBalance): Account {
313
+ if (!isBalanceUpdatable(account)) return account
314
+ const next = { ...account, balance: Math.max(0, source.totalValue) }
315
+ if ((next.type === 'taxable' || next.type === 'equityComp') && source.costBasis !== null) {
316
+ next.costBasis = Math.max(0, source.costBasis)
317
+ }
318
+ return next
319
+ }
320
+
321
+ /** Guess a plan account type from a broker account label, for the new-plan path. */
322
+ export function guessAccountTypeFromLabel(label: string): 'roth' | 'traditional' | 'hsa' | 'taxable' {
323
+ const l = label.toLowerCase()
324
+ if (/\broth\b/.test(l)) return 'roth'
325
+ if (/\bhsa\b|health savings/.test(l)) return 'hsa'
326
+ if (/\bira\b|401|403|rollover|sep[- ]|simple[- ]|pension|retirement/.test(l)) return 'traditional'
327
+ return 'taxable'
328
+ }
329
+
330
+ export type BrokerDraftResult = { ok: true; plan: Plan; review: ImportReviewItem[] } | { ok: false; message: string }
331
+
332
+ /**
333
+ * Build a draft plan from parsed broker accounts (the import wizard's "start
334
+ * from a broker CSV" path). Account types are guessed from labels and every
335
+ * guess is a review item — nothing imports silently. Like every other mapper,
336
+ * the draft goes through `parsePlan` so an unsaveable plan fails here, not at save.
337
+ */
338
+ export function draftPlanFromBrokerAccounts(
339
+ broker: BrokerId,
340
+ accounts: BrokerAccountBalance[],
341
+ newId: () => string = () => crypto.randomUUID(),
342
+ ): BrokerDraftResult {
343
+ const review: ImportReviewItem[] = []
344
+ const plan = createEmptyPlan({ newId, name: `Imported from ${BROKER_LABEL[broker]}` })
345
+ const ownerId = plan.household.people[0]!.id
346
+
347
+ for (const acc of accounts) {
348
+ const type = guessAccountTypeFromLabel(acc.accountLabel)
349
+ const base = {
350
+ id: newId(),
351
+ name: acc.accountLabel,
352
+ annualReturnPct: null,
353
+ annualContribution: 0,
354
+ }
355
+ if (type === 'taxable') {
356
+ plan.accounts.push({
357
+ ...base,
358
+ type,
359
+ ownerPersonId: null,
360
+ balance: Math.max(0, acc.totalValue),
361
+ costBasis: Math.max(0, acc.costBasis ?? acc.totalValue),
362
+ })
363
+ if (acc.costBasis === null) {
364
+ review.push({
365
+ status: 'defaulted',
366
+ source: acc.accountLabel,
367
+ detail: 'No cost basis in the file — basis was set equal to the balance (no unrealized gain). Correct it on the Accounts screen.',
368
+ })
369
+ }
370
+ } else if (type === 'roth' || type === 'traditional') {
371
+ const kind = /401|403|tsp/i.test(acc.accountLabel) ? 'employer' : 'ira'
372
+ plan.accounts.push({ ...base, type, kind, ownerPersonId: ownerId, balance: Math.max(0, acc.totalValue) })
373
+ } else {
374
+ plan.accounts.push({ ...base, type, ownerPersonId: ownerId, balance: Math.max(0, acc.totalValue) })
375
+ }
376
+ review.push({
377
+ status: 'defaulted',
378
+ source: acc.accountLabel,
379
+ detail: `Created as a ${type === 'taxable' ? 'taxable brokerage' : type} account (guessed from the name) owned by ${
380
+ type === 'taxable' ? 'the household' : 'you'
381
+ }. Change the type or owner on the Accounts screen if the guess is wrong.`,
382
+ })
383
+ }
384
+
385
+ review.push({
386
+ status: 'unmapped',
387
+ source: 'Everything except balances',
388
+ detail: 'Broker files carry no household, income, spending, or Social Security data — enter those in the planner sections.',
389
+ })
390
+ const parsed = parsePlan(plan)
391
+ if (!parsed.ok) {
392
+ return { ok: false, message: `The mapped plan failed validation: ${parsed.issues.join('; ')}` }
393
+ }
394
+ return { ok: true, plan: parsed.plan, review }
395
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Hardened CSV parsing shared by every import mapper (onboarding-import-and-migration).
3
+ *
4
+ * Imported files are hostile input (same discipline as the SSA statement XML
5
+ * parser): size/row/field caps, no exceptions on malformed text, and every
6
+ * number routed through `parseMoney` so junk becomes an explicit skip rather
7
+ * than NaN in a balance.
8
+ */
9
+
10
+ export const MAX_CSV_CHARS = 5_000_000
11
+ export const MAX_CSV_ROWS = 20_000
12
+ export const MAX_CSV_FIELDS_PER_ROW = 100
13
+ /** Balances above this are assumed to be a parsing mistake, not a portfolio. */
14
+ export const MAX_REASONABLE_DOLLARS = 1e12
15
+
16
+ export type CsvParseResult = { ok: true; rows: string[][] } | { ok: false; message: string }
17
+
18
+ /**
19
+ * RFC-4180-style parser: quoted fields (embedded commas/newlines/`""` escapes),
20
+ * LF or CRLF row breaks, optional UTF-8 BOM. Fully empty rows are dropped.
21
+ */
22
+ export function parseCsv(text: string): CsvParseResult {
23
+ if (text.length > MAX_CSV_CHARS) {
24
+ return { ok: false, message: 'File is too large to be a positions/plan export.' }
25
+ }
26
+ const src = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
27
+
28
+ const rows: string[][] = []
29
+ let row: string[] = []
30
+ let field = ''
31
+ let inQuotes = false
32
+ let i = 0
33
+
34
+ const endField = () => {
35
+ row.push(field)
36
+ field = ''
37
+ }
38
+ const endRow = (): string | null => {
39
+ endField()
40
+ if (row.length > MAX_CSV_FIELDS_PER_ROW) return 'A row has too many columns to be a supported export.'
41
+ // Keep rows that carry any content; blank separator lines are structure, not data.
42
+ if (row.some((f) => f.trim() !== '')) {
43
+ rows.push(row)
44
+ if (rows.length > MAX_CSV_ROWS) return 'File has too many rows to be a supported export.'
45
+ }
46
+ row = []
47
+ return null
48
+ }
49
+
50
+ while (i < src.length) {
51
+ const ch = src[i]!
52
+ if (inQuotes) {
53
+ if (ch === '"') {
54
+ if (src[i + 1] === '"') {
55
+ field += '"'
56
+ i += 2
57
+ continue
58
+ }
59
+ inQuotes = false
60
+ i++
61
+ continue
62
+ }
63
+ field += ch
64
+ i++
65
+ continue
66
+ }
67
+ if (ch === '"' && field === '') {
68
+ inQuotes = true
69
+ i++
70
+ continue
71
+ }
72
+ if (ch === ',') {
73
+ endField()
74
+ i++
75
+ continue
76
+ }
77
+ if (ch === '\n' || ch === '\r') {
78
+ if (ch === '\r' && src[i + 1] === '\n') i++
79
+ const err = endRow()
80
+ if (err) return { ok: false, message: err }
81
+ i++
82
+ continue
83
+ }
84
+ field += ch
85
+ i++
86
+ }
87
+ if (inQuotes) {
88
+ return { ok: false, message: 'File ends inside a quoted value — it looks truncated or corrupted.' }
89
+ }
90
+ if (field !== '' || row.length > 0) {
91
+ const err = endRow()
92
+ if (err) return { ok: false, message: err }
93
+ }
94
+ if (rows.length === 0) {
95
+ return { ok: false, message: 'File is empty.' }
96
+ }
97
+ return { ok: true, rows }
98
+ }
99
+
100
+ /**
101
+ * Parse a currency-ish cell: `$1,234.56`, `(1,234)` (negative), `1234`,
102
+ * `12.5%`-free plain numbers. Returns null for anything else (`--`, `N/A`,
103
+ * text, Infinity, absurd magnitudes) so callers must handle junk explicitly.
104
+ */
105
+ export function parseMoney(raw: string | undefined): number | null {
106
+ if (raw === undefined) return null
107
+ let s = raw.trim()
108
+ if (s === '') return null
109
+ let negative = false
110
+ if (s.startsWith('(') && s.endsWith(')')) {
111
+ negative = true
112
+ s = s.slice(1, -1).trim()
113
+ }
114
+ if (s.startsWith('-')) {
115
+ negative = true
116
+ s = s.slice(1).trim()
117
+ }
118
+ s = s.replace(/^\$/, '').replace(/,/g, '').trim()
119
+ if (s === '' || !/^\d+(\.\d+)?$/.test(s)) return null
120
+ const n = Number.parseFloat(s)
121
+ if (!Number.isFinite(n) || n > MAX_REASONABLE_DOLLARS) return null
122
+ return negative ? -n : n
123
+ }
124
+
125
+ /** Case-insensitive header lookup: the index of the first column whose name contains `needle`. */
126
+ export function findColumn(header: string[], ...needles: string[]): number {
127
+ for (const needle of needles) {
128
+ const target = needle.toLowerCase()
129
+ const idx = header.findIndex((h) => h.trim().toLowerCase().includes(target))
130
+ if (idx !== -1) return idx
131
+ }
132
+ return -1
133
+ }