@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,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic spreadsheet / RPM CSV import (onboarding-import-and-migration step 4).
|
|
3
|
+
*
|
|
4
|
+
* For everything without a recognized export: the Bogleheads Retiree Portfolio
|
|
5
|
+
* Model saved as CSV, a homegrown net-worth spreadsheet, another tool's ad-hoc
|
|
6
|
+
* dump. Two-phase: `analyzeGenericCsv` finds the header row and guesses a role
|
|
7
|
+
* for each column; the wizard lets the user correct the roles; then
|
|
8
|
+
* `draftPlanFromGenericCsv` maps rows to accounts with the shared review
|
|
9
|
+
* checklist. Reuses the hardened CSV core from step 2.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Account, Plan } from '@retiregolden/engine/model/plan'
|
|
13
|
+
import { createEmptyPlan, parsePlan } from '@retiregolden/engine/model/plan'
|
|
14
|
+
import { parseCsv, parseMoney } from './csv'
|
|
15
|
+
import { mapProjectionLabAccountType } from './projectionLab'
|
|
16
|
+
import type { ImportReviewItem } from './reviewChecklist'
|
|
17
|
+
|
|
18
|
+
export type ColumnRole = 'name' | 'type' | 'balance' | 'costBasis' | 'contribution' | 'ignore'
|
|
19
|
+
|
|
20
|
+
export const COLUMN_ROLE_LABEL: Record<ColumnRole, string> = {
|
|
21
|
+
name: 'Account name',
|
|
22
|
+
type: 'Account type',
|
|
23
|
+
balance: 'Balance / value',
|
|
24
|
+
costBasis: 'Cost basis',
|
|
25
|
+
contribution: 'Annual contribution',
|
|
26
|
+
ignore: 'Ignore',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GenericCsvAnalysis {
|
|
30
|
+
header: string[]
|
|
31
|
+
dataRows: string[][]
|
|
32
|
+
/** One guessed role per header column; the wizard lets the user override. */
|
|
33
|
+
guessedRoles: ColumnRole[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type GenericCsvAnalysisResult = { ok: true; analysis: GenericCsvAnalysis } | { ok: false; message: string }
|
|
37
|
+
|
|
38
|
+
/** Guess a column's role from its header text. */
|
|
39
|
+
export function guessColumnRole(headerCell: string): ColumnRole {
|
|
40
|
+
const h = headerCell.trim().toLowerCase()
|
|
41
|
+
if (h === '') return 'ignore'
|
|
42
|
+
if (/basis|cost/.test(h)) return 'costBasis'
|
|
43
|
+
if (/contribut|saving|deposit/.test(h)) return 'contribution'
|
|
44
|
+
if (/balance|value|amount|total|\$/.test(h)) return 'balance'
|
|
45
|
+
if (/type|class|category|tax treatment/.test(h)) return 'type'
|
|
46
|
+
if (/account|name|description|label|holding|asset/.test(h)) return 'name'
|
|
47
|
+
return 'ignore'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** True when the cell reads as data (money) rather than a label. */
|
|
51
|
+
function isMoneyish(cell: string): boolean {
|
|
52
|
+
return parseMoney(cell) !== null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Find the header row (the first row that is mostly non-numeric labels and is
|
|
57
|
+
* followed by at least one row with a money-ish cell) and guess column roles.
|
|
58
|
+
*/
|
|
59
|
+
export function analyzeGenericCsv(text: string): GenericCsvAnalysisResult {
|
|
60
|
+
const parsed = parseCsv(text)
|
|
61
|
+
if (!parsed.ok) return { ok: false, message: parsed.message }
|
|
62
|
+
const rows = parsed.rows
|
|
63
|
+
|
|
64
|
+
for (let r = 0; r < Math.min(rows.length, 30); r++) {
|
|
65
|
+
const cells = rows[r]!
|
|
66
|
+
const nonEmpty = cells.filter((c) => c.trim() !== '')
|
|
67
|
+
if (nonEmpty.length < 2) continue
|
|
68
|
+
if (nonEmpty.some(isMoneyish)) continue // data row, not a header
|
|
69
|
+
const dataRows = rows.slice(r + 1).filter((row) => row.some(isMoneyish))
|
|
70
|
+
if (dataRows.length === 0) continue
|
|
71
|
+
const guessedRoles = cells.map(guessColumnRole)
|
|
72
|
+
// A usable table needs at least a name-ish and a money-ish column somewhere;
|
|
73
|
+
// the user can still fix the guesses by hand.
|
|
74
|
+
return { ok: true, analysis: { header: cells, dataRows, guessedRoles } }
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
message:
|
|
79
|
+
'No header row followed by rows with dollar values was found. Save the sheet as CSV with one row per account and a header row (e.g. "Account, Type, Balance").',
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface GenericCsvDraft {
|
|
84
|
+
plan: Plan
|
|
85
|
+
review: ImportReviewItem[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type GenericCsvDraftResult = { ok: true } & GenericCsvDraft | { ok: false; message: string }
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Map data rows onto a draft plan using the (possibly user-corrected) column
|
|
92
|
+
* roles. Rows without a readable balance are reported and skipped; account
|
|
93
|
+
* types come from the type column when present, else from the name, else
|
|
94
|
+
* default to taxable with a review item.
|
|
95
|
+
*/
|
|
96
|
+
export function draftPlanFromGenericCsv(
|
|
97
|
+
analysis: GenericCsvAnalysis,
|
|
98
|
+
roles: ColumnRole[],
|
|
99
|
+
newId: () => string = () => crypto.randomUUID(),
|
|
100
|
+
): GenericCsvDraftResult {
|
|
101
|
+
const nameCol = roles.indexOf('name')
|
|
102
|
+
const balanceCol = roles.indexOf('balance')
|
|
103
|
+
if (balanceCol === -1) {
|
|
104
|
+
return { ok: false, message: 'Assign one column as the balance/value before importing.' }
|
|
105
|
+
}
|
|
106
|
+
const typeCol = roles.indexOf('type')
|
|
107
|
+
const basisCol = roles.indexOf('costBasis')
|
|
108
|
+
const contributionCol = roles.indexOf('contribution')
|
|
109
|
+
|
|
110
|
+
const review: ImportReviewItem[] = []
|
|
111
|
+
const plan = createEmptyPlan({ newId, name: 'Imported from spreadsheet' })
|
|
112
|
+
const ownerId = plan.household.people[0]!.id
|
|
113
|
+
|
|
114
|
+
// Spreadsheet footers that re-state sums import as phantom accounts if kept.
|
|
115
|
+
const totalRowRe = /^(sub)?total\b|grand total/i
|
|
116
|
+
const loanLikeRe = /\bloan\b|debt|mortgage|heloc|liabilit|credit/i
|
|
117
|
+
|
|
118
|
+
for (let r = 0; r < analysis.dataRows.length; r++) {
|
|
119
|
+
const cells = analysis.dataRows[r]!
|
|
120
|
+
const name = (nameCol === -1 ? '' : (cells[nameCol] ?? '').trim()) || `Row ${r + 1}`
|
|
121
|
+
const typeText = typeCol === -1 ? '' : (cells[typeCol] ?? '').trim()
|
|
122
|
+
if (totalRowRe.test(name) || totalRowRe.test(typeText)) {
|
|
123
|
+
review.push({ status: 'skipped', source: name, detail: 'Summary/total row — counting it would double the money above it.' })
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
const balance = parseMoney(cells[balanceCol])
|
|
127
|
+
// A negative balance on a loan-looking row is a liability written with a
|
|
128
|
+
// sign convention — import it as debt instead of dropping the row.
|
|
129
|
+
const negativeLiability = balance !== null && balance < 0 && loanLikeRe.test(`${name} ${typeText}`)
|
|
130
|
+
if ((balance === null || balance < 0) && !negativeLiability) {
|
|
131
|
+
review.push({ status: 'skipped', source: name, detail: 'Row had no readable non-negative balance.' })
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
const guessSource = typeText !== '' ? typeText : name
|
|
135
|
+
const typeGuess = negativeLiability ? 'debt' : mapProjectionLabAccountType(typeText, name)
|
|
136
|
+
const mapped = typeGuess ?? 'taxable'
|
|
137
|
+
const amount = Math.abs(balance!)
|
|
138
|
+
|
|
139
|
+
const base = { id: newId(), name, annualReturnPct: null }
|
|
140
|
+
const contribution = contributionCol === -1 ? null : parseMoney(cells[contributionCol])
|
|
141
|
+
const annualContribution = contribution !== null && contribution > 0 ? contribution : 0
|
|
142
|
+
|
|
143
|
+
let account: Account
|
|
144
|
+
switch (mapped) {
|
|
145
|
+
case 'taxable': {
|
|
146
|
+
const basisRaw = basisCol === -1 ? null : parseMoney(cells[basisCol])
|
|
147
|
+
// A negative basis cell (adjustment lines, sign conventions) must not
|
|
148
|
+
// sink the whole import at validation — treat it like a missing basis.
|
|
149
|
+
const basis = basisRaw !== null && basisRaw >= 0 ? basisRaw : null
|
|
150
|
+
account = { ...base, type: 'taxable', ownerPersonId: null, balance: amount, costBasis: basis ?? amount, annualContribution }
|
|
151
|
+
if (basis === null) {
|
|
152
|
+
review.push({
|
|
153
|
+
status: 'defaulted',
|
|
154
|
+
source: name,
|
|
155
|
+
detail:
|
|
156
|
+
(basisRaw !== null && basisRaw < 0 ? 'The cost basis cell was negative, so it was ignored — ' : 'No cost basis column/value — ') +
|
|
157
|
+
'basis was set equal to the balance (no unrealized gain). Correct it on the Accounts screen.',
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
break
|
|
161
|
+
}
|
|
162
|
+
case 'traditional':
|
|
163
|
+
case 'roth':
|
|
164
|
+
account = {
|
|
165
|
+
...base,
|
|
166
|
+
type: mapped,
|
|
167
|
+
ownerPersonId: ownerId,
|
|
168
|
+
kind: /401|403|457|tsp/i.test(guessSource) ? 'employer' : 'ira',
|
|
169
|
+
balance: amount,
|
|
170
|
+
annualContribution,
|
|
171
|
+
}
|
|
172
|
+
break
|
|
173
|
+
case 'hsa':
|
|
174
|
+
account = { ...base, type: 'hsa', ownerPersonId: ownerId, balance: amount, annualContribution }
|
|
175
|
+
break
|
|
176
|
+
case 'cash':
|
|
177
|
+
account = { ...base, type: 'cash', ownerPersonId: null, balance: amount, annualContribution }
|
|
178
|
+
break
|
|
179
|
+
case 'property':
|
|
180
|
+
account = { ...base, type: 'property', ownerPersonId: null, value: amount, plannedSaleYear: null, expectedNetProceeds: null }
|
|
181
|
+
break
|
|
182
|
+
case 'debt':
|
|
183
|
+
account = { ...base, type: 'debt', ownerPersonId: null, balance: amount, interestPct: 5, monthlyPayment: 0 }
|
|
184
|
+
if (negativeLiability) {
|
|
185
|
+
review.push({
|
|
186
|
+
status: 'defaulted',
|
|
187
|
+
source: name,
|
|
188
|
+
detail: `The negative balance looked like a liability sign convention — imported as a $${amount.toLocaleString('en-US', { maximumFractionDigits: 0 })} debt.`,
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
review.push({
|
|
192
|
+
status: 'defaulted',
|
|
193
|
+
source: name,
|
|
194
|
+
detail: 'Debts need an interest rate and monthly payment — defaults of 5% and $0/mo were used; set the real terms on the Accounts screen.',
|
|
195
|
+
})
|
|
196
|
+
break
|
|
197
|
+
}
|
|
198
|
+
plan.accounts.push(account)
|
|
199
|
+
review.push({
|
|
200
|
+
status: typeGuess === null ? 'defaulted' : 'mapped',
|
|
201
|
+
source: `${name}${typeText !== '' ? ` (${typeText})` : ''}`,
|
|
202
|
+
detail:
|
|
203
|
+
typeGuess === null
|
|
204
|
+
? `No recognizable account type — imported as a taxable account with a $${amount.toLocaleString('en-US', { maximumFractionDigits: 0 })} balance. Change the type on the Accounts screen if that is wrong.`
|
|
205
|
+
: `Imported as a ${mapped} account with a $${amount.toLocaleString('en-US', { maximumFractionDigits: 0 })} balance.`,
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (plan.accounts.length === 0) {
|
|
210
|
+
return { ok: false, message: 'No rows with a readable balance were found with the current column assignment.' }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
review.push({
|
|
214
|
+
status: 'unmapped',
|
|
215
|
+
source: 'Everything except accounts',
|
|
216
|
+
detail: 'Spreadsheet rows import as account balances only — enter household, income, spending, and Social Security in the planner sections.',
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const parsed = parsePlan(plan)
|
|
220
|
+
if (!parsed.ok) {
|
|
221
|
+
return { ok: false, message: `The mapped plan failed validation: ${parsed.issues.join('; ')}` }
|
|
222
|
+
}
|
|
223
|
+
return { ok: true, plan: parsed.plan, review }
|
|
224
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProjectionLab data-export mapper (onboarding-import-and-migration step 3).
|
|
3
|
+
*
|
|
4
|
+
* ProjectionLab lets users export their data as JSON. This maps that file's
|
|
5
|
+
* documented, user-visible substance — current-finances accounts, income
|
|
6
|
+
* sources, expenses, and the retirement milestone — onto a draft RetireGolden
|
|
7
|
+
* plan through the normal validated plan route. The export is version-sniffed
|
|
8
|
+
* by shape (`currentFinances.accounts`); anything else is refused with a
|
|
9
|
+
* helpful message rather than guessed at, and every mapped, assumed, or
|
|
10
|
+
* unmappable item lands on the review checklist. No scraping, no network —
|
|
11
|
+
* the user's own exported file, parsed in the browser.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Account, Plan } from '@retiregolden/engine/model/plan'
|
|
15
|
+
import { createEmptyPlan, parsePlan } from '@retiregolden/engine/model/plan'
|
|
16
|
+
import { MAX_REASONABLE_DOLLARS, parseMoney } from './csv'
|
|
17
|
+
import type { ImportReviewItem } from './reviewChecklist'
|
|
18
|
+
|
|
19
|
+
export const MAX_IMPORT_JSON_CHARS = 10_000_000
|
|
20
|
+
|
|
21
|
+
export type ProjectionLabImportResult =
|
|
22
|
+
| { ok: true; plan: Plan; review: ImportReviewItem[] }
|
|
23
|
+
| { ok: false; message: string }
|
|
24
|
+
|
|
25
|
+
const UNRECOGNIZED_MESSAGE =
|
|
26
|
+
'This file does not match a ProjectionLab data export we recognize (expected a "currentFinances" section with accounts). ' +
|
|
27
|
+
'ProjectionLab may have changed its format — you can still bring balances over with the broker CSV or spreadsheet import.'
|
|
28
|
+
|
|
29
|
+
function asRecord(v: unknown): Record<string, unknown> | null {
|
|
30
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function asArray(v: unknown): unknown[] | null {
|
|
34
|
+
return Array.isArray(v) ? v : null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A finite, sane, non-negative dollar amount — anything else is null. Numeric
|
|
39
|
+
* strings ("350000", "$4,500") are accepted through the strict money parser,
|
|
40
|
+
* since JSON exports sometimes stringify amounts; junk still fails.
|
|
41
|
+
*/
|
|
42
|
+
function asDollars(v: unknown): number | null {
|
|
43
|
+
if (typeof v === 'string') {
|
|
44
|
+
const parsed = parseMoney(v)
|
|
45
|
+
return parsed !== null && parsed >= 0 ? parsed : null
|
|
46
|
+
}
|
|
47
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) return null
|
|
48
|
+
if (v < 0 || v > MAX_REASONABLE_DOLLARS) return null
|
|
49
|
+
return v
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function asString(v: unknown): string | null {
|
|
53
|
+
return typeof v === 'string' && v.trim() !== '' ? v.trim() : null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function firstString(rec: Record<string, unknown>, ...keys: string[]): string | null {
|
|
57
|
+
for (const k of keys) {
|
|
58
|
+
const s = asString(rec[k])
|
|
59
|
+
if (s !== null) return s
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function firstDollars(rec: Record<string, unknown>, ...keys: string[]): number | null {
|
|
65
|
+
for (const k of keys) {
|
|
66
|
+
const n = asDollars(rec[k])
|
|
67
|
+
if (n !== null) return n
|
|
68
|
+
}
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type MappedType = 'cash' | 'taxable' | 'traditional' | 'roth' | 'hsa' | 'property' | 'debt'
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Keyword-match a ProjectionLab account type/name onto a RetireGolden account
|
|
76
|
+
* class. Loan/debt keywords are checked first so "401k loan" or "IRA loan" is
|
|
77
|
+
* a liability, never a retirement asset.
|
|
78
|
+
*/
|
|
79
|
+
export function mapProjectionLabAccountType(type: string, name: string): MappedType | null {
|
|
80
|
+
const t = `${type} ${name}`.toLowerCase()
|
|
81
|
+
if (/mortgage|debt|\bloan\b|liabilit|heloc/.test(t)) return 'debt'
|
|
82
|
+
if (/\broth\b/.test(t)) return 'roth'
|
|
83
|
+
if (/\bhsa\b|health savings/.test(t)) return 'hsa'
|
|
84
|
+
if (/401|403|457\b|\bira\b|pretax|pre-tax|traditional|\btsp\b|rollover|sep\b|simple\b/.test(t)) return 'traditional'
|
|
85
|
+
if (/real.?estate|property|\bhome\b|house/.test(t)) return 'property'
|
|
86
|
+
if (/taxable|brokerage|investment/.test(t)) return 'taxable'
|
|
87
|
+
if (/cash|checking|savings|money market|emergency/.test(t)) return 'cash'
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function mapProjectionLabExport(
|
|
92
|
+
json: string,
|
|
93
|
+
newId: () => string = () => crypto.randomUUID(),
|
|
94
|
+
): ProjectionLabImportResult {
|
|
95
|
+
if (json.length > MAX_IMPORT_JSON_CHARS) {
|
|
96
|
+
return { ok: false, message: 'File is too large to be a ProjectionLab export.' }
|
|
97
|
+
}
|
|
98
|
+
let raw: unknown
|
|
99
|
+
try {
|
|
100
|
+
raw = JSON.parse(json)
|
|
101
|
+
} catch {
|
|
102
|
+
return { ok: false, message: 'This file is not valid JSON.' }
|
|
103
|
+
}
|
|
104
|
+
const root = asRecord(raw)
|
|
105
|
+
if (!root) return { ok: false, message: UNRECOGNIZED_MESSAGE }
|
|
106
|
+
|
|
107
|
+
const currentFinances = asRecord(root['currentFinances'])
|
|
108
|
+
const rawAccounts = currentFinances ? asArray(currentFinances['accounts']) : null
|
|
109
|
+
if (!currentFinances || !rawAccounts) return { ok: false, message: UNRECOGNIZED_MESSAGE }
|
|
110
|
+
|
|
111
|
+
const review: ImportReviewItem[] = []
|
|
112
|
+
const plan = createEmptyPlan({ newId, name: 'Imported from ProjectionLab' })
|
|
113
|
+
const person = plan.household.people[0]!
|
|
114
|
+
|
|
115
|
+
// --- Household ------------------------------------------------------------
|
|
116
|
+
const user = asRecord(root['user']) ?? asRecord(root['profile'])
|
|
117
|
+
const birthYearRaw = user?.['birthYear']
|
|
118
|
+
const birthYear =
|
|
119
|
+
typeof birthYearRaw === 'number' && Number.isInteger(birthYearRaw) && birthYearRaw > 1900 && birthYearRaw < 2100
|
|
120
|
+
? birthYearRaw
|
|
121
|
+
: null
|
|
122
|
+
if (birthYear !== null) {
|
|
123
|
+
person.dob = `${birthYear}-07-01`
|
|
124
|
+
review.push({
|
|
125
|
+
status: 'defaulted',
|
|
126
|
+
source: `Birth year ${birthYear}`,
|
|
127
|
+
detail: 'Date of birth set to July 1 of your ProjectionLab birth year — set the exact date on the Household screen.',
|
|
128
|
+
})
|
|
129
|
+
} else {
|
|
130
|
+
review.push({
|
|
131
|
+
status: 'unmapped',
|
|
132
|
+
source: 'Date of birth',
|
|
133
|
+
detail: 'The export carried no readable birth year — set your date of birth on the Household screen.',
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
review.push({
|
|
137
|
+
status: 'defaulted',
|
|
138
|
+
source: 'Filing status & state',
|
|
139
|
+
detail: `Filing status defaulted to single and state to ${plan.household.state} — ProjectionLab exports do not carry them. Set both on the Household screen.`,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// Retirement milestone age, when present on the first plan.
|
|
143
|
+
const firstPlan = asArray(root['plans'])?.map(asRecord).find((p) => p !== null) ?? null
|
|
144
|
+
const milestones = firstPlan ? (asArray(firstPlan['milestones']) ?? []) : []
|
|
145
|
+
for (const m of milestones) {
|
|
146
|
+
const rec = asRecord(m)
|
|
147
|
+
if (!rec) continue
|
|
148
|
+
const name = (firstString(rec, 'name', 'label') ?? '').toLowerCase()
|
|
149
|
+
const age = rec['age']
|
|
150
|
+
if (name.includes('retire') && typeof age === 'number' && age >= 30 && age <= 80) {
|
|
151
|
+
person.retirementAge = age
|
|
152
|
+
review.push({ status: 'mapped', source: `Milestone "${firstString(rec, 'name', 'label')!}"`, detail: `Retirement age set to ${age}.` })
|
|
153
|
+
break
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- Accounts ---------------------------------------------------------------
|
|
158
|
+
for (const entry of rawAccounts) {
|
|
159
|
+
const rec = asRecord(entry)
|
|
160
|
+
if (!rec) continue
|
|
161
|
+
const name = firstString(rec, 'name', 'label') ?? 'Imported account'
|
|
162
|
+
const typeStr = firstString(rec, 'type', 'accountType', 'category') ?? ''
|
|
163
|
+
const balance = firstDollars(rec, 'balance', 'value', 'currentBalance', 'amount')
|
|
164
|
+
if (balance === null) {
|
|
165
|
+
review.push({ status: 'skipped', source: name, detail: 'Account had no readable balance.' })
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
const mapped = mapProjectionLabAccountType(typeStr, name)
|
|
169
|
+
if (mapped === null) {
|
|
170
|
+
review.push({
|
|
171
|
+
status: 'unmapped',
|
|
172
|
+
source: `${name} (${typeStr || 'unknown type'}, $${balance.toLocaleString('en-US', { maximumFractionDigits: 0 })})`,
|
|
173
|
+
detail: 'Account type has no RetireGolden equivalent mapping — add it by hand on the Accounts screen.',
|
|
174
|
+
})
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const base = { id: newId(), name, annualReturnPct: null }
|
|
179
|
+
let account: Account
|
|
180
|
+
switch (mapped) {
|
|
181
|
+
case 'taxable': {
|
|
182
|
+
const costBasis = firstDollars(rec, 'costBasis', 'basis')
|
|
183
|
+
account = {
|
|
184
|
+
...base,
|
|
185
|
+
type: 'taxable',
|
|
186
|
+
ownerPersonId: null,
|
|
187
|
+
balance,
|
|
188
|
+
costBasis: costBasis ?? balance,
|
|
189
|
+
annualContribution: 0,
|
|
190
|
+
}
|
|
191
|
+
if (costBasis === null) {
|
|
192
|
+
review.push({
|
|
193
|
+
status: 'defaulted',
|
|
194
|
+
source: name,
|
|
195
|
+
detail: 'No cost basis in the export — basis was set equal to the balance (no unrealized gain). Correct it on the Accounts screen.',
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
break
|
|
199
|
+
}
|
|
200
|
+
case 'traditional':
|
|
201
|
+
account = {
|
|
202
|
+
...base,
|
|
203
|
+
type: 'traditional',
|
|
204
|
+
ownerPersonId: person.id,
|
|
205
|
+
kind: /401|403|457|tsp/i.test(`${typeStr} ${name}`) ? 'employer' : 'ira',
|
|
206
|
+
balance,
|
|
207
|
+
annualContribution: 0,
|
|
208
|
+
}
|
|
209
|
+
break
|
|
210
|
+
case 'roth':
|
|
211
|
+
account = {
|
|
212
|
+
...base,
|
|
213
|
+
type: 'roth',
|
|
214
|
+
ownerPersonId: person.id,
|
|
215
|
+
kind: /401|403|457|tsp/i.test(`${typeStr} ${name}`) ? 'employer' : 'ira',
|
|
216
|
+
balance,
|
|
217
|
+
annualContribution: 0,
|
|
218
|
+
}
|
|
219
|
+
break
|
|
220
|
+
case 'hsa':
|
|
221
|
+
account = { ...base, type: 'hsa', ownerPersonId: person.id, balance, annualContribution: 0 }
|
|
222
|
+
break
|
|
223
|
+
case 'cash':
|
|
224
|
+
account = { ...base, type: 'cash', ownerPersonId: null, balance, annualContribution: 0 }
|
|
225
|
+
break
|
|
226
|
+
case 'property':
|
|
227
|
+
account = { ...base, type: 'property', ownerPersonId: null, value: balance, plannedSaleYear: null, expectedNetProceeds: null }
|
|
228
|
+
break
|
|
229
|
+
case 'debt': {
|
|
230
|
+
const interestRaw = rec['interestRate']
|
|
231
|
+
const payment = firstDollars(rec, 'payment', 'monthlyPayment')
|
|
232
|
+
// Nothing imports silently: an unreadable rate falls back to 5% with a
|
|
233
|
+
// review item, and a fraction-looking rate (0.035) is scaled with one.
|
|
234
|
+
let interestPct = 5
|
|
235
|
+
let interestNote: string | null = 'No readable interest rate in the export — 5% was assumed. Set the real rate on the Accounts screen.'
|
|
236
|
+
if (typeof interestRaw === 'number' && Number.isFinite(interestRaw) && interestRaw >= 0 && interestRaw < 100) {
|
|
237
|
+
if (interestRaw > 0 && interestRaw < 1) {
|
|
238
|
+
interestPct = Math.round(interestRaw * 100 * 10000) / 10000
|
|
239
|
+
interestNote = `The export's interest rate of ${interestRaw} looked like a fraction — imported as ${interestPct}%. Check it on the Accounts screen.`
|
|
240
|
+
} else {
|
|
241
|
+
interestPct = interestRaw
|
|
242
|
+
interestNote = null
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
account = {
|
|
246
|
+
...base,
|
|
247
|
+
type: 'debt',
|
|
248
|
+
ownerPersonId: null,
|
|
249
|
+
balance,
|
|
250
|
+
interestPct,
|
|
251
|
+
monthlyPayment: payment ?? 0,
|
|
252
|
+
}
|
|
253
|
+
if (interestNote !== null) {
|
|
254
|
+
review.push({ status: 'defaulted', source: name, detail: interestNote })
|
|
255
|
+
}
|
|
256
|
+
if (payment === null) {
|
|
257
|
+
review.push({
|
|
258
|
+
status: 'defaulted',
|
|
259
|
+
source: name,
|
|
260
|
+
detail: 'No monthly payment in the export — set the real payment on the Accounts screen.',
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
break
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
plan.accounts.push(account)
|
|
267
|
+
review.push({
|
|
268
|
+
status: 'mapped',
|
|
269
|
+
source: `${name} (${typeStr || 'type from name'})`,
|
|
270
|
+
detail: `Imported as a ${mapped} account with a $${balance.toLocaleString('en-US', { maximumFractionDigits: 0 })} balance.`,
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// --- Income sources ---------------------------------------------------------
|
|
275
|
+
const rawIncomes = asArray(currentFinances['incomeSources']) ?? asArray(currentFinances['incomes']) ?? []
|
|
276
|
+
for (const entry of rawIncomes) {
|
|
277
|
+
const rec = asRecord(entry)
|
|
278
|
+
if (!rec) continue
|
|
279
|
+
const name = firstString(rec, 'name', 'label') ?? 'Income'
|
|
280
|
+
const annual = firstDollars(rec, 'annualAmount', 'annual', 'amount')
|
|
281
|
+
if (annual === null) {
|
|
282
|
+
review.push({ status: 'skipped', source: name, detail: 'Income source had no readable annual amount.' })
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
const typeStr = (firstString(rec, 'type', 'category') ?? '').toLowerCase()
|
|
286
|
+
const looksLikeWages = /employment|salary|wage|job|work/.test(`${typeStr} ${name.toLowerCase()}`)
|
|
287
|
+
if (looksLikeWages) {
|
|
288
|
+
plan.incomes.push({ type: 'wages', id: newId(), personId: person.id, annualGross: annual, endAge: null, realGrowthPct: 0 })
|
|
289
|
+
review.push({ status: 'mapped', source: name, detail: `Imported as wages of $${annual.toLocaleString('en-US')} /yr until retirement.` })
|
|
290
|
+
} else if (/social security|\bss\b|ssa/.test(`${typeStr} ${name.toLowerCase()}`)) {
|
|
291
|
+
review.push({
|
|
292
|
+
status: 'unmapped',
|
|
293
|
+
source: name,
|
|
294
|
+
detail: 'Social Security needs a claim age and benefit basis — set it up on the Social Security screen (you can import your SSA statement there).',
|
|
295
|
+
})
|
|
296
|
+
} else {
|
|
297
|
+
plan.incomes.push({
|
|
298
|
+
type: 'recurring',
|
|
299
|
+
id: newId(),
|
|
300
|
+
label: name,
|
|
301
|
+
annualAmount: annual,
|
|
302
|
+
startYear: null,
|
|
303
|
+
endYear: null,
|
|
304
|
+
inflationAdjusted: true,
|
|
305
|
+
taxTreatment: 'ordinary',
|
|
306
|
+
})
|
|
307
|
+
review.push({
|
|
308
|
+
status: 'defaulted',
|
|
309
|
+
source: name,
|
|
310
|
+
detail: `Imported as recurring ordinary income of $${annual.toLocaleString('en-US')} /yr with no end year — set dates and tax treatment on the Income screen.`,
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// --- Expenses ----------------------------------------------------------------
|
|
316
|
+
const rawExpenses = asArray(currentFinances['expenses']) ?? []
|
|
317
|
+
let expenseTotal = 0
|
|
318
|
+
const expenseNames: string[] = []
|
|
319
|
+
for (const entry of rawExpenses) {
|
|
320
|
+
const rec = asRecord(entry)
|
|
321
|
+
if (!rec) continue
|
|
322
|
+
const annual = firstDollars(rec, 'annualAmount', 'annual', 'amount')
|
|
323
|
+
if (annual === null) continue
|
|
324
|
+
expenseTotal += annual
|
|
325
|
+
const name = firstString(rec, 'name', 'label')
|
|
326
|
+
if (name) expenseNames.push(name)
|
|
327
|
+
}
|
|
328
|
+
if (expenseTotal > 0) {
|
|
329
|
+
plan.expenses.baseAnnual = expenseTotal
|
|
330
|
+
review.push({
|
|
331
|
+
status: 'defaulted',
|
|
332
|
+
source: expenseNames.length > 0 ? expenseNames.join(', ') : 'Expenses',
|
|
333
|
+
detail: `Baseline annual spending set to the $${expenseTotal.toLocaleString('en-US')} sum of your ProjectionLab expenses — RetireGolden models one baseline plus phases/goals, so re-shape it on the Spending screen (healthcare is modeled separately).`,
|
|
334
|
+
})
|
|
335
|
+
} else {
|
|
336
|
+
review.push({ status: 'unmapped', source: 'Spending', detail: 'No readable expenses in the export — set baseline spending on the Spending screen.' })
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
review.push({
|
|
340
|
+
status: 'unmapped',
|
|
341
|
+
source: 'Strategies, assumptions & scenarios',
|
|
342
|
+
detail: 'Withdrawal strategy, Roth conversions, market assumptions, and scenarios do not transfer between tools — review the Strategy and Assumptions screens.',
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
const parsed = parsePlan(plan)
|
|
346
|
+
if (!parsed.ok) {
|
|
347
|
+
return { ok: false, message: `The mapped plan failed validation: ${parsed.issues.join('; ')}` }
|
|
348
|
+
}
|
|
349
|
+
return { ok: true, plan: parsed.plan, review }
|
|
350
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared review-checklist model for every import mapper
|
|
3
|
+
* (onboarding-import-and-migration). The rule that keeps mapping honest:
|
|
4
|
+
* nothing imports silently — every value that lands in a draft plan, every
|
|
5
|
+
* default the mapper had to invent, and every source item it could NOT map
|
|
6
|
+
* becomes a visible review item.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type ImportItemStatus = 'mapped' | 'defaulted' | 'unmapped' | 'skipped'
|
|
10
|
+
|
|
11
|
+
export interface ImportReviewItem {
|
|
12
|
+
status: ImportItemStatus
|
|
13
|
+
/** Where in the source this came from (file row, export field, 1040 line). */
|
|
14
|
+
source: string
|
|
15
|
+
/** What happened in the draft plan — or why nothing did and what to do instead. */
|
|
16
|
+
detail: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const IMPORT_STATUS_LABEL: Record<ImportItemStatus, string> = {
|
|
20
|
+
mapped: 'Imported',
|
|
21
|
+
defaulted: 'Assumed — review',
|
|
22
|
+
unmapped: 'Not imported — add by hand',
|
|
23
|
+
skipped: 'Skipped',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Display order: what needs the user's eyes first. */
|
|
27
|
+
export const IMPORT_STATUS_ORDER: ImportItemStatus[] = ['unmapped', 'defaulted', 'mapped', 'skipped']
|
|
28
|
+
|
|
29
|
+
export function countByStatus(items: ImportReviewItem[]): Record<ImportItemStatus, number> {
|
|
30
|
+
const counts: Record<ImportItemStatus, number> = { mapped: 0, defaulted: 0, unmapped: 0, skipped: 0 }
|
|
31
|
+
for (const item of items) counts[item.status]++
|
|
32
|
+
return counts
|
|
33
|
+
}
|