@profullstack/coinpay 0.10.0 → 0.11.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,25 @@ 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.11.0] - 2026-09-12
9
+
10
+ ### Added
11
+ - `coinpay finances books queue|confirm|confirm-all|categorize|rules|summary|export|send`:
12
+ every transaction auto-categorised (rules, heuristics, and Claude when the
13
+ server has a key) with a review queue, an "always" flag that turns a
14
+ correction into a rule, a tax summary by year/quarter/month in exact
15
+ arithmetic, and the CPA pack as CSV/PDF/HTML/JSON.
16
+ - `coinpay finances payloads list|download`: every raw provider response,
17
+ exactly as received.
18
+ - `coinpay finances reports send <id> --to …` and `books send --to …`: email
19
+ a report or the pack with attachments plus an expiring no-login link.
20
+ - `coinpay finances digest set|show|off|send-now`: a weekly digest on chosen
21
+ weekdays at a chosen local hour.
22
+ - `coinpay finances consent <id> on|off`: opt a connection into the
23
+ 30-minute background sync.
24
+ - SDK: `finances-reports` gained the books, payloads, email and digest
25
+ functions with declarations.
26
+
8
27
  ## [0.10.0] - 2026-09-12
9
28
 
10
29
  ### Added
package/bin/coinpay.js CHANGED
@@ -169,6 +169,10 @@ const BOOLEAN_FLAGS = new Set([
169
169
  'no-pending',
170
170
  'liability-positive',
171
171
  'acknowledge',
172
+ 'always',
173
+ 'no-model',
174
+ 'only-uncategorized',
175
+ 'no-attach',
172
176
  'plain',
173
177
  'no-stream',
174
178
  'hidden',
@@ -478,6 +482,17 @@ ${colors.cyan}Commands:${colors.reset}
478
482
  statements import <pdf> Keep an original bank statement (--account, --period|--from/--to)
479
483
  statements list|get|download|reconcile|delete
480
484
  Statement library; reconcile takes --report --opening --closing --currency
485
+ books [queue] Transactions awaiting category review (--status, --scope, --search)
486
+ books confirm <id> Confirm a row (--category, --tax, --scope, --note, --always makes a rule)
487
+ books confirm-all Accept every pending suggestion (--yes)
488
+ books categorize Rules, heuristics, then the model over unreviewed rows (--wait)
489
+ books rules [add|delete] Merchant category rules
490
+ books summary Totals by tax category (--period 2026|2026-Q3|2026-08, --scope)
491
+ books export CPA pack (--period, --scope, --format csv|pdf|html|json, --output)
492
+ payloads [list|download <id>] Raw provider responses, exactly as received
493
+ reports send <id> Email a report (--to a@x,b@y, --format pdf,csv, --message, --no-attach)
494
+ books send Email the CPA pack (--to, --period, --scope, --format)
495
+ digest [show|set|off|send-now] Weekly digest email (--days mon,fri --hour 8 --to …)
481
496
 
482
497
  ${colors.bright}escrow${colors.reset}
483
498
  create Create a new escrow
@@ -4271,7 +4286,10 @@ async function handleFinances(subcommand, args, flags) {
4271
4286
  case 'coverage':
4272
4287
  case 'report':
4273
4288
  case 'reports':
4274
- case 'statements': {
4289
+ case 'statements':
4290
+ case 'books':
4291
+ case 'payloads':
4292
+ case 'digest': {
4275
4293
  const { client } = financesClient();
4276
4294
  const { runFinancesCommand } = await import('../src/finances-commands.js');
4277
4295
  const code = await runFinancesCommand(subcommand, args, flags, {
@@ -4285,7 +4303,7 @@ async function handleFinances(subcommand, args, flags) {
4285
4303
 
4286
4304
  default:
4287
4305
  print.error(`Unknown finances command: ${subcommand}`);
4288
- console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync|connect|disconnect|backfill|jobs|coverage|report|reports|statements]');
4306
+ console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync|connect|disconnect|backfill|jobs|coverage|report|reports|statements|books|payloads|digest]');
4289
4307
  process.exit(1);
4290
4308
  }
4291
4309
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/coinpay",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",
@@ -295,6 +295,18 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
295
295
  emit(data, data.reports.length ? data.reports.map(describeReport).join('\n') : 'No reports yet.');
296
296
  return EXIT.OK;
297
297
  }
298
+ if (action === 'send') {
299
+ const id = args[1];
300
+ if (!id || typeof flags.to !== 'string') throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances reports send <report-id> --to cpa@x.com,me@x.com [--format pdf,csv] [--message "…"] [--no-attach]');
301
+ const data = await api.sendFinanceReportEmail(client, id, {
302
+ to: listFlag(flags.to),
303
+ formats: flags.format ? listFlag(flags.format) : undefined,
304
+ message: typeof flags.message === 'string' ? flags.message : null,
305
+ attach: flags['no-attach'] ? false : true,
306
+ });
307
+ emit(data, `Sent to ${data.sent.join(', ') || 'nobody'}${data.failed.length ? `; failed: ${data.failed.map((f) => `${f.to} (${f.error})`).join(', ')}` : ''}. Attached: ${data.attached.join(', ') || 'none'}. Link (expires ${data.linkExpiresAt.slice(0, 10)}): ${data.linkUrl}`);
308
+ return data.sent.length > 0 ? EXIT.OK : EXIT.FAILURE;
309
+ }
298
310
  if (action === 'get' || action === 'download' || action === 'delete') {
299
311
  const id = args[1];
300
312
  if (!id) throw new CliExit(EXIT.INVALID, `Usage: coinpay finances reports ${action} <report-id>`);
@@ -316,7 +328,7 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
316
328
  emit({ report: data.report, output: path, bytes: file.bytes.length, sha256: file.sha256 }, `Wrote ${path} (${file.bytes.length} bytes)`);
317
329
  return EXIT.OK;
318
330
  }
319
- throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances reports [list|get <id>|download <id>|delete <id>]');
331
+ throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances reports [list|get <id>|download <id>|send <id>|delete <id>]');
320
332
  }
321
333
 
322
334
  case 'statements': {
@@ -404,6 +416,184 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
404
416
  throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements [import <file>|list|get <id>|download <id>|reconcile <id>|delete <id>]');
405
417
  }
406
418
 
419
+ case 'books': {
420
+ const action = args[0] || 'queue';
421
+ if (action === 'queue' || action === 'list') {
422
+ const scope = typeof flags.scope === 'string' ? flags.scope : undefined;
423
+ const data = await api.listBooksQueue(client, {
424
+ status: typeof flags.status === 'string' ? flags.status : 'unreviewed',
425
+ scope,
426
+ accountId: typeof flags.account === 'string' ? flags.account : undefined,
427
+ search: typeof flags.search === 'string' ? flags.search : undefined,
428
+ limit: flags.limit ? Number(flags.limit) : undefined,
429
+ offset: flags.offset ? Number(flags.offset) : undefined,
430
+ });
431
+ emit(
432
+ data,
433
+ [`${data.unreviewed} row(s) awaiting review${data.modelEnabled ? '' : ' (model pass off: no ANTHROPIC_API_KEY on the server)'}`]
434
+ .concat(data.rows.map((r) => `${r.id} ${(r.posted || '').slice(0, 10)} ${r.amount} ${r.currency} ${(r.payee || r.description || '').slice(0, 40).padEnd(40)} ${r.category || '-'} / ${r.taxCategoryLabel} / ${r.scope} [${r.suggestion ? r.suggestion.by : r.categorySource} ${Math.round(((r.categoryConfidence ?? (r.suggestion && r.suggestion.confidence) ?? 0)) * 100)}%]`))
435
+ .join('\n'),
436
+ );
437
+ return EXIT.OK;
438
+ }
439
+ if (action === 'confirm') {
440
+ const id = args[1];
441
+ if (!id) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances books confirm <transaction-id> [--category c] [--tax t] [--scope business|personal] [--note …] [--always]');
442
+ const row = await api.reviewBooksTransaction(client, id, {
443
+ category: typeof flags.category === 'string' ? flags.category : undefined,
444
+ taxCategory: typeof flags.tax === 'string' ? flags.tax : undefined,
445
+ scope: typeof flags.scope === 'string' ? flags.scope : undefined,
446
+ note: typeof flags.note === 'string' ? flags.note : undefined,
447
+ createRule: flags.always === true,
448
+ });
449
+ emit({ transaction: row }, `Confirmed ${row.id}: ${row.category || '-'} / ${row.taxCategoryLabel} / ${row.scope}${flags.always ? ' (rule created)' : ''}`);
450
+ return EXIT.OK;
451
+ }
452
+ if (action === 'confirm-all') {
453
+ const data = await api.listBooksQueue(client, { status: 'unreviewed', limit: 500 });
454
+ if (!data.rows.length) { emit({ reviewed: 0 }, 'Nothing to confirm.'); return EXIT.OK; }
455
+ if (!flags.yes) throw new CliExit(EXIT.INVALID, `This accepts the suggestion on ${data.rows.length} row(s). Re-run with --yes.`);
456
+ const result = await api.bulkReviewBooks(client, data.rows.map((r) => r.id));
457
+ emit(result, `Confirmed ${result.reviewed} row(s) as suggested.`);
458
+ return EXIT.OK;
459
+ }
460
+ if (action === 'categorize') {
461
+ const data = await api.categorizeBooks(client, { useModel: flags['no-model'] ? false : true, onlyUncategorized: flags['only-uncategorized'] === true });
462
+ progress(`Queued ${describeJob(data.job)}${data.modelEnabled ? '' : ' (model pass off: no ANTHROPIC_API_KEY on the server)'}`);
463
+ let job = data.job;
464
+ if (flags.wait) job = await api.waitForFinanceJob(client, job.id, { onProgress: (j) => progress(describeJob(j)) });
465
+ const r = job.result || {};
466
+ emit({ job }, job.status === 'completed' ? `Examined ${r.examined ?? 0}: ${r.fromRules ?? 0} by rule, ${r.fromModel ?? 0} by model, ${r.autoAccepted ?? 0} accepted, ${r.queued ?? 0} for review` : describeJob(job));
467
+ return job.status === 'failed' ? EXIT.FAILURE : EXIT.OK;
468
+ }
469
+ if (action === 'rules') {
470
+ const sub = args[1];
471
+ if (sub === 'add') {
472
+ const pattern = args[2];
473
+ if (!pattern || typeof flags.category !== 'string') throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances books rules add "<pattern>" --category c [--tax t] [--scope s] [--field payee|description] [--match exact|contains]');
474
+ const rule = await api.createBooksRule(client, { pattern, category: flags.category, taxCategory: typeof flags.tax === 'string' ? flags.tax : undefined, scope: typeof flags.scope === 'string' ? flags.scope : undefined, matchField: typeof flags.field === 'string' ? flags.field : 'payee', matchType: typeof flags.match === 'string' ? flags.match : 'exact' });
475
+ emit({ rule }, `Rule ${rule.id}: ${rule.match_field} ${rule.match_type} "${rule.pattern}" → ${rule.category}`);
476
+ return EXIT.OK;
477
+ }
478
+ if (sub === 'delete') {
479
+ if (!args[2]) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances books rules delete <rule-id>');
480
+ const data = await api.deleteBooksRule(client, args[2]);
481
+ emit(data, 'Rule deleted.');
482
+ return EXIT.OK;
483
+ }
484
+ const rules = await api.listBooksRules(client);
485
+ emit({ rules }, rules.length ? rules.map((r) => `${r.id} ${r.match_field} ${r.match_type} "${r.pattern}" → ${r.category}${r.tax_category ? ' / ' + r.tax_category : ''}${r.scope ? ' / ' + r.scope : ''}`).join('\n') : 'No rules.');
486
+ return EXIT.OK;
487
+ }
488
+ if (action === 'send') {
489
+ 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 "…"]');
490
+ const period = typeof flags.period === 'string' ? flags.period : null;
491
+ const from = typeof flags.from === 'string' ? flags.from : undefined;
492
+ const toDate = typeof flags['to-date'] === 'string' ? flags['to-date'] : undefined;
493
+ if (!period && !(from && toDate)) throw new CliExit(EXIT.INVALID, 'Pass --period 2026 (or 2026-Q3, 2026-08) or --from/--to-date');
494
+ const data = await api.sendBooksEmail(client, {
495
+ to: listFlag(flags.to), period: period || undefined, from, toDate,
496
+ timezone: typeof flags.timezone === 'string' ? flags.timezone : undefined,
497
+ scope: typeof flags.scope === 'string' ? flags.scope : 'business',
498
+ formats: flags.format ? listFlag(flags.format) : undefined,
499
+ message: typeof flags.message === 'string' ? flags.message : null,
500
+ attach: flags['no-attach'] ? false : true,
501
+ });
502
+ 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}`);
503
+ return data.sent.length > 0 ? EXIT.OK : EXIT.FAILURE;
504
+ }
505
+ if (action === 'summary' || action === 'export') {
506
+ const period = typeof flags.period === 'string' ? flags.period : typeof flags.year === 'string' || typeof flags.year === 'number' ? String(flags.year) : null;
507
+ const from = typeof flags.from === 'string' ? flags.from : undefined;
508
+ const to = typeof flags.to === 'string' ? flags.to : undefined;
509
+ if (!period && !(from && to)) throw new CliExit(EXIT.INVALID, 'Pass --period 2026 (or 2026-Q3, 2026-08) or --from/--to');
510
+ const scope = typeof flags.scope === 'string' ? flags.scope : 'business';
511
+ const timezone = typeof flags.timezone === 'string' ? flags.timezone : undefined;
512
+ if (action === 'summary') {
513
+ const data = await api.getBooksSummary(client, { period: period || undefined, from, to, timezone, scope });
514
+ emit(
515
+ data,
516
+ [`${data.period.label} · ${scope} · ${data.rows} rows (${data.unreviewed} unreviewed, ${data.uncategorized} uncategorised)`]
517
+ .concat(data.totals.map((t) => `${t.currency}: income ${t.income} · expenses ${t.expenses} · net ${t.net} · excluded ${t.excluded}`))
518
+ .concat(data.lines.map((l) => ` ${l.label.padEnd(32)} ${l.currency} ${l.total.padStart(14)} (${l.rows})${l.excluded ? ' excluded' : ''}`))
519
+ .concat([data.notice])
520
+ .join('\n'),
521
+ );
522
+ return EXIT.OK;
523
+ }
524
+ const format = typeof flags.format === 'string' ? flags.format.toLowerCase() : 'csv';
525
+ const file = await api.exportBooks(client, { period: period || undefined, from, to, timezone, scope, format });
526
+ const output = typeof flags.output === 'string' ? flags.output : file.filename || `books-${period || 'range'}.${format}`;
527
+ const path = writeDownload(output, Buffer.from(file.bytes), { overwrite: flags.overwrite === true });
528
+ 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)`);
529
+ return EXIT.OK;
530
+ }
531
+ throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances books [queue|confirm <id>|confirm-all --yes|categorize|rules [add|delete]|summary|export|send]');
532
+ }
533
+
534
+ case 'digest': {
535
+ const action = args[0] || 'show';
536
+ const DAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
537
+ const describe = (s) => s
538
+ ? `${s.active ? 'on' : 'off'} · ${s.weekdays.map((d) => DAY_NAMES[d]).join(',')} at ${String(s.hour).padStart(2, '0')}:00 ${s.timezone} · ${s.scope} · to ${s.recipients.join(', ')}${s.nextRunAt ? ` · next ${s.nextRunAt}` : ''}${s.lastSentAt ? ` · last ${s.lastSentAt}` : ''}`
539
+ : 'No weekly digest configured. Set one with: coinpay finances digest set --days mon,fri --hour 8 --to you@example.com';
540
+ if (action === 'show') {
541
+ const s = await api.getWeeklyDigest(client);
542
+ emit({ schedule: s }, describe(s));
543
+ return EXIT.OK;
544
+ }
545
+ if (action === 'set' || action === 'on') {
546
+ const days = flags.days ? listFlag(flags.days).map((d) => { const i = DAY_NAMES.indexOf(d.toLowerCase().slice(0, 3)); return i === -1 ? Number(d) : i; }) : undefined;
547
+ if (days && days.some((d) => !Number.isInteger(d) || d < 0 || d > 6)) throw new CliExit(EXIT.INVALID, '--days takes names like mon,fri or numbers 0 (Sunday) to 6');
548
+ const s = await api.setWeeklyDigest(client, {
549
+ weekdays: days,
550
+ hour: flags.hour !== undefined ? Number(flags.hour) : undefined,
551
+ timezone: typeof flags.timezone === 'string' ? flags.timezone : undefined,
552
+ recipients: flags.to ? listFlag(flags.to) : undefined,
553
+ scope: typeof flags.scope === 'string' ? flags.scope : undefined,
554
+ formats: flags.format ? listFlag(flags.format) : undefined,
555
+ active: true,
556
+ });
557
+ emit({ schedule: s }, describe(s));
558
+ return EXIT.OK;
559
+ }
560
+ if (action === 'off' || action === 'pause') {
561
+ const s = await api.setWeeklyDigest(client, { active: false });
562
+ emit({ schedule: s }, describe(s));
563
+ return EXIT.OK;
564
+ }
565
+ if (action === 'delete') {
566
+ const data = await api.deleteWeeklyDigest(client);
567
+ emit(data, 'Weekly digest removed.');
568
+ return EXIT.OK;
569
+ }
570
+ if (action === 'send-now') {
571
+ const data = await api.sendWeeklyDigestNow(client);
572
+ emit(data, `Sent to ${data.sent.join(', ') || 'nobody'}${data.failed.length ? `; failed: ${data.failed.map((f) => `${f.to} (${f.error})`).join(', ')}` : ''}`);
573
+ return data.sent.length > 0 ? EXIT.OK : EXIT.FAILURE;
574
+ }
575
+ 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]');
576
+ }
577
+
578
+ case 'payloads': {
579
+ const action = args[0] || 'list';
580
+ if (action === 'list') {
581
+ const data = await api.listFinancePayloads(client, { connectionId: typeof flags.connection === 'string' ? flags.connection : undefined, limit: flags.limit ? Number(flags.limit) : undefined });
582
+ emit(data, data.payloads.length ? data.payloads.map((p) => `${p.id} ${p.fetchedAt} ${p.provider} ${p.requestClass} ${p.accounts} accounts / ${p.transactions} tx / ${p.errors} errors ${p.bytes} bytes`).join('\n') : 'No archived payloads yet.');
583
+ return EXIT.OK;
584
+ }
585
+ if (action === 'download') {
586
+ const id = args[1];
587
+ if (!id) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances payloads download <payload-id> --output <file.json>');
588
+ const file = await api.downloadFinancePayload(client, id);
589
+ const output = typeof flags.output === 'string' ? flags.output : file.filename || `${id}.json`;
590
+ const path = writeDownload(output, Buffer.from(file.bytes), { overwrite: flags.overwrite === true });
591
+ emit({ output: path, bytes: file.bytes.length, sha256: file.sha256 }, `Wrote ${path} (${file.bytes.length} bytes, as received from the provider)`);
592
+ return EXIT.OK;
593
+ }
594
+ throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances payloads [list|download <id>]');
595
+ }
596
+
407
597
  default:
