@profullstack/coinpay 0.9.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,56 @@ 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
+
27
+ ## [0.10.0] - 2026-09-12
28
+
29
+ ### Added
30
+ - `coinpay finances report`: monthly, quarterly and custom-period activity
31
+ reports generated server-side from an immutable snapshot, downloadable as
32
+ PDF, HTML, CSV or JSON (`--period 2026-08`, `--period 2026-Q2`, or
33
+ `--from/--to` with an exclusive end; `--account`, `--scope`, `--timezone`,
34
+ `--format`, `--output`, `--strict`, `--wait`, `--json`). Every report
35
+ states that it was generated by CoinPay from imported account data and is
36
+ not an institution-issued statement, and carries `local_export_complete`,
37
+ `provider_coverage` and `reconciliation_status`.
38
+ - `coinpay finances backfill`, `jobs`, `coverage`: durable, resumable
39
+ calendar-period fetches in provider-safe chunks, with per-account coverage
40
+ derived from what was actually requested.
41
+ - `coinpay finances statements import|list|get|download|reconcile|delete`:
42
+ keep original bank statements (PDF, user supplied, never parsed) beside an
43
+ account and period, and check their balances against a report revision
44
+ with exact decimal arithmetic.
45
+ - `coinpay finances connect` (setup token prompted without echo or via
46
+ `--setup-token-stdin`), `disconnect`, `consent <id> on|off`.
47
+ - SDK: `finances-reports` module (`createFinanceReport`, `waitForFinanceReport`,
48
+ `downloadFinanceReport`, `createBackfillJob`, `getFinanceCoverage`,
49
+ `importFinanceStatement`, `reconcileFinanceStatement`, ...), and
50
+ `CoinPayClient.requestBinary()` / `requestForm()` for downloads and uploads.
51
+ - Exit codes for the new subcommands: 0 done or job accepted, 2 invalid
52
+ input, 3 strict/completeness rejection, 4 authentication, 5 provider or
53
+ storage failure. In `--json` mode stdout carries only the result.
54
+
55
+ ### Fixed
56
+ - `listAllFinanceTransactions` now forwards `endDate`.
57
+
8
58
  ## [0.9.0] - 2026-09-05
9
59
 
10
60
  ### Added
@@ -31,6 +81,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
31
81
  Built from about six months of history rather than the dashboard window,
32
82
  because a monthly charge is invisible in thirty days of rows, and averaged
33
83
  over the history that exists rather than the history requested.
84
+ - `coinpay finances scope <account-id> <business|personal|clear>`, and a `Side`
85
+ column on the accounts table. Which set of books an account belongs to was a
86
+ guess from its name, which reads "Business Checking" correctly and is blind to
87
+ a personal card carrying company spend. The correction is now stored in
88
+ `finance_accounts.scope_override`, wins over the guess, and survives a re-sync
89
+ — the same contract as `kind_override`. An overridden account is marked with a
90
+ trailing `*`, so a wrong split is traceable to a guess rather than a decision.
34
91
  - Plain-text `coinpay finances summary|accounts|ledger|connections|sync`
35
92
  (`--json` on all of them) for scripts and for Node older than 22.6.
36
93
  - SDK `finances` module, also importable as `@profullstack/coinpay/finances`:
package/README.md CHANGED
@@ -697,6 +697,7 @@ coinpay finances # live dashboard (needs `coinpay login`)
697
697
  coinpay finances --days 90 # window for earnings / cashflow: 7, 30, 90, 365
698
698
  coinpay finances summary # plain text, --json for machines
699
699
  coinpay finances position # debt vs income, credits vs debits (also: debt)
700
+ coinpay finances scope <id> business # put an account on the company's books
700
701
  coinpay finances accounts # linked bank & credit-card accounts (SimpleFIN / Plaid)
701
702
  coinpay finances ledger --search anthropic --limit 20
702
703
  coinpay finances connections # institutions and their last sync
@@ -718,6 +719,13 @@ instalment, months to clear each balance at its current payment rate, debt-to-in
718
719
  debt-service ratio, months of cover, card utilisation, the recurring bills it found with
719
720
  their next due date, and a business-versus-personal split of all of it.
720
721
 
