@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
|
@@ -316,7 +316,43 @@ export function getBooksSummary(
|
|
|
316
316
|
client: CoinPayClient,
|
|
317
317
|
options?: PeriodSelection & { scope?: 'business' | 'personal' | 'all'; rows?: boolean },
|
|
318
318
|
): Promise<{ period: Record<string, unknown>; scope: string; lines: BooksSummaryLine[]; totals: Array<{ currency: string; income: string; expenses: string; net: string; excluded: string }>; rows: number; unreviewed: number; uncategorized: number; notice: string; transactions?: BooksRow[] }>;
|
|
319
|
-
export function exportBooks(client: CoinPayClient, options?: PeriodSelection & { scope?: 'business' | 'personal' | 'all'; format?: 'csv' | 'pdf' | 'html' | 'json' }): Promise<BinaryResponse>;
|
|
319
|
+
export function exportBooks(client: CoinPayClient, options?: PeriodSelection & { scope?: 'business' | 'personal' | 'all'; format?: 'csv' | 'pdf' | 'html' | 'json'; withDocuments?: boolean }): Promise<BinaryResponse>;
|
|
320
|
+
|
|
321
|
+
export interface FinanceDocument {
|
|
322
|
+
id: string;
|
|
323
|
+
title: string;
|
|
324
|
+
category: 'report' | 'statement' | 'tax' | 'invoice' | 'other';
|
|
325
|
+
periodLabel: string | null;
|
|
326
|
+
notes: string | null;
|
|
327
|
+
filename: string | null;
|
|
328
|
+
contentType: string;
|
|
329
|
+
bytes: number;
|
|
330
|
+
source: 'upload' | 'api' | 'fetch' | 'cloud';
|
|
331
|
+
institutionKey: string | null;
|
|
332
|
+
taxYear: number | null;
|
|
333
|
+
docType: string | null;
|
|
334
|
+
uploadedBy: string | null;
|
|
335
|
+
createdAt: string;
|
|
336
|
+
downloadUrl: string;
|
|
337
|
+
}
|
|
338
|
+
export function uploadFinanceDocument(
|
|
339
|
+
client: CoinPayClient,
|
|
340
|
+
options: {
|
|
341
|
+
file: Uint8Array | Blob;
|
|
342
|
+
filename?: string;
|
|
343
|
+
title?: string;
|
|
344
|
+
category?: 'report' | 'statement' | 'tax' | 'invoice' | 'other';
|
|
345
|
+
periodLabel?: string | null;
|
|
346
|
+
notes?: string | null;
|
|
347
|
+
contentType?: string;
|
|
348
|
+
source?: 'fetch';
|
|
349
|
+
institutionKey?: string;
|
|
350
|
+
taxYear?: number | null;
|
|
351
|
+
docType?: string | null;
|
|
352
|
+
},
|
|
353
|
+
): Promise<{ document: FinanceDocument; duplicate: boolean }>;
|
|
354
|
+
export function listFinanceDocuments(client: CoinPayClient, options?: { category?: FinanceDocument['category']; limit?: number }): Promise<FinanceDocument[]>;
|
|
355
|
+
export function getFinanceDocument(client: CoinPayClient, documentId: string): Promise<FinanceDocument>;
|
|
320
356
|
export function listFinancePayloads(client: CoinPayClient, options?: { connectionId?: string; limit?: number; offset?: number }): Promise<{ payloads: Array<Record<string, unknown>>; total: number }>;
|
|
321
357
|
export function downloadFinancePayload(client: CoinPayClient, payloadId: string): Promise<BinaryResponse>;
|
|
322
358
|
|
|
@@ -350,8 +386,8 @@ export function sendFinanceReportEmail(
|
|
|
350
386
|
): Promise<SendOutcome>;
|
|
351
387
|
export function sendBooksEmail(
|
|
352
388
|
client: CoinPayClient,
|
|
353
|
-
options: { to: string | string[]; period?: string; from?: string; toDate?: string; timezone?: string; scope?: 'business' | 'personal' | 'all'; formats?: string[]; message?: string | null; attach?: boolean; expiresInDays?: number },
|
|
354
|
-
): Promise<SendOutcome & { unreviewed: number; rows: number }>;
|
|
389
|
+
options: { to: string | string[]; period?: string; from?: string; toDate?: string; timezone?: string; scope?: 'business' | 'personal' | 'all'; formats?: string[]; message?: string | null; attach?: boolean; expiresInDays?: number; withDocuments?: boolean },
|
|
390
|
+
): Promise<SendOutcome & { unreviewed: number; rows: number; documents?: { attached: string[]; skipped: Array<{ title: string; bytes: number; reason: string }> } }>;
|
|
355
391
|
export function getWeeklyDigest(client: CoinPayClient): Promise<WeeklyDigest | null>;
|
|
356
392
|
export function setWeeklyDigest(
|
|
357
393
|
client: CoinPayClient,
|
package/src/finances-reports.js
CHANGED
|
@@ -256,6 +256,43 @@ export async function reconcileFinanceStatement(client, statementId, {
|
|
|
256
256
|
return data.reconciliation;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
// ── Document library ──
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* File a document (multipart POST /finances/documents). `file` is the bytes.
|
|
263
|
+
* Tax documents from the fetcher pass `category: 'tax'`, `source: 'fetch'`,
|
|
264
|
+
* `institutionKey`, `taxYear` and `docType`. Answers `{document, duplicate}`:
|
|
265
|
+
* the server keeps one copy per file (by sha256) and says when it had it.
|
|
266
|
+
*/
|
|
267
|
+
export async function uploadFinanceDocument(client, {
|
|
268
|
+
file, filename = 'document.pdf', title, category, periodLabel, notes, contentType = 'application/pdf', source, institutionKey, taxYear, docType,
|
|
269
|
+
} = {}) {
|
|
270
|
+
if (!file) throw new Error('A file is required');
|
|
271
|
+
const form = new FormData();
|
|
272
|
+
const blob = file instanceof Blob ? file : new Blob([file], { type: contentType });
|
|
273
|
+
form.append('file', blob, filename);
|
|
274
|
+
form.append('title', title || filename);
|
|
275
|
+
if (category) form.append('category', category);
|
|
276
|
+
if (periodLabel) form.append('period', periodLabel);
|
|
277
|
+
if (notes) form.append('notes', notes);
|
|
278
|
+
if (source) form.append('source', source);
|
|
279
|
+
if (institutionKey) form.append('institutionKey', institutionKey);
|
|
280
|
+
if (taxYear !== undefined && taxYear !== null) form.append('taxYear', String(taxYear));
|
|
281
|
+
if (docType) form.append('docType', docType);
|
|
282
|
+
return client.requestForm('/finances/documents', form);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The document library, newest first; `category: 'tax'` for FTB/IRS notices, letters and transcripts. */
|
|
286
|
+
export async function listFinanceDocuments(client, { category, limit } = {}) {
|
|
287
|
+
const data = await call(client, `/finances/documents${query({ category, limit })}`);
|
|
288
|
+
return data.documents || [];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function getFinanceDocument(client, documentId) {
|
|
292
|
+
const data = await call(client, `/finances/documents/${encodeURIComponent(documentId)}`);
|
|
293
|
+
return data.document;
|
|
294
|
+
}
|
|
295
|
+
|
|
259
296
|
/** Report one bank's statement fetch run (counts only). */
|
|
260
297
|
export async function reportStatementFetchRun(client, run) {
|
|
261
298
|
const data = await call(client, '/finances/statements/fetch-runs', { method: 'POST', body: JSON.stringify(run) });
|
|
@@ -272,6 +309,40 @@ export async function getStatementCoverage(client, { months } = {}) {
|
|
|
272
309
|
return call(client, `/finances/statements/coverage${query({ months })}`);
|
|
273
310
|
}
|
|
274
311
|
|
|
312
|
+
// ── Cloud statement fetching (Professional plan; free for admins) ──
|
|
313
|
+
|
|
314
|
+
/** `{access, banks}`: whether cloud fetching is available, and each bank's cloud connection. */
|
|
315
|
+
export async function getCloudStatements(client) {
|
|
316
|
+
return call(client, '/finances/statements/cloud');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Open a bank in CoinPay's cloud browser to sign in. Answers `{live, viewerUrl}`. */
|
|
320
|
+
export async function connectCloudBank(client, { institutionKey, url } = {}) {
|
|
321
|
+
return call(client, '/finances/statements/cloud/connect', { method: 'POST', body: JSON.stringify({ institutionKey, url }) });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function getCloudLiveSession(client, liveId) {
|
|
325
|
+
const data = await call(client, `/finances/statements/cloud/live/${encodeURIComponent(liveId)}`);
|
|
326
|
+
return data.live;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function cancelCloudLiveSession(client, liveId) {
|
|
330
|
+
return call(client, `/finances/statements/cloud/live/${encodeURIComponent(liveId)}`, { method: 'DELETE' });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Queue cloud fetches for one bank or all connected ones. Answers `{jobs}`. */
|
|
334
|
+
export async function fetchCloudStatements(client, { institutionKey } = {}) {
|
|
335
|
+
return call(client, '/finances/statements/cloud/fetch', { method: 'POST', body: JSON.stringify(institutionKey ? { institutionKey } : {}) });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function setCloudSchedule(client, institutionKey, schedule) {
|
|
339
|
+
return call(client, `/finances/statements/cloud/banks/${encodeURIComponent(institutionKey)}`, { method: 'PATCH', body: JSON.stringify({ schedule }) });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function disconnectCloudBank(client, institutionKey) {
|
|
343
|
+
return call(client, `/finances/statements/cloud/banks/${encodeURIComponent(institutionKey)}`, { method: 'DELETE' });
|
|
344
|
+
}
|
|
345
|
+
|
|
275
346
|
// ── Books ──
|
|
276
347
|
|
|
277
348
|
/** Rows awaiting review (default) plus the category vocabularies. */
|
|
@@ -317,9 +388,13 @@ export async function getBooksSummary(client, { period, from, to, timezone, scop
|
|
|
317
388
|
return call(client, `/finances/books/summary${query({ period, from, to, timezone, scope, rows: rows ? 1 : undefined })}`);
|
|
318
389
|
}
|
|
319
390
|
|
|
320
|
-
/**
|
|
321
|
-
|
|
322
|
-
|
|
391
|
+
/**
|
|
392
|
+
* The CPA pack as bytes. With `withDocuments` the answer is a ZIP: the pack
|
|
393
|
+
* plus the period's tax documents that fit in 8 MiB, and MANIFEST.txt naming
|
|
394
|
+
* what was left out.
|
|
395
|
+
*/
|
|
396
|
+
export async function exportBooks(client, { period, from, to, timezone, scope, format = 'csv', withDocuments = false } = {}) {
|
|
397
|
+
return client.requestBinary(`/finances/books/export${query({ period, from, to, timezone, scope, format, with_documents: withDocuments ? 1 : undefined })}`);
|
|
323
398
|
}
|
|
324
399
|
|
|
325
400
|
// ── Raw provider payloads ──
|
|
@@ -343,10 +418,10 @@ export async function sendFinanceReportEmail(client, reportId, { to, formats, me
|
|
|
343
418
|
}
|
|
344
419
|
|
|
345
420
|
/** Email the CPA pack for a period. `toDate` is the exclusive end for a custom range. */
|
|
346
|
-
export async function sendBooksEmail(client, { to, period, from, toDate, timezone, scope, formats, message, attach, expiresInDays } = {}) {
|
|
421
|
+
export async function sendBooksEmail(client, { to, period, from, toDate, timezone, scope, formats, message, attach, expiresInDays, withDocuments } = {}) {
|
|
347
422
|
return call(client, '/finances/books/send', {
|
|
348
423
|
method: 'POST',
|
|
349
|
-
body: JSON.stringify({ to, period, from, toDate, timezone, scope, formats, message, attach, expiresInDays }),
|
|
424
|
+
body: JSON.stringify({ to, period, from, toDate, timezone, scope, formats, message, attach, expiresInDays, withDocuments: withDocuments === true ? true : undefined }),
|
|
350
425
|
});
|
|
351
426
|
}
|
|
352
427
|
|
package/src/mcp.js
CHANGED
|
@@ -60,9 +60,56 @@ export const TOOLS = [
|
|
|
60
60
|
return text({
|
|
61
61
|
chrome: sf.findChrome(),
|
|
62
62
|
banks: institutions.map((i) => ({ key: i.key, name: i.name, accounts: i.accounts.map((a) => a.name), signedIn: sf.signedIn(i.key, home), ...state.institutions[i.key] })),
|
|
63
|
+
// FTB and IRS need no linked bank; each is throttled (2 visits / 30 min, 4 / day).
|
|
64
|
+
taxSources: sf.standaloneSources().filter((s) => s.kind === 'tax').map((s) => ({ key: s.key, name: s.name, signedIn: sf.signedIn(s.key, home), throttle: sf.checkLocalThrottle(home, s.key), ...state.institutions[s.key] })),
|
|
65
|
+
// Brokerages with no linked account (Webull): statements, confirmations and 1099s go to Documents.
|
|
66
|
+
brokerages: sf.standaloneSources().filter((s) => s.kind === 'brokerage' && !institutions.some((i) => i.key === s.key)).map((s) => ({ key: s.key, name: s.name, signedIn: sf.signedIn(s.key, home), ...state.institutions[s.key] })),
|
|
63
67
|
});
|
|
64
68
|
},
|
|
65
69
|
},
|
|
70
|
+
{
|
|
71
|
+
name: 'tax_documents_list',
|
|
72
|
+
description:
|
|
73
|
+
'Tax documents in the CoinPay document library: notices, letters and transcripts fetched from California FTB and the IRS (or uploaded), with tax year, type, source and period. Details only, not the files. Optionally one tax year or one source (ftb, irs, irs-business).',
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
taxYear: { type: 'integer', minimum: 1990, maximum: 2100 },
|
|
78
|
+
source: { type: 'string', description: 'ftb, irs or irs-business' },
|
|
79
|
+
limit: { type: 'integer', minimum: 1, maximum: 500 },
|
|
80
|
+
},
|
|
81
|
+
additionalProperties: false,
|
|
82
|
+
},
|
|
83
|
+
handler: async (args, { client }) => {
|
|
84
|
+
const documents = (await reports.listFinanceDocuments(client, { category: 'tax', limit: args.limit }))
|
|
85
|
+
.filter((d) => args.taxYear === undefined || d.taxYear === args.taxYear)
|
|
86
|
+
.filter((d) => args.source === undefined || d.institutionKey === args.source);
|
|
87
|
+
return text({ documents });
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'tax_documents_get',
|
|
92
|
+
description: 'One document from the CoinPay document library by id: title, type, tax year, source, period, size, and its download path in CoinPay.',
|
|
93
|
+
inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'], additionalProperties: false },
|
|
94
|
+
handler: async (args, { client }) => text({ document: await reports.getFinanceDocument(client, args.id) }),
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'statements_cloud_status',
|
|
98
|
+
description:
|
|
99
|
+
'CoinPay cloud statement fetching: whether this account has it (Professional plan, free for admins) and, per bank, whether a cloud session is connected, needs sign-in again, and when it last fetched. A bank must be connected by a person in the PWA or with `coinpay finances statements cloud connect <bank>`.',
|
|
100
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
101
|
+
handler: async (_args, { client }) => text(await reports.getCloudStatements(client)),
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'statements_cloud_fetch',
|
|
105
|
+
description: 'Queue a CoinPay cloud fetch of new PDF statements for one connected bank, or all of them. Runs on CoinPay servers; returns the jobs. Nothing runs on this machine.',
|
|
106
|
+
inputSchema: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: { bank: { type: 'string', description: 'Bank key such as "chase"; every connected bank when omitted' } },
|
|
109
|
+
additionalProperties: false,
|
|
110
|
+
},
|
|
111
|
+
handler: async (args, { client }) => text(await reports.fetchCloudStatements(client, { institutionKey: args.bank })),
|
|
112
|
+
},
|
|
66
113
|
{
|
|
67
114
|
name: 'statements_fetch',
|
|
68
115
|
description:
|
|
@@ -104,7 +151,7 @@ export async function handleMessage(message, ctx) {
|
|
|
104
151
|
capabilities: { tools: {} },
|
|
105
152
|
serverInfo: { name: 'coinpay', version: ctx.version || '0.0.0' },
|
|
106
153
|
instructions:
|
|
107
|
-
'CoinPay finances. Statement PDFs come from the banks themselves (SimpleFIN has none): statements_coverage shows what is missing, statements_fetch downloads what is new on this machine. A bank marked login_needed needs a person to run `coinpay finances statements login <bank>`.',
|
|
154
|
+
'CoinPay finances. Statement PDFs come from the banks themselves (SimpleFIN has none): statements_coverage shows what is missing, statements_fetch downloads what is new on this machine. A bank marked login_needed needs a person to run `coinpay finances statements login <bank>`. Tax notices, letters and transcripts (California FTB, IRS) are in tax_documents_list; tax data stays on CoinPay surfaces and is never sent elsewhere.',
|
|
108
155
|
});
|
|
109
156
|
case 'notifications/initialized':
|
|
110
157
|
case 'notifications/cancelled':
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `coinpay login` over OAuth 2.1: authorization code + PKCE, a loopback
|
|
3
|
+
* redirect, and rotating refresh tokens.
|
|
4
|
+
*
|
|
5
|
+
* The CLI is a public client (`coinpay-cli`, no secret). It listens on
|
|
6
|
+
* 127.0.0.1 at a free port, opens the browser at CoinPay's /api/oauth/authorize
|
|
7
|
+
* with an S256 code challenge and a random state, catches the redirect, checks
|
|
8
|
+
* the state, and exchanges the code with its verifier. The access token lasts
|
|
9
|
+
* an hour; the refresh token is single-use, so each refresh stores the new
|
|
10
|
+
* one. Over SSH, where the browser cannot reach this machine's loopback, the
|
|
11
|
+
* device flow (`coinpay login --device`) is the way in.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
15
|
+
import { createServer } from 'node:http';
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
export const CLI_CLIENT_ID = 'coinpay-cli';
|
|
19
|
+
export const CLI_SCOPE = 'openid profile email merchant';
|
|
20
|
+
|
|
21
|
+
const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
22
|
+
|
|
23
|
+
/** A PKCE verifier (43+ chars of base64url) and its S256 challenge. */
|
|
24
|
+
export function pkcePair() {
|
|
25
|
+
const verifier = b64url(randomBytes(32));
|
|
26
|
+
const challenge = b64url(createHash('sha256').update(verifier).digest());
|
|
27
|
+
return { verifier, challenge };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The site origin behind an API base such as https://coinpayportal.com/api. */
|
|
31
|
+
export function siteOrigin(apiBase) {
|
|
32
|
+
return new URL(apiBase || 'https://coinpayportal.com/api').origin;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function authorizeUrl({ origin, redirectUri, challenge, state, clientId = CLI_CLIENT_ID, scope = CLI_SCOPE }) {
|
|
36
|
+
const url = new URL('/api/oauth/authorize', origin);
|
|
37
|
+
url.searchParams.set('response_type', 'code');
|
|
38
|
+
url.searchParams.set('client_id', clientId);
|
|
39
|
+
url.searchParams.set('redirect_uri', redirectUri);
|
|
40
|
+
url.searchParams.set('scope', scope);
|
|
41
|
+
url.searchParams.set('state', state);
|
|
42
|
+
url.searchParams.set('code_challenge', challenge);
|
|
43
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
44
|
+
return url.toString();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function tokenRequest(origin, params, fetchImpl = fetch) {
|
|
48
|
+
const res = await fetchImpl(new URL('/api/oauth/token', origin), {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
51
|
+
body: new URLSearchParams(params).toString(),
|
|
52
|
+
});
|
|
53
|
+
const data = await res.json().catch(() => ({}));
|
|
54
|
+
if (!res.ok || !data.access_token) {
|
|
55
|
+
const err = new Error(data.error_description || data.error || `token endpoint answered ${res.status}`);
|
|
56
|
+
err.code = data.error || 'token_error';
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
return data;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What the CLI keeps: the access token is also stored as `jwtToken`, which every command already sends. */
|
|
63
|
+
export function toStoredSession(tokens, now = Date.now()) {
|
|
64
|
+
return {
|
|
65
|
+
accessToken: tokens.access_token,
|
|
66
|
+
refreshToken: tokens.refresh_token ?? null,
|
|
67
|
+
expiresAt: new Date(now + (Number(tokens.expires_in) || 3600) * 1000).toISOString(),
|
|
68
|
+
scope: tokens.scope ?? CLI_SCOPE,
|
|
69
|
+
clientId: CLI_CLIENT_ID,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function exchangeCode({ origin, code, verifier, redirectUri, clientId = CLI_CLIENT_ID, fetchImpl }) {
|
|
74
|
+
return tokenRequest(origin, { grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: redirectUri, client_id: clientId }, fetchImpl);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function refreshTokens({ origin, refreshToken, clientId = CLI_CLIENT_ID, fetchImpl }) {
|
|
78
|
+
return tokenRequest(origin, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId }, fetchImpl);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function openInBrowser(url) {
|
|
82
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
83
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
84
|
+
try {
|
|
85
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
86
|
+
child.on('error', () => {});
|
|
87
|
+
child.unref();
|
|
88
|
+
return true;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const DONE_PAGE = (ok, message) => `<!doctype html><meta charset="utf-8"><title>CoinPay CLI</title>
|
|
95
|
+
<body style="font-family:system-ui;background:#0b1120;color:#e5e7eb;display:grid;place-items:center;height:100vh;margin:0">
|
|
96
|
+
<div><h1 style="font-size:20px">${ok ? 'Signed in to the CoinPay CLI' : 'Sign-in failed'}</h1><p>${message}</p></div></body>`;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Run the whole loopback flow. Resolves with the token response.
|
|
100
|
+
* `open` is how the browser is launched; `log` prints the URL for a human.
|
|
101
|
+
*/
|
|
102
|
+
export async function loopbackLogin({ apiBase, open = openInBrowser, log = () => {}, timeoutMs = 5 * 60_000, fetchImpl } = {}) {
|
|
103
|
+
const origin = siteOrigin(apiBase);
|
|
104
|
+
const { verifier, challenge } = pkcePair();
|
|
105
|
+
const state = b64url(randomBytes(16));
|
|
106
|
+
|
|
107
|
+
let settle;
|
|
108
|
+
const result = new Promise((resolve, reject) => {
|
|
109
|
+
settle = { resolve, reject };
|
|
110
|
+
});
|
|
111
|
+
const server = createServer((req, res) => {
|
|
112
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
113
|
+
if (url.pathname !== '/callback') {
|
|
114
|
+
res.writeHead(404).end();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const error = url.searchParams.get('error');
|
|
118
|
+
const code = url.searchParams.get('code');
|
|
119
|
+
if (url.searchParams.get('state') !== state) {
|
|
120
|
+
res.writeHead(400, { 'Content-Type': 'text/html' }).end(DONE_PAGE(false, 'The response did not match this login. Run coinpay login again.'));
|
|
121
|
+
settle.reject(new Error('state mismatch: the callback was not for this login'));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (error || !code) {
|
|
125
|
+
res.writeHead(400, { 'Content-Type': 'text/html' }).end(DONE_PAGE(false, error === 'access_denied' ? 'You declined.' : 'No code came back.'));
|
|
126
|
+
settle.reject(new Error(error === 'access_denied' ? 'access denied' : `authorization failed: ${error || 'no code'}`));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
res.writeHead(200, { 'Content-Type': 'text/html' }).end(DONE_PAGE(true, 'You can close this tab and go back to the terminal.'));
|
|
130
|
+
settle.resolve(code);
|
|
131
|
+
});
|
|
132
|
+
await new Promise((resolve, reject) => {
|
|
133
|
+
server.once('error', reject);
|
|
134
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
135
|
+
});
|
|
136
|
+
const redirectUri = `http://127.0.0.1:${server.address().port}/callback`;
|
|
137
|
+
const url = authorizeUrl({ origin, redirectUri, challenge, state });
|
|
138
|
+
const timer = setTimeout(() => settle.reject(new Error('timed out waiting for the browser sign-in')), timeoutMs);
|
|
139
|
+
try {
|
|
140
|
+
const opened = open(url);
|
|
141
|
+
log(opened ? `Opening your browser to sign in. If it did not open, visit:\n ${url}` : `Open this URL to sign in:\n ${url}`);
|
|
142
|
+
const code = await result;
|
|
143
|
+
return await exchangeCode({ origin, code, verifier, redirectUri, fetchImpl });
|
|
144
|
+
} finally {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
server.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Before any command: if the stored OAuth access token is within a minute of
|
|
152
|
+
* expiring, rotate it. Mutates and saves `cfg`. Returns false when the
|
|
153
|
+
* refresh token is no longer good (sign in again).
|
|
154
|
+
*/
|
|
155
|
+
export async function ensureFreshSession(cfg, saveConfig, { now = Date.now(), fetchImpl } = {}) {
|
|
156
|
+
const session = cfg.oauth;
|
|
157
|
+
if (!session || !session.refreshToken) return true;
|
|
158
|
+
if (Date.parse(session.expiresAt) - now > 60_000) return true;
|
|
159
|
+
try {
|
|
160
|
+
const tokens = await refreshTokens({ origin: siteOrigin(cfg.baseUrl), refreshToken: session.refreshToken, clientId: session.clientId || CLI_CLIENT_ID, fetchImpl });
|
|
161
|
+
cfg.oauth = toStoredSession(tokens, now);
|
|
162
|
+
cfg.jwtToken = cfg.oauth.accessToken;
|
|
163
|
+
saveConfig(cfg);
|
|
164
|
+
return true;
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (err.code === 'invalid_grant') {
|
|
167
|
+
delete cfg.oauth;
|
|
168
|
+
delete cfg.jwtToken;
|
|
169
|
+
saveConfig(cfg);
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return true; // a network blip: let the command try with what it has
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/** Types for `@profullstack/coinpay/statements` (src/statements-fetch.js). */
|
|
2
|
+
|
|
3
|
+
export class StatementFetchError extends Error {}
|
|
4
|
+
|
|
5
|
+
export interface FetcherAccount {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
last4: string | null;
|
|
9
|
+
institution: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Institution {
|
|
13
|
+
key: string;
|
|
14
|
+
name: string;
|
|
15
|
+
url: string | null;
|
|
16
|
+
accounts: FetcherAccount[];
|
|
17
|
+
/** 'tax' for an agency (FTB, IRS), 'brokerage' for a brokerage with no linked account (Webull); absent for a bank. */
|
|
18
|
+
kind?: 'tax' | 'brokerage';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TaxSource {
|
|
22
|
+
key: string;
|
|
23
|
+
kind: 'tax';
|
|
24
|
+
name: string;
|
|
25
|
+
login: string;
|
|
26
|
+
lockoutMinutes: number;
|
|
27
|
+
cloudWarning?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BrokerageSource {
|
|
31
|
+
key: string;
|
|
32
|
+
kind: 'brokerage';
|
|
33
|
+
name: string;
|
|
34
|
+
login: string;
|
|
35
|
+
statements?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BrokerageDocumentInfo {
|
|
39
|
+
category: 'statement' | 'tax';
|
|
40
|
+
docType: 'form' | null;
|
|
41
|
+
taxYear: number | null;
|
|
42
|
+
periodLabel: string;
|
|
43
|
+
title: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ThrottleLimits {
|
|
47
|
+
windowMs: number;
|
|
48
|
+
perWindow: number;
|
|
49
|
+
perDay: number;
|
|
50
|
+
defaultLockoutMinutes: number;
|
|
51
|
+
marginMinutes: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type ThrottleVerdict = { ok: true } | { ok: false; reason: 'locked' | 'daily_limit' | 'window_limit'; retryAt: string; message: string };
|
|
55
|
+
|
|
56
|
+
export interface TaxDocumentInfo {
|
|
57
|
+
docType: 'transcript' | 'notice' | 'letter' | 'form' | 'return' | 'other';
|
|
58
|
+
code: string | null;
|
|
59
|
+
taxYear: number | null;
|
|
60
|
+
noticeDate: string | null;
|
|
61
|
+
periodLabel: string;
|
|
62
|
+
title: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Period {
|
|
66
|
+
month: string;
|
|
67
|
+
from: string | null;
|
|
68
|
+
to: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ImportSpan {
|
|
72
|
+
period?: string;
|
|
73
|
+
from?: string;
|
|
74
|
+
to?: string;
|
|
75
|
+
cycle?: 'custom';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface CdpEvent {
|
|
79
|
+
method: string;
|
|
80
|
+
params: Record<string, unknown>;
|
|
81
|
+
sessionId?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface Cdp {
|
|
85
|
+
send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<Record<string, unknown>>;
|
|
86
|
+
on(listener: (event: CdpEvent) => void): () => void;
|
|
87
|
+
waitFor(method: string, sessionId: string, timeoutMs: number): Promise<boolean>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface Browser {
|
|
91
|
+
cdp: Cdp;
|
|
92
|
+
exited: Promise<void>;
|
|
93
|
+
close(): Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface Download {
|
|
97
|
+
bytes: Buffer;
|
|
98
|
+
suggestedName: string;
|
|
99
|
+
label: string;
|
|
100
|
+
context: string;
|
|
101
|
+
key: string | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type PageStatus = 'ok' | 'login_needed' | 'no_statements' | 'locked';
|
|
105
|
+
|
|
106
|
+
export function lastFour(name: string): string | null;
|
|
107
|
+
export function slug(value: string): string;
|
|
108
|
+
export function institutionKey(domain: string | null | undefined, name: string | null | undefined): string;
|
|
109
|
+
export function groupInstitutions(accounts: ReadonlyArray<{ id: string; name: string; org_name?: string | null; org_domain?: string | null; is_hidden?: boolean }>): Institution[];
|
|
110
|
+
export function pickInstitution(institutions: readonly Institution[], name: string): Institution;
|
|
111
|
+
export const DRIVERS: ReadonlyArray<{ key: string; login: string; statements?: string }>;
|
|
112
|
+
export const HOST_KEYS: ReadonlyArray<[RegExp, string]>;
|
|
113
|
+
export const TAX_SOURCES: readonly TaxSource[];
|
|
114
|
+
export function taxSource(key: string): TaxSource | null;
|
|
115
|
+
export const BROKERAGE_SOURCES: readonly BrokerageSource[];
|
|
116
|
+
export function brokerageSource(key: string): BrokerageSource | null;
|
|
117
|
+
export function isBrokerageSource(institution: { key: string; kind?: string; accounts?: readonly unknown[] } | null | undefined): boolean;
|
|
118
|
+
export function sourceMode(institution: { key: string; kind?: string; accounts?: readonly unknown[] }): 'statements' | 'tax' | 'documents';
|
|
119
|
+
export function standaloneSources(): Institution[];
|
|
120
|
+
export function withStandaloneSources(institutions: readonly Institution[]): Institution[];
|
|
121
|
+
export function isTaxSource(institution: { key: string; kind?: string; accounts?: readonly unknown[] } | null | undefined): boolean;
|
|
122
|
+
export function keepCandidate(mode: 'statements' | 'tax' | 'documents', label: string, context: string, href: string | null): boolean;
|
|
123
|
+
export function collectScript(mode?: 'statements' | 'tax' | 'documents'): string;
|
|
124
|
+
export const COLLECT_TAX: string;
|
|
125
|
+
export const OPEN_TAX_DOCS: string;
|
|
126
|
+
export const COLLECT_DOCS: string;
|
|
127
|
+
export const OPEN_DOCUMENTS: string;
|
|
128
|
+
export const PAGE_TEXT: string;
|
|
129
|
+
export function classifyTaxDocument(download: { label?: string; context?: string; suggestedName?: string }, fetchedAt?: Date): TaxDocumentInfo;
|
|
130
|
+
export function keepTaxDocument(options: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
131
|
+
export function classifyBrokerageDocument(download: { label?: string; context?: string; suggestedName?: string }, fetchedAt?: Date): BrokerageDocumentInfo;
|
|
132
|
+
export function keepBrokerageDocument(options: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
133
|
+
export const TAX_THROTTLE: ThrottleLimits;
|
|
134
|
+
export function evaluateThrottle(source: { attempts?: ReadonlyArray<string | { at: string }>; lockedUntil?: string | null; lockReason?: string | null }, now?: Date, limits?: ThrottleLimits): ThrottleVerdict;
|
|
135
|
+
export function detectLockout(text: string | null | undefined): { minutes: number | null } | null;
|
|
136
|
+
export function lockoutUntil(found: { minutes: number | null } | null, now?: Date, limits?: ThrottleLimits, defaultMinutes?: number): string;
|
|
137
|
+
export function throttlePath(home: string): string;
|
|
138
|
+
export function loadThrottle(home: string): { sources: Record<string, { attempts: Array<{ at: string; kind: string }>; lockedUntil: string | null; lockReason: string | null }> };
|
|
139
|
+
export function saveThrottle(ledger: unknown, home: string): void;
|
|
140
|
+
export function checkLocalThrottle(home: string, key: string, now?: Date, limits?: ThrottleLimits): ThrottleVerdict;
|
|
141
|
+
export function takeLocalAttempt(home: string, key: string, kind: string, now?: Date, limits?: ThrottleLimits): ThrottleVerdict;
|
|
142
|
+
export function recordLocalLockout(home: string, key: string, until: string, reason?: string): unknown;
|
|
143
|
+
export function startUrls(institution: { key: string; url: string | null }, learnt?: string | null): { login: string | null; fetch: string | null };
|
|
144
|
+
export function isSignInUrl(url: string): boolean;
|
|
145
|
+
export function findDates(source: string | null | undefined): { date: string; precise: boolean }[];
|
|
146
|
+
export function periodOf(...sources: ReadonlyArray<string | null | undefined>): Period | null;
|
|
147
|
+
export function matchAccount(accounts: readonly FetcherAccount[], ...sources: ReadonlyArray<string | null | undefined>): FetcherAccount | null;
|
|
148
|
+
export function importPeriod(period: Period | null): ImportSpan | null;
|
|
149
|
+
export function candidateKey(candidate: { label: string; context: string; href: string | null }): string;
|
|
150
|
+
export function isPdf(bytes: Uint8Array): boolean;
|
|
151
|
+
export function sha256(bytes: Uint8Array): string;
|
|
152
|
+
export function statementsHome(env?: Record<string, string | undefined>): string;
|
|
153
|
+
export function profileDir(key: string, home?: string): string;
|
|
154
|
+
export function signedIn(key: string, home?: string): boolean;
|
|
155
|
+
export function findChrome(env?: Record<string, string | undefined>, home?: string): string | null;
|
|
156
|
+
export const NO_CHROME: string;
|
|
157
|
+
export function profileLock(profile: string): { host: string; pid: number } | null;
|
|
158
|
+
export function releaseProfile(profile: string, options?: { force?: boolean }): Promise<'free' | 'stale' | 'stopped'>;
|
|
159
|
+
export function preferPdfDownloads(profile: string): void;
|
|
160
|
+
export function openBrowser(options: {
|
|
161
|
+
chrome: string;
|
|
162
|
+
profile: string;
|
|
163
|
+
headless?: boolean;
|
|
164
|
+
timeoutMs?: number;
|
|
165
|
+
env?: Record<string, string | undefined>;
|
|
166
|
+
force?: boolean;
|
|
167
|
+
extraArgs?: string[];
|
|
168
|
+
handleSignals?: boolean;
|
|
169
|
+
}): Promise<Browser>;
|
|
170
|
+
export const SIGNED_OUT: string;
|
|
171
|
+
export const OPEN_STATEMENTS: string;
|
|
172
|
+
export const COLLECT: string;
|
|
173
|
+
export const SECOND_STEP: string;
|
|
174
|
+
export const CLICK_RECORDER: string;
|
|
175
|
+
export function clickScript(index: number): string;
|
|
176
|
+
export function fetchInstitution(
|
|
177
|
+
browser: Browser,
|
|
178
|
+
options: {
|
|
179
|
+
start: string;
|
|
180
|
+
seen?: Set<string>;
|
|
181
|
+
since?: string | null;
|
|
182
|
+
max?: number;
|
|
183
|
+
renderMs?: number;
|
|
184
|
+
onFile: (download: Download) => Promise<void> | void;
|
|
185
|
+
log?: (line: string) => void;
|
|
186
|
+
mode?: 'statements' | 'tax' | 'documents';
|
|
187
|
+
watchLockout?: boolean;
|
|
188
|
+
pauseMs?: number;
|
|
189
|
+
},
|
|
190
|
+
): Promise<{ status: PageStatus; url: string; candidates: number; silent: string[]; lockout?: { minutes: number | null } }>;
|
|
191
|
+
export function loginWindow(browser: Browser, url: string, options?: { onPageText?: (text: string) => unknown }): Promise<string | null>;
|
|
192
|
+
export function assistWindow(browser: Browser, options: { start: string; onFile: (download: Download) => Promise<void> | void; onPageText?: (text: string) => unknown }): Promise<number>;
|
|
193
|
+
export function loadLocal(home?: string): { institutions: Record<string, Record<string, unknown>>; entries: Record<string, unknown>[] };
|
|
194
|
+
export function saveLocal(state: unknown, home?: string): void;
|
|
195
|
+
export function archive(home: string, institution: Institution, account: FetcherAccount | null, period: Period | null, suggestedName: string, bytes: Uint8Array): string;
|
|
196
|
+
export function keepStatement(options: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
197
|
+
export function runStatementFetch(options: Record<string, unknown>): Promise<Record<string, unknown>[]>;
|
|
198
|
+
export function clientApi(client: unknown): Promise<{
|
|
199
|
+
listAccounts(): Promise<unknown[]>;
|
|
200
|
+
importStatement(options: Record<string, unknown>): Promise<unknown>;
|
|
201
|
+
reportRun(run: Record<string, unknown>): Promise<unknown>;
|
|
202
|
+
fileDocument(options: Record<string, unknown>): Promise<{ document?: { id: string }; duplicate?: boolean }>;
|
|
203
|
+
}>;
|
|
204
|
+
export function retryImports(options: Record<string, unknown>): Promise<{ imported: number; skipped: number; failed: number }>;
|