@profullstack/coinpay 0.12.1 → 0.15.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.
@@ -28,6 +28,9 @@ import { createHash } from 'node:crypto';
28
28
  import { accessSync, chmodSync, constants, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, writeFileSync } from 'node:fs';
29
29
  import { homedir, hostname, tmpdir } from 'node:os';
30
30
  import { basename, dirname, join } from 'node:path';
31
+ import { detectLockout, lockoutUntil, recordLocalLockout, takeLocalAttempt } from './statements-throttle.js';
32
+
33
+ export * from './statements-throttle.js';
31
34
 
32
35
  export class StatementFetchError extends Error {
33
36
  constructor(message) {
@@ -60,9 +63,20 @@ export function slug(value) {
60
63
  .slice(0, 60) || 'bank';
61
64
  }
62
65
 
66
+ /**
67
+ * Hosts whose key is not their second-level label: webapp.ftb.ca.gov would
68
+ * otherwise be "ca". Explicit, because a government domain says nothing
69
+ * general about which label names the agency. The server keeps the same list.
70
+ */
71
+ export const HOST_KEYS = [
72
+ [/(^|\.)ftb\.ca\.gov$/, 'ftb'],
73
+ [/(^|\.)irs\.gov$/, 'irs'],
74
+ ];
75
+
63
76
  /** secure.chase.com and chase.com are both `chase`. The server computes the same key. */
64
77
  export function institutionKey(domain, name) {
65
78
  const host = String(domain || '').replace(/^https?:\/\//, '').split(/[/:]/)[0].toLowerCase();
79
+ for (const [pattern, key] of HOST_KEYS) if (pattern.test(host)) return key;
66
80
  const labels = host.split('.').filter(Boolean);
67
81
  if (labels.length >= 2) {
68
82
  const second = labels[labels.length - 2];
@@ -97,15 +111,16 @@ export function groupInstitutions(accounts) {
97
111
  return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key));
98
112
  }
99
113
 
100
- /** A bank named on the command line: its key, or the start of its key or name. */
101
- export function pickInstitution(institutions, name) {
114
+ /** A bank or tax source named on the command line: its key, or the start of its key or name. */
115
+ export function pickInstitution(linked, name) {
116
+ const institutions = withStandaloneSources(linked);
102
117
  const wanted = String(name).toLowerCase().replace(/[^a-z0-9]/g, '');
103
118
  const exact = institutions.find((entry) => entry.key.replace(/-/g, '') === wanted);
104
119
  if (exact) return exact;
105
120
  const loose = institutions.filter((entry) => entry.key.replace(/-/g, '').startsWith(wanted) || entry.name.toLowerCase().replace(/[^a-z0-9]/g, '').startsWith(wanted));
106
121
  if (loose.length === 1) return loose[0];
107
122
  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'})`);
123
+ throw new StatementFetchError(`no bank or tax source called "${name}" (linked banks: ${(linked || []).map((entry) => entry.key).join(', ') || 'none'}; tax sources: ${TAX_SOURCES.map((entry) => entry.key).join(', ')}; brokerages: ${BROKERAGE_SOURCES.map((entry) => entry.key).join(', ')})`);
109
124
  }
110
125
 
111
126
  // ---------------------------------------------------------------------------
@@ -130,8 +145,101 @@ export const DRIVERS = [
130
145
  { key: 'dcu', login: 'https://digital.dcu.org/' },
131
146
  ];
132
147
 
148
+ const IRS_CLOUD_WARNING = (key) =>
149
+ `IRS sign-in goes through ID.me behind Cloudflare, which often refuses datacenter addresses like CoinPay cloud. If the sign-in page will not load or keeps looping, use the local window instead: coinpay finances statements assist ${key}`;
150
+
151
+ /**
152
+ * Tax agencies: sources of their own, not reached through a linked bank, so
153
+ * they work with no SimpleFIN account at all. What they hold is notices,
154
+ * letters and transcripts, filed into the document library as category
155
+ * "tax" (never the statement library: there is no account or cycle).
156
+ * Keys are explicit, never derived from the domain.
157
+ */
158
+ export const TAX_SOURCES = [
159
+ {
160
+ key: 'ftb',
161
+ kind: 'tax',
162
+ name: 'California FTB (MyFTB)',
163
+ login: 'https://webapp.ftb.ca.gov/MyFTBAccess/',
164
+ // MyFTB locks for 30 minutes and restarts the lock on any attempt inside it.
165
+ lockoutMinutes: 35,
166
+ },
167
+ {
168
+ key: 'irs',
169
+ kind: 'tax',
170
+ name: 'IRS Online Account',
171
+ login: 'https://sa.www4.irs.gov/ola/',
172
+ lockoutMinutes: 60,
173
+ cloudWarning: IRS_CLOUD_WARNING('irs'),
174
+ },
175
+ {
176
+ // https://www.irs.gov/businessaccount links "Sign in to business tax account" here.
177
+ key: 'irs-business',
178
+ kind: 'tax',
179
+ name: 'IRS Business Tax Account',
180
+ login: 'https://sa.www4.irs.gov/bola/',
181
+ lockoutMinutes: 60,
182
+ cloudWarning: IRS_CLOUD_WARNING('irs-business'),
183
+ },
184
+ ];
185
+
186
+ /**
187
+ * Brokerages: sources of their own too, because SimpleFIN rarely links a
188
+ * brokerage, and when it does it carries positions, never documents. What
189
+ * they hold is monthly statements, trade confirmations and tax forms
190
+ * (1099s), all filed into the document library: statements and confirms as
191
+ * category "statement" with their month, tax forms as category "tax".
192
+ * Unlike tax agencies they are not throttled: a brokerage session behaves
193
+ * like a bank's. A linked account with the same key keeps the bank path.
194
+ */
195
+ export const BROKERAGE_SOURCES = [
196
+ {
197
+ key: 'webull',
198
+ kind: 'brokerage',
199
+ name: 'Webull',
200
+ // E-Documents: statements, trade confirmations and tax documents in one list.
201
+ login: 'https://www.webull.com/edocs',
202
+ statements: 'https://www.webull.com/edocs',
203
+ },
204
+ ];
205
+
206
+ export function taxSource(key) {
207
+ return TAX_SOURCES.find((entry) => entry.key === key) || null;
208
+ }
209
+
210
+ export function brokerageSource(key) {
211
+ return BROKERAGE_SOURCES.find((entry) => entry.key === key) || null;
212
+ }
213
+
214
+ /** The tax and brokerage sources as institutions with no accounts, so every bank code path takes them. */
215
+ export function standaloneSources() {
216
+ return [...TAX_SOURCES, ...BROKERAGE_SOURCES].map((source) => ({ key: source.key, kind: source.kind, name: source.name, url: source.login, accounts: [] }));
217
+ }
218
+
219
+ /** Linked banks plus the tax sources (a linked bank keeps its key if it ever collides). */
220
+ export function withStandaloneSources(institutions) {
221
+ const out = [...(institutions || [])];
222
+ for (const source of standaloneSources()) if (!out.some((entry) => entry.key === source.key)) out.push(source);
223
+ return out;
224
+ }
225
+
226
+ export function isTaxSource(institution) {
227
+ return !!institution && (institution.kind === 'tax' || (!(institution.accounts || []).length && !!taxSource(institution.key)));
228
+ }
229
+
230
+ /** A brokerage source with no linked account: its documents go to the document library. */
231
+ export function isBrokerageSource(institution) {
232
+ return !!institution && (institution.kind === 'brokerage' || (!(institution.accounts || []).length && !!brokerageSource(institution.key)));
233
+ }
234
+
235
+ /** How the fetcher reads a source's page: 'tax', 'documents' (a brokerage) or 'statements' (a bank). */
236
+ export function sourceMode(institution) {
237
+ return isTaxSource(institution) ? 'tax' : isBrokerageSource(institution) ? 'documents' : 'statements';
238
+ }
239
+
133
240
  export function startUrls(institution, learnt) {
134
- const driver = DRIVERS.find((entry) => entry.key === institution.key);
241
+ const driver = DRIVERS.find((entry) => entry.key === institution.key)
242
+ || (isTaxSource(institution) ? taxSource(institution.key) : isBrokerageSource(institution) ? brokerageSource(institution.key) : null);
135
243
  return {
136
244
  login: (driver && driver.login) || institution.url || null,
137
245
  fetch: learnt || (driver && (driver.statements || driver.login)) || institution.url || null,
@@ -492,7 +600,7 @@ export async function releaseProfile(profile, { force = false } = {}) {
492
600
  return 'stopped';
493
601
  }
494
602
 
495
- export async function openBrowser({ chrome, profile, headless = true, timeoutMs = 30_000, env = process.env, force = false }) {
603
+ export async function openBrowser({ chrome, profile, headless = true, timeoutMs = 30_000, env = process.env, force = false, extraArgs = [], handleSignals = true }) {
496
604
  if (typeof WebSocket === 'undefined') throw new StatementFetchError('statement fetching needs Node 22 or newer (it drives Chrome over a WebSocket)');
497
605
  if (!chrome || !isExecutable(chrome)) throw new StatementFetchError(chrome ? `${chrome} is not an executable` : NO_CHROME);
498
606
  mkdirSync(profile, { recursive: true, mode: 0o700 });
@@ -508,6 +616,7 @@ export async function openBrowser({ chrome, profile, headless = true, timeoutMs
508
616
  '--disable-background-networking',
509
617
  '--window-size=1280,900',
510
618
  ...(sandbox ? [] : ['--no-sandbox']),
619
+ ...extraArgs,
511
620
  'about:blank',
512
621
  ];
513
622
  const child = spawn(chrome, args, { env, stdio: ['ignore', 'ignore', 'pipe'] });
@@ -526,7 +635,7 @@ export async function openBrowser({ chrome, profile, headless = true, timeoutMs
526
635
  for (const signal of signals) process.removeListener(signal, onSignal);
527
636
  };
528
637
  process.once('exit', onExit);
529
- for (const signal of signals) process.once(signal, onSignal);
638
+ if (handleSignals) for (const signal of signals) process.once(signal, onSignal);
530
639
  exited.then(detach);
531
640
  const url = await new Promise((resolve, reject) => {
532
641
  let stderr = '';
@@ -598,14 +707,55 @@ export const OPEN_STATEMENTS = `(() => {
598
707
  })()`;
599
708
 
600
709
  /**
601
- * Every visible control that looks like it downloads one dated statement,
602
- * tagged data-stmt-i for clickScript. Row text is read with innerText, which
603
- * keeps the break between table cells ("1234" and "July" stay two words).
710
+ * Whether a control is worth clicking. `mode` is 'statements' (a bank),
711
+ * 'tax' (an agency) or 'documents' (a brokerage). Pure and self-contained: its source is placed into the
712
+ * page script, so it may not refer to anything outside itself.
713
+ *
714
+ * Bank pages skip tax forms and 1099s (those are not statements). A tax page
715
+ * is the opposite: notices, letters, transcripts and forms are the point, and
716
+ * they are often listed by tax year rather than by date. A brokerage keeps
717
+ * both: dated statements and confirmations, and 1099s listed by tax year.
604
718
  */
605
- export const COLLECT = `(() => {
606
- 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;
719
+ export function keepCandidate(mode, label, context, href) {
720
+ 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;
721
+ const pdf = /\.pdf(\b|$)/i.test(href || '');
722
+ const all = `${label} ${context} ${href || ''}`;
723
+ if (mode === 'tax') {
724
+ const SKIP = /preference|paperless|setting|privacy|terms of|help|learn more|enroll|faq|instruction|contact us|log ?out|sign ?out|log ?off|feedback|survey|payment plan|make a payment|pay now|chat/i;
725
+ const ACTION = /notice|letter|transcript|correspondence|document|form\b|1099|w-?2|return|pdf|download|view|open|save|print|\bcp\s?-?\d{2,4}|\bltr\s?-?\d{3,4}/i;
726
+ const YEAR = /\b(19|20)\d{2}\b/;
727
+ const DOC = /notice|letter|transcript|correspondence|\bcp\s?-?\d{2,4}|\bltr\s?\d{3,4}/i;
728
+ if (SKIP.test(label)) return false;
729
+ if (pdf) return true;
730
+ if (!ACTION.test(label)) return false;
731
+ return DATE.test(all) || YEAR.test(all) || DOC.test(all);
732
+ }
733
+ if (mode === 'documents') {
734
+ const SKIP = /preference|paperless|setting|notification|privacy|agreement|terms|help|learn more|enroll|faq|disclosure|log ?out|sign ?out|feedback|survey|chat/i;
735
+ const ACTION = /statement|confirm|1099|1042|5498|tax|form\b|document|download|pdf|view|open|save/i;
736
+ const TAXFORM = /1099|1042|5498|tax (document|form)/i;
737
+ const YEAR = /\b(19|20)\d{2}\b/;
738
+ if (SKIP.test(label)) return false;
739
+ if (!pdf && !ACTION.test(label)) return false;
740
+ return DATE.test(all) || (TAXFORM.test(all) && YEAR.test(all));
741
+ }
607
742
  const SKIP = /preference|paperless|setting|notification|tax form|1099|privacy|agreement|terms|help|learn more|enroll/i;
608
743
  const ACTION = /statement|download|pdf|view|open|save/i;
744
+ if (!pdf && !ACTION.test(label)) return false;
745
+ if (SKIP.test(label)) return false;
746
+ return DATE.test(all);
747
+ }
748
+
749
+ /**
750
+ * Every visible control that looks like it downloads one document, tagged
751
+ * data-stmt-i for clickScript. Row text is read with innerText, which keeps
752
+ * the break between table cells ("1234" and "July" stay two words).
753
+ */
754
+ export function collectScript(mode = 'statements') {
755
+ return `(() => {
756
+ const MODE = ${JSON.stringify(mode === 'tax' || mode === 'documents' ? mode : 'statements')};
757
+ const keep = ${keepCandidate.toString()};
758
+ 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;
609
759
  const visible = (el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
610
760
  const clean = (s) => (s || '').replace(/\\s+/g, ' ').trim();
611
761
  const textOf = (el) => clean(el.innerText || el.textContent);
@@ -627,10 +777,8 @@ export const COLLECT = `(() => {
627
777
  if (!visible(el)) continue;
628
778
  const label = clean(el.getAttribute('aria-label') || el.getAttribute('title') || el.innerText || el.textContent);
629
779
  const href = el.getAttribute('href') || '';
630
- if (!/\\.pdf(\\b|$)/i.test(href) && !ACTION.test(label)) continue;
631
- if (SKIP.test(label)) continue;
632
780
  const context = textOf(rowOf(el)).slice(0, 300);
633
- if (!DATE.test(label + ' ' + context + ' ' + href)) continue;
781
+ if (!keep(MODE, label, context, href)) continue;
634
782
  el.setAttribute('data-stmt-i', String(index));
635
783
  let absolute = null;
636
784
  if (href && !href.startsWith('#') && !/^javascript:/i.test(href)) { try { absolute = new URL(href, doc.baseURI).href; } catch {} }
@@ -640,6 +788,36 @@ export const COLLECT = `(() => {
640
788
  }
641
789
  return JSON.stringify(out);
642
790
  })()`;
791
+ }
792
+
793
+ export const COLLECT = collectScript('statements');
794
+ export const COLLECT_TAX = collectScript('tax');
795
+ export const COLLECT_DOCS = collectScript('documents');
796
+
797
+ /** On a tax site: follow its own link to notices, letters or documents. */
798
+ export const OPEN_TAX_DOCS = `(() => {
799
+ const want = /^(my |view |all )?(notices?|letters?|notices? (and|&) letters?|correspondence|documents?|records?|tax records?|transcripts?|view (my )?(notices|letters|documents|records))$/i;
800
+ for (const el of document.querySelectorAll('a, button, [role=link], [role=tab], [role=menuitem]')) {
801
+ const r = el.getBoundingClientRect();
802
+ const label = (el.getAttribute('aria-label') || el.textContent || '').replace(/\\s+/g, ' ').trim();
803
+ if (label.length < 50 && want.test(label) && r.width > 0 && r.height > 0) { el.click(); return label; }
804
+ }
805
+ return null;
806
+ })()`;
807
+
808
+ /** On a brokerage: follow its own link to statements or documents. */
809
+ export const OPEN_DOCUMENTS = `(() => {
810
+ const want = /^(e-?)?(documents?|statements?)$|^(account )?statements?$|^(tax )?documents?$|^(my |view |all )?(documents|statements)$|^trade confirmations?$/i;
811
+ for (const el of document.querySelectorAll('a, button, [role=link], [role=tab], [role=menuitem]')) {
812
+ const r = el.getBoundingClientRect();
813
+ const label = (el.getAttribute('aria-label') || el.textContent || '').replace(/\\s+/g, ' ').trim();
814
+ if (label.length < 40 && want.test(label) && r.width > 0 && r.height > 0) { el.click(); return label; }
815
+ }
816
+ return null;
817
+ })()`;
818
+
819
+ /** The page's visible text, for lockout detection (bounded). */
820
+ export const PAGE_TEXT = `(() => (document.body ? (document.body.innerText || '').slice(0, 6000) : ''))()`;
643
821
 
644
822
  export function clickScript(index) {
645
823
  return `(() => {
@@ -677,7 +855,7 @@ export const CLICK_RECORDER = `document.addEventListener('click', (event) => {
677
855
 
678
856
  async function attachPage(cdp) {
679
857
  const { targetInfos } = await cdp.send('Target.getTargets');
680
- let targetId = (targetInfos.find((t) => t.type === 'page') || {}).targetId;
858
+ let targetId = (targetInfos.find((t) => t.type === 'page' && !String(t.url || '').startsWith('chrome')) || {}).targetId;
681
859
  if (!targetId) ({ targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' }));
682
860
  const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
683
861
  await cdp.send('Page.enable', {}, sessionId);
@@ -765,15 +943,19 @@ async function closeStrays(cdp, keep) {
765
943
  }
766
944
  }
767
945
 
768
- async function findCandidates(cdp, sessionId, renderMs) {
946
+ async function findCandidates(cdp, sessionId, renderMs, mode = 'statements', watchLockout = false) {
769
947
  const deadline = Date.now() + renderMs;
770
948
  let followed = false;
771
949
  for (;;) {
950
+ if (watchLockout) {
951
+ const lock = detectLockout(await evaluate(cdp, sessionId, PAGE_TEXT));
952
+ if (lock) return { signedOut: false, locked: lock, candidates: [] };
953
+ }
772
954
  if (await evaluate(cdp, sessionId, SIGNED_OUT)) return { signedOut: true, candidates: [] };
773
- const raw = await evaluate(cdp, sessionId, COLLECT);
955
+ const raw = await evaluate(cdp, sessionId, mode === 'tax' ? COLLECT_TAX : mode === 'documents' ? COLLECT_DOCS : COLLECT);
774
956
  const candidates = raw ? JSON.parse(raw) : [];
775
957
  if (candidates.length) return { signedOut: false, candidates };
776
- if (!followed && Date.now() > deadline - renderMs / 2) followed = (await evaluate(cdp, sessionId, OPEN_STATEMENTS)) !== null;
958
+ if (!followed && Date.now() > deadline - renderMs / 2) followed = (await evaluate(cdp, sessionId, mode === 'tax' ? OPEN_TAX_DOCS : mode === 'documents' ? OPEN_DOCUMENTS : OPEN_STATEMENTS)) !== null;
777
959
  if (Date.now() > deadline) return { signedOut: false, candidates: [] };
778
960
  await sleep(1500);
779
961
  }
@@ -782,8 +964,12 @@ async function findCandidates(cdp, sessionId, renderMs) {
782
964
  /**
783
965
  * Visit one bank's statements page and hand every new PDF to `onFile`.
784
966
  * `seen` holds candidate keys already fetched; returns the page's status.
967
+ * `mode: 'tax'` collects notices, letters and transcripts, `mode: 'documents'`
968
+ * a brokerage's statements, confirmations and tax forms; `watchLockout`
969
+ * stops at a lockout page (status 'locked', with the minutes it names).
970
+ * Nothing here ever submits a credential: a lost session is reported, never retried.
785
971
  */
786
- export async function fetchInstitution(browser, { start, seen = new Set(), since = null, max = 24, renderMs = 25_000, onFile, log = () => {} }) {
972
+ export async function fetchInstitution(browser, { start, seen = new Set(), since = null, max = 24, renderMs = 25_000, onFile, log = () => {}, mode = 'statements', watchLockout = false, pauseMs = 1200 }) {
787
973
  const { cdp } = browser;
788
974
  const { sessionId, targetId } = await attachPage(cdp);
789
975
  await hideHeadless(cdp, sessionId);
@@ -791,15 +977,16 @@ export async function fetchInstitution(browser, { start, seen = new Set(), since
791
977
  const downloads = await Downloads.start(cdp, staging);
792
978
  try {
793
979
  await navigate(cdp, sessionId, start);
794
- let found = await findCandidates(cdp, sessionId, renderMs);
980
+ let found = await findCandidates(cdp, sessionId, renderMs, mode, watchLockout);
795
981
  const url = (await evaluate(cdp, sessionId, 'location.href')) || start;
982
+ if (found.locked) return { status: 'locked', url, candidates: 0, silent: [], lockout: found.locked };
796
983
  if (found.signedOut) return { status: 'login_needed', url, candidates: 0, silent: [] };
797
984
  if (!found.candidates.length) return { status: 'no_statements', url, candidates: 0, silent: [] };
798
985
 
799
986
  const todo = found.candidates
800
987
  .map((candidate) => ({ candidate, key: candidateKey(candidate), period: periodOf(candidate.context, candidate.label) }))
801
988
  .filter(({ key }) => !seen.has(key))
802
- .filter(({ period }) => !since || !period || period.month >= since)
989
+ .filter(({ period }) => mode === 'tax' || !since || !period || period.month >= since)
803
990
  .filter((item, index, all) => all.findIndex((other) => other.key === item.key) === index)
804
991
  .slice(0, max);
805
992
 
@@ -811,7 +998,11 @@ export async function fetchInstitution(browser, { start, seen = new Set(), since
811
998
  if (!clicked) {
812
999
  // The last click navigated the tab; come back and find the same row.
813
1000
  await navigate(cdp, sessionId, start);
814
- found = await findCandidates(cdp, sessionId, renderMs);
1001
+ found = await findCandidates(cdp, sessionId, renderMs, mode, watchLockout);
1002
+ if (found.locked) {
1003
+ for (const line of silent) log(` ? no download from: ${line}`);
1004
+ return { status: 'locked', url, candidates: total, silent, lockout: found.locked };
1005
+ }
815
1006
  const again = found.candidates.find((other) => candidateKey(other) === key);
816
1007
  clicked = again ? await evaluate(cdp, sessionId, clickScript(again.index)) : false;
817
1008
  }
@@ -825,7 +1016,7 @@ export async function fetchInstitution(browser, { start, seen = new Set(), since
825
1016
  if (!bytes) { silent.push(label); continue; }
826
1017
  await onFile({ bytes, suggestedName: event.suggestedName, label: candidate.label, context: candidate.context, key });
827
1018
  // A bank that sees forty clicks a second ends the session.
828
- await sleep(1200);
1019
+ await sleep(pauseMs);
829
1020
  }
830
1021
  for (const line of silent) log(` ? no download from: ${line}`);
831
1022
  return { status: 'ok', url, candidates: total, silent };
@@ -835,8 +1026,35 @@ export async function fetchInstitution(browser, { start, seen = new Set(), since
835
1026
  }
836
1027
  }
837
1028
 
1029
+ /**
1030
+ * Read the page's text every few seconds while a person drives a window, so
1031
+ * a lockout page they run into is recorded. Returns a stop function.
1032
+ */
1033
+ function watchText(cdp, sessionId, onPageText, everyMs = 3000) {
1034
+ if (!onPageText) return () => {};
1035
+ let stopped = false;
1036
+ let timer = null;
1037
+ const tick = async () => {
1038
+ if (stopped) return;
1039
+ const text = await evaluate(cdp, sessionId, PAGE_TEXT);
1040
+ if (text && !stopped) {
1041
+ try {
1042
+ await onPageText(text);
1043
+ } catch {
1044
+ // Recording must never break the person's window.
1045
+ }
1046
+ }
1047
+ if (!stopped) timer = setTimeout(tick, everyMs);
1048
+ };
1049
+ timer = setTimeout(tick, everyMs);
1050
+ return () => {
1051
+ stopped = true;
1052
+ clearTimeout(timer);
1053
+ };
1054
+ }
1055
+
838
1056
  /** A sign-in window; resolves with the last real page when the person closes it. */
839
- export async function loginWindow(browser, url) {
1057
+ export async function loginWindow(browser, url, { onPageText } = {}) {
840
1058
  const { cdp } = browser;
841
1059
  await cdp.send('Target.setDiscoverTargets', { discover: true });
842
1060
  let last = null;
@@ -846,14 +1064,16 @@ export async function loginWindow(browser, url) {
846
1064
  if (info.type === 'page' && /^https:/.test(info.url || '')) last = info.url;
847
1065
  });
848
1066
  const { sessionId } = await attachPage(cdp);
1067
+ const stopWatch = watchText(cdp, sessionId, onPageText);
849
1068
  await navigate(cdp, sessionId, url).catch(() => {});
850
1069
  await browser.exited;
1070
+ stopWatch();
851
1071
  stop();
852
1072
  return last && !isSignInUrl(last) ? last : null;
853
1073
  }
854
1074
 
855
1075
  /** A window on the statements page; every PDF the person downloads goes to `onFile` until it closes. */
856
- export async function assistWindow(browser, { start, onFile }) {
1076
+ export async function assistWindow(browser, { start, onFile, onPageText }) {
857
1077
  const { cdp } = browser;
858
1078
  const { sessionId } = await attachPage(cdp);
859
1079
  await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: CLICK_RECORDER }, sessionId);
@@ -862,6 +1082,7 @@ export async function assistWindow(browser, { start, onFile }) {
862
1082
  let open = true;
863
1083
  browser.exited.then(() => { open = false; });
864
1084
  let handled = 0;
1085
+ const stopWatch = watchText(cdp, sessionId, onPageText);
865
1086
  try {
866
1087
  await navigate(cdp, sessionId, start).catch(() => {});
867
1088
  await evaluate(cdp, sessionId, CLICK_RECORDER);
@@ -875,6 +1096,7 @@ export async function assistWindow(browser, { start, onFile }) {
875
1096
  if (bytes) await onFile({ bytes, suggestedName: event.suggestedName, label: clicked.label, context: clicked.context, key: null });
876
1097
  }
877
1098
  } finally {
1099
+ stopWatch();
878
1100
  downloads.stop();
879
1101
  rmSync(staging, { recursive: true, force: true });
880
1102
  }
@@ -931,16 +1153,211 @@ export async function keepStatement({ institution, download, state, home, import
931
1153
  }
932
1154
  }
933
1155
 
1156
+ // ---------------------------------------------------------------------------
1157
+ // Tax documents: notices, letters, transcripts → the document library
1158
+ // ---------------------------------------------------------------------------
1159
+
1160
+ const GENERIC_LABEL = /^(download|view|open|pdf|print|save|get|show)\b/i;
1161
+
1162
+ /**
1163
+ * What a tax document is, from the link label, its row and the file name:
1164
+ * `{docType, code, taxYear, noticeDate, periodLabel, title}`. The period
1165
+ * label is the tax year when one is printed, else the notice date, else the
1166
+ * day it was fetched.
1167
+ */
1168
+ export function classifyTaxDocument({ label = '', context = '', suggestedName = '' } = {}, fetchedAt = new Date()) {
1169
+ // Collapsed and capped: a link label and its row never need more, and every pattern below stays linear.
1170
+ const text = [label, context, String(suggestedName || '').replace(/[_-]+/g, ' ')].filter(Boolean).join(' ').replace(/\s+/g, ' ').slice(0, 2000);
1171
+ const code = (/\b(CP\s?-?\d{2,4}[A-Z]?|LTR\s?-?\d{3,4}[A-Z]?)\b/i.exec(text) || [])[1] || null;
1172
+ const docType = /transcript/i.test(text)
1173
+ ? 'transcript'
1174
+ : /\bnotice\b|\bCP\s?-?\d{2,4}/i.test(text)
1175
+ ? 'notice'
1176
+ : /\bletter\b|\bLTR\s?-?\d{3,4}|correspondence/i.test(text)
1177
+ ? 'letter'
1178
+ : /\b(form|1099|1098|w-?2|k-1|5498)\b/i.test(text)
1179
+ ? 'form'
1180
+ : /\breturn\b/i.test(text)
1181
+ ? 'return'
1182
+ : 'other';
1183
+
1184
+ let taxYear = null;
1185
+ const explicit = /\b(?:tax\s*(?:year|period)|TY)[\s:]*((?:19|20)\d{2})\b/i.exec(text);
1186
+ if (explicit) taxYear = Number(explicit[1]);
1187
+ else if (docType === 'transcript' || docType === 'form' || docType === 'return') {
1188
+ // A lone year that is not part of a printed date ("2024 Account Transcript").
1189
+ const withoutDates = text
1190
+ .replace(/\b\d{1,2}\/\d{1,2}\/\d{2,4}\b/g, ' ')
1191
+ .replace(/\b\d{4}-\d{2}(-\d{2})?\b/g, ' ')
1192
+ .replace(/\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(\d{1,2},?\s+)?\d{4}\b/gi, ' ');
1193
+ const years = [...new Set([...withoutDates.matchAll(/\b((?:19|20)\d{2})\b/g)].map((m) => Number(m[1])))];
1194
+ if (years.length === 1) taxYear = years[0];
1195
+ }
1196
+ if (taxYear !== null && (taxYear < 1990 || taxYear > fetchedAt.getUTCFullYear() + 1)) taxYear = null;
1197
+
1198
+ const precise = findDates(text).filter((entry) => entry.precise).map((entry) => entry.date);
1199
+ const noticeDate = precise.length ? precise[0] : null;
1200
+ const periodLabel = taxYear !== null ? String(taxYear) : noticeDate || fetchedAt.toISOString().slice(0, 10);
1201
+
1202
+ const cleanLabel = String(label || '').replace(/\s+/g, ' ').trim();
1203
+ const title = (cleanLabel && !GENERIC_LABEL.test(cleanLabel) ? cleanLabel : String(context || '').replace(/\s+/g, ' ').trim().slice(0, 120) || basename(String(suggestedName || 'tax-document.pdf'), '.pdf')).slice(0, 200) || 'Tax document';
1204
+ return { docType, code, taxYear, noticeDate, periodLabel, title };
1205
+ }
1206
+
1207
+ function archiveTax(home, institution, info, suggestedName, bytes) {
1208
+ const folder = join(institution.key, info.taxYear ? String(info.taxYear) : '_undated');
1209
+ const stem = slug(basename(suggestedName || info.title || 'document', '.pdf')) || 'document';
1210
+ for (let attempt = 1; ; attempt += 1) {
1211
+ const absolute = join(home, 'files', folder, `${stem}${attempt === 1 ? '' : `-${attempt}`}.pdf`);
1212
+ if (existsSync(absolute)) continue;
1213
+ mkdirSync(dirname(absolute), { recursive: true, mode: 0o700 });
1214
+ writeFileSync(absolute, bytes, { mode: 0o600 });
1215
+ return absolute;
1216
+ }
1217
+ }
1218
+
1219
+ /**
1220
+ * Keep one PDF from a tax source: archive it locally and file it in the
1221
+ * document library (category "tax"). No account matching, no statement
1222
+ * period: a notice is not a statement. `fileDocument` is the SDK call bound
1223
+ * to a client (POST /finances/documents); the server dedupes by sha256.
1224
+ */
1225
+ export async function keepTaxDocument({ institution, download, state, home, fileDocument, how = 'fetch', now = new Date() }) {
1226
+ if (!isPdf(download.bytes)) return { status: 'not_pdf', name: download.suggestedName };
1227
+ const hash = sha256(download.bytes);
1228
+ const known = state.entries.find((entry) => entry.sha256 === hash);
1229
+ if (known) {
1230
+ if (download.key && !known.key) known.key = download.key;
1231
+ return { status: 'duplicate', entry: known };
1232
+ }
1233
+ const info = classifyTaxDocument(download, now);
1234
+ const path = archiveTax(home, institution, info, download.suggestedName, download.bytes);
1235
+ const entry = {
1236
+ sha256: hash,
1237
+ path,
1238
+ kind: 'tax',
1239
+ institution: institution.key,
1240
+ accountId: null,
1241
+ month: null,
1242
+ title: info.title,
1243
+ docType: info.docType,
1244
+ taxYear: info.taxYear,
1245
+ periodLabel: info.periodLabel,
1246
+ key: download.key,
1247
+ how,
1248
+ downloadedAt: now.toISOString(),
1249
+ statementId: null,
1250
+ documentId: null,
1251
+ importError: null,
1252
+ };
1253
+ state.entries.push(entry);
1254
+ return fileTaxEntry({ entry, institution, bytes: download.bytes, filename: download.suggestedName, fileDocument });
1255
+ }
1256
+
1257
+ // ---------------------------------------------------------------------------
1258
+ // Brokerage documents: statements, confirmations, 1099s → the document library
1259
+ // ---------------------------------------------------------------------------
1260
+
1261
+ /**
1262
+ * What a brokerage document is: `{category, docType, taxYear, periodLabel, title}`.
1263
+ * Tax forms (1099, 1042-S, 5498) are category "tax", type "form", by tax
1264
+ * year; everything else (monthly statements, trade confirmations) is
1265
+ * category "statement" with its month as the period.
1266
+ */
1267
+ export function classifyBrokerageDocument({ label = '', context = '', suggestedName = '' } = {}, fetchedAt = new Date()) {
1268
+ const text = [label, context, String(suggestedName || '').replace(/[_-]+/g, ' ')].filter(Boolean).join(' ').replace(/\s+/g, ' ').slice(0, 2000);
1269
+ const cleanLabel = String(label || '').replace(/\s+/g, ' ').trim();
1270
+ const fallback = String(context || '').replace(/\s+/g, ' ').trim().slice(0, 120) || basename(String(suggestedName || 'document.pdf'), '.pdf');
1271
+ if (/\b(1099|1042|5498)\b|tax (document|form)/i.test(text)) {
1272
+ const tax = classifyTaxDocument({ label, context, suggestedName }, fetchedAt);
1273
+ return { category: 'tax', docType: 'form', taxYear: tax.taxYear, periodLabel: tax.periodLabel, title: tax.title };
1274
+ }
1275
+ const confirm = /confirm/i.test(text);
1276
+ const period = periodOf(context, suggestedName, label);
1277
+ const periodLabel = confirm && period && period.to ? period.to : period ? period.month : fetchedAt.toISOString().slice(0, 7);
1278
+ const title = (cleanLabel && !GENERIC_LABEL.test(cleanLabel) ? cleanLabel : fallback).slice(0, 200) || (confirm ? 'Trade confirmation' : 'Statement');
1279
+ return { category: 'statement', docType: null, taxYear: null, periodLabel, title };
1280
+ }
1281
+
1282
+ /**
1283
+ * Keep one PDF from a brokerage: archive it locally and file it in the
1284
+ * document library, as `keepTaxDocument` does for agencies.
1285
+ */
1286
+ export async function keepBrokerageDocument({ institution, download, state, home, fileDocument, how = 'fetch', now = new Date() }) {
1287
+ if (!isPdf(download.bytes)) return { status: 'not_pdf', name: download.suggestedName };
1288
+ const hash = sha256(download.bytes);
1289
+ const known = state.entries.find((entry) => entry.sha256 === hash);
1290
+ if (known) {
1291
+ if (download.key && !known.key) known.key = download.key;
1292
+ return { status: 'duplicate', entry: known };
1293
+ }
1294
+ const info = classifyBrokerageDocument(download, now);
1295
+ const path = archiveTax(home, institution, { ...info, taxYear: info.taxYear || info.periodLabel.slice(0, 4) }, download.suggestedName, download.bytes);
1296
+ const entry = {
1297
+ sha256: hash,
1298
+ path,
1299
+ kind: 'brokerage',
1300
+ category: info.category,
1301
+ institution: institution.key,
1302
+ accountId: null,
1303
+ month: null,
1304
+ title: info.title,
1305
+ docType: info.docType,
1306
+ taxYear: info.taxYear,
1307
+ periodLabel: info.periodLabel,
1308
+ key: download.key,
1309
+ how,
1310
+ downloadedAt: now.toISOString(),
1311
+ statementId: null,
1312
+ documentId: null,
1313
+ importError: null,
1314
+ };
1315
+ state.entries.push(entry);
1316
+ return fileTaxEntry({ entry, institution, bytes: download.bytes, filename: download.suggestedName, fileDocument });
1317
+ }
1318
+
1319
+ /** Files a tax or brokerage entry in the document library under its own category. */
1320
+ async function fileTaxEntry({ entry, institution, bytes, filename, fileDocument }) {
1321
+ if (!fileDocument) {
1322
+ entry.importError = 'this CoinPay client cannot file documents';
1323
+ return { status: 'import_failed', entry };
1324
+ }
1325
+ try {
1326
+ const data = await fileDocument({
1327
+ file: bytes,
1328
+ filename: filename || basename(entry.path),
1329
+ title: entry.title,
1330
+ category: entry.category || 'tax',
1331
+ periodLabel: entry.periodLabel,
1332
+ taxYear: entry.taxYear,
1333
+ docType: entry.docType,
1334
+ institutionKey: institution.key,
1335
+ source: 'fetch',
1336
+ notes: `Downloaded from ${institution.name} by the CoinPay CLI (${entry.how}).`,
1337
+ });
1338
+ entry.documentId = data && data.document ? data.document.id : null;
1339
+ entry.importError = null;
1340
+ return { status: data && data.duplicate ? 'duplicate' : 'imported', entry };
1341
+ } catch (err) {
1342
+ entry.importError = err && err.message ? err.message : String(err);
1343
+ return { status: 'import_failed', entry };
1344
+ }
1345
+ }
1346
+
934
1347
  /**
935
1348
  * Fetch every signed-in bank (or the ones named) and import what is new.
936
1349
  * `api` carries `listAccounts()`, `importStatement(opts)` and `reportRun(run)`.
937
1350
  * Returns one summary per bank; never throws for one bank's failure.
938
1351
  */
939
- export async function runStatementFetch({ api, banks = [], since = null, max = 24, renderMs = 25_000, headless = true, chrome = findChrome(), home = statementsHome(), log = () => {}, client = 'coinpay-cli', force = false }) {
940
- const institutions = groupInstitutions(await api.listAccounts());
941
- if (!institutions.length) throw new StatementFetchError('no linked accounts; connect a bank in CoinPay first');
942
- const chosen = banks.length ? banks.map((name) => pickInstitution(institutions, name)) : institutions.filter((entry) => signedIn(entry.key, home));
943
- if (!chosen.length) throw new StatementFetchError(`no bank is signed in yet. Start with: coinpay finances statements login ${institutions[0].key}`);
1352
+ export async function runStatementFetch({ api, banks = [], since = null, max = 24, renderMs = 25_000, headless = true, chrome = findChrome(), home = statementsHome(), log = () => {}, client = 'coinpay-cli', force = false, now = () => new Date() }) {
1353
+ const linked = groupInstitutions(await api.listAccounts());
1354
+ const institutions = withStandaloneSources(linked);
1355
+ const chosen = banks.length ? banks.map((name) => pickInstitution(linked, name)) : institutions.filter((entry) => signedIn(entry.key, home));
1356
+ if (!chosen.length) {
1357
+ throw new StatementFetchError(linked.length
1358
+ ? `no bank is signed in yet. Start with: coinpay finances statements login ${linked[0].key}`
1359
+ : 'no linked accounts and no tax source signed in. Connect a bank in CoinPay, or start with: coinpay finances statements assist irs');
1360
+ }
944
1361
  if (!chrome) throw new StatementFetchError(NO_CHROME);
945
1362
 
946
1363
  const state = loadLocal(home);
@@ -949,20 +1366,34 @@ export async function runStatementFetch({ api, banks = [], since = null, max = 2
949
1366
  const local = (state.institutions[institution.key] ||= {});
950
1367
  const start = startUrls(institution, local.start).fetch;
951
1368
  const startedAt = new Date().toISOString();
952
- const summary = { bank: institution.key, name: institution.name, status: 'ok', candidates: 0, imported: 0, duplicates: 0, unmatched: 0, failed: 0, silent: 0, message: null };
1369
+ const tax = isTaxSource(institution);
1370
+ const mode = sourceMode(institution);
1371
+ const summary = { bank: institution.key, name: institution.name, kind: tax ? 'tax' : mode === 'documents' ? 'brokerage' : 'bank', status: 'ok', candidates: 0, imported: 0, duplicates: 0, unmatched: 0, failed: 0, silent: 0, message: null };
953
1372
  log(`${institution.key}: ${start || 'no start page'}`);
954
1373
  let browser;
955
1374
  try {
956
1375
  if (!signedIn(institution.key, home) || !start) {
957
1376
  summary.status = 'login_needed';
958
- summary.message = `not signed in: coinpay finances statements login ${institution.key}`;
1377
+ summary.message = `not signed in: coinpay finances statements ${tax ? 'assist' : 'login'} ${institution.key}`;
1378
+ } else if (tax && !(summary.throttle = takeLocalAttempt(home, institution.key, 'fetch', now())).ok) {
1379
+ // Counted before the visit; refused visits never reach the site.
1380
+ summary.status = 'throttled';
1381
+ summary.message = `not fetched: ${summary.throttle.message}`;
959
1382
  } else {
960
1383
  browser = await openBrowser({ chrome, profile: profileDir(institution.key, home), headless, force });
961
1384
  const seen = new Set(state.entries.filter((entry) => entry.institution === institution.key && entry.key).map((entry) => entry.key));
962
1385
  const page = await fetchInstitution(browser, {
963
- start, seen, since, max, renderMs, log,
1386
+ start, seen, since, renderMs, log,
1387
+ max: tax ? Math.min(max, 12) : max,
1388
+ mode,
1389
+ watchLockout: tax,
1390
+ pauseMs: tax ? 3000 : 1200,
964
1391
  onFile: async (download) => {
965
- const kept = await keepStatement({ institution, download, state, home, importStatement: api.importStatement });
1392
+ const kept = tax
1393
+ ? await keepTaxDocument({ institution, download, state, home, fileDocument: api.fileDocument, now: now() })
1394
+ : mode === 'documents'
1395
+ ? await keepBrokerageDocument({ institution, download, state, home, fileDocument: api.fileDocument, now: now() })
1396
+ : await keepStatement({ institution, download, state, home, importStatement: api.importStatement });
966
1397
  if (kept.status === 'imported') summary.imported += 1;
967
1398
  else if (kept.status === 'duplicate') summary.duplicates += 1;
968
1399
  else if (kept.status === 'unmatched') summary.unmatched += 1;
@@ -974,8 +1405,21 @@ export async function runStatementFetch({ api, banks = [], since = null, max = 2
974
1405
  summary.status = page.status;
975
1406
  summary.candidates = page.candidates;
976
1407
  summary.silent = page.silent.length;
977
- if (page.status === 'login_needed') summary.message = `the bank asks for a password again: coinpay finances statements login ${institution.key}`;
978
- 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`;
1408
+ if (page.status === 'locked') {
1409
+ // Recorded, then left alone: no retry until the site's own lock is over.
1410
+ const until = lockoutUntil(page.lockout, now(), undefined, (taxSource(institution.key) || {}).lockoutMinutes);
1411
+ recordLocalLockout(home, institution.key, until, 'lockout page during fetch');
1412
+ summary.lockedUntil = until;
1413
+ summary.message = `${institution.name} shows a locked account; nothing more will be tried before ${until}`;
1414
+ }
1415
+ if (page.status === 'login_needed') summary.message = tax
1416
+ ? `${institution.name} asks you to sign in again. CoinPay never re-enters credentials or codes; when you are ready: coinpay finances statements assist ${institution.key}`
1417
+ : `the bank asks for a password again: coinpay finances statements login ${institution.key}`;
1418
+ if (page.status === 'no_statements') summary.message = tax
1419
+ ? `no notices, letters or transcripts found at ${new URL(page.url).origin}; download them by hand with: coinpay finances statements assist ${institution.key}`
1420
+ : mode === 'documents'
1421
+ ? `no statements, confirmations or tax forms found at ${new URL(page.url).origin}; open the documents list and download by hand with: coinpay finances statements assist ${institution.key}`
1422
+ : `no statement links at ${new URL(page.url).origin}; sign in again and close the window on the statements list, or use assist`;
979
1423
  if (summary.failed) summary.message = `${summary.failed} file(s) could not be imported; see coinpay finances statements local`;
980
1424
  }
981
1425
  } catch (err) {
@@ -988,11 +1432,17 @@ export async function runStatementFetch({ api, banks = [], since = null, max = 2
988
1432
  saveLocal(state, home);
989
1433
  }
990
1434
  log(` ${summary.status}: ${summary.candidates} on the page, ${summary.imported} imported, ${summary.duplicates} already had, ${summary.unmatched} unmatched${summary.message ? `; ${summary.message}` : ''}`);
1435
+ // A refused (throttled) visit never reached the site: nothing to report.
1436
+ if (summary.status === 'throttled') {
1437
+ results.push(summary);
1438
+ continue;
1439
+ }
991
1440
  try {
992
1441
  await api.reportRun({
993
1442
  institutionKey: institution.key,
994
1443
  institutionLabel: institution.name,
995
- status: summary.status,
1444
+ // The server's run statuses predate lockouts; a lockout is an error with its message.
1445
+ status: summary.status === 'locked' ? 'error' : summary.status,
996
1446
  candidates: summary.candidates,
997
1447
  filed: summary.imported,
998
1448
  duplicates: summary.duplicates,
@@ -1018,15 +1468,25 @@ export async function clientApi(client) {
1018
1468
  listAccounts: () => listFinanceAccounts(client),
1019
1469
  importStatement: (opts) => reports.importFinanceStatement(client, opts),
1020
1470
  reportRun: (run) => reports.reportStatementFetchRun(client, run),
1471
+ fileDocument: (opts) => reports.uploadFinanceDocument(client, opts),
1021
1472
  };
1022
1473
  }
1023
1474
 
1024
1475
  /** Re-import local files whose import failed or never happened (e.g. after linking an account). */
1025
1476
  export async function retryImports({ api, home = statementsHome(), log = () => {} }) {
1026
1477
  const state = loadLocal(home);
1027
- const institutions = groupInstitutions(await api.listAccounts());
1478
+ const institutions = withStandaloneSources(groupInstitutions(await api.listAccounts()));
1028
1479
  const out = { imported: 0, skipped: 0, failed: 0 };
1029
- for (const entry of state.entries.filter((e) => !e.statementId)) {
1480
+ for (const entry of state.entries.filter((e) => !e.statementId && !e.documentId)) {
1481
+ if (entry.kind === 'tax' || entry.kind === 'brokerage') {
1482
+ const source = institutions.find((i) => i.key === entry.institution);
1483
+ if (!source || !existsSync(entry.path)) { out.skipped += 1; continue; }
1484
+ const result = await fileTaxEntry({ entry, institution: source, bytes: readFileSync(entry.path), filename: basename(entry.path), fileDocument: api.fileDocument });
1485
+ if (result.status === 'import_failed') { out.failed += 1; log(`failed ${entry.path}: ${entry.importError}`); }
1486
+ else { out.imported += 1; log(`filed ${entry.path}`); }
1487
+ saveLocal(state, home);
1488
+ continue;
1489
+ }
1030
1490
  const institution = institutions.find((i) => i.key === entry.institution);
1031
1491
  const account = institution && institution.accounts.find((a) => a.id === entry.accountId);
1032
1492
  const span = importPeriod(entry.month ? { month: entry.month, from: entry.from, to: entry.to } : null);