@profullstack/coinpay 0.13.0 → 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 +53 -0
- package/bin/coinpay.js +52 -12
- package/package.json +1 -1
- package/src/finances-commands.js +78 -19
- package/src/finances-reports.d.ts +39 -3
- package/src/finances-reports.js +46 -5
- package/src/mcp.js +31 -1
- package/src/statements-fetch.d.ts +85 -4
- package/src/statements-fetch.js +495 -36
- 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,
|
|
@@ -599,14 +707,55 @@ export const OPEN_STATEMENTS = `(() => {
|
|
|
599
707
|
})()`;
|
|
600
708
|
|
|
601
709
|
/**
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
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
|
|
607
|
-
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
|
+
}
|
|
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 (!
|
|
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(
|
|
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
|
|
942
|
-
|
|
943
|
-
const chosen = banks.length ? banks.map((name) => pickInstitution(
|
|
944
|
-
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
|
+
}
|
|
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
|
|
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,
|
|
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 =
|
|
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 === '
|
|
979
|
-
|
|
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
|
-
|
|
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);
|