@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.
@@ -0,0 +1,549 @@
1
+ #!/usr/bin/env node
2
+ // planfi-import CLI — zero-dependency, Node >= 18 (uses global fetch).
3
+ //
4
+ // (adapter ids: plaid | mx | finicity | fdx | csv | ofx — see ADAPTERS)
5
+ // planfi-import demo [--source <id>] [--json]
6
+ // Run a bundled sandbox fixture through importToPlan and pretty-print
7
+ // the plan + warnings + needsInput. No credentials, no network.
8
+ // planfi-import validate <payload> [<payload>…] --source <id> [--json]
9
+ // Run importToPlan on your own payload and print the structured
10
+ // diagnostics. Exit 0 unless the import itself fails (warnings are
11
+ // DIAGNOSTICS, not failures); exit 1 on a hard failure.
12
+ // planfi-import plan <payload> [<payload>…] --source <id> [--token pft_…]
13
+ // [--user-id <id>] [--base https://api.planfi.app] [--json]
14
+ // Build the plan AND create it for real via
15
+ // POST /v1/tools/generate_financial_plan; prints the plan_id.
16
+ // planfi-import batch <dir-or-ndjson> --source <id> --token pft_…
17
+ // [--concurrency 4] [--resume manifest.json] [--batch-size 25|--single]
18
+ // Bulk-load thousands of customers through import_financial_data_batch
19
+ // (25 items/call). Filename stem (or the NDJSON "user_id" field) = the
20
+ // customer's user_id — the (account, user_id) upsert identity, so the
21
+ // run is idempotent. Resume manifest + results file written next to the
22
+ // input; re-runs skip already-ok items.
23
+ //
24
+ // Payload files: JSON for the API-shaped sources (plaid/mx/finicity — the
25
+ // merged provider responses). The keyless sources take their files DIRECTLY:
26
+ // planfi-import validate accounts.csv positions.csv --source csv
27
+ // planfi-import validate statement.ofx --source ofx
28
+ // (a .json payload also works for csv/ofx, carrying { files | content, owner, asOf }).
29
+ //
30
+ // Colors: only when stdout is a TTY (and NO_COLOR is unset) — pipe-safe.
31
+ // Exit codes: 0 ok · 1 hard failure · 2 usage error.
32
+
33
+ import { readFileSync, readdirSync, writeFileSync, renameSync, existsSync, statSync } from 'node:fs';
34
+ import path from 'node:path';
35
+ import { importToPlan, ADAPTERS } from '../src/index.mjs';
36
+
37
+ const DEFAULT_BASE = 'https://api.planfi.app';
38
+ const KEYLESS = new Set(['csv', 'ofx']);
39
+
40
+ // ── tiny TTY-aware color helpers ─────────────────────────────────────────────
41
+ const useColor = !!process.stdout.isTTY && !process.env.NO_COLOR;
42
+ const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
43
+ const bold = paint(1);
44
+ const dim = paint(2);
45
+ const green = paint(32);
46
+ const yellow = paint(33);
47
+ const cyan = paint(36);
48
+ const red = paint(31);
49
+
50
+ const USAGE = `planfi-import — turn financial data exports into planfi plans
51
+
52
+ Usage:
53
+ planfi-import demo [--source plaid|mx|finicity|fdx|csv|ofx] [--json]
54
+ planfi-import validate <payload> [<payload>…] --source <id> [--json]
55
+ planfi-import plan <payload> [<payload>…] --source <id> [--token pft_…] [--user-id <id>] [--base <url>] [--json]
56
+ planfi-import batch <dir-or-ndjson> --source <id> --token pft_…
57
+ [--concurrency 4] [--resume manifest.json]
58
+ [--batch-size 25 | --single] [--base <url>] [--json]
59
+
60
+ Commands:
61
+ demo Run the bundled sandbox fixture for a source (default: plaid).
62
+ validate Import your payload and print structured warnings + needsInput.
63
+ Exits 0 even with warnings (they are diagnostics); 1 on failure.
64
+ plan Import AND create a real plan via POST /v1/tools/generate_financial_plan.
65
+ batch Bulk-load MANY customers via import_financial_data_batch (25/call).
66
+ Input: a directory of <user_id>.json payload files (filename stem =
67
+ user_id), or an .ndjson file with {"user_id","payload"[,"plan_name"]}
68
+ per line. (your account, user_id) is a stable upsert identity, so the
69
+ whole run is SAFE TO RE-RUN — re-imports update, never duplicate.
70
+ Writes a resume manifest + results file next to the input; a re-run
71
+ skips items already imported ok. Exits 0 all-ok / 1 if any item failed.
72
+
73
+ Payloads:
74
+ plaid|mx|finicity|fdx one .json file: the merged provider API responses.
75
+ csv one or more .csv files (passed directly), or one .json
76
+ payload of shape { files: [{name, content}], owner, asOf }.
77
+ ofx one .ofx/.qfx file (passed directly), or one .json payload.
78
+ batch dir *.json payload files only (wrap csv/ofx text as their
79
+ { files: [...] } / { content } JSON payload shapes).
80
+
81
+ Options:
82
+ --source <id> adapter id: ${Object.keys(ADAPTERS).join(' | ')}
83
+ --token <tok> planfi API token (else anonymous free-quota)
84
+ --user-id <id> end-user id, sent as X-Planfi-User-Id. The API token
85
+ identifies your (partner) tenant; this attributes the plan
86
+ and usage to one end user within it — and makes the import
87
+ an UPSERT (re-import updates that user's plan). Optional.
88
+ --base <url> API base URL (default ${DEFAULT_BASE})
89
+ --json machine-readable JSON output (implies no colors)
90
+ --concurrency <n> batch: parallel in-flight requests (default 4, max 16)
91
+ --resume <path> batch: manifest path (default <input>.planfi-manifest.json);
92
+ loaded if present — items already ok are skipped
93
+ --batch-size <n> batch: items per API call (default 25, the server max)
94
+ --single batch: one import_financial_data call per item instead of
95
+ the batch endpoint (full per-item responses; more calls)
96
+ -h, --help this help
97
+ `;
98
+
99
+ // ── arg parsing (dependency-free) ────────────────────────────────────────────
100
+ function parseArgs(argv) {
101
+ const args = { positional: [], json: false };
102
+ for (let i = 0; i < argv.length; i++) {
103
+ const a = argv[i];
104
+ if (a === '-h' || a === '--help') args.help = true;
105
+ else if (a === '--json') args.json = true;
106
+ else if (a === '--single') args.single = true;
107
+ else if (
108
+ a === '--source' || a === '--token' || a === '--base' || a === '--user-id' ||
109
+ a === '--concurrency' || a === '--resume' || a === '--batch-size'
110
+ ) {
111
+ const key = a.slice(2).replace(/-(\w)/g, (_, c) => c.toUpperCase());
112
+ const v = argv[++i];
113
+ if (v == null || v.startsWith('--')) fail(2, `Missing value for ${a}`);
114
+ args[key] = v;
115
+ } else if (a.startsWith('-')) {
116
+ fail(2, `Unknown option: ${a}`);
117
+ } else {
118
+ args.positional.push(a);
119
+ }
120
+ }
121
+ return args;
122
+ }
123
+
124
+ function fail(code, message) {
125
+ if (message) process.stderr.write(red(`error: ${message}`) + '\n\n');
126
+ process.stderr.write(USAGE);
127
+ process.exit(code);
128
+ }
129
+
130
+ // ── payload loading ──────────────────────────────────────────────────────────
131
+ /** Build the adapter-native raw payload from the positional file args. */
132
+ function loadPayload(files, source) {
133
+ if (!files.length) fail(2, 'No payload file given');
134
+ const isJson = (f) => /\.json$/i.test(f);
135
+ if (!KEYLESS.has(source) || (files.length === 1 && isJson(files[0]))) {
136
+ if (files.length > 1) fail(2, `Source "${source}" takes exactly one .json payload file`);
137
+ return JSON.parse(readFileSync(files[0], 'utf8'));
138
+ }
139
+ if (source === 'csv') {
140
+ return { files: files.map((f) => ({ name: path.basename(f), content: readFileSync(f, 'utf8') })) };
141
+ }
142
+ // ofx: one statement file
143
+ if (files.length > 1) fail(2, 'Source "ofx" takes one statement file');
144
+ return { content: readFileSync(files[0], 'utf8') };
145
+ }
146
+
147
+ async function loadFixture(source) {
148
+ const url = new URL(`../fixtures/${source}-sandbox.mjs`, import.meta.url);
149
+ const mod = await import(url.href);
150
+ const raw = mod[`${source}Raw`];
151
+ if (!raw) throw new Error(`fixtures/${source}-sandbox.mjs does not export ${source}Raw`);
152
+ return raw;
153
+ }
154
+
155
+ // ── human-readable printing ──────────────────────────────────────────────────
156
+ const usd = (n) => (Number.isFinite(n) ? '$' + Math.round(n).toLocaleString('en-US') : String(n));
157
+
158
+ function printResult({ plan, warnings, needsInput }, source) {
159
+ const out = [];
160
+ out.push(bold(`plan`) + dim(` (source: ${source})`));
161
+ out.push(` name: ${plan.name}`);
162
+ for (const e of plan.earners ?? []) {
163
+ const bits = [e.age != null ? `age ${e.age}` : null, e.retirement_age != null ? `retire at ${e.retirement_age}` : null,
164
+ e.annual_salary != null ? `${usd(e.annual_salary)}/yr` : null].filter(Boolean).join(', ');
165
+ out.push(` earner: ${e.name}${bits ? ` (${bits})` : ''}`);
166
+ }
167
+ const ab = plan.account_balances ?? {};
168
+ out.push(` stocks: ${usd(plan.stocks?.current_value)} ` +
169
+ dim(`(taxable ${usd(ab.taxable)} / traditional ${usd(ab.traditional)} / roth ${usd(ab.roth)})`) +
170
+ (plan.stocks?.monthly_contribution ? ` +${usd(plan.stocks.monthly_contribution)}/mo` : ''));
171
+ out.push(` cash: ${usd(plan.cash?.current_value)}`);
172
+ for (const p of plan.real_estate ?? []) {
173
+ out.push(` real estate: ${p.name} ${usd(p.current_value)}` +
174
+ (p.mortgage ? dim(` (mortgage ${usd(p.mortgage.balance)} @ ${(p.mortgage.rate * 100).toFixed(2)}%)`) : ''));
175
+ }
176
+ for (const d of plan.debts ?? []) {
177
+ out.push(` debt: ${d.name} ${usd(d.balance)}` + dim(` @ ${(d.rate * 100).toFixed(2)}%`));
178
+ }
179
+ if (plan.education_account) out.push(` education (529): ${usd(plan.education_account.initialBalance)}`);
180
+ for (const s of plan.speculative ?? []) out.push(` speculative: ${s.name} ${usd(s.current_value)}`);
181
+ if (plan.desired_annual_spend != null) out.push(` desired spend: ${usd(plan.desired_annual_spend)}/yr`);
182
+ out.push('');
183
+
184
+ if (warnings.length) {
185
+ out.push(bold(`warnings (${warnings.length})`) + dim(' — judgment calls the import made; verify them:'));
186
+ for (const w of warnings) {
187
+ const tag = w.severity === 'warn' ? yellow(`[warn]`) : cyan(`[info]`);
188
+ out.push(` ${tag} ${w.code}${w.accountId ? dim(` (account ${w.accountId})`) : ''}`);
189
+ out.push(dim(` ${w.message}`));
190
+ }
191
+ } else {
192
+ out.push(green('no warnings'));
193
+ }
194
+ out.push('');
195
+
196
+ if (needsInput.length) {
197
+ out.push(bold(`needs input (${needsInput.length})`) + dim(' — collect these from the user, merge into owner, re-run:'));
198
+ for (const n of needsInput) {
199
+ out.push(` ${cyan(n.field)}: ${n.label}`);
200
+ out.push(dim(` ${n.why}`));
201
+ }
202
+ } else {
203
+ out.push(green('nothing to collect — the payload carried full planning context'));
204
+ }
205
+ process.stdout.write(out.join('\n') + '\n');
206
+ }
207
+
208
+ // ── commands ─────────────────────────────────────────────────────────────────
209
+ async function cmdDemo(args) {
210
+ const source = args.source ?? 'plaid';
211
+ if (!ADAPTERS[source]) fail(2, `Unknown --source "${source}". Known: ${Object.keys(ADAPTERS).join(', ')}`);
212
+ const raw = await loadFixture(source);
213
+ const result = importToPlan(source, raw);
214
+ if (args.json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
215
+ else printResult(result, source);
216
+ }
217
+
218
+ async function cmdValidate(args) {
219
+ const source = args.source ?? fail(2, 'validate requires --source');
220
+ const raw = loadPayload(args.positional, source);
221
+ const result = importToPlan(source, raw);
222
+ if (args.json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
223
+ else printResult(result, source);
224
+ // Warnings/needsInput are structured DIAGNOSTICS, not failures → exit 0.
225
+ }
226
+
227
+ async function cmdPlan(args) {
228
+ const source = args.source ?? fail(2, 'plan requires --source');
229
+ const raw = loadPayload(args.positional, source);
230
+ const { plan, warnings, needsInput } = importToPlan(source, raw);
231
+ const base = (args.base ?? DEFAULT_BASE).replace(/\/$/, '');
232
+ const res = await fetch(`${base}/v1/tools/generate_financial_plan`, {
233
+ method: 'POST',
234
+ headers: {
235
+ 'Content-Type': 'application/json',
236
+ ...(args.token ? { Authorization: `Bearer ${args.token}` } : {}),
237
+ // The token identifies the partner TENANT; X-Planfi-User-Id attributes
238
+ // the plan/usage to one END USER within it (optional, partner-supplied).
239
+ ...(args.userId ? { 'X-Planfi-User-Id': args.userId } : {}),
240
+ },
241
+ body: JSON.stringify(plan),
242
+ });
243
+ const text = await res.text();
244
+ let body;
245
+ try { body = JSON.parse(text); } catch { body = { raw: text }; }
246
+ if (!res.ok) {
247
+ process.stderr.write(red(`error: ${res.status} from ${base}`) + '\n' + dim(text.slice(0, 2000)) + '\n');
248
+ process.exit(1);
249
+ }
250
+ if (args.json) {
251
+ process.stdout.write(JSON.stringify({ plan_id: body.plan_id, response: body, warnings, needsInput }, null, 2) + '\n');
252
+ return;
253
+ }
254
+ process.stdout.write(green('plan created') + `\n plan_id: ${bold(body.plan_id ?? '(missing from response)')}\n`);
255
+ if (warnings.length) process.stdout.write(yellow(` ${warnings.length} warning(s)`) + dim(' — re-run `validate` to review them') + '\n');
256
+ if (needsInput.length) process.stdout.write(cyan(` ${needsInput.length} needsInput field(s)`) + dim(' — collect + patch for a sharper plan') + '\n');
257
+ process.stdout.write(dim(` every planfi tool now accepts this plan_id (analyze_fire_number, run_backtesting, …)`) + '\n');
258
+ }
259
+
260
+ // ── batch: bulk customer loading via import_financial_data_batch ─────────────
261
+
262
+ const clampInt = (v, def, min, max) => {
263
+ const n = parseInt(v ?? '', 10);
264
+ return Number.isFinite(n) ? Math.max(min, Math.min(n, max)) : def;
265
+ };
266
+
267
+ /**
268
+ * Load the batch work list from a directory of *.json payload files (filename
269
+ * stem = user_id) or an .ndjson file ({user_id, payload[, plan_name, source]}
270
+ * per line). NEVER throws on one bad file/line — the item is recorded with a
271
+ * local error and the rest continue (the CLI mirror of the server's
272
+ * partial-success contract).
273
+ */
274
+ function loadBatchItems(input, defaultSource) {
275
+ const items = [];
276
+ const push = (user_id, fn) => {
277
+ try {
278
+ items.push({ user_id, ...fn() });
279
+ } catch (e) {
280
+ items.push({ user_id, error: { code: 'LOCAL_READ_FAILED', message: String(e.message ?? e).slice(0, 300) } });
281
+ }
282
+ };
283
+ const st = statSync(input); // ENOENT throws → caught by main's catch → exit 1
284
+ if (st.isDirectory()) {
285
+ const files = readdirSync(input).filter((f) => /\.json$/i.test(f)).sort();
286
+ if (!files.length) fail(2, `No *.json payload files in ${input}`);
287
+ for (const f of files) {
288
+ push(path.basename(f, path.extname(f)), () => ({
289
+ payload: JSON.parse(readFileSync(path.join(input, f), 'utf8')),
290
+ source: defaultSource,
291
+ }));
292
+ }
293
+ return items;
294
+ }
295
+ // NDJSON: one {"user_id": "...", "payload": {...}} per line.
296
+ const lines = readFileSync(input, 'utf8').split('\n');
297
+ lines.forEach((line, i) => {
298
+ const t = line.trim();
299
+ if (!t) return;
300
+ let row;
301
+ try {
302
+ row = JSON.parse(t);
303
+ } catch (e) {
304
+ items.push({ user_id: `line-${i + 1}`, error: { code: 'LOCAL_PARSE_FAILED', message: `NDJSON line ${i + 1}: ${String(e.message).slice(0, 200)}` } });
305
+ return;
306
+ }
307
+ const uid = typeof row?.user_id === 'string' && row.user_id ? row.user_id : `line-${i + 1}`;
308
+ if (!row || typeof row.payload !== 'object' || row.payload === null) {
309
+ items.push({ user_id: uid, error: { code: 'LOCAL_PARSE_FAILED', message: `NDJSON line ${i + 1}: missing "payload" object` } });
310
+ return;
311
+ }
312
+ items.push({
313
+ user_id: uid,
314
+ payload: row.payload,
315
+ source: typeof row.source === 'string' && row.source ? row.source : defaultSource,
316
+ ...(typeof row.plan_name === 'string' ? { plan_name: row.plan_name } : {}),
317
+ });
318
+ });
319
+ if (!items.length) fail(2, `No NDJSON rows in ${input}`);
320
+ return items;
321
+ }
322
+
323
+ /** The manifest doubles as the RESULTS file — per-user status keyed by user_id. */
324
+ function defaultManifestPath(input) {
325
+ return `${input.replace(/[/\\]+$/, '')}.planfi-manifest.json`;
326
+ }
327
+
328
+ function loadManifest(p) {
329
+ if (!existsSync(p)) return { version: 1, items: {} };
330
+ try {
331
+ const m = JSON.parse(readFileSync(p, 'utf8'));
332
+ return m && typeof m === 'object' && m.items ? m : { version: 1, items: {} };
333
+ } catch {
334
+ return { version: 1, items: {} }; // corrupt manifest → start fresh, never crash
335
+ }
336
+ }
337
+
338
+ /** Atomic-ish write (tmp + rename) so a crash mid-write can't corrupt the manifest. */
339
+ function saveManifest(p, manifest) {
340
+ const tmp = `${p}.tmp`;
341
+ writeFileSync(tmp, JSON.stringify(manifest, null, 2));
342
+ renameSync(tmp, p);
343
+ }
344
+
345
+ /**
346
+ * Full needsInput objects (field/label/accountId) for the RESULTS file — the
347
+ * per-customer collection worklist. The batch endpoint returns field NAMES per
348
+ * item (rollup-friendly); the CLI has the payload in hand, so it re-runs the
349
+ * SAME library locally (identical code path to the server) for the full asks.
350
+ * Best-effort: a local hiccup yields [] rather than failing the item.
351
+ */
352
+ function localDiagnostics(source, payload) {
353
+ try {
354
+ const { warnings, needsInput } = importToPlan(source, payload);
355
+ return {
356
+ warnings: warnings.map((w) => w.code),
357
+ needs_input: needsInput.map((n) => ({
358
+ field: n.field,
359
+ label: n.label,
360
+ ...(n.accountId ? { accountId: n.accountId } : {}),
361
+ })),
362
+ };
363
+ } catch {
364
+ return { warnings: [], needs_input: [] };
365
+ }
366
+ }
367
+
368
+ /** Tiny promise pool: run `worker(task)` over tasks, at most `n` in flight. */
369
+ async function pool(tasks, n, worker) {
370
+ let next = 0;
371
+ const runners = Array.from({ length: Math.min(n, tasks.length) }, async () => {
372
+ while (next < tasks.length) {
373
+ const i = next++;
374
+ await worker(tasks[i]);
375
+ }
376
+ });
377
+ await Promise.all(runners);
378
+ }
379
+
380
+ async function postJson(url, token, body) {
381
+ const res = await fetch(url, {
382
+ method: 'POST',
383
+ headers: {
384
+ 'Content-Type': 'application/json',
385
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
386
+ },
387
+ body: JSON.stringify(body),
388
+ });
389
+ const text = await res.text();
390
+ let json;
391
+ try { json = JSON.parse(text); } catch { json = { raw: text.slice(0, 500) }; }
392
+ return { status: res.status, ok: res.ok, json };
393
+ }
394
+
395
+ function printBatchReport(manifest, { skipped }) {
396
+ const rows = Object.entries(manifest.items);
397
+ const oks = rows.filter(([, r]) => r.ok);
398
+ const fails = rows.filter(([, r]) => !r.ok);
399
+ const updatedN = oks.filter(([, r]) => r.updated).length;
400
+
401
+ const out = [];
402
+ out.push(bold('batch import report'));
403
+ out.push(` ${green(`ok: ${oks.length}`)} (${updatedN} updated in place, ${oks.length - updatedN} created)` +
404
+ (skipped ? dim(` · skipped (already ok in manifest): ${skipped}`) : '') +
405
+ (fails.length ? ` · ${red(`failed: ${fails.length}`)}` : ''));
406
+
407
+ // needs_input rollup: field → customers still missing it (the founder view).
408
+ const rollup = {};
409
+ for (const [, r] of oks) for (const n of r.needs_input ?? []) rollup[n.field] = (rollup[n.field] ?? 0) + 1;
410
+ const rollupRows = Object.entries(rollup).sort((a, b) => b[1] - a[1]);
411
+ if (rollupRows.length) {
412
+ out.push('');
413
+ out.push(bold('missing data across the batch') + dim(' (needsInput field → customers):'));
414
+ for (const [field, count] of rollupRows) out.push(` ${cyan(field.padEnd(22))} ${count}`);
415
+ }
416
+ const wRollup = {};
417
+ for (const [, r] of oks) for (const c of r.warnings ?? []) wRollup[c] = (wRollup[c] ?? 0) + 1;
418
+ const wRows = Object.entries(wRollup).sort((a, b) => b[1] - a[1]);
419
+ if (wRows.length) {
420
+ out.push('');
421
+ out.push(bold('warnings across the batch') + dim(' (code → occurrences):'));
422
+ for (const [code, count] of wRows) out.push(` ${yellow(code.padEnd(28))} ${count}`);
423
+ }
424
+ if (fails.length) {
425
+ out.push('');
426
+ out.push(bold(`failed items (${fails.length})`) + dim(' — fix + re-run; the manifest skips the done ones:'));
427
+ for (const [uid, r] of fails.slice(0, 25)) {
428
+ out.push(` ${red(uid)}: ${r.error?.code ?? 'ERROR'} ${dim((r.error?.message ?? '').slice(0, 120))}`);
429
+ }
430
+ if (fails.length > 25) out.push(dim(` … and ${fails.length - 25} more (see the manifest file)`));
431
+ }
432
+ process.stdout.write(out.join('\n') + '\n');
433
+ }
434
+
435
+ async function cmdBatch(args) {
436
+ const source = args.source ?? fail(2, 'batch requires --source');
437
+ if (!ADAPTERS[source]) fail(2, `Unknown --source "${source}". Known: ${Object.keys(ADAPTERS).join(', ')}`);
438
+ const input = args.positional[0] ?? fail(2, 'batch requires a directory of *.json payloads or an .ndjson file');
439
+ const base = (args.base ?? DEFAULT_BASE).replace(/\/$/, '');
440
+ const batchSize = args.single ? 1 : clampInt(args.batchSize, 25, 1, 25);
441
+ const concurrency = clampInt(args.concurrency, 4, 1, 16);
442
+ const manifestPath = args.resume ?? defaultManifestPath(input);
443
+
444
+ const all = loadBatchItems(input, source);
445
+ const manifest = loadManifest(manifestPath);
446
+ manifest.source = source;
447
+
448
+ // Resume: anything already ok in the manifest is skipped. (The server upsert
449
+ // makes re-sends harmless anyway — skipping just saves quota + time.)
450
+ let skipped = 0;
451
+ const pending = [];
452
+ for (const item of all) {
453
+ if (manifest.items[item.user_id]?.ok) { skipped++; continue; }
454
+ if (item.error) {
455
+ // Locally-unreadable file/line: recorded, never sent, never crashes the run.
456
+ manifest.items[item.user_id] = { ok: false, error: item.error, at: Date.now() };
457
+ continue;
458
+ }
459
+ pending.push(item);
460
+ }
461
+
462
+ const record = (item, r) => {
463
+ manifest.items[item.user_id] = { ...r, at: Date.now() };
464
+ };
465
+ const recordOk = (item, res) => {
466
+ record(item, {
467
+ ok: true,
468
+ plan_id: res.plan_id,
469
+ updated: res.updated === true,
470
+ ...localDiagnostics(item.source, item.payload), // full needsInput objects + warning codes
471
+ });
472
+ };
473
+
474
+ // Chunk the pending items and drive them through a small worker pool.
475
+ const chunks = [];
476
+ for (let i = 0; i < pending.length; i += batchSize) chunks.push(pending.slice(i, i + batchSize));
477
+
478
+ await pool(chunks, concurrency, async (chunk) => {
479
+ try {
480
+ if (args.single) {
481
+ const item = chunk[0];
482
+ const { ok, status, json } = await postJson(`${base}/v1/tools/import_financial_data`, args.token, {
483
+ source: item.source, payload: item.payload, user_id: item.user_id,
484
+ ...(item.plan_name ? { plan_name: item.plan_name } : {}),
485
+ });
486
+ if (ok && json && !json.error) recordOk(item, json);
487
+ else record(item, { ok: false, error: json?.error ?? { code: `HTTP_${status}`, message: `single import returned ${status}` } });
488
+ } else {
489
+ const body = {
490
+ items: chunk.map((it) => ({
491
+ source: it.source, payload: it.payload, user_id: it.user_id,
492
+ ...(it.plan_name ? { plan_name: it.plan_name } : {}),
493
+ })),
494
+ };
495
+ const { ok, status, json } = await postJson(`${base}/v1/tools/import_financial_data_batch`, args.token, body);
496
+ if (!ok || !Array.isArray(json?.results)) {
497
+ const error = json?.error ?? { code: `HTTP_${status}`, message: `batch call returned ${status}` };
498
+ for (const item of chunk) record(item, { ok: false, error });
499
+ } else {
500
+ for (const r of json.results) {
501
+ const item = chunk[r.index];
502
+ if (!item) continue;
503
+ if (r.ok) recordOk(item, r);
504
+ else record(item, { ok: false, error: r.error ?? { code: 'ERROR', message: 'item failed' } });
505
+ }
506
+ }
507
+ }
508
+ } catch (e) {
509
+ // Network-level failure: every item in the chunk is recorded + resumable.
510
+ for (const item of chunk) {
511
+ record(item, { ok: false, error: { code: 'NETWORK', message: String(e.message ?? e).slice(0, 300) } });
512
+ }
513
+ }
514
+ // Persist progress after EVERY chunk so a crash/ctrl-C resumes cleanly.
515
+ saveManifest(manifestPath, manifest);
516
+ });
517
+
518
+ saveManifest(manifestPath, manifest);
519
+
520
+ const rows = Object.entries(manifest.items);
521
+ const failed = rows.filter(([, r]) => !r.ok).length;
522
+ if (args.json) {
523
+ const rollup = {};
524
+ for (const [, r] of rows) if (r.ok) for (const n of r.needs_input ?? []) rollup[n.field] = (rollup[n.field] ?? 0) + 1;
525
+ process.stdout.write(JSON.stringify({
526
+ summary: { total: all.length, ok: rows.length - failed, failed, skipped },
527
+ needs_input_rollup: rollup,
528
+ manifest: manifestPath,
529
+ results: manifest.items,
530
+ }, null, 2) + '\n');
531
+ } else {
532
+ printBatchReport(manifest, { skipped });
533
+ process.stdout.write(dim(` manifest/results: ${manifestPath} — re-running skips the ${rows.length - failed} ok item(s)\n`));
534
+ }
535
+ if (failed > 0) process.exit(1);
536
+ }
537
+
538
+ // ── main ─────────────────────────────────────────────────────────────────────
539
+ const [command, ...rest] = process.argv.slice(2);
540
+ const args = parseArgs(rest);
541
+ if (args.help || command === 'help' || command === '--help' || command === '-h') { process.stdout.write(USAGE); process.exit(0); }
542
+
543
+ const commands = { demo: cmdDemo, validate: cmdValidate, plan: cmdPlan, batch: cmdBatch };
544
+ if (!command || !commands[command]) fail(2, command ? `Unknown command: ${command}` : 'No command given');
545
+
546
+ commands[command](args).catch((e) => {
547
+ process.stderr.write(red(`error: ${e.message}`) + '\n');
548
+ process.exit(1);
549
+ });