408
598
  return null;
409
599
  }
@@ -420,4 +610,4 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
420
610
  }
421
611
  }
422
612
 
423
- export const EXTENDED_SUBCOMMANDS = ['connect', 'disconnect', 'consent', 'backfill', 'jobs', 'coverage', 'report', 'reports', 'statements'];
613
+ export const EXTENDED_SUBCOMMANDS = ['connect', 'disconnect', 'consent', 'backfill', 'jobs', 'coverage', 'report', 'reports', 'statements', 'books', 'payloads', 'digest'];
@@ -5,7 +5,7 @@ export type ReconciliationStatus = 'not_attempted' | 'unavailable' | 'mismatch'
5
5
  export type ReportFormat = 'pdf' | 'html' | 'csv' | 'json';
6
6
  export type ReportStatus = 'queued' | 'generating' | 'ready' | 'failed' | 'superseded' | 'deleted';
7
7
  export type JobStatus = 'queued' | 'running' | 'waiting_for_budget' | 'partial' | 'failed' | 'completed' | 'cancelled';
8
- export type JobKind = 'backfill' | 'refresh' | 'scheduled_sync' | 'report';
8
+ export type JobKind = 'backfill' | 'refresh' | 'scheduled_sync' | 'report' | 'categorize' | 'email_report';
9
9
 
