@plan-fi/imports 0.6.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/AGENTS.md ADDED
@@ -0,0 +1,54 @@
1
+ # AGENTS.md — planfi-import
2
+
3
+ planfi-import turns raw financial data — aggregator API dumps (Plaid, MX, Finicity, FDX) or
4
+ user-downloaded CSV/OFX files — into a planfi `generate_financial_plan` wire body via one canonical
5
+ model (the CFP). Adapters translate provider vocabulary; ONE shared mapper (`src/to-planfi.mjs`)
6
+ does all the planfi thinking.
7
+
8
+ ## Invariants (non-negotiable)
9
+
10
+ - **NEVER fabricate values.** Missing cost basis stays `undefined` (+ `NO_COST_BASIS` info
11
+ warning). A value no data source can know (age, goals, home value, missing APR) becomes a
12
+ structured `needsInput` ask — the shared mapper emits those; adapters never invent defaults to
13
+ paper over gaps.
14
+ - **ALL domain logic lives in `src/to-planfi.mjs`.** Adapters ONLY translate one provider's
15
+ vocabulary into the Canonical Financial Profile (`src/canonical.ts`). If you are writing IRS
16
+ limits, LTV estimates, tax buckets, or wire fields inside an adapter, stop — that belongs in the
17
+ shared mapper, once.
18
+ - **Warnings use stable codes from the catalog.** Every judgment call is a
19
+ `{ code, severity, message, accountId? }` built with `warning()` from `src/util.mjs`; `code`
20
+ MUST be a member of the append-only `WarningCode` union in `src/canonical.ts` (mirror any
21
+ addition in `planfi-import.d.ts` — a test compares them). Never invent ad-hoc codes; never
22
+ repurpose an existing one.
23
+ - **Emit `needsInput` for anything a source can't know.** Fields come from the `NeedsInputField`
24
+ enum in `src/canonical.ts`; each entry carries a form-ready `label` and a one-sentence `why`.
25
+ - **Zero runtime dependencies.** Node built-ins and sibling modules only. `tsx`/`zod` are
26
+ test-only devDependencies; nothing under `src/` or `bin/` may import a package.
27
+ - **`normalize()` is a total, deterministic function.** Any input — `null`, junk, truncated files,
28
+ hostile arrays with `null` members — returns a structurally valid CFP; never throw. Same input →
29
+ same output (only exception: `defaultAsOf()` when the payload has no `asOf`).
30
+ - **Every adapter is fully registered.** `ADAPTERS` + named export in `src/index.mjs`, types in
31
+ `planfi-import.d.ts`, source list in `bin/planfi-import.mjs` USAGE, a fixture at
32
+ `fixtures/<id>-sandbox.mjs` exporting `<id>Raw`, registered in
33
+ `test/helpers/fixture-registry.mjs` (which feeds wire-conformance), a `test/<id>.test.mjs`, and
34
+ a generator in `test/fuzz.test.mjs`. `test/adapter-contract.test.mjs` enforces most of this —
35
+ run it and read its failure messages.
36
+
37
+ ## Verify (run all three; all must pass)
38
+
39
+ ```bash
40
+ npm ci # installs the test-only dev deps (runtime stays zero-dep)
41
+ node --test # every suite: per-adapter, contract harness, fuzz, CLI, wire-conformance
42
+ npm run demo # prints a full ImportResult from the Plaid fixture — must emit valid JSON
43
+ ```
44
+
45
+ `node --test` must end `fail 0`. Inside the planfi-app monorepo, wire-conformance also round-trips
46
+ every fixture through the real engine mapper; in the standalone repo it skips loudly (that skip is
47
+ expected and not a failure).
48
+
49
+ ## To add an adapter
50
+
51
+ Follow **docs/ADAPTER_GUIDE.md** step by step — it contains the canonical-model reference, the
52
+ copy-me template (`src/adapters/_template.mjs`), the classification cheat sheet, the warning-code
53
+ catalog with when-to-emit rules, fixture requirements, and a self-verification checklist whose
54
+ checks are the executable tests in `test/adapter-contract.test.mjs`.
package/CHANGELOG.md ADDED
@@ -0,0 +1,215 @@
1
+ # Changelog
2
+
3
+ > **npm:** published as **`@plan-fi/imports`** (public, scoped to the `plan-fi` org) as of 0.6.0.
4
+
5
+
6
+ All notable changes to `planfi-import`. The format follows
7
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
8
+ [SemVer](https://semver.org/) (pre-1.0: minor bumps may break).
9
+
10
+ ## 0.6.0 — 2026-07-03
11
+
12
+ ### Added
13
+
14
+ - **`batch` CLI command** — bulk-load thousands of customers through the managed
15
+ `import_financial_data_batch` endpoint (25 items per call):
16
+ `npx planfi-import batch <dir-or-ndjson> --source plaid --token pft_…
17
+ [--concurrency 4] [--resume manifest.json] [--batch-size 25 | --single]`.
18
+ Input is a directory of `<user_id>.json` payload files (filename stem = `user_id`) or an
19
+ `.ndjson` file (`{"user_id", "payload"[, "plan_name", "source"]}` per line). The command
20
+ chunks the load through the batch endpoint with a small worker pool, writes a **resume
21
+ manifest / results file** (per-customer `ok`/`plan_id`/`updated`/`error` plus **full
22
+ `needsInput` objects** — `field`/`label`/`accountId` — for collection worklists), skips
23
+ already-ok items on re-run, never crashes on one malformed file (recorded, run continues),
24
+ and prints a final report with the missing-data rollup (needsInput field → customers).
25
+ Exit 0 all-ok / 1 if any item failed / 2 usage error.
26
+ - **Import upserts (server-side; documented here because the CLI relies on them)** — the
27
+ managed import tools now key on **(partner account, `user_id`)**: the first import for a
28
+ customer mints their plan; every re-import for the same `user_id` **updates that plan in
29
+ place** (same `plan_id` back, `updated: true`). Batch runs are therefore idempotent — the
30
+ resume manifest just saves quota. This deliberately differs from `generate_financial_plan`,
31
+ which always mints; the `plan` command (which uses `generate_financial_plan`) is unchanged.
32
+ - **Consumer-tool CSV dialects** — the existing `csv` adapter's dialect table now fingerprints the
33
+ export formats of the consumer finance tools the FIRE audience actually uses (no new adapters —
34
+ dialect work inside `src/adapters/csv.mjs`):
35
+ - **Monarch Money balances** (`Date/Account/Account Type/Institution/Balance`) — the file is a
36
+ balance HISTORY, so rows are collapsed to the newest date per account (history is never
37
+ summed). Explicit `Account Type` values classify at high confidence; the Monarch-specific
38
+ `Real Estate`/`Vehicle`/`Valuables` types become `property`-class accounts (owned homes /
39
+ physical assets), not fake investment balances.
40
+ - **Monarch Money transactions** (`Date/Merchant/Category/Account/Original Statement/Notes/
41
+ Amount/Tags`) — Mint-style signs; the Category vocabulary feeds contribution inference with
42
+ "Dividends & Capital Gains"/"Interest" excluded as growth.
43
+ - **YNAB register** (`Account/Flag/Date/Payee/Category Group/Category/Memo/Outflow/Inflow/
44
+ Cleared`) — the `Outflow`/`Inflow` column PAIR nets to a signed amount; transfers into
45
+ tracking accounts count as contributions while reconciliation balance adjustments (market
46
+ growth) do not. YNAB structurally exports **no balances**, so the import emits the new
47
+ `CSV_TRANSACTIONS_ONLY` warning telling the caller to pair the register with a balances file.
48
+ - **Empower / Personal Capital holdings** (`Account/Ticker/Name/Shares/Price/…/Value`,
49
+ community-documented column set) — rows group into accounts by the `Account` column; the
50
+ export carries no cost basis → `NO_COST_BASIS` per holding. Empower offers no official
51
+ all-accounts balances CSV; a hand-assembled one falls through to generic-accounts deliberately.
52
+ - **Copilot Money transactions** (community-documented, LOW-confidence dialect fingerprinted on
53
+ its `parent category` column) — Copilot's INVERTED sign convention (spending positive,
54
+ money-in negative) is flipped before inference; if the convention is ever wrong, inflows read
55
+ as outflows and are excluded (conservative — no contributions fabricated). Copilot's accounts
56
+ export fingerprints as generic-accounts by design.
57
+ - **`CSV_TRANSACTIONS_ONLY` warning code** (append-only catalog addition, registered in
58
+ `src/canonical.ts` + `planfi-import.d.ts` + the README/guide catalogs): the imported file's tool
59
+ structurally cannot export balances — pair it with a balances source.
60
+ - Transactions dialects gained per-dialect vocabulary switches: `labelKeys` (compose the
61
+ growth/inflow test label from several columns), `amountSign: -1` (inverted-sign sources), and an
62
+ Outflow/Inflow column-pair path; `paycheck` joined the shared inflow words.
63
+ - CSV `Type`/name cells naming real estate or vehicles now classify as `property` (same
64
+ routing-around-`classify()` pattern as the MX adapter), so they reach the plan as real estate,
65
+ not as a taxable-investment guess.
66
+ - The `csv` sandbox fixture now carries one realistic synthetic file per consumer dialect (seven
67
+ files total) and still round-trips the real monorepo mapper in wire-conformance.
68
+
69
+ ## [0.4.0] — 2026-07-02
70
+
71
+ ### Added
72
+
73
+ - **FDX adapter** — `importToPlan('fdx', { accounts, holdings, transactions, owner, asOf })`
74
+ for the Financial Data Exchange standard (the US open-banking vocabulary named by the CFPB
75
+ §1033 rule; Akoya speaks it natively). Accepts FDX Account entities both WRAPPED in their
76
+ shape keys (`{ depositAccount: {…} }`, `{ investmentAccount }`, `{ loanAccount }`,
77
+ `{ locAccount }`, `{ lineOfCredit }`, `{ annuityAccount }`) and already flattened; the wrapper
78
+ key doubles as the fallback class signal for unknown `accountType` values. Maps the FDX
79
+ `accountType` enum (CHECKING/SAVINGS/CD/MONEYMARKET → depository; BROKERAGE/IRA/ROTH/ROTH401K/
80
+ 401K/403B/457/529/HSA/KEOGH/SEPIRA/SIMPLEIRA → investment with matching tax treatment;
81
+ TDA/ANNUITY → traditional at low confidence, warned; MORTGAGE/HOMEEQUITYLOAN + LOAN/AUTOLOAN/
82
+ STUDENTLOAN/PERSONALLOAN → loans; CREDITCARD/LINEOFCREDIT → credit), InvestmentHolding
83
+ (holdingType/symbol/units/marketValue/costBasis — DIGITALASSET → speculative crypto; missing
84
+ cost basis never fabricated), and transactions with `debitCreditMemo` respected (DEBITs never
85
+ count as contributions). Liability balances are treated as positive amounts owed per FDX
86
+ conventions with an `|x|` defense; a depositAccount `interestRate` is a savings yield and never
87
+ becomes a debt APR. Ships with a two-earner sandbox fixture, a full test file, fuzz coverage
88
+ (wrapped AND flat entities), wire-conformance registration, and enum-alignment notes in
89
+ `canonical.ts`.
90
+ - **Adapter-contract harness** — `test/adapter-contract.test.mjs`: one GENERIC suite that
91
+ discovers every adapter in `ADAPTERS` and runs the identical battery — (a) `normalize(fixture)`
92
+ yields a structurally valid CFP (new `test/helpers/validate-cfp.mjs` validator) clearing a
93
+ content floor, (b) `toPlanfiPlan` succeeds with every warning code from the append-only catalog
94
+ and every needsInput field from the enum (both PARSED out of `src/canonical.ts`, with
95
+ `planfi-import.d.ts` asserted to mirror them), (c) hostile inputs never throw
96
+ (null/undefined/primitives/null-member arrays + 60 deterministic scrambles of each adapter's
97
+ own fixture), (d) determinism (two identical runs → deep-equal), (e) every adapter has a
98
+ fixture registered for wire-conformance — the fixture list moved to
99
+ `test/helpers/fixture-registry.mjs`, shared by both suites so they cannot drift.
100
+ - **AI-agent authoring docs** — `AGENTS.md` (repo purpose, the invariants as imperatives, exact
101
+ verify commands) and `docs/ADAPTER_GUIDE.md` (ships in the npm package): canonical-model
102
+ reference table, the adapter contract, classification cheat sheet, warning-code catalog with
103
+ when-to-emit rules, fixture requirements, registration checklist, and a self-verification
104
+ checklist whose checks are the contract harness. Plus `src/adapters/_template.mjs`, a fully
105
+ commented copy-me skeleton that emits an empty-but-valid CFP, is excluded from registration,
106
+ and is covered by a guide-consistency test.
107
+
108
+ ### Fixed
109
+
110
+ _All three found by the new contract harness's hostile-input battery (the fuzz suite generated
111
+ plausible payloads and never hit these):_
112
+
113
+ - **Every adapter threw on `normalize(null)`** — the `raw = {}` default parameter only covers
114
+ `undefined`. All adapters now coerce non-object payloads to `{}` (a total function returns an
115
+ empty profile instead of `TypeError`).
116
+ - **Plaid/MX/Finicity threw on `null` members inside provider arrays** (`accounts`, `holdings`,
117
+ `positions`, `transactions`, `liabilities.*`, income streams). New `objs()` helper in
118
+ `src/util.mjs` drops non-object members at every array boundary.
119
+ - **Finicity threw `RangeError: Invalid time value`** on absurd epoch-second dates (beyond the
120
+ ECMAScript ±8.64e15 ms range) — `finDateIso` now returns `undefined` for out-of-range values.
121
+ - **`toPlanfiPlan` threw on non-object members in a caller-supplied `owner.earners` array** —
122
+ they are now treated as empty earner contexts (their demographics surface as needsInput asks).
123
+
124
+ ## [0.3.0] — 2026-07-02
125
+
126
+ ### Added
127
+
128
+ - **CSV adapter (keyless)** — `importToPlan('csv', { files, owner, asOf })`:
129
+ dependency-free CSV parsing (quoted fields, embedded commas/newlines, CRLF,
130
+ BOM, unclosed quotes never throw) and a header-fingerprint DIALECTS table:
131
+ Fidelity positions, Schwab positions, Vanguard downloads, generic accounts,
132
+ and generic transactions layouts. Money cells handle `$`, thousands commas,
133
+ and `(1,850.00)` accounting negatives. Files matching no dialect import
134
+ best-effort with the new **`CSV_UNMAPPED_COLUMNS`** warning code (append-only
135
+ catalog) naming the skipped columns. Account types classify from a Type
136
+ column; absent one, name hints are used and ALWAYS surfaced as
137
+ `CLASSIFICATION_GUESSED`. Transactions files feed the shared contribution
138
+ inference with the same growth-exclusion rules as the API adapters.
139
+ - **OFX adapter (keyless)** — `importToPlan('ofx', { content, owner, asOf })`:
140
+ one tolerant dependency-free parser for both OFX 1.x SGML (unclosed leaves)
141
+ and 2.x XML. Reads BANKMSGSRSV1 (checking/savings/CD/money-market balances;
142
+ ACCTTYPE CREDITLINE → revolving credit), CREDITCARDMSGSRSV1 (card balances —
143
+ OFX reports them NEGATIVE; normalized to positive amount owed, tested),
144
+ INVSTMTMSGSRSV1 (POSSTOCK/POSMF/POSDEBT/POSOTHER positions with SECID →
145
+ SECLISTMSGSRSV1 ticker/name lookup, UNITS/MKTVAL, INVBAL cash) and
146
+ INVBANKTRAN deposits for contribution inference (INCOME/dividends excluded
147
+ as growth). OFX carries no tax-treatment info → investment accounts are
148
+ taxable at LOW confidence with `CLASSIFICATION_GUESSED`; no cost basis
149
+ exists in the format (info-noted, never fabricated).
150
+ - **CLI** — `npx planfi-import` (zero-dep, Node ≥ 18, `bin` wired):
151
+ `demo [--source id]` runs a bundled fixture offline; `validate <payload…>
152
+ --source <id>` prints structured warnings/needsInput and exits 0 unless the
153
+ import hard-fails (warnings are diagnostics); `plan <payload…> --source <id>
154
+ [--token pft_…] [--user-id <id>] [--base <url>]` creates a real plan via
155
+ `POST /v1/tools/generate_financial_plan` and prints the `plan_id`
156
+ (`--user-id` is sent as the `X-Planfi-User-Id` end-user attribution header).
157
+ CSV/OFX payloads are file paths passed directly; `--json` everywhere;
158
+ colors only on a TTY; unknown args → help + exit 2. Tested via
159
+ child-process spawns with the `plan` command hitting a node:http mock
160
+ server, never the real API.
161
+ - Both new adapters are registered in `ADAPTERS`, exported from the package
162
+ root, typed in `planfi-import.d.ts`, covered by sandbox fixtures + full test
163
+ files, added to the wire-conformance suite (fixtures round-trip the real
164
+ monorepo mapper) and to the fuzz suite (hostile/truncated CSV and OFX never
165
+ throw).
166
+
167
+ ## [0.2.0] — 2026-07-02
168
+
169
+ ### Breaking
170
+
171
+ - **`needsInput` entries are structured objects**, not strings. Each is
172
+ `{ field, accountId?, accountName?, earnerIndex?, label, why }` with `field`
173
+ one of `age | retirement_age | annual_salary | desired_annual_spend |
174
+ home_value | debt_rate`. Entries are de-duplicated on
175
+ `(field, accountId, earnerIndex)` and emitted in deterministic order
176
+ (earner demographics → per-account asks in account order → plan-level goals).
177
+ Migration: `needsInput.includes('age')` → `needsInput.some(n => n.field === 'age')`;
178
+ `'home_value:<name>'` prefixes → `n.field === 'home_value'` + `n.accountId`.
179
+ - **`warnings` entries are structured objects**, not strings. Each is
180
+ `{ code, severity: 'info' | 'warn', message, accountId? }` where `code` is a
181
+ stable SCREAMING_SNAKE id (see the README warnings catalog). Codes are
182
+ append-only. Migration: `/regex/.test(w)` → match on `w.code` (or `w.message`).
183
+ - `cfp.meta.warnings` (adapter-level warnings on the canonical profile) carry
184
+ the same structured shape.
185
+
186
+ ### Added
187
+
188
+ - **Finicity (Mastercard Open Banking) adapter** — `importToPlan('finicity', …)`
189
+ with the full Finicity account-type vocabulary (`investmentTaxDeferred`,
190
+ `529plan`, `homeEquityLoan`, `studentLoan`, …), positions → holdings,
191
+ transaction-based contribution inference (epoch-second dates handled,
192
+ dividends/interest excluded), and loan `detail` fields → liability shape.
193
+ Ships with a synthetic sandbox fixture, a full test file, fuzz coverage, and
194
+ wire-conformance round-tripping through the real monorepo mapper.
195
+ - **TypeScript declarations** — hand-written `planfi-import.d.ts` covering
196
+ `importToPlan`, `toPlanfiPlan`, adapters, the CFP, and the new structured
197
+ result types; wired via the `types` field/export condition. Runtime stays
198
+ zero-dependency ESM.
199
+ - `classify()` understands `tax-deferred` subtypes (traditional treatment at
200
+ low confidence) for Finicity's `investmentTaxDeferred`.
201
+
202
+ ### Notes on 0.1.x
203
+
204
+ - `0.1.0` was the initial release (Plaid + MX). Three wire-mapping bugs in it
205
+ were fixed on `main` after release, before this version: retirement balances
206
+ were omitted from the `stocks` total (silently shrinking projections), the
207
+ package emitted an `hsa_retirement` field that does not exist on the wire,
208
+ and `education_account` used snake_case keys the engine dropped. `0.2.0` is
209
+ the first tagged version carrying those fixes — if you are on `0.1.0`,
210
+ upgrade; do not pin it.
211
+
212
+ ## [0.1.0] — 2026-07-01
213
+
214
+ - Initial release: canonical model (CFP), Plaid + MX adapters, shared
215
+ `toPlanfiPlan` mapper, contribution inference, fuzz + fixture tests.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kameron Kales
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,314 @@
1
+ # planfi-import
2
+
3
+ [![tests](https://github.com/holdequity/planfi-import/actions/workflows/test.yml/badge.svg)](https://github.com/holdequity/planfi-import/actions/workflows/test.yml)
4
+ ![zero runtime deps](https://img.shields.io/badge/runtime%20deps-0-brightgreen)
5
+ ![node >= 18](https://img.shields.io/badge/node-%E2%89%A5%2018-blue)
6
+ ![license MIT](https://img.shields.io/badge/license-MIT-lightgrey)
7
+
8
+ **Turn raw financial data — an aggregator dump (Plaid, MX, Finicity, FDX) or the CSV/OFX files a
9
+ user can download from any bank — into a [planfi](https://api.planfi.app) financial plan.
10
+ One function call, zero runtime dependencies. Ships a CLI.**
11
+
12
+ You don't need to know Plaid or planfi to use this. An *aggregator* is a service that, with a
13
+ customer's permission, fetches their real bank/brokerage/loan data as JSON. *planfi* is a financial
14
+ projection engine with a public API: you POST a plan (balances, salaries, debts…) and get back a
15
+ `plan_id` that unlocks 100+ analysis tools (FIRE date, Roth conversions, Monte Carlo backtesting…).
16
+ This package is the bridge between the two.
17
+
18
+ ```
19
+ Plaid ────┐
20
+ MX ───────┤
21
+ Finicity ─┤
22
+ FDX ──────┼─ adapter.normalize() ─► Canonical Financial ─ toPlanfiPlan() ─► wire body ─ POST ─► plan_id
23
+ CSV files ┤ (vocabulary only) Profile (CFP) (all domain (generate_
24
+ OFX files ┘ logic, once) financial_plan)
25
+ ```
26
+
27
+ Adapters translate each provider's vocabulary into one canonical model; a single shared mapper does
28
+ all the planfi thinking. Adding a provider never means re-writing the domain logic — to write a new
29
+ adapter (human or AI), follow [docs/ADAPTER_GUIDE.md](./docs/ADAPTER_GUIDE.md) (invariants in
30
+ [AGENTS.md](./AGENTS.md)); a generic contract harness (`test/adapter-contract.test.mjs`) enforces
31
+ the guide.
32
+
33
+ ## Quick start (90 seconds)
34
+
35
+ Install (not yet on npm — install from GitHub):
36
+
37
+ ```bash
38
+ npm install @plan-fi/imports
39
+ ```
40
+
41
+ **1. Import.** Pass the merged responses from your aggregator's endpoints (a bundled sandbox
42
+ fixture works out of the box if you don't have credentials yet):
43
+
44
+ ```js
45
+ import { importToPlan } from '@plan-fi/imports';
46
+ // No Plaid account? Use the bundled fixture: fixtures/plaid-sandbox.mjs
47
+ import { plaidRaw } from '@plan-fi/imports/fixtures/plaid-sandbox.mjs';
48
+
49
+ const { plan, warnings, needsInput } = importToPlan('plaid', plaidRaw);
50
+ ```
51
+
52
+ The emitted `plan` is a complete `generate_financial_plan` request body (real fixture output,
53
+ trimmed):
54
+
55
+ ```jsonc
56
+ {
57
+ "name": "Imported plan (plaid)",
58
+ "earners": [{ "name": "Alex", "age": 41, "annual_salary": 185000,
59
+ "retirement_accounts": { "k401": { "employee_annual": 21600 } } }, /* … */],
60
+ "stocks": { "current_value": 680000, "monthly_contribution": 2400, "annual_return": 0.07 },
61
+ "account_balances": { "taxable": 255000, "traditional": 315000, "roth": 88000 },
62
+ "real_estate": [{ "current_value": 640000, "mortgage": { "balance": 512000, "rate": 0.0625 } }],
63
+ "debts": [{ "name": "Student loan", "balance": 28000, "rate": 0.055, "min_payment": 310 }],
64
+ "education_account": { "enabled": true, "initialBalance": 41000 },
65
+ "desired_annual_spend": 90000, "tax_settings": { "state": "CA" }
66
+ }
67
+ ```
68
+
69
+ **2. Mint the plan.** POST it to the public planfi API:
70
+
71
+ ```bash
72
+ node -e "import('@plan-fi/imports').then(async ({ importToPlan }) => {
73
+ const { plaidRaw } = await import('@plan-fi/imports/fixtures/plaid-sandbox.mjs');
74
+ process.stdout.write(JSON.stringify(importToPlan('plaid', plaidRaw).plan));
75
+ })" > plan.json
76
+
77
+ curl -X POST https://api.planfi.app/v1/tools/generate_financial_plan \
78
+ -H 'Content-Type: application/json' \
79
+ --data @plan.json
80
+ ```
81
+
82
+ ```jsonc
83
+ { "plan_id": "plan_af83…", "fire_age": 58, /* …full projection… */ }
84
+ ```
85
+
86
+ (Anonymous calls get a small free monthly quota; add `-H "Authorization: Bearer $PLANFI_API_TOKEN"`
87
+ with a free API key for more.)
88
+
89
+ **3. Use it.** You now have a `plan_id` — every planfi tool accepts it
90
+ (`analyze_roth_conversion`, `run_backtesting`, `analyze_fire_number`, …). The connected accounts
91
+ became a living financial plan.
92
+
93
+ For MX: `importToPlan('mx', { accounts, holdings, transactions, owner, asOf })`.
94
+ For Finicity: `importToPlan('finicity', { accounts, positions, transactions, owner, asOf })`.
95
+ For FDX: `importToPlan('fdx', { accounts, holdings, transactions, owner, asOf })` — accounts may be
96
+ FDX-wrapped (`{ depositAccount: {…} }`) or flattened.
97
+ No aggregator at all? See [Keyless import (CSV / OFX)](#keyless-import-csv--ofx) and the [CLI](#cli).
98
+ Each adapter's file header documents exactly which provider endpoints feed each key.
99
+ `owner` is your onboarding data (ages, goals — see [needsInput](#what-imports-vs-what-you-must-collect)).
100
+
101
+ ## What maps where
102
+
103
+ | Source (Plaid / MX / Finicity / FDX) | Plan field | What the engine does with it |
104
+ |---|---|---|
105
+ | depository accounts (`checking`, `SAVINGS`, `cd`, …) | `cash.current_value` | Grows at the cash rate; funds spending first |
106
+ | taxable investment (`brokerage`, `INVESTMENT`, `brokerageAccount`) | `stocks.current_value` + `account_balances.taxable` | Projected at `annual_return`; taxed as taxable in decumulation |
107
+ | pre-tax retirement (`401k`, `IRA`, `rollover`, `investmentTaxDeferred`) | `stocks.current_value` + `account_balances.traditional` | In the portfolio total; withdrawn as ordinary income |
108
+ | Roth accounts (`roth`, `ROTH_IRA`) | `stocks.current_value` + `account_balances.roth` | In the portfolio total; withdrawn tax-free |
109
+ | HSA balance | folded into `stocks.current_value` (warned) | No wire HSA-balance field exists — see [limitations](#limitations-honest) |
110
+ | 529 / education (`529`, `529plan`, `educationIRA`) | `education_account.initialBalance` | Dedicated education projection (camelCase inside — engine shape) |
111
+ | mortgage + property (MX `PROPERTY` pairs a real value; Plaid/Finicity/FDX have none → 80%-LTV estimate) | `real_estate[]` with `mortgage {balance, rate, years_remaining}` | Amortizes the loan, appreciates the home at 3.5%/yr |
112
+ | student/auto loans, credit cards | `debts[]` (`balance`, `rate`, `min_payment`) | Paid down in cash flow; APR compounds |
113
+ | crypto holdings (security type) | `speculative[]` at 10% assumed growth | Kept out of the core stock projection |
114
+ | investment transactions (deposits in) | `stocks.monthly_contribution` / `earners[].retirement_accounts.{k401,ira,hsa}` | Inferred savings rates (dividends/interest excluded; IRS-limit clamped) |
115
+ | `owner` context you pass (ages, salary, goals) | `earners[]`, `desired_annual_spend`, `tax_settings.state` | Drives retirement timing and tax math |
116
+
117
+ Everything else the provider sent lands untouched in `cfp.meta.unmapped` — nothing is silently
118
+ dropped. The full canonical profile (`cfp`) preserves per-holding ticker/shares/cost-basis.
119
+
120
+ ## What imports vs what you must collect
121
+
122
+ Aggregators know *balances*, not *people*. Anything they can't know arrives in `needsInput` as a
123
+ structured, form-ready ask — with the why:
124
+
125
+ ```jsonc
126
+ // needsInput — real fixture output
127
+ [{
128
+ "field": "home_value",
129
+ "accountId": "mtg1",
130
+ "accountName": "Home mortgage",
131
+ "label": "Home value for Home mortgage",
132
+ "why": "The provider reported the mortgage but not the property's market value — currently estimated at 80% LTV."
133
+ }]
134
+ ```
135
+
136
+ | `field` | Why the import can't supply it |
137
+ |---|---|
138
+ | `age`, `retirement_age` | Aggregators report balances, not birthdays or goals (`earnerIndex` says whose) |
139
+ | `annual_salary` | Only payroll-linked products (e.g. Plaid Income) carry it; otherwise ask |
140
+ | `desired_annual_spend` | A retirement-spending goal — no account data implies it |
141
+ | `home_value` | Providers report the *mortgage*, not the home's market value (MX `PROPERTY` is the exception) |
142
+ | `debt_rate` | Some institutions omit the APR; the debt is modeled at 0% (optimistic) until supplied |
143
+
144
+ Entries are de-duplicated on `(field, accountId, earnerIndex)` and deterministic in order. Collect
145
+ them at onboarding, merge into `owner` (or patch the plan), re-run.
146
+
147
+ ## Warnings catalog
148
+
149
+ Every judgment call is surfaced as `{ code, severity, message, accountId? }`. Codes are **stable and
150
+ append-only** — switch on them; the human `message` may improve between versions.
151
+
152
+ | Code | Sev | Meaning → suggested handling |
153
+ |---|---|---|
154
+ | `CLASSIFICATION_GUESSED` | warn | Ambiguous account type; tax treatment guessed → show the account, let the user reclassify |
155
+ | `NO_COST_BASIS` | info | Institution omitted a holding's cost basis → fine for projections; collect before tax-lot analysis |
156
+ | `COARSE_INFERENCE` | warn | Unlabeled deposits counted as contributions → have the user verify savings rates |
157
+ | `CONTRIBUTION_CLAMPED` | warn | Inferred contribution exceeded the IRS limit; clamped → likely a rollover; verify |
158
+ | `CONTRIBUTION_IMPLAUSIBLE` | warn | Inferred savings > 50% of known salary → likely transfers counted; verify |
159
+ | `HSA_FOLDED_INTO_PORTFOLIO` | info | HSA balance modeled inside the aggregate portfolio → no action; see limitations |
160
+ | `HSA_COVERAGE_ASSUMED` | info | HSA coverage type assumed `family` → ask self/family if precision matters |
161
+ | `IRA_SPLIT_ASSUMED` | info | Trad + Roth IRA contributions merged as type `both` (engine models 50/50) → note if lopsided |
162
+ | `HOME_VALUE_ESTIMATED` | warn | Home value estimated at 80% LTV → replace via the `home_value` ask |
163
+ | `MORTGAGE_SKIPPED` | warn | Mortgage had no balance or value; dropped → check the source record |
164
+ | `NEGATIVE_BALANCE_CLAMPED` | warn | Negative *asset* balance clamped to $0 → check for margin/overdraft |
165
+ | `DEBT_RATE_MISSING` | warn | Debt modeled at 0% APR → supply the rate via the `debt_rate` ask |
166
+ | `CSV_UNMAPPED_COLUMNS` | warn | CSV columns matched no dialect mapping; named in the message → rename headers or accept the best-effort import |
167
+ | `CSV_TRANSACTIONS_ONLY` | warn | The file's tool (e.g. YNAB) structurally exports no account balances → pair it with a balances file or collect balances from the user |
168
+ | `IMPORT_EMPTY` | warn | Zero accounts recognized in the payload — almost always a format/shape problem. At batch scale, a systematic export error shows up as this code in the rollup instead of hiding behind ok-counts. |
169
+
170
+ ## Limitations (honest)
171
+
172
+ - **No catch-up contributions.** Inferred contributions clamp to the base 2026 IRS limits
173
+ (401k $24,500 / IRA $7,500 / HSA family $8,750); age-50+ catch-ups are not modeled.
174
+ - **Home values estimated at 80% LTV** when the provider has no property record (Plaid, Finicity) —
175
+ always warned, always asked for.
176
+ - **HSA balances ride inside the portfolio total.** The wire schema has no HSA-balance field; the
177
+ engine's dedicated `hsaRetirement` block is `NetWorthInput`-only (targeting it is the next hop,
178
+ alongside per-ticker `individualHoldings`).
179
+ - **IRA `both` = 50/50.** An earner with both traditional and Roth IRA contributions gets one wire
180
+ block the engine splits evenly, whatever the real split (warned with the real numbers).
181
+ - **Contribution inference is a heuristic.** Transfers and rollovers look like savings in a
182
+ transaction feed; MX/Finicity credits without labels are counted coarsely (`COARSE_INFERENCE`).
183
+ Sanity checks (salary %, IRS limits) warn, not fix.
184
+ - **A defined-benefit pension "balance"** is bucketed as a traditional account, low confidence — a
185
+ coarse stand-in for an income stream.
186
+ - **Keyless formats guess account types.** CSV positions exports and OFX carry no tax-treatment
187
+ vocabulary — types come from a CSV Type column when present, else the account NAME, else default
188
+ to taxable; every guess is a `CLASSIFICATION_GUESSED` warning. OFX cost basis doesn't exist in
189
+ the format; OFX 401(k)-specific records (`INV401K`) and CSV non-US number formats (`1.234,56`)
190
+ are not parsed.
191
+
192
+ ## Adapters
193
+
194
+ | Source | `importToPlan(id, …)` | Notes |
195
+ |---|---|---|
196
+ | Plaid | `'plaid'` | accounts + holdings + liabilities + income + investment transactions |
197
+ | MX | `'mx'` | accounts + holdings + transactions; `PROPERTY` gives real home values |
198
+ | Finicity (Mastercard Open Banking) | `'finicity'` | accounts + positions + transactions; epoch-second dates handled |
199
+ | FDX (Financial Data Exchange) | `'fdx'` | the US open-banking standard (CFPB §1033; Akoya speaks it natively); wrapped or flat Account entities, `debitCreditMemo`-aware contribution inference |
200
+ | CSV files | `'csv'` | keyless; dialect table for Fidelity/Schwab/Vanguard positions, Monarch Money, YNAB, Empower/Personal Capital, Copilot Money exports + generic accounts/transactions layouts |
201
+ | OFX files | `'ofx'` | keyless; OFX 1.x SGML and 2.x XML; bank + card + investment message sets |
202
+
203
+ ### Keyless import (CSV / OFX)
204
+
205
+ No aggregator contract? Every US bank and brokerage still offers **Download → CSV** and most offer
206
+ **Download → Quicken (.ofx/.qfx)**. The `csv` and `ofx` adapters turn those files into the same
207
+ canonical profile — same mapper, same warnings, same `needsInput` asks:
208
+
209
+ ```js
210
+ import { importToPlan } from '@plan-fi/imports';
211
+ import { readFileSync } from 'node:fs';
212
+
213
+ // CSV: any mix of files; the header fingerprint picks the dialect per file
214
+ const { plan, warnings } = importToPlan('csv', {
215
+ files: [
216
+ { name: 'fidelity-positions.csv', content: readFileSync('fidelity-positions.csv', 'utf8') },
217
+ { name: 'accounts.csv', content: readFileSync('accounts.csv', 'utf8') },
218
+ ],
219
+ owner: { age: 39, retirementAge: 60, annualSalary: 165000 },
220
+ });
221
+
222
+ // OFX: one statement file (SGML or XML — both parse)
223
+ importToPlan('ofx', { content: readFileSync('statement.ofx', 'utf8'), owner: { age: 45 } });
224
+ ```
225
+
226
+ Recognized CSV dialects — brokerages: **Fidelity positions** (Account Number/Account Name/Symbol/…/
227
+ Cost Basis Total), **Schwab positions** (Symbol/Description/Qty/Price/Mkt Val/Cost Basis),
228
+ **Vanguard downloads** (Account Number/Investment Name/Symbol/Shares/Share Price/Total Value).
229
+ Consumer finance tools: **Monarch Money** balances (Date/Account/Account Type/Institution/Balance —
230
+ a balance HISTORY, collapsed to the newest row per account, never summed) and transactions
231
+ (Date/Merchant/Category/…/Amount; the "Dividends & Capital Gains" category is excluded as growth),
232
+ **YNAB** register (Account/…/Outflow/Inflow pair — transactions ONLY: YNAB structurally exports no
233
+ balances, so the import says so with `CSV_TRANSACTIONS_ONLY` and expects a balances file alongside),
234
+ **Empower / Personal Capital** holdings (Account/Ticker/Name/Shares/Price/Value; the export carries
235
+ no cost basis → `NO_COST_BASIS` per holding), and **Copilot Money** transactions
236
+ (community-documented format; its inverted sign convention — spending positive — is flipped). Plus
237
+ **generic accounts** (Account Name/Type/Balance + optional Interest Rate/Minimum Payment) and
238
+ **generic transactions** (Account/Date/Amount/Description) layouts — Copilot's accounts export
239
+ fingerprints as generic accounts by design. Files matching no dialect import best-effort with a
240
+ `CSV_UNMAPPED_COLUMNS` warning naming what was skipped. Money cells handle `$`, thousands commas,
241
+ and accounting-style `(1,850.00)` negatives.
242
+
243
+ Keyless honesty (both formats carry less signal than an API — the gaps are surfaced, not papered
244
+ over): positions CSVs and OFX carry **no tax-treatment info**, so account types are guessed
245
+ (from a Type column when present, else the account name) and every guess is a
246
+ `CLASSIFICATION_GUESSED` warning; OFX reports **card balances negative** — normalized to positive
247
+ amount owed; OFX positions carry **no cost basis** (noted, never fabricated); contribution
248
+ inference uses the same growth-exclusion rules as every other adapter.
249
+
250
+ ## CLI
251
+
252
+ The package ships a zero-dependency CLI (Node ≥ 18):
253
+
254
+ ```bash
255
+ npx @plan-fi/imports demo --source csv # run a bundled sandbox fixture, no network
256
+ npx @plan-fi/imports validate accounts.csv --source csv # your files, structured diagnostics
257
+ npx @plan-fi/imports validate statement.ofx --source ofx --json # machine-readable output
258
+ npx @plan-fi/imports validate payload.json --source plaid # API-shaped sources take one .json
259
+ npx @plan-fi/imports plan accounts.csv --source csv --token pft_… [--user-id u123] # create a REAL plan
260
+ npx @plan-fi/imports batch ./payloads --source plaid --token pft_… # bulk-load THOUSANDS of customers
261
+ ```
262
+
263
+ - `demo` prints the plan + warnings + needsInput for a bundled fixture (colors only on a TTY).
264
+ - `validate` runs `importToPlan` on your payload and exits **0 even with warnings** (they are
265
+ diagnostics, not failures); a hard failure (unreadable file, bad JSON, unknown source) exits 1.
266
+ - `plan` POSTs the emitted body to `POST /v1/tools/generate_financial_plan` and prints the
267
+ `plan_id`. `--base` overrides the API host. `--user-id <id>` is sent as the `X-Planfi-User-Id`
268
+ header: the API token identifies your (partner) tenant, while `X-Planfi-User-Id` attributes the
269
+ plan and its usage to a specific end user within that tenant — optional, partner-supplied.
270
+ - `batch` drives the managed `import_financial_data_batch` endpoint (25 items per call) over a
271
+ directory of `<user_id>.json` payload files (**filename stem = `user_id`**) or an `.ndjson`
272
+ file with `{"user_id", "payload"[, "plan_name", "source"]}` per line. Every item carries its
273
+ own `user_id`, and **(your account, `user_id`) is a stable upsert identity** — re-importing a
274
+ customer *updates* their plan (same `plan_id`) instead of duplicating it, so the whole run is
275
+ **safe to re-run**. 5,000 customers = 200 requests; `--concurrency 4` (default) finishes in
276
+ ~10 minutes at typical latencies.
277
+ - Writes a **resume manifest / results file** next to the input
278
+ (`<input>.planfi-manifest.json`, or `--resume <path>`): per-customer `ok` / `plan_id` /
279
+ `updated` / `error`, plus **full `needsInput` objects** (`field`, `label`, `accountId`) for
280
+ building collection worklists. A re-run skips customers already imported ok.
281
+ - **Partial failure never stops the run** — a malformed file or a rejected payload is recorded
282
+ and the rest continue. The final report prints ok/failed counts plus the missing-data rollup
283
+ (needsInput field → customers). Exit 0 all-ok, 1 if any item failed.
284
+ - `--batch-size N` (≤ 25) tunes items per call; `--single` sends one `import_financial_data`
285
+ call per item instead of the batch endpoint.
286
+ - `--json` on every command for machine output; unknown commands/flags print help and exit 2.
287
+
288
+ ## Testing
289
+
290
+ ```bash
291
+ npm install # dev deps for the conformance test only — runtime stays zero-dep
292
+ npm test # node --test: fixtures, CLI spawns, the generic adapter-contract harness,
293
+ # fuzz (6×3000 randomized/hostile payloads), wire conformance
294
+ npm run demo # print the full ImportResult built from the Plaid sandbox fixture
295
+ ```
296
+
297
+ `test/adapter-contract.test.mjs` is the generic floor: it discovers every adapter in `ADAPTERS`
298
+ and runs the identical battery (structural CFP validity, cataloged warning codes, hostile inputs
299
+ never throw, determinism, fixture registered for wire-conformance). It is the executable version
300
+ of the checklist in [docs/ADAPTER_GUIDE.md](./docs/ADAPTER_GUIDE.md).
301
+
302
+ Inside the planfi-app monorepo, `test/wire-conformance.test.mjs` round-trips every fixture through
303
+ the **real** engine mapper and asserts each emitted field is consumed (in this standalone repo that
304
+ test skips loudly; the monorepo CI enforces it).
305
+
306
+ ## Versioning
307
+
308
+ SemVer, pre-1.0 (minor bumps may break — see [CHANGELOG.md](./CHANGELOG.md)). v0.2.0 made
309
+ `warnings`/`needsInput` structured objects and added Finicity; warning **codes** are append-only
310
+ from here. TypeScript types ship in `planfi-import.d.ts`.
311
+
312
+ ## License
313
+
314
+ MIT.