@profullstack/coinpay 0.13.0 → 0.16.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,
@@ -599,14 +707,55 @@ export const OPEN_STATEMENTS = `(() => {
599
707
  })()`;
600
708
 
601
709
  /**
602
- * Every visible control that looks like it downloads one dated statement,
603
- * tagged data-stmt-i for clickScript. Row text is read with innerText, which
604
- * 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.
605
718
  */
606
- export const COLLECT = `(() => {
607
- 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
+ }
608
742
  const SKIP = /preference|paperless|setting|notification|tax form|1099|privacy|agreement|terms|help|learn more|enroll/i;
609
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;
610
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'; };
611
760
  const clean = (s) => (s || '').replace(/\\s+/g, ' ').trim();
612
761
  const textOf = (el) => clean(el.innerText || el.textContent);
@@ -628,10 +777,8 @@ export const COLLECT = `(() => {
628
777
  if (!visible(el)) continue;
629
778
  const label = clean(el.getAttribute('aria-label') || el.getAttribute('title') || el.innerText || el.textContent);
630
779
  const href = el.getAttribute('href') || '';
631
- if (!/\\.pdf(\\b|$)/i.test(href) && !ACTION.test(label)) continue;
632
- if (SKIP.test(label)) continue;
633
780
  const context = textOf(rowOf(el)).slice(0, 300);
634
- if (!DATE.test(label + ' ' + context + ' ' + href)) continue;
781
+ if (!keep(MODE, label, context, href)) continue;
635
782
  el.setAttribute('data-stmt-i', String(index));
636
783
  let absolute = null;
637
784
  if (href && !href.startsWith('#') && !/^javascript:/i.test(href)) { try { absolute = new URL(href, doc.baseURI).href; } catch {} }
@@ -641,6 +788,36 @@ export const COLLECT = `(() => {
641
788
  }
642
789
  return JSON.stringify(out);
643
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) : ''))()`;
644
821
 
645
822
  export function clickScript(index) {
646
823
  return `(() => {
@@ -766,15 +943,19 @@ async function closeStrays(cdp, keep) {
766
943
  }
767
944
  }
768
945
 
769
- async function findCandidates(cdp, sessionId, renderMs) {
946
+ async function findCandidates(cdp, sessionId, renderMs, mode = 'statements', watchLockout = false) {
770
947
  const deadline = Date.now() + renderMs;
771
948
  let followed = false;
772
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
+ }
773
954
  if (await evaluate(cdp, sessionId, SIGNED_OUT)) return { signedOut: true, candidates: [] };
774
- const raw = await evaluate(cdp, sessionId, COLLECT);
955
+ const raw = await evaluate(cdp, sessionId, mode === 'tax' ? COLLECT_TAX : mode === 'documents' ? COLLECT_DOCS : COLLECT);
775
956
  const candidates = raw ? JSON.parse(raw) : [];
776
957
  if (candidates.length) return { signedOut: false, candidates };
777
- 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;
778
959
  if (Date.now() > deadline) return { signedOut: false, candidates: [] };
779
960
  await sleep(1500);
780
961
  }
@@ -783,8 +964,12 @@ async function findCandidates(cdp, sessionId, renderMs) {
783
964
  /**
784
965
  * Visit one bank's statements page and hand every new PDF to `onFile`.
785
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.
786
971
  */
787
- 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 }) {
788
973
  const { cdp } = browser;
789
974
  const { sessionId, targetId } = await attachPage(cdp);
790
975
  await hideHeadless(cdp, sessionId);
@@ -792,15 +977,16 @@ export async function fetchInstitution(browser, { start, seen = new Set(), since
792
977
  const downloads = await Downloads.start(cdp, staging);
793
978
  try {
794
979
  await navigate(cdp, sessionId, start);
795
- let found = await findCandidates(cdp, sessionId, renderMs);
980
+ let found = await findCandidates(cdp, sessionId, renderMs, mode, watchLockout);
796
981
  const url = (await evaluate(cdp, sessionId, 'location.href')) || start;
982
+ if (found.locked) return { status: 'locked', url, candidates: 0, silent: [], lockout: found.locked };
797
983
  if (found.signedOut) return { status: 'login_needed', url, candidates: 0, silent: [] };
798
984
  if (!found.candidates.length) return { status: 'no_statements', url, candidates: 0, silent: [] };
799
985
 
800
986
  const todo = found.candidates
801
987
  .map((candidate) => ({ candidate, key: candidateKey(candidate), period: periodOf(candidate.context, candidate.label) }))
802
988
  .filter(({ key }) => !seen.has(key))
803
- .filter(({ period }) => !since || !period || period.month >= since)
989
+ .filter(({ period }) => mode === 'tax' || !since || !period || period.month >= since)
804
990
  .filter((item, index, all) => all.findIndex((other) => other.key === item.key) === index)
805
991
  .slice(0, max);
806
992
 
@@ -812,7 +998,11 @@ export async function fetchInstitution(browser, { start, seen = new Set(), since
812
998
  if (!clicked) {
813
999
  // The last click navigated the tab; come back and find the same row.
814
1000
  await navigate(cdp, sessionId, start);
815
- 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
+ }
816
1006
  const again = found.candidates.find((other) => candidateKey(other) === key);
817
1007
  clicked = again ? await evaluate(cdp, sessionId, clickScript(again.index)) : false;
818
1008
  }
@@ -826,7 +1016,7 @@ export async function fetchInstitution(browser, { start, seen = new Set(), since
826
1016
  if (!bytes) { silent.push(label); continue; }
827
1017
  await onFile({ bytes, suggestedName: event.suggestedName, label: candidate.label, context: candidate.context, key });
828
1018
  // A bank that sees forty clicks a second ends the session.
829
- await sleep(1200);
1019
+ await sleep(pauseMs);
830
1020
  }
831
1021
  for (const line of silent) log(` ? no download from: ${line}`);
832
1022
  return { status: 'ok', url, candidates: total, silent };
@@ -836,8 +1026,35 @@ export async function fetchInstitution(browser, { start, seen = new Set(), since
836
1026
  }
837
1027
  }
838
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
+
839
1056
  /** A sign-in window; resolves with the last real page when the person closes it. */
840
- export async function loginWindow(browser, url) {
1057
+ export async function loginWindow(browser, url, { onPageText } = {}) {
841
1058
  const { cdp } = browser;
842
1059
  await cdp.send('Target.setDiscoverTargets', { discover: true });
843
1060
  let last = null;
@@ -847,14 +1064,16 @@ export async function loginWindow(browser, url) {
847
1064
  if (info.type === 'page' && /^https:/.test(info.url || '')) last = info.url;
848
1065
  });
849
1066
  const { sessionId } = await attachPage(cdp);
1067
+ const stopWatch = watchText(cdp, sessionId, onPageText);
850
1068
  await navigate(cdp, sessionId, url).catch(() => {});
851
1069
  await browser.exited;
1070
+ stopWatch();
852
1071
  stop();
853
1072
  return last && !isSignInUrl(last) ? last : null;
854
1073
  }
855
1074
 
856
1075
  /** A window on the statements page; every PDF the person downloads goes to `onFile` until it closes. */
857
- export async function assistWindow(browser, { start, onFile }) {
1076
+ export async function assistWindow(browser, { start, onFile, onPageText }) {
858
1077
  const { cdp } = browser;
859
1078
  const { sessionId } = await attachPage(cdp);
860
1079
  await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: CLICK_RECORDER }, sessionId);
@@ -863,6 +1082,7 @@ export async function assistWindow(browser, { start, onFile }) {
863
1082
  let open = true;
864
1083
  browser.exited.then(() => { open = false; });
865
1084
  let handled = 0;
1085
+ const stopWatch = watchText(cdp, sessionId, onPageText);
866
1086
  try {
867
1087
  await navigate(cdp, sessionId, start).catch(() => {});
868
1088
  await evaluate(cdp, sessionId, CLICK_RECORDER);
@@ -876,6 +1096,7 @@ export async function assistWindow(browser, { start, onFile }) {
876
1096
  if (bytes) await onFile({ bytes, suggestedName: event.suggestedName, label: clicked.label, context: clicked.context, key: null });
877
1097
  }
878
1098
  } finally {
1099
+ stopWatch();
879
1100
  downloads.stop();
880
1101
  rmSync(staging, { recursive: true, force: true });
881
1102
  }
@@ -932,16 +1153,211 @@ export async function keepStatement({ institution, download, state, home, import
932
1153
  }
933
1154
  }
934
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
+
935
1347
  /**
936
1348
  * Fetch every signed-in bank (or the ones named) and import what is new.
937
1349
  * `api` carries `listAccounts()`, `importStatement(opts)` and `reportRun(run)`.
938
1350
  * Returns one summary per bank; never throws for one bank's failure.
939
1351
  */
940
- 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 }) {
941
- const institutions = groupInstitutions(await api.listAccounts());
942
- if (!institutions.length) throw new StatementFetchError('no linked accounts; connect a bank in CoinPay first');
943
- const chosen = banks.length ? banks.map((name) => pickInstitution(institutions, name)) : institutions.filter((entry) => signedIn(entry.key, home));
944
- 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
+ }
945
1361
  if (!chrome) throw new StatementFetchError(NO_CHROME);
946
1362
 
947
1363
  const state = loadLocal(home);
@@ -950,20 +1366,34 @@ export async function runStatementFetch({ api, banks = [], since = null, max = 2
950
1366
  const local = (state.institutions[institution.key] ||= {});
951
1367
  const start = startUrls(institution, local.start).fetch;
952
1368
  const startedAt = new Date().toISOString();
953
- 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 };
954
1372
  log(`${institution.key}: ${start || 'no start page'}`);
955
1373
  let browser;
956
1374
  try {
957
1375
  if (!signedIn(institution.key, home) || !start) {
958
1376
  summary.status = 'login_needed';
959
- 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}`;
960
1382
  } else {
961
1383
  browser = await openBrowser({ chrome, profile: profileDir(institution.key, home), headless, force });
962
1384
  const seen = new Set(state.entries.filter((entry) => entry.institution === institution.key && entry.key).map((entry) => entry.key));
963
1385
  const page = await fetchInstitution(browser, {
964
- 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,
965
1391
  onFile: async (download) => {
966
- 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 });
967
1397
  if (kept.status === 'imported') summary.imported += 1;
968
1398
  else if (kept.status === 'duplicate') summary.duplicates += 1;
969
1399
  else if (kept.status === 'unmatched') summary.unmatched += 1;
@@ -975,8 +1405,21 @@ export async function runStatementFetch({ api, banks = [], since = null, max = 2
975
1405
  summary.status = page.status;
976
1406
  summary.candidates = page.candidates;
977
1407
  summary.silent = page.silent.length;
978
- if (page.status === 'login_needed') summary.message = `the bank asks for a password again: coinpay finances statements login ${institution.key}`;
979
- 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`;
980
1423
  if (summary.failed) summary.message = `${summary.failed} file(s) could not be imported; see coinpay finances statements local`;
981
1424
  }
982
1425
  } catch (err) {
@@ -989,11 +1432,17 @@ export async function runStatementFetch({ api, banks = [], since = null, max = 2
989
1432
  saveLocal(state, home);
990
1433
  }
991
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
+ }
992
1440
  try {
993
1441
  await api.reportRun({
994
1442
  institutionKey: institution.key,
995
1443
  institutionLabel: institution.name,
996
- 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,
997
1446
  candidates: summary.candidates,
998
1447
  filed: summary.imported,
999
1448
  duplicates: summary.duplicates,
@@ -1019,15 +1468,25 @@ export async function clientApi(client) {
1019
1468
  listAccounts: () => listFinanceAccounts(client),
1020
1469
  importStatement: (opts) => reports.importFinanceStatement(client, opts),
1021
1470
  reportRun: (run) => reports.reportStatementFetchRun(client, run),
1471
+ fileDocument: (opts) => reports.uploadFinanceDocument(client, opts),
1022
1472
  };
1023
1473
  }
1024
1474
 
1025
1475
  /** Re-import local files whose import failed or never happened (e.g. after linking an account). */
1026
1476
  export async function retryImports({ api, home = statementsHome(), log = () => {} }) {
1027
1477
  const state = loadLocal(home);
1028
- const institutions = groupInstitutions(await api.listAccounts());
1478
+ const institutions = withStandaloneSources(groupInstitutions(await api.listAccounts()));
1029
1479
  const out = { imported: 0, skipped: 0, failed: 0 };
1030
- 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
+ }
1031
1490
  const institution = institutions.find((i) => i.key === entry.institution);
1032
1491
  const account = institution && institution.accounts.find((a) => a.id === entry.accountId);
1033
1492
  const span = importPeriod(entry.month ? { month: entry.month, from: entry.from, to: entry.to } : null);