@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
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# ADAPTER_GUIDE.md — how to write a planfi-import adapter
|
|
2
|
+
|
|
3
|
+
This guide is written to be followed **step by step by an AI coding agent** (it works for humans
|
|
4
|
+
too). Every requirement stated here is enforced by an executable test — the generic contract
|
|
5
|
+
harness `test/adapter-contract.test.mjs` IS this guide's checklist. If the harness passes, you
|
|
6
|
+
followed the guide; if you change one, change the other.
|
|
7
|
+
|
|
8
|
+
Read `AGENTS.md` first for the invariants. Short version: adapters translate ONE provider's
|
|
9
|
+
vocabulary into the Canonical Financial Profile (CFP); all planfi domain logic lives once in
|
|
10
|
+
`src/to-planfi.mjs`; never fabricate values; zero runtime dependencies.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
provider raw ──your normalize()──► CFP ──toPlanfiPlan() (shared, DON'T touch)──► wire body
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## 1. The canonical model (what you must emit)
|
|
17
|
+
|
|
18
|
+
Source of truth: `src/canonical.ts` (types only). Your `normalize(raw)` returns a
|
|
19
|
+
`CanonicalFinancialProfile`:
|
|
20
|
+
|
|
21
|
+
| Field | Type | Meaning | Who sets it |
|
|
22
|
+
|---|---|---|---|
|
|
23
|
+
| `source` | `string` | Your adapter id (must equal the `ADAPTERS` key and `adapter.source`) | adapter |
|
|
24
|
+
| `asOf` | ISO string | Snapshot timestamp. Use `raw.asOf \|\| defaultAsOf()` — NEVER a hardcoded epoch | adapter |
|
|
25
|
+
| `owner` | `OwnerContext` | Ages/goals/salary/state from onboarding. Pass through `{ ...(raw.owner ?? {}) }` untouched | caller (via adapter passthrough) |
|
|
26
|
+
| `accounts` | `CanonicalAccount[]` | One entry per provider account (see below) | adapter |
|
|
27
|
+
| `meta.warnings` | `ImportWarning[]` | Structured judgment calls — build with `warning()` from `src/util.mjs` | adapter (mapper appends its own) |
|
|
28
|
+
| `meta.unmapped` | `unknown[]` | Raw entities you could NOT map. Push them here; never drop silently | adapter |
|
|
29
|
+
|
|
30
|
+
`CanonicalAccount`:
|
|
31
|
+
|
|
32
|
+
| Field | Type | Meaning | Who sets it |
|
|
33
|
+
|---|---|---|---|
|
|
34
|
+
| `id` | `string` (required, non-empty) | Stable provider account id — `String()`-coerce it | adapter |
|
|
35
|
+
| `institution` | `string?` | Institution name/id when the provider carries one | adapter |
|
|
36
|
+
| `name` | `string?` | Human account name | adapter |
|
|
37
|
+
| `class` | `'depository' \| 'investment' \| 'loan' \| 'credit' \| 'property'` | From `classify()` | adapter (via `classify()`) |
|
|
38
|
+
| `subtype` | `string?` | Provider subtype, lowercased (`'401k'`, `'roth ira'`, `'mortgage'`) | adapter |
|
|
39
|
+
| `taxTreatment` | `'taxable' \| 'traditional' \| 'roth' \| 'hsa' \| '529' \| 'na'` | From `classify()` | adapter (via `classify()`) |
|
|
40
|
+
| `balance` | `number` (required, finite) | Asset value, or outstanding principal for a liability — **positive for debts**: take `Math.abs()` on loan/credit balances | adapter |
|
|
41
|
+
| `currency` | `string?` | ISO code, default `'USD'` | adapter |
|
|
42
|
+
| `holdings` | `CanonicalHolding[]?` | Investment accounts only: `{ ticker?, name?, quantity?, value?, costBasis?, assetType }`. `costBasis` missing → leave `undefined` + `NO_COST_BASIS` info warning. `assetType` via `classifyAsset()` | adapter |
|
|
43
|
+
| `liability` | `LiabilityDetail?` | Loan/credit only: `{ rate?, minPayment?, monthsRemaining?, originationPrincipal?, assetName?, assetValue? }`. `rate` is a **FRACTION** (use `pct()`: `6.25` → `0.0625`). `monthsRemaining` via `monthsBetween(raw.asOf, maturityDate)` | adapter |
|
|
44
|
+
| `ownerIndex` | `number?` (0-based int) | Which earner owns the account in a joint household; default `0` | adapter (caller-supplied field) |
|
|
45
|
+
| `estMonthlyContribution` | `number?` (>= 0) | Inferred monthly savings into this account, from `contributionsByAccount()` | adapter |
|
|
46
|
+
|
|
47
|
+
Everything downstream — tax buckets, the stocks total, 80%-LTV home estimates, IRS-limit clamps,
|
|
48
|
+
`needsInput` asks, the wire body — is `toPlanfiPlan()`'s job. **Do not** duplicate any of it.
|
|
49
|
+
|
|
50
|
+
## 2. The adapter contract
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
export const <source>Adapter = {
|
|
54
|
+
source: '<source>', // adapter id, lowercase, matches file name
|
|
55
|
+
normalize(raw): CanonicalFinancialProfile,
|
|
56
|
+
};
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`normalize` MUST be:
|
|
60
|
+
|
|
61
|
+
- **Total** — any input (`null`, `undefined`, primitives, arrays with `null` members, truncated
|
|
62
|
+
garbage) returns a valid CFP; never throws. Start with
|
|
63
|
+
`raw = raw && typeof raw === 'object' ? raw : {};` and use `objs()` (from `src/util.mjs`) at
|
|
64
|
+
every provider-array boundary — it drops non-object members that would otherwise crash property
|
|
65
|
+
access.
|
|
66
|
+
- **Deterministic** — same input, same output. The only allowed nondeterminism is
|
|
67
|
+
`defaultAsOf()` (= now) when the payload carries no `asOf`.
|
|
68
|
+
- **Honest** — a guessed classification always emits `CLASSIFICATION_GUESSED`; unmappable raw
|
|
69
|
+
entities go to `meta.unmapped`; nothing is invented.
|
|
70
|
+
|
|
71
|
+
## 3. Start from the template
|
|
72
|
+
|
|
73
|
+
Copy `src/adapters/_template.mjs` → `src/adapters/<source>.mjs`. It is a fully commented skeleton
|
|
74
|
+
that returns an empty-but-structurally-valid CFP; the TODO comments walk you through transactions
|
|
75
|
+
→ contribution inference → account mapping. As shipped it would fail the harness's fixture-content
|
|
76
|
+
floor (≥ 3 accounts) — that failure is your to-do list. The template itself is **not registered**
|
|
77
|
+
in `ADAPTERS` and must stay that way (a harness test asserts it).
|
|
78
|
+
|
|
79
|
+
Model implementations, most useful first: `src/adapters/fdx.mjs` (wrapped entities, container
|
|
80
|
+
fallbacks), `finicity.mjs` (flat entities, epoch dates), `mx.mjs` (property accounts),
|
|
81
|
+
`csv.mjs`/`ofx.mjs` (keyless file parsing).
|
|
82
|
+
|
|
83
|
+
## 4. Classification cheat sheet
|
|
84
|
+
|
|
85
|
+
Call `classify(type, subtype)` with a generic `type` of `'depository' | 'investment' | 'loan' |
|
|
86
|
+
'credit'` (also accepts `'brokerage'` as investment) and a lowercase `subtype`. Build a lookup
|
|
87
|
+
table from YOUR provider's vocabulary to these words (see `FDX_TYPE` in `fdx.mjs`). Words
|
|
88
|
+
`classify()` understands inside `subtype`:
|
|
89
|
+
|
|
90
|
+
| You want | Subtype words that get there | Confidence |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| depository (cash) | any subtype under type `depository` (`checking`, `savings`, `cd`, `money market`) | high |
|
|
93
|
+
| investment / hsa | `hsa` (under `depository` OR `investment`) | high |
|
|
94
|
+
| investment / 529 | `529`, `education savings` | high |
|
|
95
|
+
| investment / roth | anything containing `roth` (`roth ira`, `roth 401k`) | high |
|
|
96
|
+
| investment / traditional | `401k`, `403b`, `457b`, `401a`, `ira` (word), `sep`, `simple`, `keogh`, `thrift`, `tsp`, `retirement` | high |
|
|
97
|
+
| investment / traditional (guess) | `tax-deferred`, `pension` | **low → warn** |
|
|
98
|
+
| investment / taxable | `brokerage`, `mutual fund`, `cash management`, `stock plan`, `crypto`, `ugma`, `utma`, `other` | high |
|
|
99
|
+
| investment / taxable (guess) | `non-taxable brokerage`, any unknown subtype, any unknown top-level type | **low → warn** |
|
|
100
|
+
| loan / credit | type `loan` / `credit` (any subtype; use `mortgage`, `home equity`, `student`, `auto` — the mapper routes `mortgage\|home equity` to real estate, everything else to debts) | high |
|
|
101
|
+
|
|
102
|
+
Whenever `classify()` returns `confidence: 'low'` — or your provider's type wasn't in your lookup
|
|
103
|
+
table at all — emit `CLASSIFICATION_GUESSED` (severity `warn`) naming the account and the guess.
|
|
104
|
+
|
|
105
|
+
For holdings, `classifyAsset(securityType)` understands: `etf`, `mutual fund`, `equity`/`stock`/
|
|
106
|
+
`common stock`, `fixed income`/`bond`, `cash`/`cash equivalent`, `crypto*`, `derivative` →
|
|
107
|
+
otherwise `'other'`. Translate your provider's holding-type enum into those words first (see
|
|
108
|
+
`FDX_HOLDING_TYPE`).
|
|
109
|
+
|
|
110
|
+
## 5. Warning-code catalog (when to emit each)
|
|
111
|
+
|
|
112
|
+
Codes live in the append-only `WarningCode` union in `src/canonical.ts` (mirrored in
|
|
113
|
+
`planfi-import.d.ts` — a harness test compares the two). Codes an **adapter** emits:
|
|
114
|
+
|
|
115
|
+
| Code | Severity | Emit when |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| `CLASSIFICATION_GUESSED` | warn | `classify()` returned low confidence, or the provider type wasn't in your lookup table, or you typed an account from its NAME |
|
|
118
|
+
| `NO_COST_BASIS` | info | A holding has no cost basis in the source. One per holding (API adapters) or one per account (structural to the format, e.g. OFX) |
|
|
119
|
+
| `COARSE_INFERENCE` | warn | Contribution inference counted deposits that carried NO category/description/type label. Emit ONCE per import, not per transaction |
|
|
120
|
+
| `CSV_UNMAPPED_COLUMNS` | warn | (csv adapter) columns matched no dialect mapping; name them in the message |
|
|
121
|
+
| `CSV_TRANSACTIONS_ONLY` | warn | (csv adapter) the file's TOOL structurally exports no balances (e.g. a YNAB register) — the user must pair it with a balances source |
|
|
122
|
+
|
|
123
|
+
Codes the **shared mapper** emits (never emit these from an adapter — they fire automatically
|
|
124
|
+
when your CFP is right): `CONTRIBUTION_CLAMPED`, `CONTRIBUTION_IMPLAUSIBLE`,
|
|
125
|
+
`HSA_FOLDED_INTO_PORTFOLIO`, `HSA_COVERAGE_ASSUMED`, `IRA_SPLIT_ASSUMED`, `HOME_VALUE_ESTIMATED`,
|
|
126
|
+
`MORTGAGE_SKIPPED`, `NEGATIVE_BALANCE_CLAMPED`, `DEBT_RATE_MISSING`.
|
|
127
|
+
|
|
128
|
+
Need a genuinely new code? Append it to BOTH `src/canonical.ts` and `planfi-import.d.ts`, document
|
|
129
|
+
it in the README catalog, and never change the meaning of an existing code.
|
|
130
|
+
|
|
131
|
+
`needsInput` is emitted by the mapper only, from the `NeedsInputField` enum:
|
|
132
|
+
`age | retirement_age | annual_salary | desired_annual_spend | home_value | debt_rate`. Your job
|
|
133
|
+
is merely to NOT fill gaps that trigger them (e.g. leave `liability.rate` undefined when the
|
|
134
|
+
provider has no APR — the mapper then models 0% AND asks).
|
|
135
|
+
|
|
136
|
+
## 6. Contribution inference (if your provider has transactions)
|
|
137
|
+
|
|
138
|
+
Normalize provider transactions to `{ account_id, subtype: 'contribution', amount: -|x|, date }`
|
|
139
|
+
and run `contributionsByAccount()` — copy the pattern from `fdx.mjs`. Rules (identical in every
|
|
140
|
+
adapter):
|
|
141
|
+
|
|
142
|
+
1. Only money flowing INTO **investment** accounts counts.
|
|
143
|
+
2. Dividends/interest/capital gains/reinvestments are GROWTH → exclude (already modeled by
|
|
144
|
+
`annual_return`).
|
|
145
|
+
3. A labeled deposit that is neither growth nor a recognized inflow word → exclude.
|
|
146
|
+
4. An UNLABELED deposit → include, and emit `COARSE_INFERENCE` once.
|
|
147
|
+
5. Money out (debits/withdrawals) never counts.
|
|
148
|
+
|
|
149
|
+
## 7. Fixture requirements (enforced by the harness)
|
|
150
|
+
|
|
151
|
+
Create `fixtures/<source>-sandbox.mjs` exporting **`<source>Raw`** (exact name — the CLI `demo`
|
|
152
|
+
command imports it by convention). The fixture MUST:
|
|
153
|
+
|
|
154
|
+
- carry an explicit ISO `asOf` (determinism: without it, `normalize()` stamps "now"),
|
|
155
|
+
- carry `owner` with at least one named earner (age, retirementAge, annualSalary) — the demo must
|
|
156
|
+
print a full plan,
|
|
157
|
+
- produce **≥ 3 accounts**, at least one of class `investment`,
|
|
158
|
+
- exercise **at least one warning path** end-to-end (a no-cost-basis holding, an unknown-type
|
|
159
|
+
account, a mortgage without a property value, a debt without an APR — pick several; the shipped
|
|
160
|
+
fixtures each exercise most of them),
|
|
161
|
+
- be synthetic (no real customer data), shaped like the provider's REAL response fields.
|
|
162
|
+
|
|
163
|
+
## 8. Registration checklist
|
|
164
|
+
|
|
165
|
+
Wire the adapter everywhere (the harness fails with a pointed message for most omissions):
|
|
166
|
+
|
|
167
|
+
1. `src/adapters/<source>.mjs` — the adapter.
|
|
168
|
+
2. `src/index.mjs` — named export + entry in `ADAPTERS` (key === `adapter.source`).
|
|
169
|
+
3. `planfi-import.d.ts` — `export declare const <source>Adapter: SourceAdapter<object>;` and add
|
|
170
|
+
the id to the `importToPlan` source union.
|
|
171
|
+
4. `bin/planfi-import.mjs` — add the id to the USAGE source lists.
|
|
172
|
+
5. `fixtures/<source>-sandbox.mjs` — the fixture (section 7).
|
|
173
|
+
6. `test/helpers/fixture-registry.mjs` — register the fixture (this feeds BOTH wire-conformance
|
|
174
|
+
and the contract harness).
|
|
175
|
+
7. `test/<source>.test.mjs` — source-specific tests: vocabulary mapping, sign conventions, date
|
|
176
|
+
handling, contribution inference, and one `importToPlan('<source>', fixture)` smoke test.
|
|
177
|
+
8. `test/fuzz.test.mjs` — a `<source>Payload()` generator + a row in the adapter loop.
|
|
178
|
+
9. `README.md` — a row in the Adapters table.
|
|
179
|
+
10. `CHANGELOG.md` — an entry under the next version.
|
|
180
|
+
|
|
181
|
+
## 9. SELF-VERIFICATION CHECKLIST (run these; expected outputs given)
|
|
182
|
+
|
|
183
|
+
Run from the `planfi-import/` directory.
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
# 1. Install test-only dev deps (runtime stays zero-dependency)
|
|
187
|
+
npm ci
|
|
188
|
+
# expected: exits 0
|
|
189
|
+
|
|
190
|
+
# 2. The generic contract harness — YOUR adapter appears in its output
|
|
191
|
+
node --test test/adapter-contract.test.mjs
|
|
192
|
+
# expected: "# fail 0", and for your <source> these subtests all "ok":
|
|
193
|
+
# [contract:<source>] adapter identity + registration shape
|
|
194
|
+
# [contract:<source>] (e) a sandbox fixture is registered for wire-conformance
|
|
195
|
+
# [contract:<source>] (a) normalize(fixture) → structurally valid CFP + content floor
|
|
196
|
+
# [contract:<source>] (b) toPlanfiPlan(fixture CFP) succeeds; diagnostics use the catalog
|
|
197
|
+
# [contract:<source>] (c) hostile inputs never throw and still yield clean plans
|
|
198
|
+
# [contract:<source>] (d) determinism: identical runs → deep-equal output
|
|
199
|
+
|
|
200
|
+
# 3. The whole suite (per-adapter tests, fuzz, CLI spawns, wire-conformance)
|
|
201
|
+
node --test
|
|
202
|
+
# expected: "# fail 0" (150+ tests). In the standalone repo, wire-conformance
|
|
203
|
+
# prints a loud SKIP about the monorepo mapper — that is expected, not a failure.
|
|
204
|
+
|
|
205
|
+
# 4. The CLI demo runs your fixture offline and emits valid JSON
|
|
206
|
+
node bin/planfi-import.mjs demo --source <source> --json | node -e \
|
|
207
|
+
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const r=JSON.parse(s);
|
|
208
|
+
if(r.cfp.source!=='<source>'||!r.plan.earners.length) process.exit(1);
|
|
209
|
+
console.log('demo OK:', r.plan.stocks.current_value)})"
|
|
210
|
+
# expected: "demo OK: <a positive number>", exit 0
|
|
211
|
+
|
|
212
|
+
# 5. The default demo still works (regression guard for the package script)
|
|
213
|
+
npm run demo > /dev/null
|
|
214
|
+
# expected: exits 0
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
If any step fails, the failure message names the file to fix — the harness messages are written
|
|
218
|
+
to be actionable. Do not weaken a harness assertion to get green; fix the adapter.
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Appendix: extending the CSV dialect table (vs writing an adapter)
|
|
223
|
+
|
|
224
|
+
Field feedback from the first dialect build (2026-07-03). Dialect work is NOT full adapter work —
|
|
225
|
+
only this subset of the checklist applies: the dialect entry in `src/adapters/csv.mjs`, a fixture
|
|
226
|
+
FILE added to `fixtures/csv-sandbox.mjs` (the registry is one-entry-per-adapter — extend the CSV
|
|
227
|
+
fixture, don't add a new registry entry), tests in `test/csv.test.mjs`, README dialect table,
|
|
228
|
+
CHANGELOG. Sections 5 and 9 of this guide apply as written; sections 7–8 mostly do not.
|
|
229
|
+
|
|
230
|
+
Rules the table's comments encode (now stated here):
|
|
231
|
+
- **Ordering is first-match-wins.** Put SPECIFIC fingerprints (a column name unique to the tool,
|
|
232
|
+
e.g. Copilot's `parent category`, Empower's `ticker`) ABOVE the generic dialects, and run a
|
|
233
|
+
collision check against `generic-accounts`/`generic-holdings` before shipping — a name+value
|
|
234
|
+
file will happily match generic.
|
|
235
|
+
- **Transactions-only tools** (YNAB-style) must set `transactionsOnly` and yield ZERO accounts —
|
|
236
|
+
emit `CSV_TRANSACTIONS_ONLY`, never fabricate balances from a register.
|
|
237
|
+
- **Balance-history exports** (Monarch-style) collapse to the newest row per account — never sum.
|
|
238
|
+
- **Property-class rows** ('Real Estate', 'Vehicle') route AROUND `classify()` to the `property`
|
|
239
|
+
class (see the pattern in `mx.mjs` and `csv.mjs` — the classification cheat sheet covers
|
|
240
|
+
investment/depository/loan/credit only).
|
|
241
|
+
- **Unknown sign conventions**: prefer the mapping whose failure mode excludes inflows
|
|
242
|
+
(conservative) over one that could fabricate contributions.
|
|
243
|
+
- New warning codes go in `src/canonical.ts` + `planfi-import.d.ts` + the README catalog **and
|
|
244
|
+
this guide's warning table**.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// CSV-shaped payload for the keyless adapter, exercising SEVEN dialects at once:
|
|
2
|
+
// files[0] — a Fidelity "Positions" export (Account Number/Account Name/
|
|
3
|
+
// Symbol/Description/Quantity/Last Price/Current Value/Cost Basis
|
|
4
|
+
// Total), complete with the real-world noise: a preamble line
|
|
5
|
+
// before the header, quoted money cells with commas, a "--" cost
|
|
6
|
+
// basis, a Pending Activity row, and a trailing disclaimer line.
|
|
7
|
+
// Two accounts live in one file (Fidelity groups by Account
|
|
8
|
+
// Number): a taxable brokerage and an "Employer 401(k)" whose
|
|
9
|
+
// type must be guessed from its NAME (positions exports carry no
|
|
10
|
+
// type column → CLASSIFICATION_GUESSED, always).
|
|
11
|
+
// files[1] — a generic accounts CSV (Account Name/Type/Balance + optional
|
|
12
|
+
// Interest Rate/Minimum Payment), with a currency-symbol balance,
|
|
13
|
+
// a parenthesized-negative card balance, a mortgage with no
|
|
14
|
+
// property value (→ 80%-LTV estimate), and a stray "Notes"
|
|
15
|
+
// column that must surface in CSV_UNMAPPED_COLUMNS.
|
|
16
|
+
// files[2] — a Monarch Money balances download: balance HISTORY (two dated
|
|
17
|
+
// rows for the Vanguard account — only the newest may survive;
|
|
18
|
+
// summing history would fabricate a balance) with explicit
|
|
19
|
+
// Account Type values (no classification guessing).
|
|
20
|
+
// files[3] — a Monarch Money transactions export: Mint-style signs (money-in
|
|
21
|
+
// positive), monthly transfers into the Vanguard brokerage, a
|
|
22
|
+
// "Dividends & Capital Gains" row that must be excluded as
|
|
23
|
+
// growth, and a negative (sell/withdrawal) row that never counts.
|
|
24
|
+
// files[4] — a YNAB register export: Outflow/Inflow column PAIR, transfers
|
|
25
|
+
// into the Roth IRA (matched to files[1] by account name), a
|
|
26
|
+
// balance-adjustment row (market growth — excluded), and the
|
|
27
|
+
// structural CSV_TRANSACTIONS_ONLY warning (YNAB carries no
|
|
28
|
+
// balances).
|
|
29
|
+
// files[5] — an Empower (Personal Capital) holdings export: Account column
|
|
30
|
+
// groups rows, no Cost Basis column → NO_COST_BASIS per holding,
|
|
31
|
+
// account typed from its NAME ("Rollover IRA" → traditional).
|
|
32
|
+
// files[6] — a Copilot Money transactions export (community-documented,
|
|
33
|
+
// low-confidence dialect): INVERTED signs (spending positive,
|
|
34
|
+
// money-in negative), transfers into the Schwab account from
|
|
35
|
+
// files[2], a dividend row excluded as growth.
|
|
36
|
+
|
|
37
|
+
const fidelityPositions = [
|
|
38
|
+
'Positions for account(s) as of Jul-02-2026',
|
|
39
|
+
'Account Number,Account Name,Symbol,Description,Quantity,Last Price,Current Value,Cost Basis Total,Type',
|
|
40
|
+
'Z12345678,Individual Brokerage,VTI,VANGUARD TOTAL STOCK MARKET ETF,420,$305.12,"$128,150.40","$95,000.00",Cash',
|
|
41
|
+
'Z12345678,Individual Brokerage,SPAXX**,FIDELITY GOVERNMENT MONEY MARKET,5200,$1.00,"$5,200.00",--,Cash',
|
|
42
|
+
'Z12345678,Individual Brokerage,Pending Activity,,,,"$250.00",,',
|
|
43
|
+
'X98765432,Employer 401(k),FXAIX,FIDELITY 500 INDEX FUND,850,$211.76,"$179,996.00","$140,000.00",Cash',
|
|
44
|
+
'',
|
|
45
|
+
'"The data and information in this spreadsheet is provided to you solely for your use."',
|
|
46
|
+
].join('\r\n');
|
|
47
|
+
|
|
48
|
+
const genericAccounts = [
|
|
49
|
+
'Account Name,Type,Balance,Interest Rate,Minimum Payment,Notes',
|
|
50
|
+
'Everyday Checking,Checking,"$8,450.25",,,primary household account',
|
|
51
|
+
'High-Yield Savings,Savings,"$32,000.00",,,',
|
|
52
|
+
'Roth IRA,Roth IRA,"$54,000.00",,,',
|
|
53
|
+
'College Fund,529,"$21,500.00",,,for Sam',
|
|
54
|
+
'Home Mortgage,Mortgage,"$310,000.00",5.25%,"$1,980.00",30yr fixed',
|
|
55
|
+
'Rewards Visa,Credit Card,"($1,850.00)",21.99%,$50.00,carries a balance',
|
|
56
|
+
].join('\n');
|
|
57
|
+
|
|
58
|
+
const monarchBalances = [
|
|
59
|
+
'Date,Account,Account Type,Institution,Balance',
|
|
60
|
+
'2026-06-30,Vanguard Brokerage,Brokerage,Vanguard,"$61,000.00"', // stale history row — must lose
|
|
61
|
+
'2026-07-01,Vanguard Brokerage,Brokerage,Vanguard,"$62,400.00"',
|
|
62
|
+
'2026-07-01,Schwab Taxable,Brokerage,Charles Schwab,"$18,000.00"',
|
|
63
|
+
'2026-07-01,Ally Savings,Savings,Ally Bank,"$12,000.00"',
|
|
64
|
+
].join('\n');
|
|
65
|
+
|
|
66
|
+
const monarchTransactions = [
|
|
67
|
+
'Date,Merchant,Category,Account,Original Statement,Notes,Amount,Tags',
|
|
68
|
+
...['2026-01-05', '2026-02-05', '2026-03-05', '2026-04-05', '2026-05-05', '2026-06-05']
|
|
69
|
+
.map((d) => `${d},Vanguard,Transfer,Vanguard Brokerage,VANGUARD BUY INVESTMENT,,"$1,250.00",`),
|
|
70
|
+
'2026-03-12,Vanguard,Dividends & Capital Gains,Vanguard Brokerage,VANGUARD DIV PAYMENT,,$180.00,', // growth → excluded
|
|
71
|
+
'2026-04-20,Vanguard,Transfer,Vanguard Brokerage,VANGUARD SELL,,"-$400.00",', // money out → excluded
|
|
72
|
+
].join('\n');
|
|
73
|
+
|
|
74
|
+
const ynabRegister = [
|
|
75
|
+
'"Account","Flag","Date","Payee","Category Group/Category","Category Group","Category","Memo","Outflow","Inflow","Cleared"',
|
|
76
|
+
...['01/12/2026', '02/12/2026', '03/12/2026', '04/12/2026', '05/12/2026', '06/12/2026']
|
|
77
|
+
.map((d) => `"Roth IRA",,"${d}","Transfer : Everyday Checking",,,,"monthly Roth transfer","$0.00","$450.00","Cleared"`),
|
|
78
|
+
'"Roth IRA",,"03/28/2026","Reconciliation Balance Adjustment",,,,"market growth","$0.00","$1,200.00","Reconciled"', // adjustment ≠ contribution
|
|
79
|
+
'"Everyday Checking",,"02/03/2026","Grocery Store","Everyday Expenses: Groceries","Everyday Expenses","Groceries",,"$142.19","$0.00","Cleared"',
|
|
80
|
+
].join('\n');
|
|
81
|
+
|
|
82
|
+
const empowerHoldings = [
|
|
83
|
+
'Account,Ticker,Name,Shares,Price,Change,1 Day %,1 Day $,Value',
|
|
84
|
+
'Empower Rollover IRA,VTI,Vanguard Total Stock Market ETF,100,"$305.12","$1.20","0.39%","$120.00","$30,512.00"',
|
|
85
|
+
'Empower Rollover IRA,VBTLX,Vanguard Total Bond Market Index,500,"$10.50","-$0.02","-0.19%","-$10.00","$5,250.00"',
|
|
86
|
+
].join('\n');
|
|
87
|
+
|
|
88
|
+
const copilotTransactions = [
|
|
89
|
+
'date,name,amount,status,category,parent category,excluded,tags,type,account,account mask,note,recurring',
|
|
90
|
+
...['2026-02-10', '2026-03-10', '2026-04-10', '2026-05-10']
|
|
91
|
+
.map((d) => `${d},Schwab Transfer,-500.00,posted,Transfers,Transfers,false,,internal transfer,Schwab Taxable,1234,,monthly`),
|
|
92
|
+
'2026-04-15,Schwab Dividend,-75.00,posted,Dividends,Investment,false,,income,Schwab Taxable,1234,,', // growth → excluded
|
|
93
|
+
'2026-03-22,Chipotle,18.40,posted,Restaurants,Food,false,,regular,Everyday Checking,5678,,', // spending (positive) → excluded
|
|
94
|
+
].join('\n');
|
|
95
|
+
|
|
96
|
+
export const csvRaw = {
|
|
97
|
+
asOf: '2026-07-02T00:00:00.000Z',
|
|
98
|
+
owner: {
|
|
99
|
+
desiredAnnualSpend: 80000,
|
|
100
|
+
filingState: 'WA',
|
|
101
|
+
earners: [{ name: 'Jordan', age: 39, retirementAge: 60, annualSalary: 165000 }],
|
|
102
|
+
},
|
|
103
|
+
files: [
|
|
104
|
+
{ name: 'fidelity-positions.csv', content: '\uFEFF' + fidelityPositions }, // BOM, the Excel way
|
|
105
|
+
{ name: 'accounts.csv', kind: 'accounts', content: genericAccounts },
|
|
106
|
+
{ name: 'monarch-balances.csv', content: monarchBalances },
|
|
107
|
+
{ name: 'monarch-transactions.csv', content: monarchTransactions },
|
|
108
|
+
{ name: 'ynab-register.csv', content: ynabRegister },
|
|
109
|
+
{ name: 'empower-holdings.csv', content: empowerHoldings },
|
|
110
|
+
{ name: 'copilot-transactions.csv', content: copilotTransactions },
|
|
111
|
+
],
|
|
112
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// FDX (Financial Data Exchange)-shaped payload, trimmed to the fields the
|
|
2
|
+
// adapter reads. Mirrors the FDX API conventions:
|
|
3
|
+
// accounts — GET /accounts: each account WRAPPED in its shape key
|
|
4
|
+
// ({ depositAccount }, { investmentAccount }, { loanAccount },
|
|
5
|
+
// { locAccount }), accountType UPPERCASE, currentValue /
|
|
6
|
+
// currentBalance / principalBalance per shape, ISO dates
|
|
7
|
+
// holdings — flattened InvestmentHolding[] from the account-details
|
|
8
|
+
// calls, each tagged with its accountId (holdingType,
|
|
9
|
+
// symbol, units, marketValue, costBasis)
|
|
10
|
+
// transactions — wrapped { investmentTransaction } records with
|
|
11
|
+
// debitCreditMemo + ISO postedTimestamp
|
|
12
|
+
//
|
|
13
|
+
// Two-earner household with one of each interesting account: HSA, 529,
|
|
14
|
+
// mortgage (FDX has no property entity → exercises the 80%-LTV estimate),
|
|
15
|
+
// student loan, a credit card with NO APR on record, a no-cost-basis
|
|
16
|
+
// DIGITALASSET holding, and an unknown-accountType account.
|
|
17
|
+
|
|
18
|
+
export const fdxRaw = {
|
|
19
|
+
asOf: '2026-07-02T00:00:00.000Z',
|
|
20
|
+
owner: {
|
|
21
|
+
desiredAnnualSpend: 92000, filingState: 'NY',
|
|
22
|
+
earners: [
|
|
23
|
+
{ name: 'Avery', age: 44, retirementAge: 63, annualSalary: 200000 },
|
|
24
|
+
{ name: 'Morgan', age: 42, retirementAge: 63, annualSalary: 118000 },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
accounts: [
|
|
28
|
+
{ depositAccount: { accountId: 'fdx-chk', accountType: 'CHECKING', nickname: 'Everyday Checking', currentBalance: 16800, currency: { currencyCode: 'USD' } } },
|
|
29
|
+
// interestRate on a depositAccount is a savings YIELD — must NOT become a debt APR.
|
|
30
|
+
{ depositAccount: { accountId: 'fdx-sav', accountType: 'SAVINGS', nickname: 'Emergency Savings', currentBalance: 54000, interestRate: 4.2 } },
|
|
31
|
+
{ investmentAccount: { accountId: 'fdx-brk', accountType: 'BROKERAGE', nickname: 'Joint Brokerage', currentValue: 295000, ownerIndex: 0 } },
|
|
32
|
+
{ investmentAccount: { accountId: 'fdx-401k', accountType: '401K', nickname: 'Acme 401(k)', currentValue: 380000, ownerIndex: 0 } },
|
|
33
|
+
{ investmentAccount: { accountId: 'fdx-roth', accountType: 'ROTH', nickname: 'Roth IRA', currentValue: 81000, ownerIndex: 1 } },
|
|
34
|
+
{ investmentAccount: { accountId: 'fdx-hsa', accountType: 'HSA', nickname: 'Health Savings', currentValue: 24000 } },
|
|
35
|
+
{ investmentAccount: { accountId: 'fdx-529', accountType: '529', nickname: 'College 529', currentValue: 36000 } },
|
|
36
|
+
// Unknown accountType → the investmentAccount container is the fallback
|
|
37
|
+
// class signal; taxable at LOW confidence + CLASSIFICATION_GUESSED.
|
|
38
|
+
{ investmentAccount: { accountId: 'fdx-mystery', accountType: 'DIGITALWALLET', nickname: 'Mystery Wallet', currentValue: 9000 } },
|
|
39
|
+
// FDX has no property entity → the mapper estimates the home value (80% LTV).
|
|
40
|
+
{ loanAccount: { accountId: 'fdx-mtg', accountType: 'MORTGAGE', nickname: 'Home Mortgage', principalBalance: 405000, interestRate: 5.25, nextPaymentAmount: 2750, originalPrincipal: 480000, maturityDate: '2049-04-01' } },
|
|
41
|
+
{ loanAccount: { accountId: 'fdx-stu', accountType: 'STUDENTLOAN', nickname: 'Grad Student Loan', principalBalance: 27000, interestRate: 4.6, nextPaymentAmount: 320 } },
|
|
42
|
+
// Card with NO APR on record → DEBT_RATE_MISSING + a debt_rate ask.
|
|
43
|
+
{ locAccount: { accountId: 'fdx-card', accountType: 'CREDITCARD', nickname: 'Travel Card', currentBalance: 3400, minimumPaymentAmount: 85 } },
|
|
44
|
+
],
|
|
45
|
+
holdings: [
|
|
46
|
+
{ accountId: 'fdx-brk', holdingId: 'h1', symbol: 'VTI', securityName: 'Vanguard Total Stock Market ETF', holdingType: 'ETF', units: 780, marketValue: 238000, costBasis: 170000 },
|
|
47
|
+
// No cost basis reported → NO_COST_BASIS warning; DIGITALASSET → speculative.
|
|
48
|
+
{ accountId: 'fdx-brk', holdingId: 'h2', symbol: 'ETH', securityName: 'Ether', holdingType: 'DIGITALASSET', units: 12, marketValue: 57000, costBasis: null },
|
|
49
|
+
{ accountId: 'fdx-401k', holdingId: 'h3', symbol: 'FXAIX', securityName: 'Fidelity 500 Index Fund', holdingType: 'MUTUALFUND', units: 1795, marketValue: 380000, costBasis: 290000 },
|
|
50
|
+
],
|
|
51
|
+
transactions: [
|
|
52
|
+
// Monthly credits Jan–Jun 2026 (ISO timestamps — the FDX way).
|
|
53
|
+
...credits('fdx-brk', 2200, { description: 'ACH TRANSFER IN' }),
|
|
54
|
+
...credits('fdx-401k', 1650, { transactionType: 'CONTRIBUTION' }),
|
|
55
|
+
...credits('fdx-roth', 500, { transactionType: 'CONTRIBUTION' }),
|
|
56
|
+
// A dividend credit that must be EXCLUDED from contribution inference.
|
|
57
|
+
{ investmentTransaction: { transactionId: 'fdx-div-1', accountId: 'fdx-brk', transactionType: 'DIVIDEND', totalAmount: 700, debitCreditMemo: 'CREDIT', postedTimestamp: '2026-03-20T00:00:00.000Z', description: 'VTI dividend' } },
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function credits(accountId, amount, extra) {
|
|
62
|
+
return ['2026-01-15', '2026-02-15', '2026-03-15', '2026-04-15', '2026-05-15', '2026-06-15']
|
|
63
|
+
.map((d, i) => ({ investmentTransaction: {
|
|
64
|
+
transactionId: `${accountId}-t${i + 1}`, accountId, totalAmount: amount,
|
|
65
|
+
debitCreditMemo: 'CREDIT', postedTimestamp: `${d}T00:00:00.000Z`, ...extra,
|
|
66
|
+
} }));
|
|
67
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Finicity (Mastercard Open Banking)-shaped payload, trimmed to the fields the
|
|
2
|
+
// adapter reads. Mirrors documented Finicity response shapes:
|
|
3
|
+
// accounts — GET /aggregation/v1/customers/{id}/accounts (type, balance,
|
|
4
|
+
// detail{} for loans/cards)
|
|
5
|
+
// positions — flattened investment position[] from the account-details
|
|
6
|
+
// calls, each tagged with its accountId
|
|
7
|
+
// transactions — GET /aggregation/v3/customers/{id}/transactions
|
|
8
|
+
// (amounts positive = deposit; dates are EPOCH SECONDS)
|
|
9
|
+
//
|
|
10
|
+
// Two-earner household with one of each interesting account: HSA, 529,
|
|
11
|
+
// mortgage (no property record — Finicity has none, exercises the 80%-LTV
|
|
12
|
+
// estimate), student loan, a negative-reported credit card with no APR, a
|
|
13
|
+
// no-cost-basis crypto holding, and a low-confidence investmentTaxDeferred
|
|
14
|
+
// wrapper.
|
|
15
|
+
|
|
16
|
+
const epoch = (iso) => Math.floor(Date.parse(iso) / 1000);
|
|
17
|
+
|
|
18
|
+
export const finicityRaw = {
|
|
19
|
+
asOf: '2026-07-02T00:00:00.000Z',
|
|
20
|
+
owner: {
|
|
21
|
+
desiredAnnualSpend: 88000, filingState: 'CO',
|
|
22
|
+
earners: [
|
|
23
|
+
{ name: 'Riley', age: 43, retirementAge: 62, annualSalary: 190000 },
|
|
24
|
+
{ name: 'Casey', age: 41, retirementAge: 62, annualSalary: 105000 },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
accounts: [
|
|
28
|
+
{ id: 5001, name: 'Everyday Checking', type: 'checking', balance: 14200, currency: 'USD', institutionId: 101732 },
|
|
29
|
+
{ id: 5002, name: 'Online Savings', type: 'savings', balance: 48000 },
|
|
30
|
+
{ id: 5003, name: 'Taxable Brokerage', type: 'brokerageAccount', balance: 268000, ownerIndex: 0 },
|
|
31
|
+
{ id: 5004, name: 'Employer 401(k)', type: '401k', balance: 350000, ownerIndex: 0 },
|
|
32
|
+
{ id: 5005, name: 'Roth IRA', type: 'roth', balance: 74000, ownerIndex: 1 },
|
|
33
|
+
{ id: 5006, name: 'Health Savings', type: 'hsa', balance: 26000 },
|
|
34
|
+
{ id: 5007, name: 'College 529', type: '529plan', balance: 38000 },
|
|
35
|
+
// Pre-tax wrapper of unknown flavor → traditional at LOW confidence (warned).
|
|
36
|
+
{ id: 5008, name: 'Old Variable Annuity', type: 'investmentTaxDeferred', balance: 45000, ownerIndex: 0 },
|
|
37
|
+
// Finicity has no property account type → the mapper estimates home value.
|
|
38
|
+
{ id: 5009, name: 'Home Mortgage', type: 'mortgage', balance: 420000, detail: { interestRate: 5.5, payment: 2900, maturityDate: epoch('2048-05-01') } },
|
|
39
|
+
{ id: 5010, name: 'Student Loan', type: 'studentLoan', balance: 31000, detail: { interestRate: 4.8, paymentMinAmount: 340 } },
|
|
40
|
+
// Negative-reported card balance (institution quirk) and NO APR on record.
|
|
41
|
+
{ id: 5011, name: 'Cashback Card', type: 'creditCard', balance: -2600, detail: {} },
|
|
42
|
+
],
|
|
43
|
+
positions: [
|
|
44
|
+
{ accountId: 5003, id: 9001, symbol: 'VTI', description: 'Vanguard Total Market ETF', units: 900, marketValue: 210000, costBasis: 150000, securityType: 'ETF' },
|
|
45
|
+
// No cost basis reported → NO_COST_BASIS warning; crypto → speculative.
|
|
46
|
+
{ accountId: 5003, id: 9002, symbol: 'BTC', description: 'Bitcoin', units: 0.55, marketValue: 58000, costBasis: null, securityType: 'Cryptocurrency' },
|
|
47
|
+
{ accountId: 5004, id: 9003, symbol: 'FXAIX', description: 'Fidelity 500 Index', units: 1750, marketValue: 350000, costBasis: 280000, securityType: 'Mutual Fund' },
|
|
48
|
+
],
|
|
49
|
+
transactions: [
|
|
50
|
+
// Monthly deposits Jan–Jun 2026 (epoch-second dates — the Finicity way).
|
|
51
|
+
...deposits(5003, 2100, { categorization: { category: 'Transfer' } }),
|
|
52
|
+
...deposits(5004, 1700, { investmentTransactionType: 'contribution' }),
|
|
53
|
+
...deposits(5005, 450, { investmentTransactionType: 'contribution' }),
|
|
54
|
+
// A dividend credit that must be EXCLUDED from contribution inference.
|
|
55
|
+
{ id: 8000, accountId: 5003, amount: 800, transactedDate: epoch('2026-03-20'), categorization: { category: 'Dividends & Interest Income' }, description: 'VTI dividend' },
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function deposits(accountId, amount, extra) {
|
|
60
|
+
return ['2026-01-15', '2026-02-15', '2026-03-15', '2026-04-15', '2026-05-15', '2026-06-15']
|
|
61
|
+
.map((d, i) => ({ id: accountId * 10 + i, accountId, amount, transactedDate: epoch(d), ...extra }));
|
|
62
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// MX-Platform-shaped payload (accounts + holdings + transactions), trimmed to
|
|
2
|
+
// the fields the adapter reads. Includes a PROPERTY account (MX gives the home's
|
|
3
|
+
// market value) and an HSA + 529 to exercise the same paths as the Plaid fixture.
|
|
4
|
+
|
|
5
|
+
export const mxRaw = {
|
|
6
|
+
asOf: '2026-07-02T00:00:00.000Z',
|
|
7
|
+
owner: {
|
|
8
|
+
desiredAnnualSpend: 84000, filingState: 'TX',
|
|
9
|
+
earners: [{ name: 'Jordan', age: 45, retirementAge: 60, annualSalary: 210000 }],
|
|
10
|
+
},
|
|
11
|
+
accounts: [
|
|
12
|
+
{ guid: 'ACT-chk', name: 'Checking', type: 'CHECKING', balance: 21000, currency_code: 'USD' },
|
|
13
|
+
{ guid: 'ACT-sav', name: 'High-Yield Savings', type: 'SAVINGS', balance: 65000 },
|
|
14
|
+
{ guid: 'ACT-brk', name: 'Individual Brokerage', type: 'INVESTMENT', subtype: 'BROKERAGE', balance: 305000 },
|
|
15
|
+
{ guid: 'ACT-401k', name: 'Workplace 401(k)', type: 'INVESTMENT', subtype: '401K', balance: 420000 },
|
|
16
|
+
{ guid: 'ACT-roth', name: 'Roth IRA', type: 'INVESTMENT', subtype: 'ROTH_IRA', balance: 96000 },
|
|
17
|
+
{ guid: 'ACT-hsa', name: 'HSA', type: 'INVESTMENT', subtype: 'HSA', balance: 30000 },
|
|
18
|
+
{ guid: 'ACT-529', name: '529 College', type: 'INVESTMENT', subtype: '529', balance: 52000 },
|
|
19
|
+
{ guid: 'ACT-home', name: 'Primary Home', type: 'PROPERTY', market_value: 1450000 },
|
|
20
|
+
{ guid: 'ACT-mtg', name: 'Primary Home Mortgage', type: 'MORTGAGE', balance: 610000, interest_rate: 5.75, minimum_payment: 3800, original_balance: 700000, maturity_date: '2049-03-01' },
|
|
21
|
+
{ guid: 'ACT-auto', name: 'Auto Loan', type: 'LOAN', balance: 24000, interest_rate: 6.9, minimum_payment: 455 },
|
|
22
|
+
{ guid: 'ACT-cc', name: 'Rewards Card', type: 'CREDIT_CARD', balance: 3100, apr: 22.9, minimum_payment: 75 },
|
|
23
|
+
],
|
|
24
|
+
holdings: [
|
|
25
|
+
{ account_guid: 'ACT-brk', symbol: 'VOO', description: 'Vanguard S&P 500', shares: 500, market_value: 260000, cost_basis: 180000, holding_type: 'ETF' },
|
|
26
|
+
{ account_guid: 'ACT-brk', symbol: 'GBTC', description: 'Grayscale Bitcoin', shares: 100, market_value: 45000, cost_basis: null, holding_type: 'Cryptocurrency' },
|
|
27
|
+
{ account_guid: 'ACT-401k', symbol: 'FXAIX', description: 'Fidelity 500 Index', shares: 2100, market_value: 420000, cost_basis: 300000, holding_type: 'Mutual Fund' },
|
|
28
|
+
],
|
|
29
|
+
transactions: [
|
|
30
|
+
...credits('ACT-brk', 2500), ...credits('ACT-401k', 1800),
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function credits(account_guid, amount) {
|
|
35
|
+
return ['2026-01-10', '2026-02-10', '2026-03-10', '2026-04-10', '2026-05-10', '2026-06-10']
|
|
36
|
+
.map((date) => ({ account_guid, type: 'CREDIT', amount, category: 'Transfer', date }));
|
|
37
|
+
}
|