10
10
  /** Thrown by every call here when the server answers with its structured error body. */
11
11
  export interface FinanceApiError extends Error {
@@ -241,3 +241,119 @@ export function reconcileFinanceStatement(
241
241
  acknowledge?: boolean;
242
242
  },
243
243
  ): Promise<FinanceReconciliation>;
244
+
245
+ export interface BooksRow {
246
+ id: string;
247
+ accountId: string;
248
+ accountName: string;
249
+ orgName: string | null;
250
+ currency: string;
251
+ posted: string | null;
252
+ amount: string;
253
+ payee: string | null;
254
+ description: string | null;
255
+ memo: string | null;
256
+ category: string | null;
257
+ categorySource: 'auto' | 'rule' | 'model' | 'user';
258
+ categoryConfidence: number | null;
259
+ taxCategory: string | null;
260
+ taxCategoryLabel: string;
261
+ scope: 'business' | 'personal' | string;
262
+ scopeOverride: string | null;
263
+ accountScope: string;
264
+ reviewedAt: string | null;
265
+ note: string | null;
266
+ suggestion: { category: string | null; taxCategory: string | null; confidence: number | null; by: string | null } | null;
267
+ }
268
+
269
+ export interface BooksRule {
270
+ id: string;
271
+ match_field: 'payee' | 'description';
272
+ match_type: 'exact' | 'contains';
273
+ pattern: string;
274
+ category: string;
275
+ tax_category: string | null;
276
+ scope: 'business' | 'personal' | null;
277
+ hits: number;
278
+ active: boolean;
279
+ created_at: string;
280
+ }
281
+
282
+ export interface BooksSummaryLine {
283
+ taxCategory: string;
284
+ label: string;
285
+ currency: string;
286
+ total: string;
287
+ rows: number;
288
+ excluded: boolean;
289
+ income: boolean;
290
+ }
291
+
292
+ export function listBooksQueue(
293
+ client: CoinPayClient,
294
+ options?: { status?: 'unreviewed' | 'reviewed' | 'all'; scope?: 'business' | 'personal' | 'all'; accountId?: string; search?: string; start?: string; end?: string; limit?: number; offset?: number },
295
+ ): Promise<{ rows: BooksRow[]; total: number; unreviewed: number; categories: string[]; taxCategories: Array<{ id: string; label: string }>; modelEnabled: boolean }>;
296
+ export function reviewBooksTransaction(
297
+ client: CoinPayClient,
298
+ transactionId: string,
299
+ options?: { category?: string | null; taxCategory?: string | null; scope?: 'business' | 'personal' | null; note?: string | null; createRule?: boolean },
300
+ ): Promise<BooksRow>;
301
+ export function bulkReviewBooks(
302
+ client: CoinPayClient,
303
+ ids: string[],
304
+ options?: { category?: string | null; taxCategory?: string | null; scope?: 'business' | 'personal' | null; createRule?: boolean },
305
+ ): Promise<{ reviewed: number }>;
306
+ export function categorizeBooks(client: CoinPayClient, options?: { useModel?: boolean; onlyUncategorized?: boolean }): Promise<{ job: FinanceJob; modelEnabled: boolean }>;
307
+ export function listBooksRules(client: CoinPayClient): Promise<BooksRule[]>;
308
+ export function createBooksRule(
309
+ client: CoinPayClient,
310
+ options: { matchField?: 'payee' | 'description'; matchType?: 'exact' | 'contains'; pattern: string; category: string; taxCategory?: string | null; scope?: 'business' | 'personal' | null },
311
+ ): Promise<BooksRule>;
312
+ export function deleteBooksRule(client: CoinPayClient, ruleId: string): Promise<{ success: boolean }>;
313
+ export function getBooksSummary(
314
+ client: CoinPayClient,
315
+ options?: PeriodSelection & { scope?: 'business' | 'personal' | 'all'; rows?: boolean },
316
+ ): 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[] }>;
317
+ export function exportBooks(client: CoinPayClient, options?: PeriodSelection & { scope?: 'business' | 'personal' | 'all'; format?: 'csv' | 'pdf' | 'html' | 'json' }): Promise<BinaryResponse>;
318
+ export function listFinancePayloads(client: CoinPayClient, options?: { connectionId?: string; limit?: number; offset?: number }): Promise<{ payloads: Array<Record<string, unknown>>; total: number }>;
319
+ export function downloadFinancePayload(client: CoinPayClient, payloadId: string): Promise<BinaryResponse>;
320
+
321
+ export interface SendOutcome {
322
+ sent: string[];
323
+ failed: Array<{ to: string; error: string }>;
324
+ attached: string[];
325
+ linkUrl: string;
326
+ linkExpiresAt: string;
327
+ }
328
+
329
+ export interface WeeklyDigest {
330
+ id: string;
331
+ kind: 'weekly_digest';
332
+ weekdays: number[];
333
+ hour: number;
334
+ timezone: string;
335
+ recipients: string[];
336
+ scope: 'business' | 'personal' | 'all';
337
+ formats: string[];
338
+ active: boolean;
339
+ lastSentAt: string | null;
340
+ nextRunAt: string | null;
341
+ createdAt: string;
342
+ }
343
+
344
+ export function sendFinanceReportEmail(
345
+ client: CoinPayClient,
346
+ reportId: string,
347
+ options: { to: string | string[]; formats?: ReportFormat[]; message?: string | null; attach?: boolean; expiresInDays?: number },
348
+ ): Promise<SendOutcome>;
349
+ export function sendBooksEmail(
350
+ client: CoinPayClient,
351
+ 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 },
352
+ ): Promise<SendOutcome & { unreviewed: number; rows: number }>;
353
+ export function getWeeklyDigest(client: CoinPayClient): Promise<WeeklyDigest | null>;
354
+ export function setWeeklyDigest(
355
+ client: CoinPayClient,
356
+ options?: { weekdays?: number[]; hour?: number; timezone?: string; recipients?: string[]; scope?: 'business' | 'personal' | 'all'; formats?: string[]; active?: boolean },
357
+ ): Promise<WeeklyDigest>;
358
+ export function deleteWeeklyDigest(client: CoinPayClient): Promise<{ success: boolean }>;
359
+ export function sendWeeklyDigestNow(client: CoinPayClient): Promise<SendOutcome>;
@@ -255,3 +255,102 @@ export async function reconcileFinanceStatement(client, statementId, {
255
255
  });
