@profullstack/coinpay 0.11.2 → 0.12.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,959 @@
1
+ /**
2
+ * Statement fetching: download the original PDF statements behind every
3
+ * linked account, straight from each bank, and keep them in CoinPay.
4
+ *
5
+ * SimpleFIN gives CoinPay balances and transactions and nothing else: the
6
+ * protocol has no document endpoint and the Bridge keeps no PDFs. So this
7
+ * runs on the merchant's own machine and goes to the banks:
8
+ *
9
+ * - The account list comes from CoinPay (`/finances/accounts`): institution,
10
+ * site and the last four digits of each account.
11
+ * - Every bank gets its own Chrome profile, signed in once by a person in a
12
+ * real window (`coinpay finances statements login <bank>`). No bank
13
+ * password is asked for or stored; the session is the bank's own cookie,
14
+ * and it never leaves this machine.
15
+ * - `fetch` drives that profile headless: open the statements page, click
16
+ * every control in a dated row that downloads a statement, catch the PDF
17
+ * through the DevTools download events, keep a copy under
18
+ * ~/.coinpay/statements/files, and import it into the statement library
19
+ * with its account and period. Each bank's run is reported as counts only.
20
+ *
21
+ * Bank pages change without notice, so nothing here knows a page's markup.
22
+ * The finder looks for what every statements page has (a dated row with a
23
+ * download or PDF control) and says what it saw. Needs Node 22+ (WebSocket).
24
+ */
25
+
26
+ import { spawn } from 'node:child_process';
27
+ import { createHash } from 'node:crypto';
28
+ import { accessSync, chmodSync, constants, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
29
+ import { homedir, tmpdir } from 'node:os';
30
+ import { basename, dirname, join } from 'node:path';
31
+
32
+ export class StatementFetchError extends Error {
33
+ constructor(message) {
34
+ super(message);
35
+ this.name = 'StatementFetchError';
36
+ }
37
+ }
38
+
39
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Accounts and institutions
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /** The four digits an account name ends with: "SAPPHIRE (6496)", "Checking ...1234". */
46
+ export function lastFour(name) {
47
+ const tail = /(\d{4})\)?\s*$/.exec(name || '');
48
+ if (tail) return tail[1];
49
+ const all = [...String(name || '').matchAll(/(?<!\d)(\d{4})(?!\d)/g)];
50
+ return all.length ? all[all.length - 1][1] : null;
51
+ }
52
+
53
+ export function slug(value) {
54
+ return String(value || '')
55
+ .normalize('NFKD')
56
+ .replace(/[̀-ͯ]/g, '')
57
+ .toLowerCase()
58
+ .replace(/[^a-z0-9]+/g, '-')
59
+ .replace(/^-+|-+$/g, '')
60
+ .slice(0, 60) || 'bank';
61
+ }
62
+
63
+ /** secure.chase.com and chase.com are both `chase`. The server computes the same key. */
64
+ export function institutionKey(domain, name) {
65
+ const host = String(domain || '').replace(/^https?:\/\//, '').split(/[/:]/)[0].toLowerCase();
66
+ const labels = host.split('.').filter(Boolean);
67
+ if (labels.length >= 2) {
68
+ const second = labels[labels.length - 2];
69
+ return slug(labels.length >= 3 && /^(co|com|org|net|gov|ac)$/.test(second) ? labels[labels.length - 3] : second);
70
+ }
71
+ return slug(name || host || 'bank');
72
+ }
73
+
74
+ /** CoinPay's account list, grouped by bank. */
75
+ export function groupInstitutions(accounts) {
76
+ const byKey = new Map();
77
+ for (const account of accounts || []) {
78
+ if (account.is_hidden) continue;
79
+ const key = institutionKey(account.org_domain, account.org_name);
80
+ const entry = {
81
+ id: account.id,
82
+ name: account.name,
83
+ last4: lastFour(account.name),
84
+ institution: key,
85
+ };
86
+ const existing = byKey.get(key);
87
+ if (existing) existing.accounts.push(entry);
88
+ else {
89
+ byKey.set(key, {
90
+ key,
91
+ name: account.org_name || account.org_domain || key,
92
+ url: account.org_domain ? `https://${String(account.org_domain).replace(/^https?:\/\//, '')}` : null,
93
+ accounts: [entry],
94
+ });
95
+ }
96
+ }
97
+ return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key));
98
+ }
99
+
100
+ /** A bank named on the command line: its key, or the start of its key or name. */
101
+ export function pickInstitution(institutions, name) {
102
+ const wanted = String(name).toLowerCase().replace(/[^a-z0-9]/g, '');
103
+ const exact = institutions.find((entry) => entry.key.replace(/-/g, '') === wanted);
104
+ if (exact) return exact;
105
+ const loose = institutions.filter((entry) => entry.key.replace(/-/g, '').startsWith(wanted) || entry.name.toLowerCase().replace(/[^a-z0-9]/g, '').startsWith(wanted));
106
+ if (loose.length === 1) return loose[0];
107
+ if (loose.length) throw new StatementFetchError(`"${name}" matches ${loose.map((entry) => entry.key).join(', ')}; be more specific`);
108
+ throw new StatementFetchError(`no bank called "${name}" among your linked accounts (${institutions.map((entry) => entry.key).join(', ') || 'none linked'})`);
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Where each bank keeps its statements
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * Sign-in pages for common banks. The statements page itself is learnt:
117
+ * whatever page the person closes the login window on is where fetch starts,
118
+ * so a bank not listed here works the same way from the site SimpleFIN names.
119
+ */
120
+ export const DRIVERS = [
121
+ { key: 'chase', login: 'https://secure.chase.com/web/auth/dashboard', statements: 'https://secure.chase.com/web/auth/dashboard#/dashboard/documents/myDocs/index;mode=documents' },
122
+ { key: 'citi', login: 'https://online.citi.com/US/login.do' },
123
+ { key: 'apple', login: 'https://card.apple.com/' },
124
+ { key: 'americanexpress', login: 'https://www.americanexpress.com/en-us/account/login', statements: 'https://global.americanexpress.com/activity/statements' },
125
+ { key: 'capitalone', login: 'https://verified.capitalone.com/auth/signin' },
126
+ { key: 'discover', login: 'https://portal.discover.com/customersvcs/universalLogin/ac_main' },
127
+ { key: 'wellsfargo', login: 'https://connect.secure.wellsfargo.com/auth/login/present' },
128
+ { key: 'bankofamerica', login: 'https://secure.bankofamerica.com/login/sign-in/signOnV2Screen.go' },
129
+ { key: 'usbank', login: 'https://onlinebanking.usbank.com/auth/login/' },
130
+ { key: 'dcu', login: 'https://digital.dcu.org/' },
131
+ ];
132
+
133
+ export function startUrls(institution, learnt) {
134
+ const driver = DRIVERS.find((entry) => entry.key === institution.key);
135
+ return {
136
+ login: (driver && driver.login) || institution.url || null,
137
+ fetch: learnt || (driver && (driver.statements || driver.login)) || institution.url || null,
138
+ };
139
+ }
140
+
141
+ export function isSignInUrl(url) {
142
+ return /log-?in|log-?on|sign-?in|sign-?on|logout|log-?off|sign-?off|auth\/(login|signin)|universallogin/i.test(url);
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Dates, periods, accounts
147
+ // ---------------------------------------------------------------------------
148
+
149
+ const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
150
+ const MONTH_WORD = '(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)';
151
+
152
+ function iso(year, month, day) {
153
+ if (year < 1990 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) return null;
154
+ const date = new Date(Date.UTC(year, month - 1, day));
155
+ if (date.getUTCMonth() !== month - 1) return null;
156
+ return date.toISOString().slice(0, 10);
157
+ }
158
+
159
+ /** Every date in statement text or a file name, in the order written: [{date, precise}]. */
160
+ export function findDates(source) {
161
+ const found = [];
162
+ const add = (at, date, precise) => { if (date) found.push({ at, value: { date, precise } }); };
163
+ const monthIndex = (word) => MONTHS.indexOf(word.slice(0, 3).toLowerCase()) + 1;
164
+ const year = (value) => (value.length === 2 ? 2000 + Number(value) : Number(value));
165
+ const text = String(source || '');
166
+
167
+ for (const m of text.matchAll(/(?<!\d)(\d{4})-(\d{2})-(\d{2})(?!\d)/g)) add(m.index, iso(+m[1], +m[2], +m[3]), true);
168
+ for (const m of text.matchAll(/(?<!\d)(20\d{2})(\d{2})(\d{2})(?!\d)/g)) add(m.index, iso(+m[1], +m[2], +m[3]), true);
169
+ for (const m of text.matchAll(/(?<![\d/])(\d{1,2})\/(\d{1,2})\/(\d{4}|\d{2})(?![\d/])/g)) add(m.index, iso(year(m[3]), +m[1], +m[2]), true);
170
+ for (const m of text.matchAll(new RegExp(`(?<![a-z])${MONTH_WORD}\\.?[\\s_-]*(\\d{1,2})(?:st|nd|rd|th)?,?[\\s_-]+(\\d{4})(?!\\d)`, 'gi'))) add(m.index, iso(+m[3], monthIndex(m[1]), +m[2]), true);
171
+ for (const m of text.matchAll(new RegExp(`(?<![a-z])${MONTH_WORD}\\.?[\\s_,-]+(\\d{4})(?!\\d)`, 'gi'))) add(m.index, iso(+m[2], monthIndex(m[1]), 1), false);
172
+ for (const m of text.matchAll(/(?<!\d)(\d{4})-(\d{2})(?![-\d])/g)) add(m.index, iso(+m[1], +m[2], 1), false);
173
+
174
+ found.sort((a, b) => a.at - b.at);
175
+ return found
176
+ .filter((entry, _i, all) => entry.value.precise || !all.some((other) => other.value.precise && other.value.date.slice(0, 7) === entry.value.date.slice(0, 7) && Math.abs(other.at - entry.at) < 20))
177
+ .map((entry) => entry.value);
178
+ }
179
+
180
+ /** The closing month, and the statement's own range when one is printed. */
181
+ export function periodOf(...sources) {
182
+ for (const source of sources) {
183
+ if (!source) continue;
184
+ const dates = findDates(source);
185
+ if (!dates.length) continue;
186
+ const precise = dates.filter((entry) => entry.precise).map((entry) => entry.date).sort();
187
+ if (precise.length >= 2) {
188
+ const from = precise[0];
189
+ const to = precise[precise.length - 1];
190
+ const days = (Date.parse(to) - Date.parse(from)) / 86_400_000;
191
+ if (days >= 20 && days <= 100) return { month: to.slice(0, 7), from, to };
192
+ }
193
+ const latest = dates.map((entry) => entry.date).sort().at(-1);
194
+ return { month: latest.slice(0, 7), from: null, to: precise.includes(latest) ? latest : null };
195
+ }
196
+ return null;
197
+ }
198
+
199
+ /** The account a statement belongs to: by the last four digits it shows, or the bank's only account. */
200
+ export function matchAccount(accounts, ...sources) {
201
+ if (accounts.length === 1) return accounts[0];
202
+ const known = accounts.filter((entry) => entry.last4);
203
+ for (const source of sources) {
204
+ if (!source) continue;
205
+ const hits = known.filter((entry) => new RegExp(`(?<!\\d)${entry.last4}(?!\\d)`).test(source));
206
+ if (hits.length === 1) return hits[0];
207
+ }
208
+ return null;
209
+ }
210
+
211
+ /** The import arguments for a period: the statement's own cycle (exclusive end) or its month. */
212
+ export function importPeriod(period) {
213
+ if (!period) return null;
214
+ if (period.from && period.to) {
215
+ const end = new Date(`${period.to}T00:00:00Z`);
216
+ end.setUTCDate(end.getUTCDate() + 1);
217
+ return { from: period.from, to: end.toISOString().slice(0, 10), cycle: 'custom' };
218
+ }
219
+ return { period: period.month };
220
+ }
221
+
222
+ export function candidateKey(candidate) {
223
+ if (candidate.href && !/[?&](token|session|nonce|ts|_)=/i.test(candidate.href)) return candidate.href;
224
+ const period = periodOf(candidate.context, candidate.label);
225
+ return `${candidate.label}|${period ? `${period.month}|${period.to || ''}` : String(candidate.context).slice(0, 80)}`;
226
+ }
227
+
228
+ export function isPdf(bytes) {
229
+ return Buffer.from(bytes.subarray(0, 1024)).toString('latin1').includes('%PDF-');
230
+ }
231
+
232
+ export function sha256(bytes) {
233
+ return createHash('sha256').update(bytes).digest('hex');
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Local state: ~/.coinpay/statements
238
+ // ---------------------------------------------------------------------------
239
+
240
+ export function statementsHome(env = process.env) {
241
+ return env.COINPAY_STATEMENTS_DIR || join(homedir(), '.coinpay', 'statements');
242
+ }
243
+
244
+ function writePrivateJson(path, value) {
245
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
246
+ const temp = `${path}.${process.pid}.tmp`;
247
+ writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
248
+ renameSync(temp, path);
249
+ chmodSync(path, 0o600);
250
+ }
251
+
252
+ function readJson(path, fallback) {
253
+ if (!existsSync(path)) return fallback;
254
+ try {
255
+ return JSON.parse(readFileSync(path, 'utf8'));
256
+ } catch (err) {
257
+ throw new StatementFetchError(`${path} is not valid JSON (${err.message}); fix or remove it`);
258
+ }
259
+ }
260
+
261
+ /** `{institutions: {key: {start, loggedInAt, lastFetchAt, lastStatus}}, entries: [...]}` */
262
+ export function loadLocal(home = statementsHome()) {
263
+ const state = readJson(join(home, 'state.json'), {});
264
+ state.institutions = state.institutions || {};
265
+ state.entries = state.entries || [];
266
+ return state;
267
+ }
268
+
269
+ export function saveLocal(state, home = statementsHome()) {
270
+ writePrivateJson(join(home, 'state.json'), state);
271
+ }
272
+
273
+ export function profileDir(key, home = statementsHome()) {
274
+ return join(home, 'profiles', key);
275
+ }
276
+
277
+ export function signedIn(key, home = statementsHome()) {
278
+ return existsSync(join(profileDir(key, home), 'Default'));
279
+ }
280
+
281
+ /** Keep a copy of every statement on this machine, never overwriting a different file. */
282
+ export function archive(home, institution, account, period, suggestedName, bytes) {
283
+ const folder = account ? join(institution.key, `${slug(account.name)}${account.last4 && !slug(account.name).includes(account.last4) ? `-${account.last4}` : ''}`) : join(institution.key, '_unfiled');
284
+ const stem = account && period ? period.month : slug(basename(suggestedName || 'statement', '.pdf')) || 'statement';
285
+ for (let attempt = 1; ; attempt += 1) {
286
+ const relative = join(folder, `${stem}${attempt === 1 ? '' : `-${attempt}`}.pdf`);
287
+ const absolute = join(home, 'files', relative);
288
+ if (existsSync(absolute)) continue;
289
+ mkdirSync(dirname(absolute), { recursive: true, mode: 0o700 });
290
+ writeFileSync(absolute, bytes, { mode: 0o600 });
291
+ return absolute;
292
+ }
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Chrome
297
+ // ---------------------------------------------------------------------------
298
+
299
+ function listDir(path) {
300
+ try {
301
+ return readdirSync(path);
302
+ } catch {
303
+ return [];
304
+ }
305
+ }
306
+
307
+ function isExecutable(path) {
308
+ try {
309
+ accessSync(path, constants.X_OK);
310
+ return true;
311
+ } catch {
312
+ return false;
313
+ }
314
+ }
315
+
316
+ const byVersionDesc = (a, b) => {
317
+ const num = (s) => s.split(/[^0-9]+/).filter(Boolean).map(Number);
318
+ const x = num(a);
319
+ const y = num(b);
320
+ for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
321
+ const d = (y[i] || 0) - (x[i] || 0);
322
+ if (d) return d;
323
+ }
324
+ return 0;
325
+ };
326
+
327
+ /** CHROME_PATH, then Chrome/Chromium on PATH, then the Puppeteer and Playwright caches, then macOS. */
328
+ export function findChrome(env = process.env, home = homedir()) {
329
+ const candidates = [];
330
+ if (env.CHROME_PATH) candidates.push(env.CHROME_PATH);
331
+ for (const name of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome']) {
332
+ for (const dir of String(env.PATH || '').split(':').filter(Boolean)) candidates.push(join(dir, name));
333
+ }
334
+ candidates.push('/opt/google/chrome/chrome');
335
+ const puppeteer = join(home, '.cache', 'puppeteer', 'chrome');
336
+ for (const build of listDir(puppeteer).sort(byVersionDesc)) {
337
+ const inner = listDir(join(puppeteer, build)).find((entry) => entry.startsWith('chrome'));
338
+ if (inner) candidates.push(join(puppeteer, build, inner, 'chrome'));
339
+ }
340
+ const playwright = join(home, '.cache', 'ms-playwright');
341
+ for (const build of listDir(playwright).sort(byVersionDesc)) {
342
+ if (build.startsWith('chromium-')) candidates.push(join(playwright, build, 'chrome-linux', 'chrome'));
343
+ }
344
+ candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome');
345
+ candidates.push('/Applications/Chromium.app/Contents/MacOS/Chromium');
346
+ return candidates.find(isExecutable) || null;
347
+ }
348
+
349
+ export const NO_CHROME = 'no Chrome found. Set CHROME_PATH to a Chrome or Chromium binary, or install one (apt install chromium, brew install --cask google-chrome)';
350
+
351
+ /** The slice of the DevTools protocol this needs, over one flat-session socket. */
352
+ class Cdp {
353
+ constructor(socket) {
354
+ this.socket = socket;
355
+ this.nextId = 1;
356
+ this.pending = new Map();
357
+ this.listeners = new Set();
358
+ socket.addEventListener('message', (event) => {
359
+ const message = JSON.parse(String(event.data));
360
+ if (message.id !== undefined && this.pending.has(message.id)) {
361
+ const { resolve, reject } = this.pending.get(message.id);
362
+ this.pending.delete(message.id);
363
+ if (message.error) reject(new StatementFetchError(message.error.message));
364
+ else resolve(message.result || {});
365
+ return;
366
+ }
367
+ if (message.method) for (const listener of this.listeners) listener(message);
368
+ });
369
+ socket.addEventListener('close', () => {
370
+ for (const { reject } of this.pending.values()) reject(new StatementFetchError('browser closed'));
371
+ this.pending.clear();
372
+ });
373
+ }
374
+
375
+ send(method, params = {}, sessionId) {
376
+ const id = this.nextId++;
377
+ return new Promise((resolve, reject) => {
378
+ this.pending.set(id, { resolve, reject });
379
+ this.socket.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params }));
380
+ });
381
+ }
382
+
383
+ on(listener) {
384
+ this.listeners.add(listener);
385
+ return () => this.listeners.delete(listener);
386
+ }
387
+
388
+ waitFor(method, sessionId, timeoutMs) {
389
+ return new Promise((resolve) => {
390
+ const timer = setTimeout(() => { this.listeners.delete(listener); resolve(false); }, timeoutMs);
391
+ const listener = (message) => {
392
+ if (message.method === method && message.sessionId === sessionId) {
393
+ clearTimeout(timer);
394
+ this.listeners.delete(listener);
395
+ resolve(true);
396
+ }
397
+ };
398
+ this.listeners.add(listener);
399
+ });
400
+ }
401
+ }
402
+
403
+ /** Make Chrome save a PDF instead of opening it in its viewer, where nothing is ever downloaded. */
404
+ export function preferPdfDownloads(profile) {
405
+ const path = join(profile, 'Default', 'Preferences');
406
+ let prefs = {};
407
+ try {
408
+ if (existsSync(path)) prefs = JSON.parse(readFileSync(path, 'utf8'));
409
+ } catch {
410
+ prefs = {};
411
+ }
412
+ prefs.plugins = { ...(prefs.plugins || {}), always_open_pdf_externally: true };
413
+ prefs.download = { ...(prefs.download || {}), prompt_for_download: false };
414
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
415
+ writeFileSync(path, JSON.stringify(prefs));
416
+ }
417
+
418
+ /**
419
+ * Start Chrome on a kept profile. Closing waits for a clean exit, because
420
+ * cookies only reach disk then: a killed Chrome forgets a session the bank
421
+ * refreshed during the run.
422
+ */
423
+ export async function openBrowser({ chrome, profile, headless = true, timeoutMs = 30_000, env = process.env }) {
424
+ if (typeof WebSocket === 'undefined') throw new StatementFetchError('statement fetching needs Node 22 or newer (it drives Chrome over a WebSocket)');
425
+ if (!chrome || !isExecutable(chrome)) throw new StatementFetchError(chrome ? `${chrome} is not an executable` : NO_CHROME);
426
+ mkdirSync(profile, { recursive: true, mode: 0o700 });
427
+ preferPdfDownloads(profile);
428
+ const sandbox = !(env.CHROME_NO_SANDBOX || process.getuid?.() === 0);
429
+ const args = [
430
+ ...(headless ? ['--headless=new'] : []),
431
+ '--remote-debugging-port=0',
432
+ `--user-data-dir=${profile}`,
433
+ '--no-first-run',
434
+ '--no-default-browser-check',
435
+ '--disable-background-networking',
436
+ '--window-size=1280,900',
437
+ ...(sandbox ? [] : ['--no-sandbox']),
438
+ 'about:blank',
439
+ ];
440
+ const child = spawn(chrome, args, { env, stdio: ['ignore', 'ignore', 'pipe'] });
441
+ const exited = new Promise((resolve) => child.once('exit', () => resolve()));
442
+ const url = await new Promise((resolve, reject) => {
443
+ let stderr = '';
444
+ const timer = setTimeout(() => reject(new StatementFetchError(`${chrome} did not start within ${timeoutMs / 1000}s\n${stderr}`)), timeoutMs);
445
+ child.stderr.on('data', (chunk) => {
446
+ stderr += chunk.toString();
447
+ const match = /DevTools listening on (ws:\/\/\S+)/.exec(stderr);
448
+ if (match) {
449
+ clearTimeout(timer);
450
+ resolve(match[1]);
451
+ }
452
+ });
453
+ child.on('exit', (code) => {
454
+ clearTimeout(timer);
455
+ const locked = /SingletonLock|ProcessSingleton/.test(stderr);
456
+ reject(new StatementFetchError(locked ? `the ${profile} profile is open in another Chrome; close that window first` : `${chrome} exited with ${code} before it was ready\n${stderr.trim()}`));
457
+ });
458
+ }).catch((err) => {
459
+ child.kill();
460
+ throw err;
461
+ });
462
+ const socket = new WebSocket(url);
463
+ await new Promise((resolve, reject) => {
464
+ socket.addEventListener('open', () => resolve(), { once: true });
465
+ socket.addEventListener('error', () => reject(new StatementFetchError(`could not connect to ${url}`)), { once: true });
466
+ });
467
+ const cdp = new Cdp(socket);
468
+ return {
469
+ cdp,
470
+ exited,
471
+ async close() {
472
+ try {
473
+ await Promise.race([cdp.send('Browser.close'), sleep(2000)]);
474
+ } catch {
475
+ // already gone
476
+ }
477
+ await Promise.race([exited, sleep(10_000)]);
478
+ socket.close();
479
+ child.kill();
480
+ },
481
+ };
482
+ }
483
+
484
+ // ---------------------------------------------------------------------------
485
+ // In-page scripts
486
+ // ---------------------------------------------------------------------------
487
+
488
+ const ALL_DOCS = `const docs = [document];
489
+ for (let i = 0; i < docs.length; i += 1) {
490
+ for (const frame of docs[i].querySelectorAll('iframe, frame')) { try { if (frame.contentDocument) docs.push(frame.contentDocument); } catch {} }
491
+ }`;
492
+
493
+ /** True when the page asks for a password: the session is gone. */
494
+ export const SIGNED_OUT = `(() => {
495
+ ${ALL_DOCS}
496
+ const visible = (el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
497
+ return docs.some((doc) => [...doc.querySelectorAll('input[type=password]')].some(visible));
498
+ })()`;
499
+
500
+ /** Follow the page's own "Statements" link, if it has one. */
501
+ export const OPEN_STATEMENTS = `(() => {
502
+ const want = /^(e-?)?statements?(\\s*(&|and)\\s*(documents|disclosures))?$|^(view |my )?statements?$|^documents$/i;
503
+ for (const el of document.querySelectorAll('a, button, [role=link], [role=tab], [role=menuitem]')) {
504
+ const r = el.getBoundingClientRect();
505
+ const label = (el.getAttribute('aria-label') || el.textContent || '').replace(/\\s+/g, ' ').trim();
506
+ if (label.length < 40 && want.test(label) && r.width > 0 && r.height > 0) { el.click(); return label; }
507
+ }
508
+ return null;
509
+ })()`;
510
+
511
+ /**
512
+ * Every visible control that looks like it downloads one dated statement,
513
+ * tagged data-stmt-i for clickScript. Row text is read with innerText, which
514
+ * keeps the break between table cells ("1234" and "July" stay two words).
515
+ */
516
+ export const COLLECT = `(() => {
517
+ const DATE = /((?<![a-z])(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\\.?\\s+(\\d{1,2},?\\s+)?\\d{4}\\b)|(\\b\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}\\b)|(\\b\\d{4}-\\d{2}(-\\d{2})?\\b)/i;
518
+ const SKIP = /preference|paperless|setting|notification|tax form|1099|privacy|agreement|terms|help|learn more|enroll/i;
519
+ const ACTION = /statement|download|pdf|view|open|save/i;
520
+ const visible = (el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
521
+ const clean = (s) => (s || '').replace(/\\s+/g, ' ').trim();
522
+ const textOf = (el) => clean(el.innerText || el.textContent);
523
+ const rowOf = (el) => {
524
+ const row = el.closest('tr, li, [role=row], [role=listitem], article');
525
+ if (row && textOf(row).length < 400) return row;
526
+ let node = el;
527
+ for (let i = 0; i < 4 && node.parentElement; i += 1) {
528
+ node = node.parentElement;
529
+ if (DATE.test(textOf(node)) && textOf(node).length < 300) return node;
530
+ }
531
+ return el;
532
+ };
533
+ ${ALL_DOCS}
534
+ const out = [];
535
+ let index = 0;
536
+ for (const doc of docs) {
537
+ for (const el of doc.querySelectorAll('a, button, [role=button], [role=link]')) {
538
+ if (!visible(el)) continue;
539
+ const label = clean(el.getAttribute('aria-label') || el.getAttribute('title') || el.innerText || el.textContent);
540
+ const href = el.getAttribute('href') || '';
541
+ if (!/\\.pdf(\\b|$)/i.test(href) && !ACTION.test(label)) continue;
542
+ if (SKIP.test(label)) continue;
543
+ const context = textOf(rowOf(el)).slice(0, 300);
544
+ if (!DATE.test(label + ' ' + context + ' ' + href)) continue;
545
+ el.setAttribute('data-stmt-i', String(index));
546
+ let absolute = null;
547
+ if (href && !href.startsWith('#') && !/^javascript:/i.test(href)) { try { absolute = new URL(href, doc.baseURI).href; } catch {} }
548
+ out.push({ index, label: label.slice(0, 120), context, href: absolute });
549
+ index += 1;
550
+ }
551
+ }
552
+ return JSON.stringify(out);
553
+ })()`;
554
+
555
+ export function clickScript(index) {
556
+ return `(() => {
557
+ ${ALL_DOCS}
558
+ for (const doc of docs) { const el = doc.querySelector('[data-stmt-i="${Number(index)}"]'); if (el) { el.click(); return true; } }
559
+ return false;
560
+ })()`;
561
+ }
562
+
563
+ /** A click that opened a dialog: press its Download button. */
564
+ export const SECOND_STEP = `(() => {
565
+ const want = /^(download( pdf| statement)?|pdf|save( pdf)?|view pdf)$/i;
566
+ for (const el of document.querySelectorAll('[role=dialog] button, [role=dialog] a, dialog button, dialog a, .modal button, .modal a, button, a')) {
567
+ const r = el.getBoundingClientRect();
568
+ const label = (el.getAttribute('aria-label') || el.textContent || '').replace(/\\s+/g, ' ').trim();
569
+ if (r.width > 0 && r.height > 0 && want.test(label) && !el.hasAttribute('data-stmt-i')) { el.click(); return label; }
570
+ }
571
+ return null;
572
+ })()`;
573
+
574
+ /** In assist mode: remember the row of whatever the person clicked last. */
575
+ export const CLICK_RECORDER = `document.addEventListener('click', (event) => {
576
+ const el = event.target instanceof Element ? event.target.closest('a, button, [role=button], [role=link]') || event.target : null;
577
+ if (!el) return;
578
+ const row = el.closest('tr, li, [role=row], [role=listitem], article') || el.parentElement || el;
579
+ window.__statementsLastClick = JSON.stringify({
580
+ label: (el.getAttribute('aria-label') || el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 120),
581
+ context: (row.innerText || row.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 300),
582
+ });
583
+ }, true);`;
584
+
585
+ // ---------------------------------------------------------------------------
586
+ // Driving a page
587
+ // ---------------------------------------------------------------------------
588
+
589
+ async function attachPage(cdp) {
590
+ const { targetInfos } = await cdp.send('Target.getTargets');
591
+ let targetId = (targetInfos.find((t) => t.type === 'page') || {}).targetId;
592
+ if (!targetId) ({ targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' }));
593
+ const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
594
+ await cdp.send('Page.enable', {}, sessionId);
595
+ return { sessionId, targetId };
596
+ }
597
+
598
+ /** Headless Chrome names itself in its user agent, and banks turn that away. */
599
+ async function hideHeadless(cdp, sessionId) {
600
+ const { userAgent } = await cdp.send('Browser.getVersion');
601
+ if (userAgent.includes('Headless')) await cdp.send('Network.setUserAgentOverride', { userAgent: userAgent.replace('HeadlessChrome', 'Chrome') }, sessionId);
602
+ }
603
+
604
+ async function evaluate(cdp, sessionId, expression) {
605
+ try {
606
+ const { result, exceptionDetails } = await cdp.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId);
607
+ return exceptionDetails ? null : result.value ?? null;
608
+ } catch {
609
+ return null;
610
+ }
611
+ }
612
+
613
+ async function navigate(cdp, sessionId, url, timeoutMs = 45_000) {
614
+ const loaded = cdp.waitFor('Page.loadEventFired', sessionId, timeoutMs);
615
+ const result = await cdp.send('Page.navigate', { url }, sessionId);
616
+ if (result.errorText && result.errorText !== 'net::ERR_ABORTED') throw new StatementFetchError(`could not open ${url}: ${result.errorText}`);
617
+ // A hash route never fires a load event; the poll that follows is what waits.
618
+ await Promise.race([loaded, sleep(Math.min(timeoutMs, 15_000))]);
619
+ }
620
+
621
+ /** Browser-wide downloads, saved as dir/<guid> by allowAndName. */
622
+ class Downloads {
623
+ constructor(cdp, dir) {
624
+ this.dir = dir;
625
+ this.begun = [];
626
+ this.finished = new Map();
627
+ this.waiters = new Set();
628
+ this.stop = cdp.on(({ method, params }) => {
629
+ if (method === 'Browser.downloadWillBegin') this.begun.push({ guid: String(params.guid), suggestedName: String(params.suggestedFilename || 'statement.pdf') });
630
+ else if (method === 'Browser.downloadProgress' && (params.state === 'completed' || params.state === 'canceled')) this.finished.set(String(params.guid), params.state);
631
+ else return;
632
+ for (const wake of this.waiters) wake();
633
+ });
634
+ }
635
+
636
+ static async start(cdp, dir) {
637
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
638
+ const downloads = new Downloads(cdp, dir);
639
+ await cdp.send('Browser.setDownloadBehavior', { behavior: 'allowAndName', downloadPath: dir, eventsEnabled: true });
640
+ return downloads;
641
+ }
642
+
643
+ async until(check, timeoutMs) {
644
+ const deadline = Date.now() + timeoutMs;
645
+ for (;;) {
646
+ const value = check();
647
+ if (value !== null && value !== undefined) return value;
648
+ const left = deadline - Date.now();
649
+ if (left <= 0) return null;
650
+ await new Promise((resolve) => {
651
+ const wake = () => { this.waiters.delete(wake); clearTimeout(timer); resolve(); };
652
+ const timer = setTimeout(wake, left);
653
+ this.waiters.add(wake);
654
+ });
655
+ }
656
+ }
657
+
658
+ next(after, timeoutMs) {
659
+ return this.until(() => this.begun[after] ?? null, timeoutMs);
660
+ }
661
+
662
+ async bytes(event, timeoutMs = 120_000) {
663
+ const state = await this.until(() => this.finished.get(event.guid) ?? null, timeoutMs);
664
+ if (state !== 'completed') return null;
665
+ const path = join(this.dir, event.guid);
666
+ const bytes = readFileSync(path);
667
+ rmSync(path, { force: true });
668
+ return bytes;
669
+ }
670
+ }
671
+
672
+ async function closeStrays(cdp, keep) {
673
+ const { targetInfos } = await cdp.send('Target.getTargets');
674
+ for (const target of targetInfos) {
675
+ if (target.type === 'page' && target.targetId !== keep) await cdp.send('Target.closeTarget', { targetId: target.targetId }).catch(() => {});
676
+ }
677
+ }
678
+
679
+ async function findCandidates(cdp, sessionId, renderMs) {
680
+ const deadline = Date.now() + renderMs;
681
+ let followed = false;
682
+ for (;;) {
683
+ if (await evaluate(cdp, sessionId, SIGNED_OUT)) return { signedOut: true, candidates: [] };
684
+ const raw = await evaluate(cdp, sessionId, COLLECT);
685
+ const candidates = raw ? JSON.parse(raw) : [];
686
+ if (candidates.length) return { signedOut: false, candidates };
687
+ if (!followed && Date.now() > deadline - renderMs / 2) followed = (await evaluate(cdp, sessionId, OPEN_STATEMENTS)) !== null;
688
+ if (Date.now() > deadline) return { signedOut: false, candidates: [] };
689
+ await sleep(1500);
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Visit one bank's statements page and hand every new PDF to `onFile`.
695
+ * `seen` holds candidate keys already fetched; returns the page's status.
696
+ */
697
+ export async function fetchInstitution(browser, { start, seen = new Set(), since = null, max = 24, renderMs = 25_000, onFile, log = () => {} }) {
698
+ const { cdp } = browser;
699
+ const { sessionId, targetId } = await attachPage(cdp);
700
+ await hideHeadless(cdp, sessionId);
701
+ const staging = mkdtempSync(join(tmpdir(), 'coinpay-statements-'));
702
+ const downloads = await Downloads.start(cdp, staging);
703
+ try {
704
+ await navigate(cdp, sessionId, start);
705
+ let found = await findCandidates(cdp, sessionId, renderMs);
706
+ const url = (await evaluate(cdp, sessionId, 'location.href')) || start;
707
+ if (found.signedOut) return { status: 'login_needed', url, candidates: 0, silent: [] };
708
+ if (!found.candidates.length) return { status: 'no_statements', url, candidates: 0, silent: [] };
709
+
710
+ const todo = found.candidates
711
+ .map((candidate) => ({ candidate, key: candidateKey(candidate), period: periodOf(candidate.context, candidate.label) }))
712
+ .filter(({ key }) => !seen.has(key))
713
+ .filter(({ period }) => !since || !period || period.month >= since)
714
+ .filter((item, index, all) => all.findIndex((other) => other.key === item.key) === index)
715
+ .slice(0, max);
716
+
717
+ const silent = [];
718
+ const total = found.candidates.length;
719
+ for (const { candidate, key } of todo) {
720
+ const label = `${candidate.label} | ${candidate.context}`.slice(0, 160);
721
+ let clicked = await evaluate(cdp, sessionId, clickScript(candidate.index));
722
+ if (!clicked) {
723
+ // The last click navigated the tab; come back and find the same row.
724
+ await navigate(cdp, sessionId, start);
725
+ found = await findCandidates(cdp, sessionId, renderMs);
726
+ const again = found.candidates.find((other) => candidateKey(other) === key);
727
+ clicked = again ? await evaluate(cdp, sessionId, clickScript(again.index)) : false;
728
+ }
729
+ if (!clicked) { silent.push(label); continue; }
730
+ const before = downloads.begun.length;
731
+ let event = await downloads.next(before, 8000);
732
+ if (!event && (await evaluate(cdp, sessionId, SECOND_STEP))) event = await downloads.next(before, 12_000);
733
+ if (!event) { silent.push(label); await closeStrays(cdp, targetId); continue; }
734
+ const bytes = await downloads.bytes(event);
735
+ await closeStrays(cdp, targetId);
736
+ if (!bytes) { silent.push(label); continue; }
737
+ await onFile({ bytes, suggestedName: event.suggestedName, label: candidate.label, context: candidate.context, key });
738
+ // A bank that sees forty clicks a second ends the session.
739
+ await sleep(1200);
740
+ }
741
+ for (const line of silent) log(` ? no download from: ${line}`);
742
+ return { status: 'ok', url, candidates: total, silent };
743
+ } finally {
744
+ downloads.stop();
745
+ rmSync(staging, { recursive: true, force: true });
746
+ }
747
+ }
748
+
749
+ /** A sign-in window; resolves with the last real page when the person closes it. */
750
+ export async function loginWindow(browser, url) {
751
+ const { cdp } = browser;
752
+ await cdp.send('Target.setDiscoverTargets', { discover: true });
753
+ let last = null;
754
+ const stop = cdp.on(({ method, params }) => {
755
+ if (method !== 'Target.targetInfoChanged' && method !== 'Target.targetCreated') return;
756
+ const info = params.targetInfo || {};
757
+ if (info.type === 'page' && /^https:/.test(info.url || '')) last = info.url;
758
+ });
759
+ const { sessionId } = await attachPage(cdp);
760
+ await navigate(cdp, sessionId, url).catch(() => {});
761
+ await browser.exited;
762
+ stop();
763
+ return last && !isSignInUrl(last) ? last : null;
764
+ }
765
+
766
+ /** A window on the statements page; every PDF the person downloads goes to `onFile` until it closes. */
767
+ export async function assistWindow(browser, { start, onFile }) {
768
+ const { cdp } = browser;
769
+ const { sessionId } = await attachPage(cdp);
770
+ await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: CLICK_RECORDER }, sessionId);
771
+ const staging = mkdtempSync(join(tmpdir(), 'coinpay-statements-'));
772
+ const downloads = await Downloads.start(cdp, staging);
773
+ let open = true;
774
+ browser.exited.then(() => { open = false; });
775
+ let handled = 0;
776
+ try {
777
+ await navigate(cdp, sessionId, start).catch(() => {});
778
+ await evaluate(cdp, sessionId, CLICK_RECORDER);
779
+ while (open) {
780
+ const event = await downloads.next(handled, 1000);
781
+ if (!event) continue;
782
+ handled += 1;
783
+ const raw = await evaluate(cdp, sessionId, 'window.__statementsLastClick || null');
784
+ const clicked = raw ? JSON.parse(raw) : { label: '', context: '' };
785
+ const bytes = await downloads.bytes(event);
786
+ if (bytes) await onFile({ bytes, suggestedName: event.suggestedName, label: clicked.label, context: clicked.context, key: null });
787
+ }
788
+ } finally {
789
+ downloads.stop();
790
+ rmSync(staging, { recursive: true, force: true });
791
+ }
792
+ return handled;
793
+ }
794
+
795
+ // ---------------------------------------------------------------------------
796
+ // The whole run: every signed-in bank → CoinPay
797
+ // ---------------------------------------------------------------------------
798
+
799
+ /**
800
+ * Classify, archive and import one downloaded file. `importStatement` is
801
+ * the SDK's importFinanceStatement bound to a client; `state` is the local
802
+ * state, whose `entries` record every file by hash.
803
+ */
804
+ export async function keepStatement({ institution, download, state, home, importStatement, how = 'fetch' }) {
805
+ if (!isPdf(download.bytes)) return { status: 'not_pdf', name: download.suggestedName };
806
+ const hash = sha256(download.bytes);
807
+ const known = state.entries.find((entry) => entry.sha256 === hash);
808
+ if (known) {
809
+ if (download.key && !known.key) known.key = download.key;
810
+ return { status: 'duplicate', entry: known };
811
+ }
812
+ const account = matchAccount(institution.accounts, download.context, download.suggestedName, download.label);
813
+ const period = periodOf(download.context, download.suggestedName, download.label);
814
+ const path = archive(home, institution, account, period, download.suggestedName, download.bytes);
815
+ const entry = {
816
+ sha256: hash,
817
+ path,
818
+ institution: institution.key,
819
+ accountId: account ? account.id : null,
820
+ month: period ? period.month : null,
821
+ from: period ? period.from : null,
822
+ to: period ? period.to : null,
823
+ key: download.key,
824
+ how,
825
+ downloadedAt: new Date().toISOString(),
826
+ statementId: null,
827
+ importError: null,
828
+ };
829
+ state.entries.push(entry);
830
+ const span = importPeriod(period);
831
+ if (!account || !span) {
832
+ entry.importError = account ? 'no statement period found in the row or file name' : 'account not recognised';
833
+ return { status: 'unmatched', entry };
834
+ }
835
+ try {
836
+ const data = await importStatement({ file: download.bytes, filename: download.suggestedName || `${period.month}.pdf`, accountId: account.id, institutionLabel: institution.name, ...span });
837
+ entry.statementId = data && data.statement ? data.statement.id : null;
838
+ return { status: data && data.duplicateOf ? 'duplicate' : 'imported', entry };
839
+ } catch (err) {
840
+ entry.importError = err && err.message ? err.message : String(err);
841
+ return { status: 'import_failed', entry };
842
+ }
843
+ }
844
+
845
+ /**
846
+ * Fetch every signed-in bank (or the ones named) and import what is new.
847
+ * `api` carries `listAccounts()`, `importStatement(opts)` and `reportRun(run)`.
848
+ * Returns one summary per bank; never throws for one bank's failure.
849
+ */
850
+ export async function runStatementFetch({ api, banks = [], since = null, max = 24, renderMs = 25_000, headless = true, chrome = findChrome(), home = statementsHome(), log = () => {}, client = 'coinpay-cli' }) {
851
+ const institutions = groupInstitutions(await api.listAccounts());
852
+ if (!institutions.length) throw new StatementFetchError('no linked accounts; connect a bank in CoinPay first');
853
+ const chosen = banks.length ? banks.map((name) => pickInstitution(institutions, name)) : institutions.filter((entry) => signedIn(entry.key, home));
854
+ if (!chosen.length) throw new StatementFetchError(`no bank is signed in yet. Start with: coinpay finances statements login ${institutions[0].key}`);
855
+ if (!chrome) throw new StatementFetchError(NO_CHROME);
856
+
857
+ const state = loadLocal(home);
858
+ const results = [];
859
+ for (const institution of chosen) {
860
+ const local = (state.institutions[institution.key] ||= {});
861
+ const start = startUrls(institution, local.start).fetch;
862
+ const startedAt = new Date().toISOString();
863
+ const summary = { bank: institution.key, name: institution.name, status: 'ok', candidates: 0, imported: 0, duplicates: 0, unmatched: 0, failed: 0, silent: 0, message: null };
864
+ log(`${institution.key}: ${start || 'no start page'}`);
865
+ let browser;
866
+ try {
867
+ if (!signedIn(institution.key, home) || !start) {
868
+ summary.status = 'login_needed';
869
+ summary.message = `not signed in: coinpay finances statements login ${institution.key}`;
870
+ } else {
871
+ browser = await openBrowser({ chrome, profile: profileDir(institution.key, home), headless });
872
+ const seen = new Set(state.entries.filter((entry) => entry.institution === institution.key && entry.key).map((entry) => entry.key));
873
+ const page = await fetchInstitution(browser, {
874
+ start, seen, since, max, renderMs, log,
875
+ onFile: async (download) => {
876
+ const kept = await keepStatement({ institution, download, state, home, importStatement: api.importStatement });
877
+ if (kept.status === 'imported') summary.imported += 1;
878
+ else if (kept.status === 'duplicate') summary.duplicates += 1;
879
+ else if (kept.status === 'unmatched') summary.unmatched += 1;
880
+ else if (kept.status === 'import_failed') summary.failed += 1;
881
+ log(` ${kept.status}: ${kept.entry ? kept.entry.path : kept.name}${kept.entry && kept.entry.importError ? ` (${kept.entry.importError})` : ''}`);
882
+ saveLocal(state, home);
883
+ },
884
+ });
885
+ summary.status = page.status;
886
+ summary.candidates = page.candidates;
887
+ summary.silent = page.silent.length;
888
+ if (page.status === 'login_needed') summary.message = `the bank asks for a password again: coinpay finances statements login ${institution.key}`;
889
+ if (page.status === 'no_statements') summary.message = `no statement links at ${new URL(page.url).origin}; sign in again and close the window on the statements list, or use assist`;
890
+ if (summary.failed) summary.message = `${summary.failed} file(s) could not be imported; see coinpay finances statements local`;
891
+ }
892
+ } catch (err) {
893
+ summary.status = 'error';
894
+ summary.message = (err && err.message ? err.message : String(err)).slice(0, 400);
895
+ } finally {
896
+ if (browser) await browser.close();
897
+ local.lastFetchAt = new Date().toISOString();
898
+ local.lastStatus = summary.status;
899
+ saveLocal(state, home);
900
+ }
901
+ log(` ${summary.status}: ${summary.candidates} on the page, ${summary.imported} imported, ${summary.duplicates} already had, ${summary.unmatched} unmatched${summary.message ? `; ${summary.message}` : ''}`);
902
+ try {
903
+ await api.reportRun({
904
+ institutionKey: institution.key,
905
+ institutionLabel: institution.name,
906
+ status: summary.status,
907
+ candidates: summary.candidates,
908
+ filed: summary.imported,
909
+ duplicates: summary.duplicates,
910
+ unmatched: summary.unmatched + summary.failed,
911
+ silent: summary.silent,
912
+ message: summary.message,
913
+ client,
914
+ startedAt,
915
+ finishedAt: new Date().toISOString(),
916
+ });
917
+ } catch (err) {
918
+ log(` (could not report the run to CoinPay: ${err && err.message ? err.message : err})`);
919
+ }
920
+ results.push(summary);
921
+ }
922
+ return results;
923
+ }
924
+
925
+ /** The three calls a run makes, bound to an authenticated CoinPay client. */
926
+ export async function clientApi(client) {
927
+ const [{ listFinanceAccounts }, reports] = await Promise.all([import('./finances.js'), import('./finances-reports.js')]);
928
+ return {
929
+ listAccounts: () => listFinanceAccounts(client),
930
+ importStatement: (opts) => reports.importFinanceStatement(client, opts),
931
+ reportRun: (run) => reports.reportStatementFetchRun(client, run),
932
+ };
933
+ }
934
+
935
+ /** Re-import local files whose import failed or never happened (e.g. after linking an account). */
936
+ export async function retryImports({ api, home = statementsHome(), log = () => {} }) {
937
+ const state = loadLocal(home);
938
+ const institutions = groupInstitutions(await api.listAccounts());
939
+ const out = { imported: 0, skipped: 0, failed: 0 };
940
+ for (const entry of state.entries.filter((e) => !e.statementId)) {
941
+ const institution = institutions.find((i) => i.key === entry.institution);
942
+ const account = institution && institution.accounts.find((a) => a.id === entry.accountId);
943
+ const span = importPeriod(entry.month ? { month: entry.month, from: entry.from, to: entry.to } : null);
944
+ if (!account || !span || !existsSync(entry.path)) { out.skipped += 1; continue; }
945
+ try {
946
+ const data = await api.importStatement({ file: readFileSync(entry.path), filename: basename(entry.path), accountId: account.id, institutionLabel: institution.name, ...span });
947
+ entry.statementId = data && data.statement ? data.statement.id : null;
948
+ entry.importError = null;
949
+ out.imported += 1;
950
+ log(`imported ${entry.path}`);
951
+ } catch (err) {
952
+ entry.importError = err.message;
953
+ out.failed += 1;
954
+ log(`failed ${entry.path}: ${err.message}`);
955
+ }
956
+ saveLocal(state, home);
957
+ }
958
+ return out;
959
+ }