@profullstack/coinpay 0.9.0 → 0.10.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,37 @@ 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.10.0] - 2026-09-12
9
+
10
+ ### Added
11
+ - `coinpay finances report`: monthly, quarterly and custom-period activity
12
+ reports generated server-side from an immutable snapshot, downloadable as
13
+ PDF, HTML, CSV or JSON (`--period 2026-08`, `--period 2026-Q2`, or
14
+ `--from/--to` with an exclusive end; `--account`, `--scope`, `--timezone`,
15
+ `--format`, `--output`, `--strict`, `--wait`, `--json`). Every report
16
+ states that it was generated by CoinPay from imported account data and is
17
+ not an institution-issued statement, and carries `local_export_complete`,
18
+ `provider_coverage` and `reconciliation_status`.
19
+ - `coinpay finances backfill`, `jobs`, `coverage`: durable, resumable
20
+ calendar-period fetches in provider-safe chunks, with per-account coverage
21
+ derived from what was actually requested.
22
+ - `coinpay finances statements import|list|get|download|reconcile|delete`:
23
+ keep original bank statements (PDF, user supplied, never parsed) beside an
24
+ account and period, and check their balances against a report revision
25
+ with exact decimal arithmetic.
26
+ - `coinpay finances connect` (setup token prompted without echo or via
27
+ `--setup-token-stdin`), `disconnect`, `consent <id> on|off`.
28
+ - SDK: `finances-reports` module (`createFinanceReport`, `waitForFinanceReport`,
29
+ `downloadFinanceReport`, `createBackfillJob`, `getFinanceCoverage`,
30
+ `importFinanceStatement`, `reconcileFinanceStatement`, ...), and
31
+ `CoinPayClient.requestBinary()` / `requestForm()` for downloads and uploads.
32
+ - Exit codes for the new subcommands: 0 done or job accepted, 2 invalid
33
+ input, 3 strict/completeness rejection, 4 authentication, 5 provider or
34
+ storage failure. In `--json` mode stdout carries only the result.
35
+
36
+ ### Fixed
37
+ - `listAllFinanceTransactions` now forwards `endDate`.
38
+
8
39
  ## [0.9.0] - 2026-09-05
9
40
 
10
41
  ### Added
@@ -31,6 +62,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
31
62
  Built from about six months of history rather than the dashboard window,
32
63
  because a monthly charge is invisible in thirty days of rows, and averaged
33
64
  over the history that exists rather than the history requested.
65
+ - `coinpay finances scope <account-id> <business|personal|clear>`, and a `Side`
66
+ column on the accounts table. Which set of books an account belongs to was a
67
+ guess from its name, which reads "Business Checking" correctly and is blind to
68
+ a personal card carrying company spend. The correction is now stored in
69
+ `finance_accounts.scope_override`, wins over the guess, and survives a re-sync
70
+ — the same contract as `kind_override`. An overridden account is marked with a
71
+ trailing `*`, so a wrong split is traceable to a guess rather than a decision.
34
72
  - Plain-text `coinpay finances summary|accounts|ledger|connections|sync`
35
73
  (`--json` on all of them) for scripts and for Node older than 22.6.
36
74
  - 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,14 @@ 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',
164
172
  'plain',
165
173
  'no-stream',
166
174
  'hidden',
@@ -450,10 +458,26 @@ ${colors.cyan}Commands:${colors.reset}
450
458
  summary Plain-text headline numbers (--json for machines)
451
459
  position Debt vs income, credits vs debits, recurring bills
452
460
  (also: coinpay finances debt)
461
+ scope <id> <side> Put an account on the business or personal books
462
+ (side: business | personal | clear)
453
463
  accounts Linked bank and card accounts with balances
454
464
  ledger Bank/card transactions (--limit, --search, --category, --account)
455
465
  connections Linked institutions and their last sync
456
466
  sync Pull fresh balances from the bank bridge (--days)
467
+ connect Link SimpleFIN: token prompted without echo, or --setup-token-stdin
468
+ disconnect <id> Stop syncs and drop the credential; history stays (--yes)
469
+ consent <id> on|off Opt a connection into a once-daily background sync
470
+ backfill Fetch a calendar period in safe chunks (--period 2026-Q2 |
471
+ --from/--to, --connection, --timezone, --wait, --json)
472
+ jobs [get|cancel <id>] Persisted sync/report jobs; reading one costs no request
473
+ coverage What part of a period was ever fetched (--period, --account)
474
+ report Monthly/quarterly/custom activity report (--period 2026-08 |
475
+ --from/--to; --account, --scope, --timezone, --format pdf|csv|html|json,
476
+ --output <file>, --strict, --wait, --json)
477
+ reports [get|download|delete <id>] Report history and downloads
478
+ statements import <pdf> Keep an original bank statement (--account, --period|--from/--to)
479
+ statements list|get|download|reconcile|delete
480
+ Statement library; reconcile takes --report --opening --closing --currency
457
481
 