256
256
  return data.reconciliation;
257
257
  }
258
+
259
+ // ── Books ──
260
+
261
+ /** Rows awaiting review (default) plus the category vocabularies. */
262
+ export async function listBooksQueue(client, { status, scope, accountId, search, start, end, limit, offset } = {}) {
263
+ return call(client, `/finances/books/queue${query({ status, scope, account: accountId, search, start, end, limit, offset })}`);
264
+ }
265
+
266
+ /** Confirm one row. Omitted fields keep the row's current values. */
267
+ export async function reviewBooksTransaction(client, transactionId, { category, taxCategory, scope, note, createRule } = {}) {
268
+ const data = await call(client, `/finances/books/transactions/${encodeURIComponent(transactionId)}`, {
269
+ method: 'PATCH',
270
+ body: JSON.stringify({ category, taxCategory, scope, note, createRule }),
271
+ });
272
+ return data.transaction;
273
+ }
274
+
275
+ /** Confirm many rows; with no category given each row's suggestion is accepted. */
276
+ export async function bulkReviewBooks(client, ids, { category, taxCategory, scope, createRule } = {}) {
277
+ return call(client, '/finances/books/bulk', { method: 'POST', body: JSON.stringify({ ids, category, taxCategory, scope, createRule }) });
278
+ }
279
+
280
+ /** Queue an auto-categorisation run over unreviewed rows. Answers with the job. */
281
+ export async function categorizeBooks(client, { useModel = true, onlyUncategorized = false } = {}) {
282
+ return call(client, '/finances/books/categorize', { method: 'POST', body: JSON.stringify({ useModel, onlyUncategorized }) });
283
+ }
284
+
285
+ export async function listBooksRules(client) {
286
+ const data = await call(client, '/finances/books/rules');
287
+ return data.rules || [];
288
+ }
289
+
290
+ export async function createBooksRule(client, { matchField = 'payee', matchType = 'exact', pattern, category, taxCategory, scope } = {}) {
291
+ const data = await call(client, '/finances/books/rules', { method: 'POST', body: JSON.stringify({ matchField, matchType, pattern, category, taxCategory, scope }) });
292
+ return data.rule;
293
+ }
294
+
295
+ export async function deleteBooksRule(client, ruleId) {
296
+ return call(client, `/finances/books/rules/${encodeURIComponent(ruleId)}`, { method: 'DELETE' });
297
+ }
298
+
299
+ /** Totals by tax category for a year (`2026`), quarter or month. */
300
+ export async function getBooksSummary(client, { period, from, to, timezone, scope, rows } = {}) {
301
+ return call(client, `/finances/books/summary${query({ period, from, to, timezone, scope, rows: rows ? 1 : undefined })}`);
302
+ }
303
+
304
+ /** The CPA pack as bytes. */
305
+ export async function exportBooks(client, { period, from, to, timezone, scope, format = 'csv' } = {}) {
306
+ return client.requestBinary(`/finances/books/export${query({ period, from, to, timezone, scope, format })}`);
307
+ }
308
+
309
+ // ── Raw provider payloads ──
310
+
311
+ export async function listFinancePayloads(client, { connectionId, limit, offset } = {}) {
312
+ return call(client, `/finances/payloads${query({ connection: connectionId, limit, offset })}`);
313
+ }
314
+
315
+ export async function downloadFinancePayload(client, payloadId) {
316
+ return client.requestBinary(`/finances/payloads/${encodeURIComponent(payloadId)}/download`);
317
+ }
318
+
319
+ // ── Email ──
320
+
321
+ /** Email a ready report: attachments plus an expiring no-login link. */
322
+ export async function sendFinanceReportEmail(client, reportId, { to, formats, message, attach, expiresInDays } = {}) {
323
+ return call(client, `/finances/reports/${encodeURIComponent(reportId)}/send`, {
324
+ method: 'POST',
325
+ body: JSON.stringify({ to, formats, message, attach, expiresInDays }),
326
+ });
327
+ }
328
+
329
+ /** Email the CPA pack for a period. `toDate` is the exclusive end for a custom range. */
330
+ export async function sendBooksEmail(client, { to, period, from, toDate, timezone, scope, formats, message, attach, expiresInDays } = {}) {
331
+ return call(client, '/finances/books/send', {
332
+ method: 'POST',
333
+ body: JSON.stringify({ to, period, from, toDate, timezone, scope, formats, message, attach, expiresInDays }),
334
+ });
335
+ }
336
+
337
+ export async function getWeeklyDigest(client) {
338
+ const data = await call(client, '/finances/email-schedules');
339
+ return data.schedule;
340
+ }
341
+
342
+ export async function setWeeklyDigest(client, { weekdays, hour, timezone, recipients, scope, formats, active } = {}) {
343
+ const data = await call(client, '/finances/email-schedules', {
344
+ method: 'POST',
345
+ body: JSON.stringify({ weekdays, hour, timezone, recipients, scope, formats, active }),
346
+ });
347
+ return data.schedule;
348
+ }
349
+
350
+ export async function deleteWeeklyDigest(client) {
351
+ return call(client, '/finances/email-schedules', { method: 'DELETE' });
352
+ }
353
+
354
+ export async function sendWeeklyDigestNow(client) {
355
+ return call(client, '/finances/email-schedules/send-now', { method: 'POST', body: '{}' });
356
+ }