@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 +54 -0
- package/CHANGELOG.md +215 -0
- package/LICENSE +21 -0
- package/README.md +314 -0
- package/bin/planfi-import.mjs +549 -0
- package/docs/ADAPTER_GUIDE.md +244 -0
- package/fixtures/csv-sandbox.mjs +112 -0
- package/fixtures/fdx-sandbox.mjs +67 -0
- package/fixtures/finicity-sandbox.mjs +62 -0
- package/fixtures/mx-sandbox.mjs +37 -0
- package/fixtures/ofx-sandbox.mjs +196 -0
- package/fixtures/plaid-sandbox.mjs +56 -0
- package/package.json +69 -0
- package/planfi-import.d.ts +270 -0
- package/src/adapters/_template.mjs +112 -0
- package/src/adapters/csv.mjs +763 -0
- package/src/adapters/fdx.mjs +303 -0
- package/src/adapters/finicity.mjs +243 -0
- package/src/adapters/mx.mjs +159 -0
- package/src/adapters/ofx.mjs +324 -0
- package/src/adapters/plaid.mjs +140 -0
- package/src/canonical.ts +185 -0
- package/src/classify.mjs +72 -0
- package/src/contributions.mjs +65 -0
- package/src/index.mjs +42 -0
- package/src/to-planfi.mjs +340 -0
- package/src/util.mjs +65 -0
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
|
+
[](https://github.com/holdequity/planfi-import/actions/workflows/test.yml)
|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
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.
|