722
+ The business/personal split starts as a guess from the account name, which gets
723
+ "Business Checking (4672)" right and cannot see a personal card carrying company
724
+ spend. Correct it with `coinpay finances scope <account-id> business|personal`,
725
+ or `clear` to hand it back to the guess. The correction is stored separately from
726
+ the derived value, so a re-sync cannot clobber it, and corrected accounts carry a
727
+ trailing `*` in the `Side` column.
728
+
721
729
  Two things about those numbers. Transfers and card payments are excluded from both
722
730
  income and spending — a feed holding both a checking account and the card it pays
723
731
  contains every card payment twice, so counting raw credits as income inflates both
package/bin/coinpay.js CHANGED
@@ -161,6 +161,18 @@ function createUnauthenticatedClient() {
161
161
 
162
162
  const BOOLEAN_FLAGS = new Set([
163
163
  'active',
164
+ // finances reports / statements
165
+ 'wait',
166
+ 'strict',
167
+ 'setup-token-stdin',
168
+ 'overwrite',
169
+ 'no-pending',
170
+ 'liability-positive',
171
+ 'acknowledge',
172
+ 'always',
173
+ 'no-model',
174
+ 'only-uncategorized',
175
+ 'no-attach',
164
176
  'plain',
165
177
  'no-stream',
166
178
  'hidden',
@@ -450,10 +462,37 @@ ${colors.cyan}Commands:${colors.reset}
450
462
  summary Plain-text headline numbers (--json for machines)
451
463
  position Debt vs income, credits vs debits, recurring bills
452
464
  (also: coinpay finances debt)
465
+ scope <id> <side> Put an account on the business or personal books
466
+ (side: business | personal | clear)
453
467
  accounts Linked bank and card accounts with balances
454
468
  ledger Bank/card transactions (--limit, --search, --category, --account)
455
469
  connections Linked institutions and their last sync
456
470
  sync Pull fresh balances from the bank bridge (--days)
471
+ connect Link SimpleFIN: token prompted without echo, or --setup-token-stdin
472
+ disconnect <id> Stop syncs and drop the credential; history stays (--yes)
473
+ consent <id> on|off Opt a connection into a once-daily background sync
474
+ backfill Fetch a calendar period in safe chunks (--period 2026-Q2 |
475
+ --from/--to, --connection, --timezone, --wait, --json)
476
+ jobs [get|cancel <id>] Persisted sync/report jobs; reading one costs no request
477
+ coverage What part of a period was ever fetched (--period, --account)
478
+ report Monthly/quarterly/custom activity report (--period 2026-08 |
479
+ --from/--to; --account, --scope, --timezone, --format pdf|csv|html|json,
480
+ --output <file>, --strict, --wait, --json)
481
+ reports [get|download|delete <id>] Report history and downloads
482
+ statements import <pdf> Keep an original bank statement (--account, --period|--from/--to)
483
+ statements list|get|download|reconcile|delete
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 …)
457
496
 
458
497
  ${colors.bright}escrow${colors.reset}
459
498
  create Create a new escrow
@@ -3486,7 +3525,7 @@ const MENU_COMMANDS = [
3486
3525
  const SUBCOMMANDS = {
3487
3526
  config: [['set-key', 'Set your API key'], ['set-url', 'Set custom API URL'], ['show', 'Show current configuration']],
3488
3527
  auth: [['register', 'Register new merchant account'], ['login', 'Login to merchant account'], ['me', 'Show current merchant info']],
3489
- finances: [['tui', 'Live dashboard (default)'], ['summary', 'Headline numbers as text'], ['position', 'Debt vs income, credits vs debits'], ['accounts', 'Bank & card accounts'], ['ledger', 'Bank/card transactions'], ['connections', 'Linked institutions'], ['sync', 'Pull fresh bank data']],
3528
+ finances: [['tui', 'Live dashboard (default)'], ['summary', 'Headline numbers as text'], ['position', 'Debt vs income, credits vs debits'], ['scope', 'Set an account business or personal'], ['accounts', 'Bank & card accounts'], ['ledger', 'Bank/card transactions'], ['connections', 'Linked institutions'], ['sync', 'Pull fresh bank data'], ['report', 'Monthly/quarterly activity report'], ['backfill', 'Fetch a calendar period'], ['statements', 'Original bank statements'], ['jobs', 'Sync and report jobs']],
3490
3529
  payment: [['create', 'Create a new payment'], ['get', 'Get payment details <id>'], ['list', 'List payments'], ['qr', 'Get payment QR code <id>']],
3491
3530
  invoice: [['create', 'Create a draft invoice'], ['list', 'List invoices'], ['get', 'Get invoice details <id>'], ['update', 'Update a draft invoice <id>'], ['publish', 'Publish an invoice <id>'], ['send', 'Send an invoice <id>'], ['delete', 'Delete a draft invoice <id>']],
3492
3531
  tokens: [['list', 'List checkout tokens']],
@@ -4023,6 +4062,58 @@ async function handleFinances(subcommand, args, flags) {
4023
4062
  case 'dashboard':
4024
4063
  case 'watch': {
4025
4064
  const { client, token, baseUrl } = financesClient();
4065
+
4066
+ // A document for somebody who bills by the hour. Every question it
4067
+ // answers up front is a question nobody pays to have asked, so it
4068
+ // defaults to the whole transaction list and a year of history.
4069
+ if (flags.pdf || flags.report) {
4070
+ const reportDays = Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : 365;
4071
+ const limit = Number.parseInt(String(flags.limit ?? ''), 10);
4072
+ const snapshot = await fin.collectFinanceSnapshot(client, {
4073
+ days: reportDays,
4074
+ limit: Number.isFinite(limit) && limit > 0 ? limit : 5000,
4075
+ businessId: flags['business-id'],
4076
+ // Page the whole window. Asking for 5000 returns 500, and a report
4077
+ // built on one page understates the period without saying so.
4078
+ allTransactions: true,
4079
+ });
4080
+ const { buildFinanceReportHtml } = await import('../src/finances-report.js');
4081
+ const html = buildFinanceReportHtml(snapshot, {
4082
+ preparedFor: typeof flags.for === 'string' ? flags.for : '',
4083
+ preparedBy: typeof flags.by === 'string' ? flags.by : '',
4084
+ entity: typeof flags.entity === 'string' ? flags.entity : '',
4085
+ includeTransactions: !flags['no-transactions'],
4086
+ transactionLimit: 0,
4087
+ title: typeof flags.title === 'string' ? flags.title : 'Financial summary',
4088
+ });
4089
+
4090
+ const stamp = new Date().toISOString().slice(0, 10);
4091
+ const base = typeof flags.pdf === 'string' && flags.pdf
4092
+ ? String(flags.pdf).replace(/\.pdf$/i, '')
4093
+ : `finances-${stamp}`;
4094
+ const pdfPath = `${base}.pdf`;
4095
+ const htmlOut = `${base}.html`;
4096
+
4097
+ const { writeFileSync } = await import('node:fs');
4098
+ writeFileSync(htmlOut, html, 'utf8');
4099
+
4100
+ const { htmlToPdf, installHint } = await import('../src/pdf.js');
4101
+ const result = await htmlToPdf(html, pdfPath);
4102
+ if (result.ok) {
4103
+ console.log(`Wrote ${pdfPath}`);
4104
+ console.log(` ${htmlOut}`);
4105
+ } else {
4106
+ // The HTML is the report. Losing the PDF is a downgrade, not a failure.
4107
+ console.log(`Wrote ${htmlOut}`);
4108
+ console.log('');
4109
+ console.log('Could not render a PDF on this machine.');
4110
+ console.log(installHint(result.tried));
4111
+ console.log('');
4112
+ console.log('Or open the HTML in a browser and print it to PDF.');
4113
+ }
4114
+ return;
4115
+ }
4116
+
4026
4117
  const wantsText = flags.plain || flags.json || !process.stdout.isTTY || !process.stdin.isTTY;
4027
4118
  if (wantsText) {
4028
4119
  const snapshot = await fin.collectFinanceSnapshot(client, { days, businessId: flags['business-id'] });
@@ -4073,6 +4164,30 @@ async function handleFinances(subcommand, args, flags) {
4073
4164
  return;
4074
4165
  }
4075
4166
 
4167
+ case 'scope': {
4168
+ // Which set of books an account belongs to. The name-derived guess is
4169
+ // right about "Business Checking" and blind to a personal card carrying
4170
+ // company spend, so the correction is stored and always wins.
4171
+ const { client } = financesClient();
4172
+ const accountId = args[0];
4173
+ const value = (args[1] || '').toLowerCase();
4174
+ if (!accountId) {
4175
+ print.error('Usage: coinpay finances scope <account-id> <business|personal|clear> (ids: coinpay finances accounts)');
4176
+ process.exit(1);
4177
+ }
4178
+ const scope = value === 'clear' || value === 'auto' || value === '' ? null : value;
4179
+ if (scope !== null && scope !== 'business' && scope !== 'personal') {
4180
+ print.error(`Unknown scope '${value}'. Use business, personal, or clear.`);
4181
+ process.exit(1);
4182
+ }
4183
+ const account = await fin.updateFinanceAccount(client, accountId, { scope });
4184
+ if (flags.json) { print.json({ account }); return; }
4185
+ print.success(
4186
+ `${account.name} → ${account.effective_scope}${scope === null ? ' (derived from the name again)' : ''}`,
4187
+ );
4188
+ return;
4189
+ }
4190
+
4076
4191
  case 'accounts': {
4077
4192
  const { client } = financesClient();
4078
4193
  const accounts = await fin.listFinanceAccounts(client, { includeHidden: Boolean(flags.hidden) });
@@ -4084,6 +4199,7 @@ async function handleFinances(subcommand, args, flags) {
4084
4199
  { title: 'Institution', render: (a) => a.org_name || '—' },
4085
4200
  { title: 'Account', render: (a) => a.name },
4086
4201
  { title: 'Kind', render: (a) => a.effective_kind || a.kind },
4202
+ { title: 'Side', render: (a) => `${a.effective_scope || '—'}${a.scope_override ? '*' : ''}` },
4087
4203
  { title: 'Balance', align: 'right', render: (a) => money(a.display_balance ?? a.balance ?? 0, a.currency || 'USD') },
4088
4204
  { title: 'Available', align: 'right', render: (a) => (a.available_balance == null ? '—' : money(a.available_balance, a.currency || 'USD')) },
4089
4205
  { title: 'As of', render: (a) => shortDate(a.balance_date) },
@@ -4162,9 +4278,32 @@ async function handleFinances(subcommand, args, flags) {
4162
4278
  return;
4163
4279
  }
4164
4280
 
4281
+ case 'connect':
4282
+ case 'disconnect':
4283
+ case 'consent':
4284
+ case 'backfill':
4285
+ case 'jobs':
4286
+ case 'coverage':
4287
+ case 'report':
4288
+ case 'reports':
4289
+ case 'statements':
4290
+ case 'books':
4291
+ case 'payloads':
4292
+ case 'digest': {
4293
+ const { client } = financesClient();
4294
+ const { runFinancesCommand } = await import('../src/finances-commands.js');
4295
+ const code = await runFinancesCommand(subcommand, args, flags, {
4296
+ client,
4297
+ out: (line) => console.log(line),
4298
+ err: (line) => console.error(line),
4299
+ });
4300
+ if (code !== 0) process.exit(code);
4301
+ return;
4302
+ }
4303
+
4165
4304
  default:
4166
4305
  print.error(`Unknown finances command: ${subcommand}`);
4167
- console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync]');
4306
+ console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync|connect|disconnect|backfill|jobs|coverage|report|reports|statements|books|payloads|digest]');
4168
4307
  process.exit(1);
4169
4308
  }
4170
4309
  }
@@ -4508,12 +4647,16 @@ async function handleOAuth(subcommand, args, flags) {
4508
4647
  description,
4509
4648
  });
4510
4649
 
4650
+ // The API wraps the record: { success, client: { ..., client_secret }, warning }.
4651
+ // Accept a bare record too so either shape prints.
4652
+ const created = result.client ?? result;
4653
+
4511
4654
  print.success('OAuth client created');
4512
- if (result.client_id) {
4513
- console.log(`\n ${colors.bright}Client ID:${colors.reset} ${result.client_id}`);
4655
+ if (created.client_id) {
4656
+ console.log(`\n ${colors.bright}Client ID:${colors.reset} ${created.client_id}`);
4514
4657
  }
4515
- if (result.client_secret) {
4516
- console.log(` ${colors.bright}Client Secret:${colors.reset} ${colors.yellow}${result.client_secret}${colors.reset}`);
4658
+ if (created.client_secret) {
4659
+ console.log(` ${colors.bright}Client Secret:${colors.reset} ${colors.yellow}${created.client_secret}${colors.reset}`);
4517
4660
  console.log();
4518
4661
  print.warn('Save the client secret — it is only shown once!');
4519
4662
  }
@@ -4531,13 +4674,15 @@ async function handleOAuth(subcommand, args, flags) {
4531
4674
  }
4532
4675
 
4533
4676
  const result = await client.getOAuthClient(id);
4534
-
4535
- print.success(`OAuth Client: ${result.name || id}`);
4536
- if (result.client_id) print.info(` Client ID: ${result.client_id}`);
4537
- if (result.description) print.info(` Description: ${result.description}`);
4538
- if (result.redirect_uris) print.info(` Redirect URIs: ${result.redirect_uris.join(', ')}`);
4539
- if (result.scopes) print.info(` Scopes: ${Array.isArray(result.scopes) ? result.scopes.join(', ') : result.scopes}`);
4540
- if (result.created_at) print.info(` Created: ${result.created_at}`);
4677
+ // GET /oauth/clients/:id also wraps the record as { success, client }.
4678
+ const found = result.client ?? result;
4679
+
4680
+ print.success(`OAuth Client: ${found.name || id}`);
4681
+ if (found.client_id) print.info(` Client ID: ${found.client_id}`);
4682
+ if (found.description) print.info(` Description: ${found.description}`);
4683
+ if (found.redirect_uris) print.info(` Redirect URIs: ${found.redirect_uris.join(', ')}`);
4684
+ if (found.scopes) print.info(` Scopes: ${Array.isArray(found.scopes) ? found.scopes.join(', ') : found.scopes}`);
4685
+ if (found.created_at) print.info(` Created: ${found.created_at}`);
4541
4686
 
4542
4687
  if (flags.json) print.json(result);
4543
4688
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/coinpay",
3
- "version": "0.9.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",
@@ -58,6 +58,19 @@
58
58
  },
59
59
  "./finances-tui": {
60
60
  "import": "./src/finances-tui.js"
61
+ },
62
+ "./finances-reports": {
63
+ "types": "./src/finances-reports.d.ts",
64
+ "import": "./src/finances-reports.js",
65
+ "default": "./src/finances-reports.js"
66
+ },
67
+ "./finances-commands": {
68
+ "import": "./src/finances-commands.js",
69
+ "default": "./src/finances-commands.js"
70
+ },
71
+ "./finances-report": {
72
+ "import": "./src/finances-report.js",
73
+ "default": "./src/finances-report.js"
61
74
  }
62
75
  },
63
76
  "files": [
@@ -120,7 +133,7 @@
120
133
  "devDependencies": {
121
134
  "eslint": "^8.57.0",
122
135
  "prettier": "^3.4.1",
123
- "vitest": "^4.1.0"
136
+ "vitest": "^4.1.11"
124
137
  },
125
138
  "dependencies": {
126
139
  "@inquirer/prompts": "^8.3.0",
package/src/client.d.ts CHANGED
@@ -313,6 +313,16 @@ export class CoinPayClient {
313
313
  * @param eventType - Event type to simulate (default: `'payment.completed'`)
314
314
  */
315
315
  testWebhook(businessId: string, eventType?: string): Promise<Record<string, unknown>>;
316
+ /** Fetch a binary body (PDF, CSV) with its describing headers. */
317
+ requestBinary(endpoint: string, options?: RequestInit): Promise<{
318
+ bytes: Uint8Array;
319
+ contentType: string;
320
+ filename: string | null;
321
+ sha256: string | null;
322
+ headers: Record<string, string>;
323
+ }>;
324
+ /** POST a multipart form (file upload). */
325
+ requestForm(endpoint: string, form: FormData): Promise<any>;
316
326
  }
317
327
 
318
328
  export default CoinPayClient;
package/src/client.js CHANGED
@@ -77,6 +77,97 @@ export class CoinPayClient {
77
77
  }
78
78
  }
79
79
 
80
+ /**
81
+ * Fetch a binary body (a PDF, a CSV) rather than JSON.
82
+ *
83
+ * `request()` always parses JSON, which a file download is not. This
84
+ * returns the bytes plus the headers that describe them; an error body
85
+ * is still parsed as JSON so its `code` survives.
86
+ * @param {string} endpoint - API endpoint
87
+ * @param {Object} [options] - Fetch options
88
+ * @returns {Promise<{bytes: Uint8Array, contentType: string, filename: string|null, sha256: string|null, headers: Record<string,string>}>}
89
+ */
90
+ async requestBinary(endpoint, options = {}) {
91
+ if (!this.#apiKey) {
92
+ throw new Error('API key is required for authenticated requests.');
93
+ }
94
+ const url = `${this.#baseUrl}${endpoint}`;
95
+ const controller = new AbortController();
96
+ const timeoutId = setTimeout(() => controller.abort(), this.#timeout);
97
+ try {
98
+ const response = await fetch(url, {
99
+ ...options,
100
+ signal: controller.signal,
101
+ headers: { 'Authorization': `Bearer ${this.#apiKey}`, ...options.headers },
102
+ });
103
+ if (!response.ok) {
104
+ let data = {};
105
+ try { data = await response.json(); } catch { /* not JSON */ }
106
+ const structured = data && data.error && typeof data.error === 'object' ? data.error : null;
107
+ const error = new Error((structured && structured.message) || data.error || `HTTP ${response.status}`);
108
+ error.status = response.status;
109
+ error.response = data;
110
+ if (structured && structured.code) error.code = structured.code;
111
+ throw error;
112
+ }
113
+ const headers = {};
114
+ response.headers.forEach((value, key) => { headers[key] = value; });
115
+ const disposition = headers['content-disposition'] || '';
116
+ const match = /filename="?([^";]+)"?/i.exec(disposition);
117
+ return {
118
+ bytes: new Uint8Array(await response.arrayBuffer()),
119
+ contentType: headers['content-type'] || 'application/octet-stream',
120
+ filename: match ? match[1] : null,
121
+ sha256: headers['x-content-sha256'] || null,
122
+ headers,
123
+ };
124
+ } catch (error) {
125
+ if (error.name === 'AbortError') throw new Error(`Request timeout after ${this.#timeout}ms`);
126
+ throw error;
127
+ } finally {
128
+ clearTimeout(timeoutId);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * POST a multipart form (a file upload). The browser/undici sets the
134
+ * boundary, so no Content-Type is forced here.
135
+ * @param {string} endpoint - API endpoint
136
+ * @param {FormData} form - The form to send
137
+ * @returns {Promise<Object>} API response
138
+ */
139
+ async requestForm(endpoint, form) {
140
+ if (!this.#apiKey) {
141
+ throw new Error('API key is required for authenticated requests.');
142
+ }
143
+ const url = `${this.#baseUrl}${endpoint}`;
144
+ const controller = new AbortController();
145
+ const timeoutId = setTimeout(() => controller.abort(), this.#timeout);
146
+ try {
147
+ const response = await fetch(url, {
148
+ method: 'POST',
149
+ body: form,
150
+ signal: controller.signal,
151
+ headers: { 'Authorization': `Bearer ${this.#apiKey}` },
152
+ });
153
+ const data = await response.json();
154
+ if (!response.ok) {
155
+ const structured = data && data.error && typeof data.error === 'object' ? data.error : null;
156
+ const error = new Error((structured && structured.message) || data.error || `HTTP ${response.status}`);
157
+ error.status = response.status;
158
+ error.response = data;
159
+ if (structured && structured.code) error.code = structured.code;
160
+ throw error;
161
+ }
162
+ return data;
163
+ } catch (error) {
164
+ if (error.name === 'AbortError') throw new Error(`Request timeout after ${this.#timeout}ms`);
165
+ throw error;
166
+ } finally {
167
+ clearTimeout(timeoutId);
168
+ }
169
+ }
170
+
80
171
  /**
81
172
  * Make an unauthenticated API request (for registration/login)
82
173
  * @param {string} endpoint - API endpoint