@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.
- package/CHANGELOG.md +71 -0
- package/bin/coinpay.js +96 -14
- package/package.json +5 -1
- package/src/client.js +5 -0
- package/src/finances-commands.js +162 -18
- package/src/finances-reports.d.ts +39 -3
- package/src/finances-reports.js +80 -5
- package/src/mcp.js +48 -1
- package/src/oauth-login.js +174 -0
- package/src/statements-fetch.d.ts +204 -0
- package/src/statements-fetch.js +499 -39
- package/src/statements-throttle.js +158 -0
package/src/statements-fetch.js
CHANGED
|
@@ -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(
|
|
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}"
|
|
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
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
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
|
|
606
|
-
const DATE = /((?<![a-z])(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]
|
|
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 (!
|
|
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(
|
|
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
|
|
941
|
-
|
|
942
|
-
const chosen = banks.length ? banks.map((name) => pickInstitution(
|
|
943
|
-
if (!chosen.length)
|
|
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
|
|
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,
|
|
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 =
|
|
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 === '
|
|
978
|
-
|
|
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
|
-
|
|
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);
|