@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,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
|
+
}
|