@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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,59 @@ All notable changes to `@profullstack/coinpay` will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.15.0] - 2026-10-07
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Brokerage sources, starting with `webull` (Webull E-Documents). Like the tax
|
|
12
|
+
sources they need no linked account: `coinpay finances statements
|
|
13
|
+
login|assist|fetch webull` and `statements cloud connect|fetch|schedule
|
|
14
|
+
webull`. A new `documents` collector mode keeps dated statements and trade
|
|
15
|
+
confirmations and 1099s listed by tax year (the bank collector skips 1099s).
|
|
16
|
+
Monthly statements and confirmations are filed into the document library as
|
|
17
|
+
category `statement` with their month (a confirmation with its day); 1099,
|
|
18
|
+
1042-S and 5498 forms as category `tax`, type `form`, with the tax year. One
|
|
19
|
+
copy per file; `statements retry` re-files failures. Brokerages are not
|
|
20
|
+
throttled like the agencies. If SimpleFIN ever links a Webull account, it
|
|
21
|
+
keeps the bank path (statements matched to the account).
|
|
22
|
+
- MCP `statements_banks` lists `brokerages`; statements engine exports
|
|
23
|
+
`BROKERAGE_SOURCES`, `brokerageSource`, `isBrokerageSource`, `sourceMode`,
|
|
24
|
+
`classifyBrokerageDocument`, `keepBrokerageDocument`, `COLLECT_DOCS`,
|
|
25
|
+
`OPEN_DOCUMENTS`.
|
|
26
|
+
|
|
27
|
+
## [0.14.0] - 2026-10-05
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- Tax document sources: `ftb` (California FTB, MyFTB), `irs` (IRS Online
|
|
31
|
+
Account) and `irs-business` (IRS Business Tax Account). They need no linked
|
|
32
|
+
bank: `coinpay finances statements login|assist|fetch <source>` and
|
|
33
|
+
`statements cloud connect|fetch|schedule <source>` take them. Notices,
|
|
34
|
+
letters and transcripts are collected in a tax mode (the bank collector
|
|
35
|
+
still skips them) and filed into the document library as category `tax`,
|
|
36
|
+
with tax year, document type and the source key; one copy per file.
|
|
37
|
+
- Attempt throttle for tax sources: at most 2 visits per 30 minutes and 4 per
|
|
38
|
+
day, counted before the visit, lockout pages detected and respected (no
|
|
39
|
+
override), credentials never re-entered. Ledger at
|
|
40
|
+
`~/.coinpay/statements/throttle.json`.
|
|
41
|
+
- `coinpay finances books export --with-documents` (a ZIP with the period's
|
|
42
|
+
tax documents and a manifest) and `books send --with-documents` (to the
|
|
43
|
+
account owner's own address only), within 8 MiB; what does not fit is listed.
|
|
44
|
+
- MCP: `tax_documents_list`, `tax_documents_get`; `statements_banks` lists the
|
|
45
|
+
tax sources with their throttle state. API: `GET /finances/documents?category=`
|
|
46
|
+
and `GET /finances/documents/:id`; SDK `getFinanceDocument`.
|
|
47
|
+
- SDK: `uploadFinanceDocument`, `listFinanceDocuments`; statements engine
|
|
48
|
+
exports `TAX_SOURCES`, `standaloneSources`, `withStandaloneSources`,
|
|
49
|
+
`keepCandidate`, `collectScript`, `classifyTaxDocument`, `keepTaxDocument`
|
|
50
|
+
and the throttle helpers (`evaluateThrottle`, `detectLockout`, …).
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
- `coinpay auth login` is the OAuth 2.1 sign-in the help always advertised
|
|
54
|
+
(`--device` works); `--email/--password` keeps the old password path with a
|
|
55
|
+
deprecation note.
|
|
56
|
+
- `coinpay self update|upgrade|remove|uninstall` now work as listed in the help
|
|
57
|
+
(the top-level forms still do). `coinpay mcp` is listed on its own.
|
|
58
|
+
- `institutionKey` maps `*.ftb.ca.gov` to `ftb` (was `ca`) and `*.irs.gov`
|
|
59
|
+
to `irs`.
|
|
60
|
+
|
|
8
61
|
## [0.13.0] - 2026-10-04
|
|
9
62
|
|
|
10
63
|
### Added
|
package/bin/coinpay.js
CHANGED
|
@@ -53,6 +53,13 @@ function handleSelfManage(action) {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** `coinpay self <sub>`: which installer action, or null. */
|
|
57
|
+
function selfAction(sub) {
|
|
58
|
+
if (sub === 'update' || sub === 'upgrade' || sub === 'self-update') return 'update';
|
|
59
|
+
if (sub === 'remove' || sub === 'uninstall') return 'remove';
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
56
63
|
// ANSI colors
|
|
57
64
|
const colors = {
|
|
58
65
|
reset: '\x1b[0m',
|
|
@@ -176,6 +183,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
176
183
|
'no-model',
|
|
177
184
|
'only-uncategorized',
|
|
178
185
|
'no-attach',
|
|
186
|
+
'with-documents',
|
|
179
187
|
'estimate-gaps',
|
|
180
188
|
'plain',
|
|
181
189
|
'no-stream',
|
|
@@ -379,9 +387,13 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
379
387
|
set-url <base-url> Set custom API URL
|
|
380
388
|
show Show current configuration
|
|
381
389
|
|
|
390
|
+
${colors.bright}login${colors.reset} Sign in (OAuth 2.1 in your browser; --device on a machine without one)
|
|
391
|
+
${colors.bright}logout${colors.reset} Forget the saved session
|
|
392
|
+
|
|
382
393
|
${colors.bright}auth${colors.reset}
|
|
383
394
|
register Register new merchant account
|
|
384
|
-
login
|
|
395
|
+
login Same as \`coinpay login\` (OAuth 2.1; --device over SSH).
|
|
396
|
+
--email/--password is the old password sign-in (deprecated)
|
|
385
397
|
me Show current merchant info
|
|
386
398
|
|
|
387
399
|
${colors.bright}payment${colors.reset}
|
|
@@ -491,10 +503,16 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
491
503
|
statements fetch [bank…] Download every new PDF statement from the banks and import it
|
|
492
504
|
(--since YYYY-MM, --max N, --headed, --render S; exits 3 if a bank needs you)
|
|
493
505
|
statements assist <bank> A window: every PDF you download is imported
|
|
506
|
+
statements assist ftb|irs|irs-business Tax sources (no linked bank needed): every
|
|
507
|
+
notice, letter or transcript you download is filed under
|
|
508
|
+
Documents (tax). IRS: use this, locally (ID.me blocks clouds).
|
|
509
|
+
Throttled: 2 visits per 30 min, 4 per day; lockouts respected
|
|
510
|
+
statements login|fetch|assist webull Brokerage (no linked account needed): monthly
|
|
511
|
+
statements, trade confirmations and 1099s are filed under Documents
|
|
494
512
|
statements coverage Which account-months have a statement (--months 12)
|
|
495
513
|
statements runs|local|retry Fetch history, the local archive, re-import what failed
|
|
496
514
|
statements cloud [status] CoinPay cloud fetching (Professional; free for admins)
|
|
497
|
-
statements cloud connect <bank> Sign in once in CoinPay's cloud browser (opens your browser)
|
|
515
|
+
statements cloud connect <bank|ftb|irs|webull> Sign in once in CoinPay's cloud browser (opens your browser)
|
|
498
516
|
statements cloud fetch [bank] [--wait] Fetch now in the cloud; weekly by default
|
|
499
517
|
statements cloud schedule <bank> weekly|off | cloud forget <bank>
|
|
500
518
|
books [queue] Transactions awaiting category review (--status, --scope, --search)
|
|
@@ -503,10 +521,13 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
503
521
|
books categorize Rules, heuristics, then the model over unreviewed rows (--wait)
|
|
504
522
|
books rules [add|delete] Merchant category rules
|
|
505
523
|
books summary Totals by tax category (--period 2026|2026-Q3|2026-08, --scope)
|
|
506
|
-
books export CPA pack (--period, --scope, --format csv|pdf|html|json, --output
|
|
524
|
+
books export CPA pack (--period, --scope, --format csv|pdf|html|json, --output;
|
|
525
|
+
--with-documents: a ZIP with the period's tax documents, up to 8 MiB)
|
|
507
526
|
payloads [list|download <id>] Raw provider responses, exactly as received
|
|
508
527
|
reports send <id> Email a report (--to a@x,b@y, --format pdf,csv, --message, --no-attach)
|
|
509
|
-
books send Email the CPA pack (--to, --period, --scope, --format
|
|
528
|
+
books send Email the CPA pack (--to, --period, --scope, --format,
|
|
529
|
+
--with-documents attaches the period's tax documents within
|
|
530
|
+
8 MiB; only to your own address)
|
|
510
531
|
digest [show|set|off|send-now] Weekly digest email (--days mon,fri --hour 8 --to …)
|
|
511
532
|
|
|
512
533
|
${colors.bright}escrow${colors.reset}
|
|
@@ -546,14 +567,15 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
546
567
|
logs <business-id> Get webhook logs
|
|
547
568
|
test <business-id> Send test webhook
|
|
548
569
|
|
|
549
|
-
${colors.bright}self${colors.reset}
|
|
570
|
+
${colors.bright}self${colors.reset} (each also works without "self": coinpay update)
|
|
550
571
|
update Update the coinpay CLI to the latest version
|
|
551
|
-
mcp Serve CoinPay finances to an AI agent over MCP (stdio):
|
|
552
|
-
claude mcp add coinpay -- coinpay mcp
|
|
553
572
|
upgrade Alias for update
|
|
554
573
|
remove Uninstall the coinpay CLI
|
|
555
574
|
uninstall Alias for remove
|
|
556
575
|
|
|
576
|
+
${colors.bright}mcp${colors.reset} Serve CoinPay finances to an AI agent over MCP (stdio):
|
|
577
|
+
claude mcp add coinpay -- coinpay mcp
|
|
578
|
+
|
|
557
579
|
${colors.cyan}Wallet Options:${colors.reset}
|
|
558
580
|
--words <12|24> Number of mnemonic words (default: 12)
|
|
559
581
|
--chains <BTC,ETH,...> Chains to derive (default: BTC,ETH,SOL,POL,BCH)
|
|
@@ -2699,13 +2721,20 @@ async function handleAuth(subcommand, args, flags) {
|
|
|
2699
2721
|
case 'login': {
|
|
2700
2722
|
const email = flags.email;
|
|
2701
2723
|
const password = flags.password;
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2724
|
+
|
|
2725
|
+
// `auth login` is the documented way in; it is the same OAuth 2.1 sign-in
|
|
2726
|
+
// as `coinpay login`. Only an explicit --email/--password takes the old path.
|
|
2727
|
+
if (email === undefined && password === undefined) {
|
|
2728
|
+
await handleLogin(flags);
|
|
2706
2729
|
return;
|
|
2707
2730
|
}
|
|
2708
|
-
|
|
2731
|
+
print.warn('Password sign-in (--email/--password) is deprecated; use `coinpay login` (OAuth 2.1) or `coinpay login --device`.');
|
|
2732
|
+
if (!email || !password || email === true || password === true) {
|
|
2733
|
+
print.error('Required: --email <email> --password <password>');
|
|
2734
|
+
print.info('Or sign in without a password: coinpay auth login');
|
|
2735
|
+
process.exit(1);
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2709
2738
|
try {
|
|
2710
2739
|
const client = createUnauthenticatedClient();
|
|
2711
2740
|
const { loginMerchant } = await import('../src/auth.js');
|
|
@@ -4644,6 +4673,17 @@ async function handleLightning(subcommand, args, flags) {
|
|
|
4644
4673
|
handleSelfManage('update');
|
|
4645
4674
|
break;
|
|
4646
4675
|
|
|
4676
|
+
case 'self': {
|
|
4677
|
+
const action = selfAction(subcommand);
|
|
4678
|
+
if (!action) {
|
|
4679
|
+
print.error(`Unknown self command: ${subcommand || '(none)'}`);
|
|
4680
|
+
print.info('Available: coinpay self update|upgrade|remove|uninstall');
|
|
4681
|
+
process.exit(1);
|
|
4682
|
+
}
|
|
4683
|
+
handleSelfManage(action);
|
|
4684
|
+
break;
|
|
4685
|
+
}
|
|
4686
|
+
|
|
4647
4687
|
case 'remove':
|
|
4648
4688
|
case 'uninstall':
|
|
4649
4689
|
handleSelfManage('remove');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/coinpay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "CoinPay SDK & CLI \u2014 Accept cryptocurrency and card payments (BTC, ETH, SOL, POL, BCH, USDC) with wallet, swap and a live finances dashboard",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
package/src/finances-commands.js
CHANGED
|
@@ -146,7 +146,7 @@ async function runCloudAction(rest, flags, { client, emit, progress }) {
|
|
|
146
146
|
const lines = [data.access.allowed ? `CoinPay cloud: ${data.access.message}` : `CoinPay cloud is not available: ${data.access.message}`, ''];
|
|
147
147
|
for (const bank of data.banks) {
|
|
148
148
|
const c = bank.cloud;
|
|
149
|
-
lines.push(`${bank.key} ${bank.name} · ${!c ? 'not connected' : c.state === 'login_needed' ? 'needs sign-in' : c.state}${c ? ` · last fetch ${ago(c.lastFetchAt)}${c.lastStatus ? ` (${c.lastStatus})` : ''} · ${c.schedule}` : ''}`);
|
|
149
|
+
lines.push(`${bank.key} ${bank.name}${bank.kind === 'tax' ? ' (tax documents)' : bank.kind === 'brokerage' ? ' (brokerage documents)' : ''} · ${!c ? 'not connected' : c.state === 'login_needed' ? 'needs sign-in' : c.state}${c ? ` · last fetch ${ago(c.lastFetchAt)}${c.lastStatus ? ` (${c.lastStatus})` : ''} · ${c.schedule}` : ''}`);
|
|
150
150
|
}
|
|
151
151
|
const next = data.banks.find((b) => !b.cloud || b.cloud.state !== 'active');
|
|
152
152
|
if (data.access.allowed && next) lines.push('', `Next: coinpay finances statements cloud connect ${next.key}`);
|
|
@@ -155,8 +155,9 @@ async function runCloudAction(rest, flags, { client, emit, progress }) {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
if (sub === 'connect') {
|
|
158
|
-
if (!name) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements cloud connect <bank> [--url <sign-in page>]');
|
|
158
|
+
if (!name) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements cloud connect <bank|ftb|irs|irs-business|webull> [--url <sign-in page>]');
|
|
159
159
|
const data = await api.connectCloudBank(client, { institutionKey: name, url: typeof flags.url === 'string' ? flags.url : undefined });
|
|
160
|
+
if (data.warning) progress(`warning: ${data.warning}`);
|
|
160
161
|
const { siteOrigin, openInBrowser } = await import('./oauth-login.js');
|
|
161
162
|
const viewer = new URL(data.viewerUrl, siteOrigin(client.baseUrl)).toString();
|
|
162
163
|
const opened = flags['no-open'] ? false : openInBrowser(viewer);
|
|
@@ -262,7 +263,8 @@ async function runStatementFetchAction(action, rest, flags, { client, out, emit,
|
|
|
262
263
|
}
|
|
263
264
|
|
|
264
265
|
const bound = await sf.clientApi(client);
|
|
265
|
-
const
|
|
266
|
+
const linked = sf.groupInstitutions(await bound.listAccounts());
|
|
267
|
+
const institutions = sf.withStandaloneSources(linked);
|
|
266
268
|
if (action === 'banks') {
|
|
267
269
|
const state = sf.loadLocal(home);
|
|
268
270
|
let latest = [];
|
|
@@ -271,27 +273,65 @@ async function runStatementFetchAction(action, rest, flags, { client, out, emit,
|
|
|
271
273
|
} catch {
|
|
272
274
|
// An older deployment without the route still lists the banks.
|
|
273
275
|
}
|
|
274
|
-
const rows = institutions.map((i) => ({
|
|
276
|
+
const rows = institutions.map((i) => ({
|
|
277
|
+
...i,
|
|
278
|
+
signedIn: sf.signedIn(i.key, home),
|
|
279
|
+
local: state.institutions[i.key] || {},
|
|
280
|
+
lastRun: latest.find((r) => r.institutionKey === i.key) || null,
|
|
281
|
+
throttle: sf.isTaxSource(i) ? sf.checkLocalThrottle(home, i.key) : null,
|
|
282
|
+
}));
|
|
283
|
+
const banks = rows.filter((b) => !sf.isTaxSource(b) && !sf.isBrokerageSource(b));
|
|
284
|
+
const taxRows = rows.filter((b) => sf.isTaxSource(b));
|
|
285
|
+
const brokerageRows = rows.filter((b) => sf.isBrokerageSource(b));
|
|
286
|
+
const line = (b) => `${b.key} ${b.name} · ${b.signedIn ? `signed in ${ago(b.local.loggedInAt)}` : 'not signed in'} · last fetch ${b.lastRun ? `${b.lastRun.status} ${ago(b.lastRun.finishedAt)}` : 'never'}${b.throttle && !b.throttle.ok ? ` · waiting: ${b.throttle.message}` : ''}`;
|
|
275
287
|
emit({ home, chrome, banks: rows }, [
|
|
276
|
-
...
|
|
277
|
-
...(
|
|
288
|
+
...banks.map((b) => `${line(b)}\n${b.accounts.map((a) => ` ${a.name}${a.last4 ? '' : ' (no last four digits: its statements may need filing by hand)'}`).join('\n')}`),
|
|
289
|
+
...(banks.length ? [] : ['No linked banks.']),
|
|
290
|
+
'',
|
|
291
|
+
'Tax sources (notices, letters, transcripts -> Documents; at most 2 visits per 30 min, 4 per day):',
|
|
292
|
+
...taxRows.map((b) => line(b)),
|
|
293
|
+
'',
|
|
294
|
+
'Brokerages (statements, trade confirmations, 1099s -> Documents):',
|
|
295
|
+
...brokerageRows.map((b) => line(b)),
|
|
296
|
+
...(banks.some((b) => !b.signedIn) ? ['', `Next: coinpay finances statements login ${banks.find((b) => !b.signedIn).key}`] : []),
|
|
278
297
|
...(chrome ? [] : ['', sf.NO_CHROME]),
|
|
279
298
|
].join('\n'));
|
|
280
299
|
return EXIT.OK;
|
|
281
300
|
}
|
|
282
301
|
if (action === 'login' || action === 'assist') {
|
|
283
|
-
if (rest.length !== 1) throw new CliExit(EXIT.INVALID, `Usage: coinpay finances statements ${action} <bank>`);
|
|
302
|
+
if (rest.length !== 1) throw new CliExit(EXIT.INVALID, `Usage: coinpay finances statements ${action} <bank|ftb|irs|irs-business|webull>`);
|
|
284
303
|
if (!chrome) throw new CliExit(EXIT.FAILURE, sf.NO_CHROME);
|
|
285
|
-
const institution = sf.pickInstitution(
|
|
304
|
+
const institution = sf.pickInstitution(linked, rest[0]);
|
|
305
|
+
const tax = sf.isTaxSource(institution);
|
|
306
|
+
const brokerage = sf.isBrokerageSource(institution);
|
|
286
307
|
const state = sf.loadLocal(home);
|
|
287
308
|
const local = (state.institutions[institution.key] ||= {});
|
|
288
309
|
const urls = sf.startUrls(institution, local.start);
|
|
310
|
+
let onPageText;
|
|
311
|
+
if (tax) {
|
|
312
|
+
// Counted before the window opens. --force never lifts a lockout.
|
|
313
|
+
const verdict = sf.takeLocalAttempt(home, institution.key, action);
|
|
314
|
+
if (!verdict.ok) throw new CliExit(EXIT.STRICT, `${institution.name}: not opening, ${verdict.message}`);
|
|
315
|
+
const source = sf.taxSource(institution.key) || {};
|
|
316
|
+
let warned = false;
|
|
317
|
+
onPageText = (text) => {
|
|
318
|
+
const lock = sf.detectLockout(text);
|
|
319
|
+
if (!lock) return;
|
|
320
|
+
const until = sf.lockoutUntil(lock, new Date(), undefined, source.lockoutMinutes);
|
|
321
|
+
sf.recordLocalLockout(home, institution.key, until, `lockout page during ${action}`);
|
|
322
|
+
if (!warned) {
|
|
323
|
+
warned = true;
|
|
324
|
+
progress(`${institution.name} shows a locked account. Stop trying and close the window; CoinPay will not open it again before ${until}.`);
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
progress(`${institution.name}: sign in once, by hand. If a code arrives late, do not resubmit the form; close the window and wait. (${sf.TAX_THROTTLE.perWindow} visits per 30 minutes, ${sf.TAX_THROTTLE.perDay} per day.)`);
|
|
328
|
+
}
|
|
289
329
|
const browser = await sf.openBrowser({ chrome, profile: sf.profileDir(institution.key, home), headless: false, force: flags.force === true });
|
|
290
330
|
if (action === 'login') {
|
|
291
331
|
const url = typeof flags.url === 'string' ? flags.url : urls.login;
|
|
292
332
|
if (!url) throw new CliExit(EXIT.INVALID, `CoinPay has no site for ${institution.name}; pass --url with its sign-in page`);
|
|
293
333
|
out(`Opening ${institution.name}. Sign in, go to the page that lists your statements, then close the window.`);
|
|
294
|
-
const last = await sf.loginWindow(browser, url);
|
|
334
|
+
const last = await sf.loginWindow(browser, url, { onPageText });
|
|
295
335
|
await browser.close();
|
|
296
336
|
local.loggedInAt = new Date().toISOString();
|
|
297
337
|
if (last) local.start = last;
|
|
@@ -299,13 +339,22 @@ async function runStatementFetchAction(action, rest, flags, { client, out, emit,
|
|
|
299
339
|
emit({ bank: institution.key, start: local.start || urls.fetch }, last ? `Saved. fetch will start at ${last}` : 'Saved. The window closed on a sign-in page, so fetch starts from the bank\'s known page.');
|
|
300
340
|
return EXIT.OK;
|
|
301
341
|
}
|
|
302
|
-
out(
|
|
342
|
+
out(tax
|
|
343
|
+
? `Opening ${institution.name}. Sign in, open your notices, letters or transcripts, and download each PDF; each is filed under Documents (tax) as it lands. Close the window when done.`
|
|
344
|
+
: brokerage
|
|
345
|
+
? `Opening ${institution.name}. Sign in, open its documents, and download each statement, trade confirmation or 1099; each is filed under Documents as it lands. Close the window when done.`
|
|
346
|
+
: `Opening ${institution.name}. Download each statement you want; each is imported as it lands. Close the window when done.`);
|
|
303
347
|
let kept = 0;
|
|
304
348
|
try {
|
|
305
349
|
await sf.assistWindow(browser, {
|
|
306
350
|
start: urls.fetch,
|
|
351
|
+
onPageText,
|
|
307
352
|
onFile: async (download) => {
|
|
308
|
-
const result =
|
|
353
|
+
const result = tax
|
|
354
|
+
? await sf.keepTaxDocument({ institution, download, state, home, fileDocument: bound.fileDocument, how: 'assist' })
|
|
355
|
+
: brokerage
|
|
356
|
+
? await sf.keepBrokerageDocument({ institution, download, state, home, fileDocument: bound.fileDocument, how: 'assist' })
|
|
357
|
+
: await sf.keepStatement({ institution, download, state, home, importStatement: bound.importStatement, how: 'assist' });
|
|
309
358
|
if (result.status === 'imported') kept += 1;
|
|
310
359
|
progress(` ${result.status}: ${result.entry ? result.entry.path : result.name}${result.entry && result.entry.importError ? ` (${result.entry.importError})` : ''}`);
|
|
311
360
|
sf.saveLocal(state, home);
|
|
@@ -314,7 +363,7 @@ async function runStatementFetchAction(action, rest, flags, { client, out, emit,
|
|
|
314
363
|
} finally {
|
|
315
364
|
await browser.close();
|
|
316
365
|
}
|
|
317
|
-
emit({ bank: institution.key, imported: kept }, `${kept} imported`);
|
|
366
|
+
emit({ bank: institution.key, imported: kept }, tax ? `${kept} filed under Documents (tax)` : brokerage ? `${kept} filed under Documents` : `${kept} imported`);
|
|
318
367
|
return EXIT.OK;
|
|
319
368
|
}
|
|
320
369
|
// fetch
|
|
@@ -325,7 +374,7 @@ async function runStatementFetchAction(action, rest, flags, { client, out, emit,
|
|
|
325
374
|
const render = flags.render !== undefined ? Number(flags.render) : 25;
|
|
326
375
|
if (!Number.isFinite(render) || render < 3 || render > 300) throw new CliExit(EXIT.INVALID, '--render is seconds, 3 to 300');
|
|
327
376
|
const results = await sf.runStatementFetch({ api: bound, banks: rest, since, max, renderMs: render * 1000, headless: flags.headed !== true, chrome, home, log: progress, force: flags.force === true });
|
|
328
|
-
emit({ banks: results }, results.map((r) => `${r.bank}: ${r.status}, ${r.imported} imported, ${r.duplicates} already had
|
|
377
|
+
emit({ banks: results }, results.map((r) => `${r.bank}: ${r.status}, ${r.imported} ${r.kind === 'bank' ? 'imported' : 'filed'}, ${r.duplicates} already had${r.kind !== 'bank' ? '' : `, ${r.unmatched} unmatched`}${r.message ? ` (${r.message})` : ''}`).join('\n'));
|
|
329
378
|
return results.every((r) => r.status === 'ok' && r.failed === 0) ? EXIT.OK : EXIT.STRICT;
|
|
330
379
|
} catch (err) {
|
|
331
380
|
if (err instanceof sf.StatementFetchError) throw new CliExit(EXIT.INVALID, err.message);
|
|
@@ -625,7 +674,7 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
625
674
|
return EXIT.OK;
|
|
626
675
|
}
|
|
627
676
|
if (STATEMENT_FETCH_ACTIONS.has(action)) return await runStatementFetchAction(action, args.slice(1), flags, { client, out, emit, progress });
|
|
628
|
-
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements [import <file>|list|get <id>|download <id>|reconcile <id>|delete <id>|banks|login <bank>|fetch [bank…]|assist <bank>|coverage|runs|local|retry]');
|
|
677
|
+
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements [import <file>|list|get <id>|download <id>|reconcile <id>|delete <id>|banks|login <bank>|fetch [bank…]|assist <bank>|coverage|runs|local|retry|cloud …] (tax sources: ftb, irs, irs-business; brokerages: webull)');
|
|
629
678
|
}
|
|
630
679
|
|
|
631
680
|
case 'books': {
|
|
@@ -698,7 +747,7 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
698
747
|
return EXIT.OK;
|
|
699
748
|
}
|
|
700
749
|
if (action === 'send') {
|
|
701
|
-
if (typeof flags.to !== 'string') throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances books send --to cpa@x.com,me@x.com --period 2026 [--scope business] [--format pdf,csv] [--message "…"]');
|
|
750
|
+
if (typeof flags.to !== 'string') throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances books send --to cpa@x.com,me@x.com --period 2026 [--scope business] [--format pdf,csv] [--message "…"] [--with-documents (your own address only)]');
|
|
702
751
|
const period = typeof flags.period === 'string' ? flags.period : null;
|
|
703
752
|
const from = typeof flags.from === 'string' ? flags.from : undefined;
|
|
704
753
|
const toDate = typeof flags['to-date'] === 'string' ? flags['to-date'] : undefined;
|
|
@@ -710,8 +759,12 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
710
759
|
formats: flags.format ? listFlag(flags.format) : undefined,
|
|
711
760
|
message: typeof flags.message === 'string' ? flags.message : null,
|
|
712
761
|
attach: flags['no-attach'] ? false : true,
|
|
762
|
+
withDocuments: flags['with-documents'] === true,
|
|
713
763
|
});
|
|
714
|
-
|
|
764
|
+
const docs = data.documents
|
|
765
|
+
? ` Tax documents attached: ${data.documents.attached.length ? data.documents.attached.join(', ') : 'none'}.${data.documents.skipped.length ? ` Not attached (in CoinPay under Documents): ${data.documents.skipped.map((d) => `${d.title} (${d.reason})`).join(', ')}.` : ''}`
|
|
766
|
+
: '';
|
|
767
|
+
emit(data, `Sent to ${data.sent.join(', ') || 'nobody'}${data.failed.length ? `; failed: ${data.failed.map((f) => `${f.to} (${f.error})`).join(', ')}` : ''}. ${data.rows} rows, ${data.unreviewed} unreviewed.${docs} Link (expires ${data.linkExpiresAt.slice(0, 10)}): ${data.linkUrl}`);
|
|
715
768
|
return data.sent.length > 0 ? EXIT.OK : EXIT.FAILURE;
|
|
716
769
|
}
|
|
717
770
|
if (action === 'summary' || action === 'export') {
|
|
@@ -734,10 +787,16 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
734
787
|
return EXIT.OK;
|
|
735
788
|
}
|
|
736
789
|
const format = typeof flags.format === 'string' ? flags.format.toLowerCase() : 'csv';
|
|
737
|
-
const
|
|
738
|
-
const
|
|
790
|
+
const withDocuments = flags['with-documents'] === true;
|
|
791
|
+
const file = await api.exportBooks(client, { period: period || undefined, from, to, timezone, scope, format, withDocuments });
|
|
792
|
+
const output = typeof flags.output === 'string' ? flags.output : file.filename || `books-${period || 'range'}.${withDocuments ? 'zip' : format}`;
|
|
739
793
|
const path = writeDownload(output, Buffer.from(file.bytes), { overwrite: flags.overwrite === true });
|
|
740
|
-
|
|
794
|
+
const attachedDocs = Number(file.headers['x-documents-attached'] || 0);
|
|
795
|
+
const skippedDocs = Number(file.headers['x-documents-skipped'] || 0);
|
|
796
|
+
emit(
|
|
797
|
+
{ output: path, bytes: file.bytes.length, unreviewed: Number(file.headers['x-unreviewed-rows'] || 0), ...(withDocuments ? { documentsAttached: attachedDocs, documentsSkipped: skippedDocs } : {}) },
|
|
798
|
+
`Wrote ${path} (${file.bytes.length} bytes; ${file.headers['x-unreviewed-rows'] || 0} unreviewed rows)${withDocuments ? `. ${attachedDocs} tax document(s) inside${skippedDocs ? `, ${skippedDocs} left out to stay under 8 MiB (listed in MANIFEST.txt)` : ''}` : ''}`,
|
|
799
|
+
);
|
|
741
800
|
return EXIT.OK;
|
|
742
801
|
}
|
|
743
802
|
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances books [queue|confirm <id>|confirm-all --yes|categorize|rules [add|delete]|summary|export|send]');
|
|
@@ -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) });
|
|
@@ -351,9 +388,13 @@ export async function getBooksSummary(client, { period, from, to, timezone, scop
|
|
|
351
388
|
return call(client, `/finances/books/summary${query({ period, from, to, timezone, scope, rows: rows ? 1 : undefined })}`);
|
|
352
389
|
}
|
|
353
390
|
|
|
354
|
-
/**
|
|
355
|
-
|
|
356
|
-
|
|
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 })}`);
|
|
357
398
|
}
|
|
358
399
|
|
|
359
400
|
// ── Raw provider payloads ──
|
|
@@ -377,10 +418,10 @@ export async function sendFinanceReportEmail(client, reportId, { to, formats, me
|
|
|
377
418
|
}
|
|
378
419
|
|
|
379
420
|
/** Email the CPA pack for a period. `toDate` is the exclusive end for a custom range. */
|
|
380
|
-
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 } = {}) {
|
|
381
422
|
return call(client, '/finances/books/send', {
|
|
382
423
|
method: 'POST',
|
|
383
|
-
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 }),
|
|
384
425
|
});
|
|
385
426
|
}
|
|
386
427
|
|
package/src/mcp.js
CHANGED
|
@@ -60,9 +60,39 @@ 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
|
+
},
|
|
66
96
|
{
|
|
67
97
|
name: 'statements_cloud_status',
|
|
68
98
|
description:
|
|
@@ -121,7 +151,7 @@ export async function handleMessage(message, ctx) {
|
|
|
121
151
|
capabilities: { tools: {} },
|
|
122
152
|
serverInfo: { name: 'coinpay', version: ctx.version || '0.0.0' },
|
|
123
153
|
instructions:
|
|
124
|
-
'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.',
|
|
125
155
|
});
|
|
126
156
|
case 'notifications/initialized':
|
|
127
157
|
case 'notifications/cancelled':
|