@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,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
|
+
});
|