@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 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 Login to merchant account
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
- if (!email || !password) {
2699
- print.error('Required: --email <email> --password <password>');
2700
- print.info('Example: coinpay auth login --email user@example.com --password mysecret');
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 token = process.env.COINPAY_SESSION_TOKEN || loadConfig().jwtToken;
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.12.1",
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
@@ -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 institutions = sf.groupInstitutions(await bound.listAccounts());
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) => ({ ...i, signedIn: sf.signedIn(i.key, home), local: state.institutions[i.key] || {}, lastRun: latest.find((r) => r.institutionKey === i.key) || null }));
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
- ...rows.map((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'}\n${b.accounts.map((a) => ` ${a.name}${a.last4 ? '' : ' (no last four digits: its statements may need filing by hand)'}`).join('\n')}`),
192
- ...(rows.some((b) => !b.signedIn) ? ['', `Next: coinpay finances statements login ${rows.find((b) => !b.signedIn).key}`] : []),
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(institutions, rest[0]);
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(`Opening ${institution.name}. Download each statement you want; each is imported as it lands. Close the window when done.`);
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 = await sf.keepStatement({ institution, download, state, home, importStatement: bound.importStatement, how: 'assist' });
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, ${r.unmatched} unmatched${r.message ? ` (${r.message})` : ''}`).join('\n'));
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
- 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. Link (expires ${data.linkExpiresAt.slice(0, 10)}): ${data.linkUrl}`);
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 file = await api.exportBooks(client, { period: period || undefined, from, to, timezone, scope, format });
653
- const output = typeof flags.output === 'string' ? flags.output : file.filename || `books-${period || 'range'}.${format}`;
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
- emit({ output: path, bytes: file.bytes.length, unreviewed: Number(file.headers['x-unreviewed-rows'] || 0) }, `Wrote ${path} (${file.bytes.length} bytes; ${file.headers['x-unreviewed-rows'] || 0} unreviewed rows)`);
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]');