@profullstack/coinpay 0.12.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/bin/coinpay.js +96 -14
- package/package.json +5 -1
- package/src/client.js +5 -0
- package/src/finances-commands.js +162 -18
- package/src/finances-reports.d.ts +39 -3
- package/src/finances-reports.js +80 -5
- package/src/mcp.js +48 -1
- package/src/oauth-login.js +174 -0
- package/src/statements-fetch.d.ts +204 -0
- package/src/statements-fetch.js +499 -39
- package/src/statements-throttle.js +158 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,77 @@ 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
|
+
|
|
61
|
+
## [0.13.0] - 2026-10-04
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
- `coinpay login` signs in with OAuth 2.1: authorization code + PKCE over a
|
|
65
|
+
loopback redirect, as the public `coinpay-cli` client, with single-use
|
|
66
|
+
refresh tokens rotated before any command whose access token is about to
|
|
67
|
+
expire. `coinpay login --device` keeps the device flow for SSH.
|
|
68
|
+
- `coinpay finances statements cloud [status|connect|fetch|schedule|forget]`:
|
|
69
|
+
CoinPay cloud statement fetching (Professional plan, free for admins).
|
|
70
|
+
`connect` opens CoinPay's cloud browser to sign in to the bank once; CoinPay
|
|
71
|
+
keeps the bank's session sealed and fetches every week.
|
|
72
|
+
- MCP: `statements_cloud_status`, `statements_cloud_fetch`.
|
|
73
|
+
- `@profullstack/coinpay/statements` export with types; `openBrowser` takes
|
|
74
|
+
`extraArgs` and `handleSignals`; `CoinPayClient#baseUrl` getter.
|
|
75
|
+
|
|
76
|
+
### Fixed
|
|
77
|
+
- The statement fetcher no longer attaches to Chrome's own omnibox page.
|
|
78
|
+
|
|
8
79
|
## [0.12.1] - 2026-10-04
|
|
9
80
|
|
|
10
81
|
### Fixed
|
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',
|
|
@@ -165,6 +172,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
165
172
|
'wait',
|
|
166
173
|
'strict',
|
|
167
174
|
'headed',
|
|
175
|
+
'device',
|
|
168
176
|
'force',
|
|
169
177
|
'setup-token-stdin',
|
|
170
178
|
'overwrite',
|
|
@@ -175,6 +183,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
175
183
|
'no-model',
|
|
176
184
|
'only-uncategorized',
|
|
177
185
|
'no-attach',
|
|
186
|
+
'with-documents',
|
|
178
187
|
'estimate-gaps',
|
|
179
188
|
'plain',
|
|
180
189
|
'no-stream',
|
|
@@ -378,9 +387,13 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
378
387
|
set-url <base-url> Set custom API URL
|
|
379
388
|
show Show current configuration
|
|
380
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
|
+
|
|
381
393
|
${colors.bright}auth${colors.reset}
|
|
382
394
|
register Register new merchant account
|
|
383
|
-
login
|
|
395
|
+
login Same as \`coinpay login\` (OAuth 2.1; --device over SSH).
|
|
396
|
+
--email/--password is the old password sign-in (deprecated)
|
|
384
397
|
me Show current merchant info
|
|
385
398
|
|
|
386
399
|
${colors.bright}payment${colors.reset}
|
|
@@ -490,18 +503,31 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
490
503
|
statements fetch [bank…] Download every new PDF statement from the banks and import it
|
|
491
504
|
(--since YYYY-MM, --max N, --headed, --render S; exits 3 if a bank needs you)
|
|
492
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
|
|
493
512
|
statements coverage Which account-months have a statement (--months 12)
|
|
494
513
|
statements runs|local|retry Fetch history, the local archive, re-import what failed
|
|
514
|
+
statements cloud [status] CoinPay cloud fetching (Professional; free for admins)
|
|
515
|
+
statements cloud connect <bank|ftb|irs|webull> Sign in once in CoinPay's cloud browser (opens your browser)
|
|
516
|
+
statements cloud fetch [bank] [--wait] Fetch now in the cloud; weekly by default
|
|
517
|
+
statements cloud schedule <bank> weekly|off | cloud forget <bank>
|
|
495
518
|
books [queue] Transactions awaiting category review (--status, --scope, --search)
|
|
496
519
|
books confirm <id> Confirm a row (--category, --tax, --scope, --note, --always makes a rule)
|
|
497
520
|
books confirm-all Accept every pending suggestion (--yes)
|
|
498
521
|
books categorize Rules, heuristics, then the model over unreviewed rows (--wait)
|
|
499
522
|
books rules [add|delete] Merchant category rules
|
|
500
523
|
books summary Totals by tax category (--period 2026|2026-Q3|2026-08, --scope)
|
|
501
|
-
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)
|
|
502
526
|
payloads [list|download <id>] Raw provider responses, exactly as received
|
|
503
527
|
reports send <id> Email a report (--to a@x,b@y, --format pdf,csv, --message, --no-attach)
|
|
504
|
-
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)
|
|
505
531
|
digest [show|set|off|send-now] Weekly digest email (--days mon,fri --hour 8 --to …)
|
|
506
532
|
|
|
507
533
|
${colors.bright}escrow${colors.reset}
|
|
@@ -541,14 +567,15 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
541
567
|
logs <business-id> Get webhook logs
|
|
542
568
|
test <business-id> Send test webhook
|
|
543
569
|
|
|
544
|
-
${colors.bright}self${colors.reset}
|
|
570
|
+
${colors.bright}self${colors.reset} (each also works without "self": coinpay update)
|
|
545
571
|
update Update the coinpay CLI to the latest version
|
|
546
|
-
mcp Serve CoinPay finances to an AI agent over MCP (stdio):
|
|
547
|
-
claude mcp add coinpay -- coinpay mcp
|
|
548
572
|
upgrade Alias for update
|
|
549
573
|
remove Uninstall the coinpay CLI
|
|
550
574
|
uninstall Alias for remove
|
|
551
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
|
+
|
|
552
579
|
${colors.cyan}Wallet Options:${colors.reset}
|
|
553
580
|
--words <12|24> Number of mnemonic words (default: 12)
|
|
554
581
|
--chains <BTC,ETH,...> Chains to derive (default: BTC,ETH,SOL,POL,BCH)
|
|
@@ -2694,13 +2721,20 @@ async function handleAuth(subcommand, args, flags) {
|
|
|
2694
2721
|
case 'login': {
|
|
2695
2722
|
const email = flags.email;
|
|
2696
2723
|
const password = flags.password;
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
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);
|
|
2701
2729
|
return;
|
|
2702
2730
|
}
|
|
2703
|
-
|
|
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
|
+
|
|
2704
2738
|
try {
|
|
2705
2739
|
const client = createUnauthenticatedClient();
|
|
2706
2740
|
const { loginMerchant } = await import('../src/auth.js');
|
|
@@ -3781,7 +3815,26 @@ async function runInteractiveMenu() {
|
|
|
3781
3815
|
// Headless login: ask the server for a device code, show a URL to approve on any
|
|
3782
3816
|
// device, and poll until a merchant session token is minted and stored. No
|
|
3783
3817
|
// password on this machine — works over SSH / on a server.
|
|
3784
|
-
async function handleLogin() {
|
|
3818
|
+
async function handleLogin(flags = {}) {
|
|
3819
|
+
// OAuth 2.1 (authorization code + PKCE, loopback redirect, rotating refresh)
|
|
3820
|
+
// unless asked for the device flow, which is what works over SSH.
|
|
3821
|
+
if (!flags.device) {
|
|
3822
|
+
const cfg = loadConfig();
|
|
3823
|
+
const { loopbackLogin, toStoredSession } = await import('../src/oauth-login.js');
|
|
3824
|
+
try {
|
|
3825
|
+
const tokens = await loopbackLogin({ apiBase: cfg.baseUrl, log: (line) => console.log(line) });
|
|
3826
|
+
cfg.oauth = toStoredSession(tokens);
|
|
3827
|
+
cfg.jwtToken = cfg.oauth.accessToken;
|
|
3828
|
+
saveConfig(cfg);
|
|
3829
|
+
const me = await fetchMe();
|
|
3830
|
+
print.success(me ? `Logged in as ${meLabel(me)} (OAuth 2.1)` : 'Logged in! Session saved to ' + CONFIG_FILE);
|
|
3831
|
+
return;
|
|
3832
|
+
} catch (err) {
|
|
3833
|
+
print.error(`Browser sign-in failed: ${err.message}`);
|
|
3834
|
+
print.info('On a machine without a local browser, use: coinpay login --device');
|
|
3835
|
+
process.exit(1);
|
|
3836
|
+
}
|
|
3837
|
+
}
|
|
3785
3838
|
const cfg = loadConfig();
|
|
3786
3839
|
const base = (cfg.baseUrl || 'https://coinpayportal.com/api').replace(/\/$/, '');
|
|
3787
3840
|
|
|
@@ -3848,6 +3901,7 @@ async function handleLogin() {
|
|
|
3848
3901
|
function handleLogout() {
|
|
3849
3902
|
const cfg = loadConfig();
|
|
3850
3903
|
delete cfg.jwtToken;
|
|
3904
|
+
delete cfg.oauth;
|
|
3851
3905
|
saveConfig(cfg);
|
|
3852
3906
|
print.success('Logged out — session removed from ' + CONFIG_FILE);
|
|
3853
3907
|
}
|
|
@@ -4489,9 +4543,21 @@ async function handleLightning(subcommand, args, flags) {
|
|
|
4489
4543
|
}
|
|
4490
4544
|
}
|
|
4491
4545
|
|
|
4546
|
+
// An OAuth 2.1 session's access token lasts an hour: rotate it before any
|
|
4547
|
+
// command that might use it, keeping the new single-use refresh token.
|
|
4548
|
+
if (!['login', 'logout', 'help', 'version', '--version', '-v'].includes(command)) {
|
|
4549
|
+
const sessionCfg = loadConfig();
|
|
4550
|
+
if (sessionCfg.oauth) {
|
|
4551
|
+
const { ensureFreshSession } = await import('../src/oauth-login.js');
|
|
4552
|
+
if (!(await ensureFreshSession(sessionCfg, saveConfig))) {
|
|
4553
|
+
print.warn('Your CoinPay sign-in expired. Run: coinpay login');
|
|
4554
|
+
}
|
|
4555
|
+
}
|
|
4556
|
+
}
|
|
4557
|
+
|
|
4492
4558
|
switch (command) {
|
|
4493
4559
|
case 'login':
|
|
4494
|
-
await handleLogin();
|
|
4560
|
+
await handleLogin(flags);
|
|
4495
4561
|
break;
|
|
4496
4562
|
|
|
4497
4563
|
case 'logout':
|
|
@@ -4583,7 +4649,12 @@ async function handleLightning(subcommand, args, flags) {
|
|
|
4583
4649
|
await serveStdio({
|
|
4584
4650
|
version: VERSION,
|
|
4585
4651
|
getClient: async () => {
|
|
4586
|
-
const
|
|
4652
|
+
const cfg = loadConfig();
|
|
4653
|
+
if (cfg.oauth) {
|
|
4654
|
+
const { ensureFreshSession } = await import('../src/oauth-login.js');
|
|
4655
|
+
await ensureFreshSession(cfg, saveConfig);
|
|
4656
|
+
}
|
|
4657
|
+
const token = process.env.COINPAY_SESSION_TOKEN || cfg.jwtToken;
|
|
4587
4658
|
if (!token) throw new Error('Not logged in: run `coinpay login` (finance tools need the merchant session)');
|
|
4588
4659
|
return new CoinPayClient({ apiKey: token, baseUrl: getBaseUrl(), timeout: 120000 });
|
|
4589
4660
|
},
|
|
@@ -4602,6 +4673,17 @@ async function handleLightning(subcommand, args, flags) {
|
|
|
4602
4673
|
handleSelfManage('update');
|
|
4603
4674
|
break;
|
|
4604
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
|
+
|
|
4605
4687
|
case 'remove':
|
|
4606
4688
|
case 'uninstall':
|
|
4607
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",
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"import": "./src/swap.js",
|
|
38
38
|
"types": "./src/swap.d.ts"
|
|
39
39
|
},
|
|
40
|
+
"./statements": {
|
|
41
|
+
"import": "./src/statements-fetch.js",
|
|
42
|
+
"types": "./src/statements-fetch.d.ts"
|
|
43
|
+
},
|
|
40
44
|
"./x402": {
|
|
41
45
|
"import": "./src/x402.js"
|
|
42
46
|
},
|
package/src/client.js
CHANGED
|
@@ -31,6 +31,11 @@ export class CoinPayClient {
|
|
|
31
31
|
this.lightning = new LightningClient(this);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** The API base this client talks to (read-only). */
|
|
35
|
+
get baseUrl() {
|
|
36
|
+
return this.#baseUrl;
|
|
37
|
+
}
|
|
38
|
+
|
|
34
39
|
/**
|
|
35
40
|
* Make an authenticated API request
|
|
36
41
|
* @param {string} endpoint - API endpoint
|
package/src/finances-commands.js
CHANGED
|
@@ -123,7 +123,7 @@ function describeReport(report) {
|
|
|
123
123
|
return parts.filter(Boolean).join(' · ');
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
const STATEMENT_FETCH_ACTIONS = new Set(['banks', 'login', 'fetch', 'assist', 'coverage', 'runs', 'local', 'retry']);
|
|
126
|
+
const STATEMENT_FETCH_ACTIONS = new Set(['banks', 'login', 'fetch', 'assist', 'coverage', 'runs', 'local', 'retry', 'cloud']);
|
|
127
127
|
|
|
128
128
|
function ago(iso) {
|
|
129
129
|
if (!iso) return 'never';
|
|
@@ -132,6 +132,91 @@ function ago(iso) {
|
|
|
132
132
|
return hours < 48 ? `${Math.round(hours)}h ago` : `${Math.round(hours / 24)}d ago`;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* `coinpay finances statements cloud …`: the same statement fetching, run by
|
|
137
|
+
* CoinPay cloud with a bank session the merchant saved through CoinPay's
|
|
138
|
+
* cloud browser. Professional plan; free for admins.
|
|
139
|
+
*/
|
|
140
|
+
async function runCloudAction(rest, flags, { client, emit, progress }) {
|
|
141
|
+
const [sub = 'status', name] = rest;
|
|
142
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
143
|
+
|
|
144
|
+
if (sub === 'status' || sub === 'banks') {
|
|
145
|
+
const data = await api.getCloudStatements(client);
|
|
146
|
+
const lines = [data.access.allowed ? `CoinPay cloud: ${data.access.message}` : `CoinPay cloud is not available: ${data.access.message}`, ''];
|
|
147
|
+
for (const bank of data.banks) {
|
|
148
|
+
const c = bank.cloud;
|
|
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
|
+
}
|
|
151
|
+
const next = data.banks.find((b) => !b.cloud || b.cloud.state !== 'active');
|
|
152
|
+
if (data.access.allowed && next) lines.push('', `Next: coinpay finances statements cloud connect ${next.key}`);
|
|
153
|
+
emit(data, lines.join('\n'));
|
|
154
|
+
return EXIT.OK;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (sub === 'connect') {
|
|
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
|
+
const data = await api.connectCloudBank(client, { institutionKey: name, url: typeof flags.url === 'string' ? flags.url : undefined });
|
|
160
|
+
if (data.warning) progress(`warning: ${data.warning}`);
|
|
161
|
+
const { siteOrigin, openInBrowser } = await import('./oauth-login.js');
|
|
162
|
+
const viewer = new URL(data.viewerUrl, siteOrigin(client.baseUrl)).toString();
|
|
163
|
+
const opened = flags['no-open'] ? false : openInBrowser(viewer);
|
|
164
|
+
progress(`${opened ? 'Opened' : 'Open'} ${viewer}`);
|
|
165
|
+
progress('Sign in to your bank there, go to the page that lists your statements, and press Save. Waiting…');
|
|
166
|
+
for (;;) {
|
|
167
|
+
await pause(3000);
|
|
168
|
+
let live;
|
|
169
|
+
try {
|
|
170
|
+
live = await api.getCloudLiveSession(client, data.live.id);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (err.status === 404) throw new CliExit(EXIT.FAILURE, 'The sign-in session is gone (the server restarted?). Run the command again.');
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
if (live.status === 'saved') {
|
|
176
|
+
emit({ live }, `Saved. ${name} is connected to CoinPay cloud and its first fetch is queued: coinpay finances statements cloud status`);
|
|
177
|
+
return EXIT.OK;
|
|
178
|
+
}
|
|
179
|
+
if (['cancelled', 'expired', 'failed'].includes(live.status)) {
|
|
180
|
+
throw new CliExit(EXIT.FAILURE, `The sign-in session ended: ${live.status}${live.error ? ` (${live.error})` : ''}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (sub === 'fetch') {
|
|
186
|
+
const data = await api.fetchCloudStatements(client, { institutionKey: name });
|
|
187
|
+
if (!flags.wait) {
|
|
188
|
+
emit(data, data.jobs.map((j) => `queued ${j.id} (${j.params?.institutionKey ?? ''})`).join('\n') + '\nFollow with: coinpay finances statements runs');
|
|
189
|
+
return EXIT.OK;
|
|
190
|
+
}
|
|
191
|
+
const jobs = new Map(data.jobs.map((j) => [j.id, j]));
|
|
192
|
+
const deadline = Date.now() + 20 * 60_000;
|
|
193
|
+
while (Date.now() < deadline && [...jobs.values()].some((j) => ['queued', 'running', 'waiting_for_budget'].includes(j.status))) {
|
|
194
|
+
await pause(5000);
|
|
195
|
+
for (const id of jobs.keys()) jobs.set(id, await api.getFinanceJob(client, id));
|
|
196
|
+
}
|
|
197
|
+
const done = [...jobs.values()];
|
|
198
|
+
emit({ jobs: done }, done.map((j) => `${j.params?.institutionKey ?? j.id}: ${j.status}${j.result ? `, ${j.result.imported ?? 0} imported, ${j.result.duplicates ?? 0} already had` : ''}${j.errorMessage ? ` (${j.errorMessage})` : ''}`).join('\n'));
|
|
199
|
+
return done.every((j) => j.status === 'completed') ? EXIT.OK : EXIT.STRICT;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (sub === 'schedule') {
|
|
203
|
+
const value = rest[2];
|
|
204
|
+
if (!name || (value !== 'weekly' && value !== 'off')) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements cloud schedule <bank> weekly|off');
|
|
205
|
+
const data = await api.setCloudSchedule(client, name, value);
|
|
206
|
+
emit(data, `${name}: ${value === 'weekly' ? 'fetched every week' : 'schedule off'}`);
|
|
207
|
+
return EXIT.OK;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (sub === 'forget' || sub === 'disconnect') {
|
|
211
|
+
if (!name) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements cloud forget <bank>');
|
|
212
|
+
const data = await api.disconnectCloudBank(client, name);
|
|
213
|
+
emit(data, data.note);
|
|
214
|
+
return EXIT.OK;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements cloud [status|connect <bank>|fetch [bank] [--wait]|schedule <bank> weekly|off|forget <bank>]');
|
|
218
|
+
}
|
|
219
|
+
|
|
135
220
|
/**
|
|
136
221
|
* Downloading statements from the banks themselves (SimpleFIN has no
|
|
137
222
|
* documents). Runs on this machine: each bank's session lives in a local
|
|
@@ -150,6 +235,7 @@ async function runStatementFetchAction(action, rest, flags, { client, out, emit,
|
|
|
150
235
|
};
|
|
151
236
|
|
|
152
237
|
try {
|
|
238
|
+
if (action === 'cloud') return await runCloudAction(rest, flags, { client, out, emit, progress });
|
|
153
239
|
if (action === 'coverage') {
|
|
154
240
|
const data = await api.getStatementCoverage(client, { months: months(flags.months, 12) });
|
|
155
241
|
const mark = { have: '■', missing: '·', open: '○' };
|
|
@@ -177,7 +263,8 @@ async function runStatementFetchAction(action, rest, flags, { client, out, emit,
|
|
|
177
263
|
}
|
|
178
264
|
|
|
179
265
|
const bound = await sf.clientApi(client);
|
|
180
|
-
const
|
|
266
|
+
const linked = sf.groupInstitutions(await bound.listAccounts());
|
|
267
|
+
const institutions = sf.withStandaloneSources(linked);
|
|
181
268
|
if (action === 'banks') {
|
|
182
269
|
const state = sf.loadLocal(home);
|
|
183
270
|
let latest = [];
|
|
@@ -186,27 +273,65 @@ async function runStatementFetchAction(action, rest, flags, { client, out, emit,
|
|
|
186
273
|
} catch {
|
|
187
274
|
// An older deployment without the route still lists the banks.
|
|
188
275
|
}
|
|
189
|
-
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}` : ''}`;
|
|
190
287
|
emit({ home, chrome, banks: rows }, [
|
|
191
|
-
...
|
|
192
|
-
...(
|
|
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}`] : []),
|
|
193
297
|
...(chrome ? [] : ['', sf.NO_CHROME]),
|
|
194
298
|
].join('\n'));
|
|
195
299
|
return EXIT.OK;
|
|
196
300
|
}
|
|
197
301
|
if (action === 'login' || action === 'assist') {
|
|
198
|
-
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>`);
|
|
199
303
|
if (!chrome) throw new CliExit(EXIT.FAILURE, sf.NO_CHROME);
|
|
200
|
-
const institution = sf.pickInstitution(
|
|
304
|
+
const institution = sf.pickInstitution(linked, rest[0]);
|
|
305
|
+
const tax = sf.isTaxSource(institution);
|
|
306
|
+
const brokerage = sf.isBrokerageSource(institution);
|
|
201
307
|
const state = sf.loadLocal(home);
|
|
202
308
|
const local = (state.institutions[institution.key] ||= {});
|
|
203
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
|
+
}
|
|
204
329
|
const browser = await sf.openBrowser({ chrome, profile: sf.profileDir(institution.key, home), headless: false, force: flags.force === true });
|
|
205
330
|
if (action === 'login') {
|
|
206
331
|
const url = typeof flags.url === 'string' ? flags.url : urls.login;
|
|
207
332
|
if (!url) throw new CliExit(EXIT.INVALID, `CoinPay has no site for ${institution.name}; pass --url with its sign-in page`);
|
|
208
333
|
out(`Opening ${institution.name}. Sign in, go to the page that lists your statements, then close the window.`);
|
|
209
|
-
const last = await sf.loginWindow(browser, url);
|
|
334
|
+
const last = await sf.loginWindow(browser, url, { onPageText });
|
|
210
335
|
await browser.close();
|
|
211
336
|
local.loggedInAt = new Date().toISOString();
|
|
212
337
|
if (last) local.start = last;
|
|
@@ -214,13 +339,22 @@ async function runStatementFetchAction(action, rest, flags, { client, out, emit,
|
|
|
214
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.');
|
|
215
340
|
return EXIT.OK;
|
|
216
341
|
}
|
|
217
|
-
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.`);
|
|
218
347
|
let kept = 0;
|
|
219
348
|
try {
|
|
220
349
|
await sf.assistWindow(browser, {
|
|
221
350
|
start: urls.fetch,
|
|
351
|
+
onPageText,
|
|
222
352
|
onFile: async (download) => {
|
|
223
|
-
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' });
|
|
224
358
|
if (result.status === 'imported') kept += 1;
|
|
225
359
|
progress(` ${result.status}: ${result.entry ? result.entry.path : result.name}${result.entry && result.entry.importError ? ` (${result.entry.importError})` : ''}`);
|
|
226
360
|
sf.saveLocal(state, home);
|
|
@@ -229,7 +363,7 @@ async function runStatementFetchAction(action, rest, flags, { client, out, emit,
|
|
|
229
363
|
} finally {
|
|
230
364
|
await browser.close();
|
|
231
365
|
}
|
|
232
|
-
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`);
|
|
233
367
|
return EXIT.OK;
|
|
234
368
|
}
|
|
235
369
|
// fetch
|
|
@@ -240,7 +374,7 @@ async function runStatementFetchAction(action, rest, flags, { client, out, emit,
|
|
|
240
374
|
const render = flags.render !== undefined ? Number(flags.render) : 25;
|
|
241
375
|
if (!Number.isFinite(render) || render < 3 || render > 300) throw new CliExit(EXIT.INVALID, '--render is seconds, 3 to 300');
|
|
242
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 });
|
|
243
|
-
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'));
|
|
244
378
|
return results.every((r) => r.status === 'ok' && r.failed === 0) ? EXIT.OK : EXIT.STRICT;
|
|
245
379
|
} catch (err) {
|
|
246
380
|
if (err instanceof sf.StatementFetchError) throw new CliExit(EXIT.INVALID, err.message);
|
|
@@ -540,7 +674,7 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
540
674
|
return EXIT.OK;
|
|
541
675
|
}
|
|
542
676
|
if (STATEMENT_FETCH_ACTIONS.has(action)) return await runStatementFetchAction(action, args.slice(1), flags, { client, out, emit, progress });
|
|
543
|
-
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)');
|
|
544
678
|
}
|
|
545
679
|
|
|
546
680
|
case 'books': {
|
|
@@ -613,7 +747,7 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
613
747
|
return EXIT.OK;
|
|
614
748
|
}
|
|
615
749
|
if (action === 'send') {
|
|
616
|
-
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)]');
|
|
617
751
|
const period = typeof flags.period === 'string' ? flags.period : null;
|
|
618
752
|
const from = typeof flags.from === 'string' ? flags.from : undefined;
|
|
619
753
|
const toDate = typeof flags['to-date'] === 'string' ? flags['to-date'] : undefined;
|
|
@@ -625,8 +759,12 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
625
759
|
formats: flags.format ? listFlag(flags.format) : undefined,
|
|
626
760
|
message: typeof flags.message === 'string' ? flags.message : null,
|
|
627
761
|
attach: flags['no-attach'] ? false : true,
|
|
762
|
+
withDocuments: flags['with-documents'] === true,
|
|
628
763
|
});
|
|
629
|
-
|
|
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}`);
|
|
630
768
|
return data.sent.length > 0 ? EXIT.OK : EXIT.FAILURE;
|
|
631
769
|
}
|
|
632
770
|
if (action === 'summary' || action === 'export') {
|
|
@@ -649,10 +787,16 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
649
787
|
return EXIT.OK;
|
|
650
788
|
}
|
|
651
789
|
const format = typeof flags.format === 'string' ? flags.format.toLowerCase() : 'csv';
|
|
652
|
-
const
|
|
653
|
-
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}`;
|
|
654
793
|
const path = writeDownload(output, Buffer.from(file.bytes), { overwrite: flags.overwrite === true });
|
|
655
|
-
|
|
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
|
+
);
|
|
656
800
|
return EXIT.OK;
|
|
657
801
|
}
|
|
658
802
|
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances books [queue|confirm <id>|confirm-all --yes|categorize|rules [add|delete]|summary|export|send]');
|