458
482
  ${colors.bright}escrow${colors.reset}
459
483
  create Create a new escrow
@@ -3486,7 +3510,7 @@ const MENU_COMMANDS = [
3486
3510
  const SUBCOMMANDS = {
3487
3511
  config: [['set-key', 'Set your API key'], ['set-url', 'Set custom API URL'], ['show', 'Show current configuration']],
3488
3512
  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']],
3513
+ 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
3514
  payment: [['create', 'Create a new payment'], ['get', 'Get payment details <id>'], ['list', 'List payments'], ['qr', 'Get payment QR code <id>']],
3491
3515
  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
3516
  tokens: [['list', 'List checkout tokens']],
@@ -4023,6 +4047,58 @@ async function handleFinances(subcommand, args, flags) {
4023
4047
  case 'dashboard':
4024
4048
  case 'watch': {
4025
4049
  const { client, token, baseUrl } = financesClient();
4050
+
4051
+ // A document for somebody who bills by the hour. Every question it
4052
+ // answers up front is a question nobody pays to have asked, so it
4053
+ // defaults to the whole transaction list and a year of history.
4054
+ if (flags.pdf || flags.report) {
4055
+ const reportDays = Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : 365;
4056
+ const limit = Number.parseInt(String(flags.limit ?? ''), 10);
4057
+ const snapshot = await fin.collectFinanceSnapshot(client, {
4058
+ days: reportDays,
4059
+ limit: Number.isFinite(limit) && limit > 0 ? limit : 5000,
4060
+ businessId: flags['business-id'],
4061
+ // Page the whole window. Asking for 5000 returns 500, and a report
4062
+ // built on one page understates the period without saying so.
4063
+ allTransactions: true,
4064
+ });
4065
+ const { buildFinanceReportHtml } = await import('../src/finances-report.js');
4066
+ const html = buildFinanceReportHtml(snapshot, {
4067
+ preparedFor: typeof flags.for === 'string' ? flags.for : '',
4068
+ preparedBy: typeof flags.by === 'string' ? flags.by : '',
4069
+ entity: typeof flags.entity === 'string' ? flags.entity : '',
4070
+ includeTransactions: !flags['no-transactions'],
4071
+ transactionLimit: 0,
4072
+ title: typeof flags.title === 'string' ? flags.title : 'Financial summary',
4073
+ });
4074
+
4075
+ const stamp = new Date().toISOString().slice(0, 10);
4076
+ const base = typeof flags.pdf === 'string' && flags.pdf
4077
+ ? String(flags.pdf).replace(/\.pdf$/i, '')
4078
+ : `finances-${stamp}`;
4079
+ const pdfPath = `${base}.pdf`;
4080
+ const htmlOut = `${base}.html`;
4081
+
4082
+ const { writeFileSync } = await import('node:fs');
4083
+ writeFileSync(htmlOut, html, 'utf8');
4084
+
4085
+ const { htmlToPdf, installHint } = await import('../src/pdf.js');
4086
+ const result = await htmlToPdf(html, pdfPath);
4087
+ if (result.ok) {
4088
+ console.log(`Wrote ${pdfPath}`);
4089
+ console.log(` ${htmlOut}`);
4090
+ } else {
4091
+ // The HTML is the report. Losing the PDF is a downgrade, not a failure.
4092
+ console.log(`Wrote ${htmlOut}`);
4093
+ console.log('');
4094
+ console.log('Could not render a PDF on this machine.');
4095
+ console.log(installHint(result.tried));
4096
+ console.log('');
4097
+ console.log('Or open the HTML in a browser and print it to PDF.');
4098
+ }
4099
+ return;
4100
+ }
4101
+
4026
4102
  const wantsText = flags.plain || flags.json || !process.stdout.isTTY || !process.stdin.isTTY;
4027
4103
  if (wantsText) {
4028
4104
  const snapshot = await fin.collectFinanceSnapshot(client, { days, businessId: flags['business-id'] });
@@ -4073,6 +4149,30 @@ async function handleFinances(subcommand, args, flags) {
4073
4149
  return;
4074
4150
  }
4075
4151
 
4152
+ case 'scope': {
4153
+ // Which set of books an account belongs to. The name-derived guess is
4154
+ // right about "Business Checking" and blind to a personal card carrying
4155
+ // company spend, so the correction is stored and always wins.
4156
+ const { client } = financesClient();
4157
+ const accountId = args[0];
4158
+ const value = (args[1] || '').toLowerCase();
4159
+ if (!accountId) {
4160
+ print.error('Usage: coinpay finances scope <account-id> <business|personal|clear> (ids: coinpay finances accounts)');
4161
+ process.exit(1);
4162
+ }
4163
+ const scope = value === 'clear' || value === 'auto' || value === '' ? null : value;
4164
+ if (scope !== null && scope !== 'business' && scope !== 'personal') {
4165
+ print.error(`Unknown scope '${value}'. Use business, personal, or clear.`);
4166
+ process.exit(1);
4167
+ }
4168
+ const account = await fin.updateFinanceAccount(client, accountId, { scope });
4169
+ if (flags.json) { print.json({ account }); return; }
4170
+ print.success(
4171
+ `${account.name} → ${account.effective_scope}${scope === null ? ' (derived from the name again)' : ''}`,
4172
+ );
4173
+ return;
4174
+ }
4175
+
4076
4176
  case 'accounts': {
4077
4177
  const { client } = financesClient();
4078
4178
  const accounts = await fin.listFinanceAccounts(client, { includeHidden: Boolean(flags.hidden) });
@@ -4084,6 +4184,7 @@ async function handleFinances(subcommand, args, flags) {
4084
4184
  { title: 'Institution', render: (a) => a.org_name || '—' },
4085
4185
  { title: 'Account', render: (a) => a.name },
4086
4186
  { title: 'Kind', render: (a) => a.effective_kind || a.kind },
4187
+ { title: 'Side', render: (a) => `${a.effective_scope || '—'}${a.scope_override ? '*' : ''}` },
4087
4188
  { title: 'Balance', align: 'right', render: (a) => money(a.display_balance ?? a.balance ?? 0, a.currency || 'USD') },
4088
4189
  { title: 'Available', align: 'right', render: (a) => (a.available_balance == null ? '—' : money(a.available_balance, a.currency || 'USD')) },
4089
4190
  { title: 'As of', render: (a) => shortDate(a.balance_date) },
@@ -4162,9 +4263,29 @@ async function handleFinances(subcommand, args, flags) {
4162
4263
  return;
4163
4264
  }
4164
4265
 
4266
+ case 'connect':
4267
+ case 'disconnect':
4268
+ case 'consent':
4269
+ case 'backfill':
4270
+ case 'jobs':
4271
+ case 'coverage':
4272
+ case 'report':
4273
+ case 'reports':
4274
+ case 'statements': {
4275
+ const { client } = financesClient();
4276
+ const { runFinancesCommand } = await import('../src/finances-commands.js');
4277
+ const code = await runFinancesCommand(subcommand, args, flags, {
4278
+ client,
4279
+ out: (line) => console.log(line),
4280
+ err: (line) => console.error(line),
4281
+ });
4282
+ if (code !== 0) process.exit(code);
4283
+ return;
4284
+ }
4285
+
4165
4286
  default:
4166
4287
  print.error(`Unknown finances command: ${subcommand}`);
4167
- console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync]');
4288
+ console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync|connect|disconnect|backfill|jobs|coverage|report|reports|statements]');
4168
4289
  process.exit(1);
4169
4290
  }
4170
4291
  }
@@ -4508,12 +4629,16 @@ async function handleOAuth(subcommand, args, flags) {
4508
4629
  description,
4509
4630
  });
4510
4631
 
4632
+ // The API wraps the record: { success, client: { ..., client_secret }, warning }.
4633
+ // Accept a bare record too so either shape prints.
4634
+ const created = result.client ?? result;
4635
+
4511
4636
  print.success('OAuth client created');
4512
- if (result.client_id) {
4513
- console.log(`\n ${colors.bright}Client ID:${colors.reset} ${result.client_id}`);
4637
+ if (created.client_id) {
4638
+ console.log(`\n ${colors.bright}Client ID:${colors.reset} ${created.client_id}`);
4514
4639
  }
4515
- if (result.client_secret) {
4516
- console.log(` ${colors.bright}Client Secret:${colors.reset} ${colors.yellow}${result.client_secret}${colors.reset}`);
4640
+ if (created.client_secret) {
4641
+ console.log(` ${colors.bright}Client Secret:${colors.reset} ${colors.yellow}${created.client_secret}${colors.reset}`);
4517
4642
  console.log();
4518
4643
  print.warn('Save the client secret — it is only shown once!');
4519
4644
  }
@@ -4531,13 +4656,15 @@ async function handleOAuth(subcommand, args, flags) {
4531
4656
  }
4532
4657
 
4533
4658
  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}`);
4659
+ // GET /oauth/clients/:id also wraps the record as { success, client }.
4660
+ const found = result.client ?? result;
4661
+
4662
+ print.success(`OAuth Client: ${found.name || id}`);
4663
+ if (found.client_id) print.info(` Client ID: ${found.client_id}`);
4664
+ if (found.description) print.info(` Description: ${found.description}`);
4665
+ if (found.redirect_uris) print.info(` Redirect URIs: ${found.redirect_uris.join(', ')}`);
4666
+ if (found.scopes) print.info(` Scopes: ${Array.isArray(found.scopes) ? found.scopes.join(', ') : found.scopes}`);
4667
+ if (found.created_at) print.info(` Created: ${found.created_at}`);
4541
4668
 
4542
4669
  if (flags.json) print.json(result);
4543
4670
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/coinpay",
3
- "version": "0.9.0",
3
+ "version": "0.10.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