@profullstack/coinpay 0.13.0 → 0.16.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,81 @@ 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.16.0] - 2026-10-12
9
+
10
+ ### Added
11
+ - `coinpay finances due [--days 7]`: what is due, overdue or not posted yet
12
+ across every linked account, projected from each recurring charge's own
13
+ cadence plus statement due days set on cards and loans.
14
+ - `coinpay finances recurring`: every recurring charge with cadence, last and
15
+ next date and monthly cost.
16
+ - `coinpay finances due-day <account-id> <1-31|clear> [--amount N|balance]`:
17
+ a card or loan's statement due day, which no bank feed carries.
18
+ - `coinpay finances daily [show|on|off|send-now]`: the daily summary email
19
+ (due soon, new activity since the last one, balance sheet).
20
+ - MCP: `finance_due_soon`, `finance_recurring`, `finance_summary`,
21
+ `finance_transactions`, `finance_set_due_day`, `finance_daily_summary`.
22
+ - SDK: `getDueSoon`, `getDailySummary`, `setDailySummary`,
23
+ `deleteDailySummary`, `sendDailySummaryNow`, `setAccountDueDay`.
24
+
25
+ ### Fixed
26
+ - `coinpay mcp` `tools/list` now carries `resultType`, `ttlMs` and
27
+ `cacheScope`; without them Claude Code rejected the whole list
28
+ ("connected · tools fetch failed").
29
+
30
+ ## [0.15.0] - 2026-10-07
31
+
32
+ ### Added
33
+ - Brokerage sources, starting with `webull` (Webull E-Documents). Like the tax
34
+ sources they need no linked account: `coinpay finances statements
35
+ login|assist|fetch webull` and `statements cloud connect|fetch|schedule
36
+ webull`. A new `documents` collector mode keeps dated statements and trade
37
+ confirmations and 1099s listed by tax year (the bank collector skips 1099s).
38
+ Monthly statements and confirmations are filed into the document library as
39
+ category `statement` with their month (a confirmation with its day); 1099,
40
+ 1042-S and 5498 forms as category `tax`, type `form`, with the tax year. One
41
+ copy per file; `statements retry` re-files failures. Brokerages are not
42
+ throttled like the agencies. If SimpleFIN ever links a Webull account, it
43
+ keeps the bank path (statements matched to the account).
44
+ - MCP `statements_banks` lists `brokerages`; statements engine exports
45
+ `BROKERAGE_SOURCES`, `brokerageSource`, `isBrokerageSource`, `sourceMode`,
46
+ `classifyBrokerageDocument`, `keepBrokerageDocument`, `COLLECT_DOCS`,
47
+ `OPEN_DOCUMENTS`.
48
+
49
+ ## [0.14.0] - 2026-10-05
50
+
51
+ ### Added
52
+ - Tax document sources: `ftb` (California FTB, MyFTB), `irs` (IRS Online
53
+ Account) and `irs-business` (IRS Business Tax Account). They need no linked
54
+ bank: `coinpay finances statements login|assist|fetch <source>` and
55
+ `statements cloud connect|fetch|schedule <source>` take them. Notices,
56
+ letters and transcripts are collected in a tax mode (the bank collector
57
+ still skips them) and filed into the document library as category `tax`,
58
+ with tax year, document type and the source key; one copy per file.
59
+ - Attempt throttle for tax sources: at most 2 visits per 30 minutes and 4 per
60
+ day, counted before the visit, lockout pages detected and respected (no
61
+ override), credentials never re-entered. Ledger at
62
+ `~/.coinpay/statements/throttle.json`.
63
+ - `coinpay finances books export --with-documents` (a ZIP with the period's
64
+ tax documents and a manifest) and `books send --with-documents` (to the
65
+ account owner's own address only), within 8 MiB; what does not fit is listed.
66
+ - MCP: `tax_documents_list`, `tax_documents_get`; `statements_banks` lists the
67
+ tax sources with their throttle state. API: `GET /finances/documents?category=`
68
+ and `GET /finances/documents/:id`; SDK `getFinanceDocument`.
69
+ - SDK: `uploadFinanceDocument`, `listFinanceDocuments`; statements engine
70
+ exports `TAX_SOURCES`, `standaloneSources`, `withStandaloneSources`,
71
+ `keepCandidate`, `collectScript`, `classifyTaxDocument`, `keepTaxDocument`
72
+ and the throttle helpers (`evaluateThrottle`, `detectLockout`, …).
73
+
74
+ ### Fixed
75
+ - `coinpay auth login` is the OAuth 2.1 sign-in the help always advertised
76
+ (`--device` works); `--email/--password` keeps the old password path with a
77
+ deprecation note.
78
+ - `coinpay self update|upgrade|remove|uninstall` now work as listed in the help
79
+ (the top-level forms still do). `coinpay mcp` is listed on its own.
80
+ - `institutionKey` maps `*.ftb.ca.gov` to `ftb` (was `ca`) and `*.irs.gov`
81
+ to `irs`.
82
+
8
83
  ## [0.13.0] - 2026-10-04
9
84
 
10
85
  ### 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 Sign in (OAuth 2.1 in your browser; --device on a machine without one)
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,11 +521,18 @@ ${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 …)
532
+ due [--days 7] What is due and overdue across every account (--days up to 60)
533
+ recurring Every recurring charge detected, with next date and monthly cost
534
+ due-day <id> <1-31|clear> A card/loan's statement due day (--amount 810.46, or balance)
535
+ daily [show|on|off|send-now] Daily summary email: due soon, new activity, position (--hour 7 --to …)
511
536
 
512
537
  ${colors.bright}escrow${colors.reset}
513
538
  create Create a new escrow
@@ -546,14 +571,15 @@ ${colors.cyan}Commands:${colors.reset}
546
571
  logs <business-id> Get webhook logs
547
572
  test <business-id> Send test webhook
548
573
 
549
- ${colors.bright}self${colors.reset}
574
+ ${colors.bright}self${colors.reset} (each also works without "self": coinpay update)
550
575
  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
576
  upgrade Alias for update
554
577
  remove Uninstall the coinpay CLI
555
578
  uninstall Alias for remove
556
579
 
580
+ ${colors.bright}mcp${colors.reset} Serve CoinPay finances to an AI agent over MCP (stdio):
581
+ claude mcp add coinpay -- coinpay mcp
582
+
557
583
  ${colors.cyan}Wallet Options:${colors.reset}
558
584
  --words <12|24> Number of mnemonic words (default: 12)
559
585
  --chains <BTC,ETH,...> Chains to derive (default: BTC,ETH,SOL,POL,BCH)
@@ -2699,13 +2725,20 @@ async function handleAuth(subcommand, args, flags) {
2699
2725
  case 'login': {
2700
2726
  const email = flags.email;
2701
2727
  const password = flags.password;
2702
-
2703
- if (!email || !password) {
2704
- print.error('Required: --email <email> --password <password>');
2705
- print.info('Example: coinpay auth login --email user@example.com --password mysecret');
2728
+
2729
+ // `auth login` is the documented way in; it is the same OAuth 2.1 sign-in
2730
+ // as `coinpay login`. Only an explicit --email/--password takes the old path.
2731
+ if (email === undefined && password === undefined) {
2732
+ await handleLogin(flags);
2706
2733
  return;
2707
2734
  }
2708
-
2735
+ print.warn('Password sign-in (--email/--password) is deprecated; use `coinpay login` (OAuth 2.1) or `coinpay login --device`.');
2736
+ if (!email || !password || email === true || password === true) {
2737
+ print.error('Required: --email <email> --password <password>');
2738
+ print.info('Or sign in without a password: coinpay auth login');
2739
+ process.exit(1);
2740
+ }
2741
+
2709
2742
  try {
2710
2743
  const client = createUnauthenticatedClient();
2711
2744
  const { loginMerchant } = await import('../src/auth.js');
@@ -4326,7 +4359,12 @@ async function handleFinances(subcommand, args, flags) {
4326
4359
  case 'statements':
4327
4360
  case 'books':
4328
4361
  case 'payloads':
4329
- case 'digest': {
4362
+ case 'digest':
4363
+ case 'due':
4364
+ case 'due-soon':
4365
+ case 'recurring':
4366
+ case 'due-day':
4367
+ case 'daily': {
4330
4368
  const { client } = financesClient();
4331
4369
  const { runFinancesCommand } = await import('../src/finances-commands.js');
4332
4370
  const code = await runFinancesCommand(subcommand, args, flags, {
@@ -4340,7 +4378,7 @@ async function handleFinances(subcommand, args, flags) {
4340
4378
 
4341
4379
  default:
4342
4380
  print.error(`Unknown finances command: ${subcommand}`);
4343
- console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync|connect|disconnect|backfill|jobs|coverage|report|reports|statements|books|payloads|digest]');
4381
+ console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync|connect|disconnect|backfill|jobs|coverage|report|reports|statements|books|payloads|digest|due|recurring|due-day|daily]');
4344
4382
  process.exit(1);
4345
4383
  }
4346
4384
  }
@@ -4644,6 +4682,17 @@ async function handleLightning(subcommand, args, flags) {
4644
4682
  handleSelfManage('update');
4645
4683
  break;
4646
4684
 
4685
+ case 'self': {
4686
+ const action = selfAction(subcommand);
4687
+ if (!action) {
4688
+ print.error(`Unknown self command: ${subcommand || '(none)'}`);
4689
+ print.info('Available: coinpay self update|upgrade|remove|uninstall');
4690
+ process.exit(1);
4691
+ }
4692
+ handleSelfManage(action);
4693
+ break;
4694
+ }
4695
+
4647
4696
  case 'remove':
4648
4697
  case 'uninstall':
4649
4698
  handleSelfManage('remove');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/coinpay",
3
- "version": "0.13.0",
3
+ "version": "0.16.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",
@@ -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 institutions = sf.groupInstitutions(await bound.listAccounts());
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) => ({ ...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}` : ''}`;
275
287
  emit({ home, chrome, banks: rows }, [
276
- ...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')}`),
277
- ...(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}`] : []),
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(institutions, rest[0]);
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(`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.`);
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 = 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' });
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, ${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'));
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
- 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}`);
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 file = await api.exportBooks(client, { period: period || undefined, from, to, timezone, scope, format });
738
- 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}`;
739
793
  const path = writeDownload(output, Buffer.from(file.bytes), { overwrite: flags.overwrite === true });
740
- 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
+ );
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]');
@@ -787,6 +846,80 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
787
846
  throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances digest [show|set --days mon,fri --hour 8 --to a@x,b@y [--scope business]|off|delete|send-now]');
788
847
  }
789
848
 
849
+ case 'due':
850
+ case 'due-soon':
851
+ case 'recurring': {
852
+ const days = flags.days !== undefined ? Number(flags.days) : undefined;
853
+ if (days !== undefined && (!Number.isInteger(days) || days < 0 || days > 60)) throw new CliExit(EXIT.INVALID, '--days must be 0 to 60');
854
+ const d = await api.getDueSoon(client, { days, timezone: typeof flags.timezone === 'string' ? flags.timezone : undefined });
855
+ const cur = d.currency || 'USD';
856
+ const money = (n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: cur }).format(n);
857
+ if (subcommand === 'recurring') {
858
+ if (json) { out(JSON.stringify(d.recurring, null, 2)); return EXIT.OK; }
859
+ const rows = [...d.recurring.charges].sort((a, b) => a.nextExpected.localeCompare(b.nextExpected));
860
+ out(`${rows.length} recurring charges · ${money(d.recurring.monthlyTotal)}/mo${d.recurring.monthlyDebtService ? ` (${money(d.recurring.monthlyDebtService)} debt service)` : ''}`);
861
+ for (const c of rows) {
862
+ out(` next ${c.nextExpected.slice(0, 10)} ${c.cadence.padEnd(9)} ${money(c.amount).padStart(11)} ${c.payee} · ${c.accountName}${c.isDebtService ? ' · debt' : ''}`);
863
+ }
864
+ return EXIT.OK;
865
+ }
866
+ if (json) { out(JSON.stringify(d, null, 2)); return EXIT.OK; }
867
+ const when = (i) => i.status === 'paid' ? 'paid' : i.status === 'awaiting' ? 'not yet?' : i.daysUntil === 0 ? 'today' : i.daysUntil === 1 ? 'tomorrow' : i.daysUntil < 0 ? `${-i.daysUntil}d LATE` : `in ${i.daysUntil}d`;
868
+ const amt = (i) => i.amount !== null ? money(i.amount) : i.owed !== null ? `bal ${money(i.owed)}` : 'statement';
869
+ out(`${d.today} (${d.timezone}) · ${money(d.totals.due)} due in ${d.horizonDays} days${d.totals.overdue ? ` · ${money(d.totals.overdue)} OVERDUE` : ''}${d.totals.unpriced ? ` · +${d.totals.unpriced} statement balance(s)` : ''}`);
870
+ if (d.items.length === 0 && d.paid.length === 0) out(' Nothing due.');
871
+ for (const i of [...d.items, ...d.paid]) {
872
+ out(` ${i.dueOn} ${when(i).padEnd(9)} ${amt(i).padStart(14)} ${i.payee} · ${i.accountName}${i.source === 'scheduled' ? ' · statement due' : ''}`);
873
+ }
874
+ if (d.missed.length) out(` Not seen lately: ${d.missed.slice(0, 8).map((m) => `${m.payee} (expected ${m.expectedOn})`).join(', ')}`);
875
+ return EXIT.OK;
876
+ }
877
+
878
+ case 'due-day': {
879
+ const id = args[0];
880
+ const value = args[1];
881
+ if (!id || value === undefined) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances due-day <account-id> <1-31|clear> [--amount 810.46|--amount balance] (ids: coinpay finances accounts)');
882
+ const day = value === 'clear' || value === 'off' ? null : Number(value);
883
+ if (day !== null && (!Number.isInteger(day) || day < 1 || day > 31)) throw new CliExit(EXIT.INVALID, 'The due day must be 1 to 31, or clear');
884
+ const amount = flags.amount === undefined ? undefined : flags.amount === 'balance' || flags.amount === true ? null : String(flags.amount);
885
+ const account = await api.setAccountDueDay(client, id, { day, amount });
886
+ emit({ account }, day === null ? `Cleared the due day on ${account.name}.` : `${account.name}: due on day ${day}${account.payment_due_amount != null ? `, ${account.payment_due_amount}` : ', statement balance'}.`);
887
+ return EXIT.OK;
888
+ }
889
+
890
+ case 'daily': {
891
+ const action = flags.off ? 'off' : args[0] || 'show';
892
+ const describe = (s) => s
893
+ ? `Daily summary ${s.active ? 'on' : 'off'} · ${String(s.hour).padStart(2, '0')}:00 ${s.timezone} · to ${s.recipients.join(', ')}${s.nextRunAt ? ` · next ${s.nextRunAt}` : ''}${s.lastSentAt ? ` · last ${s.lastSentAt}` : ''}`
894
+ : 'No daily summary configured. Turn it on with: coinpay finances daily on --hour 7 --to you@example.com';
895
+ if (action === 'show') {
896
+ const s = await api.getDailySummary(client);
897
+ emit({ schedule: s }, describe(s));
898
+ return EXIT.OK;
899
+ }
900
+ if (action === 'on' || action === 'set') {
901
+ const s = await api.setDailySummary(client, {
902
+ hour: flags.hour !== undefined ? Number(flags.hour) : undefined,
903
+ timezone: typeof flags.timezone === 'string' ? flags.timezone : undefined,
904
+ recipients: flags.to ? listFlag(flags.to) : undefined,
905
+ active: true,
906
+ });
907
+ emit({ schedule: s }, describe(s));
908
+ return EXIT.OK;
909
+ }
910
+ if (action === 'off' || action === 'delete') {
911
+ const data = await api.deleteDailySummary(client);
912
+ emit(data, 'Daily summary turned off.');
913
+ return EXIT.OK;
914
+ }
915
+ if (action === 'send-now') {
916
+ const data = await api.sendDailySummaryNow(client);
917
+ emit(data, `Sent to ${data.sent.join(', ') || 'nobody'} (${data.due} due, ${data.overdue} overdue)${data.failed.length ? `; failed: ${data.failed.map((f) => `${f.to} (${f.error})`).join(', ')}` : ''}`);
918
+ return data.sent.length > 0 ? EXIT.OK : EXIT.FAILURE;
919
+ }
920
+ throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances daily [show|on --hour 7 --to a@x|off|send-now]');
921
+ }
922
+
790
923
  case 'payloads': {
791
924
  const action = args[0] || 'list';
792
925
  if (action === 'list') {
@@ -822,4 +955,4 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
822
955
  }
823
956
  }
824
957
 
825
- export const EXTENDED_SUBCOMMANDS = ['connect', 'disconnect', 'consent', 'backfill', 'jobs', 'coverage', 'report', 'reports', 'statements', 'books', 'payloads', 'digest'];
958
+ export const EXTENDED_SUBCOMMANDS = ['connect', 'disconnect', 'consent', 'backfill', 'jobs', 'coverage', 'report', 'reports', 'statements', 'books', 'payloads', 'digest', 'due', 'due-soon', 'recurring', 'due-day', 'daily'];
@@ -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,
@@ -359,3 +395,44 @@ export function setWeeklyDigest(
359
395
  ): Promise<WeeklyDigest>;
360
396
  export function deleteWeeklyDigest(client: CoinPayClient): Promise<{ success: boolean }>;
361
397
  export function sendWeeklyDigestNow(client: CoinPayClient): Promise<SendOutcome>;
398
+
399
+ export interface DueItem {
400
+ source: 'projected' | 'scheduled';
401
+ payee: string;
402
+ amount: number | null;
403
+ owed: number | null;
404
+ cadence: string;
405
+ dueOn: string;
406
+ daysUntil: number;
407
+ status: 'overdue' | 'awaiting' | 'today' | 'soon' | 'paid';
408
+ accountId: string;
409
+ accountName: string;
410
+ scope: 'business' | 'personal';
411
+ isDebtService: boolean;
412
+ }
413
+
414
+ export interface DueSoon {
415
+ timezone: string;
416
+ today: string;
417
+ horizonDays: number;
418
+ items: DueItem[];
419
+ paid: DueItem[];
420
+ missed: Array<{ payee: string; amount: number; expectedOn: string; lastSeen: string; accountName: string }>;
421
+ totals: { due: number; overdue: number; unpriced: number };
422
+ recurring: {
423
+ charges: Array<{ payee: string; amount: number; cadence: string; occurrences: number; lastSeen: string; nextExpected: string; monthlyEquivalent: number; scope: 'business' | 'personal'; isDebtService: boolean; accountId: string; accountName: string }>;
424
+ monthlyTotal: number;
425
+ monthlyDebtService: number;
426
+ };
427
+ currency: string | null;
428
+ }
429
+
430
+ export function getDueSoon(client: CoinPayClient, opts?: { days?: number; timezone?: string }): Promise<DueSoon>;
431
+ export function getDailySummary(client: CoinPayClient): Promise<WeeklyDigest | null>;
432
+ export function setDailySummary(
433
+ client: CoinPayClient,
434
+ opts?: { weekdays?: number[]; hour?: number; timezone?: string; recipients?: string[]; active?: boolean },
435
+ ): Promise<WeeklyDigest>;
436
+ export function deleteDailySummary(client: CoinPayClient): Promise<{ success: boolean }>;
437
+ export function sendDailySummaryNow(client: CoinPayClient): Promise<{ sent: string[]; failed: Array<{ to: string; error: string }>; due: number; overdue: number }>;
438
+ export function setAccountDueDay(client: CoinPayClient, accountId: string, opts: { day: number | null; amount?: number | string | null }): Promise<Record<string, unknown>>;