@profullstack/coinpay 0.6.11 → 0.9.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/bin/coinpay.js CHANGED
@@ -15,16 +15,43 @@ import {
15
15
  DEFAULT_CHAINS,
16
16
  } from '../src/wallet.js';
17
17
  import { SwapClient, SwapCoins } from '../src/swap.js';
18
- import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
19
- import { execSync } from 'child_process';
18
+ import {
19
+ createInvoice,
20
+ deleteInvoice,
21
+ getInvoice,
22
+ listInvoices,
23
+ publishInvoice,
24
+ sendInvoice,
25
+ updateInvoice,
26
+ InvoiceStatus,
27
+ } from '../src/invoices.js';
28
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, chmodSync } from 'fs';
29
+ import { execSync, spawn } from 'child_process';
20
30
  import { createInterface } from 'readline';
21
- import { homedir } from 'os';
31
+ import { homedir, hostname } from 'os';
22
32
  import { join } from 'path';
23
33
  import { tmpdir } from 'os';
24
34
 
25
35
  const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
26
- const CONFIG_FILE = join(homedir(), '.coinpay.json');
27
- const DEFAULT_WALLET_FILE = join(homedir(), '.coinpay-wallet.gpg');
36
+ const HOME_DIRECTORY = process.env.COINPAY_HOME || process.env.HOME || homedir();
37
+ const CONFIG_FILE = join(HOME_DIRECTORY, '.coinpay.json');
38
+ const DEFAULT_WALLET_FILE = join(HOME_DIRECTORY, '.coinpay-wallet.gpg');
39
+ // Where `coinpay update|upgrade|remove|uninstall` re-invoke the installer.
40
+ const INSTALL_URL = process.env.COINPAY_INSTALL_URL || 'https://coinpayportal.com/install.sh';
41
+
42
+ // Self-manage the CLI by delegating to the installer, which owns the
43
+ // fetch-from-GitHub / uninstall logic. Works whether the user hit the
44
+ // shell wrapper (which also intercepts these) or the raw binary directly.
45
+ function handleSelfManage(action) {
46
+ const label = action === 'remove' ? 'Removing' : 'Updating';
47
+ print.info(`${label} the coinpay CLI via ${INSTALL_URL}`);
48
+ try {
49
+ execSync(`curl -fsSL "${INSTALL_URL}" | sh -s -- ${action}`, { stdio: 'inherit' });
50
+ } catch {
51
+ print.error(`coinpay ${action} failed — run it manually:\n curl -fsSL ${INSTALL_URL} | sh -s -- ${action}`);
52
+ process.exit(1);
53
+ }
54
+ }
28
55
 
29
56
  // ANSI colors
30
57
  const colors = {
@@ -74,11 +101,11 @@ function saveConfig(config) {
74
101
  */
75
102
  function getWalletFilePath(flags = {}) {
76
103
  if (flags['wallet-file']) {
77
- return flags['wallet-file'].replace(/^~/, homedir());
104
+ return flags['wallet-file'].replace(/^~/, HOME_DIRECTORY);
78
105
  }
79
106
  const config = loadConfig();
80
107
  if (config.walletFile) {
81
- return config.walletFile.replace(/^~/, homedir());
108
+ return config.walletFile.replace(/^~/, HOME_DIRECTORY);
82
109
  }
83
110
  return DEFAULT_WALLET_FILE;
84
111
  }
@@ -107,13 +134,19 @@ function getBaseUrl() {
107
134
  return process.env.COINPAY_BASE_URL || config.baseUrl || 'https://coinpayportal.com/api';
108
135
  }
109
136
 
137
+ function getInvoiceShareUrl(invoiceId) {
138
+ return new URL(`/now/${encodeURIComponent(invoiceId)}`, getBaseUrl()).toString();
139
+ }
140
+
110
141
  /**
111
142
  * Create client instance
112
143
  */
113
144
  function createClient() {
114
- const apiKey = getApiKey();
145
+ // An API key, or the session token from `coinpay login` (the merchant JWT is
146
+ // accepted by the API the same way — via its userId claim).
147
+ const apiKey = getApiKey() || loadConfig().jwtToken;
115
148
  if (!apiKey) {
116
- print.error('API key not configured. Run: coinpay config set-key <api-key>');
149
+ print.error('Not authenticated. Run: coinpay login (or coinpay config set-key <api-key>)');
117
150
  process.exit(1);
118
151
  }
119
152
  return new CoinPayClient({ apiKey, baseUrl: getBaseUrl() });
@@ -126,6 +159,26 @@ function createUnauthenticatedClient() {
126
159
  return new CoinPayClient({ baseUrl: getBaseUrl() });
127
160
  }
128
161
 
162
+ const BOOLEAN_FLAGS = new Set([
163
+ 'active',
164
+ 'plain',
165
+ 'no-stream',
166
+ 'hidden',
167
+ 'active-only',
168
+ 'debug',
169
+ 'escrow',
170
+ 'escrow-mode',
171
+ 'h',
172
+ 'help',
173
+ 'json',
174
+ 'no-open',
175
+ 'no-save',
176
+ 'show',
177
+ 'v',
178
+ 'version',
179
+ 'yes',
180
+ ]);
181
+
129
182
  /**
130
183
  * Parse command line arguments
131
184
  */
@@ -140,16 +193,20 @@ function parseArgs(args) {
140
193
  if (eqIdx !== -1) {
141
194
  result.flags[arg.slice(2, eqIdx)] = arg.slice(eqIdx + 1);
142
195
  } else {
196
+ const name = arg.slice(2);
143
197
  const next = args[i + 1];
144
- if (next && !next.startsWith('-')) {
145
- result.flags[arg.slice(2)] = next;
198
+ if (BOOLEAN_FLAGS.has(name)) {
199
+ result.flags[name] = true;
200
+ } else if (next && !next.startsWith('-')) {
201
+ result.flags[name] = next;
146
202
  i++;
147
203
  } else {
148
- result.flags[arg.slice(2)] = true;
204
+ result.flags[name] = true;
149
205
  }
150
206
  }
151
207
  } else if (arg.startsWith('-')) {
152
- result.flags[arg.slice(1)] = args[++i] ?? true;
208
+ const name = arg.slice(1);
209
+ result.flags[name] = BOOLEAN_FLAGS.has(name) ? true : args[++i] ?? true;
153
210
  } else if (!result.command) {
154
211
  result.command = arg;
155
212
  } else if (!result.subcommand) {
@@ -162,6 +219,134 @@ function parseArgs(args) {
162
219
  return result;
163
220
  }
164
221
 
222
+ function validateBooleanFlags(flags) {
223
+ for (const name of BOOLEAN_FLAGS) {
224
+ if (flags[name] !== undefined && flags[name] !== true) {
225
+ print.error('--' + name + ' does not take a value');
226
+ process.exit(1);
227
+ }
228
+ }
229
+ }
230
+
231
+ function parsePositiveInteger(value) {
232
+ if (typeof value !== 'string' || !/^\d+$/.test(value.trim())) {
233
+ return null;
234
+ }
235
+
236
+ const parsed = Number(value);
237
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
238
+ }
239
+
240
+ function parsePositiveAmount(value) {
241
+ if (typeof value !== 'string') return null;
242
+
243
+ const normalized = value.trim();
244
+ const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(normalized);
245
+ if (!match) return null;
246
+
247
+ const cents = BigInt(match[1]) * 100n + BigInt((match[2] || '').padEnd(2, '0'));
248
+ if (cents <= 0n || cents > BigInt(Number.MAX_SAFE_INTEGER)) return null;
249
+
250
+ return Number(cents) / 100;
251
+ }
252
+
253
+ function isValidDate(value) {
254
+ if (typeof value !== 'string') return false;
255
+
256
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
257
+ if (!match) return false;
258
+
259
+ const [, year, month, day] = match.map(Number);
260
+ const date = new Date(Date.UTC(year, month - 1, day));
261
+ return (
262
+ date.getUTCFullYear() === year &&
263
+ date.getUTCMonth() === month - 1 &&
264
+ date.getUTCDate() === day
265
+ );
266
+ }
267
+
268
+ function requireFlagValues(flags, names) {
269
+ for (const name of names) {
270
+ if (flags[name] === true) {
271
+ print.error('--' + name + ' requires a value');
272
+ process.exit(1);
273
+ }
274
+ }
275
+ }
276
+
277
+ function requireSingleInvoiceId(args, subcommand) {
278
+ if (args.length !== 1) {
279
+ print.error('Usage: coinpay invoice ' + subcommand + ' <id>');
280
+ process.exit(1);
281
+ }
282
+ return args[0];
283
+ }
284
+
285
+ function rejectUnsupportedFlags(flags, allowed) {
286
+ const allowedFlags = new Set([...allowed, 'debug', 'json']);
287
+ const unsupported = Object.keys(flags).filter((name) => !allowedFlags.has(name));
288
+ if (unsupported.length > 0) {
289
+ print.error('Unsupported option' + (unsupported.length > 1 ? 's' : '') + ': ' +
290
+ unsupported.map((name) => '--' + name).join(', '));
291
+ process.exit(1);
292
+ }
293
+ }
294
+
295
+ function invoiceForOutput(data, fallbackId) {
296
+ const invoice = data?.invoice ?? data ?? {};
297
+ const output = {
298
+ id: invoice.id ?? fallbackId,
299
+ invoiceNumber: invoice.invoiceNumber ?? invoice.invoice_number,
300
+ businessId: invoice.businessId ?? invoice.business_id,
301
+ clientId: invoice.clientId ?? invoice.client_id,
302
+ currency: invoice.currency,
303
+ amount: invoice.amount,
304
+ cryptoCurrency: invoice.cryptoCurrency ?? invoice.crypto_currency,
305
+ cryptoAmount: invoice.cryptoAmount ?? invoice.crypto_amount,
306
+ status: invoice.status,
307
+ dueDate: invoice.dueDate ?? invoice.due_date,
308
+ notes: invoice.notes,
309
+ walletId: invoice.walletId ?? invoice.wallet_id,
310
+ merchantWalletAddress:
311
+ invoice.merchantWalletAddress ?? invoice.merchant_wallet_address,
312
+ paymentAddress: invoice.paymentAddress ?? invoice.payment_address,
313
+ stripeCheckoutUrl: invoice.stripeCheckoutUrl ?? invoice.stripe_checkout_url,
314
+ paidAt: invoice.paidAt ?? invoice.paid_at,
315
+ sentAt: invoice.sentAt ?? invoice.sent_at,
316
+ createdAt: invoice.createdAt ?? invoice.created_at,
317
+ updatedAt: invoice.updatedAt ?? invoice.updated_at,
318
+ };
319
+
320
+ return Object.fromEntries(
321
+ Object.entries(output).filter(([, value]) => value !== undefined)
322
+ );
323
+ }
324
+
325
+ function assertInvoiceUpdatesApplied(invoice, updates) {
326
+ if (invoice.status !== InvoiceStatus.DRAFT) {
327
+ throw new Error('Invoice changed state before the update completed; no draft update was confirmed');
328
+ }
329
+
330
+ const mismatches = Object.entries(updates).filter(([field, expected]) => {
331
+ const actual = invoice[field];
332
+ if (field === 'amount') return Number(actual) !== Number(expected);
333
+ if (field === 'dueDate') return String(actual || '').slice(0, 10) !== expected;
334
+ return actual !== expected;
335
+ });
336
+
337
+ if (mismatches.length > 0) {
338
+ throw new Error(
339
+ 'Invoice update was not confirmed for: ' + mismatches.map(([field]) => field).join(', ')
340
+ );
341
+ }
342
+ }
343
+
344
+ function describeInvoiceRouting(invoice) {
345
+ const crypto = invoice.cryptoCurrency || 'unset crypto';
346
+ const destination = invoice.merchantWalletAddress || 'resolved payee';
347
+ return crypto + ' -> ' + destination;
348
+ }
349
+
165
350
  /**
166
351
  * Show help
167
352
  */
@@ -189,8 +374,30 @@ ${colors.cyan}Commands:${colors.reset}
189
374
  list List payments
190
375
  qr <id> Get payment QR code
191
376
 
377
+ ${colors.bright}invoice${colors.reset}
378
+ create Create a draft invoice (--amount required)
379
+ --wallet-id <id> Route settlement through a saved wallet
380
+ --merchant-wallet-address <address>
381
+ Route settlement to a direct address
382
+ get <id> Get invoice details
383
+ list List invoices with optional filters
384
+ update <id> Update editable fields on a draft invoice
385
+ publish <id> Create payment details without emailing the client
386
+ send <id> Create payment details and email the client
387
+ delete <id> Permanently delete a draft invoice
388
+ --yes Skip confirmation for publish/send/delete
389
+
390
+ ${colors.bright}tokens${colors.reset}
391
+ list List checkout tokens (--business-id, --active-only)
392
+
192
393
  ${colors.bright}business${colors.reset}
193
394
  create Create a new business
395
+ --name <name> Required
396
+ --category <slug> Required; see: coinpay business categories
397
+ --description <s> Optional
398
+ --webhook-url <url> Optional; enables webhooks and a generated secret
399
+ --webhook-secret <s> Optional; supply your own instead
400
+ categories List the valid business category slugs
194
401
  get <id> Get business details
195
402
  list List businesses
196
403
  update <id> Update business
@@ -229,11 +436,25 @@ ${colors.cyan}Commands:${colors.reset}
229
436
  create Create a card payment via Stripe
230
437
  get <id> Get card payment status
231
438
  list List card payments
232
- connect onboard <id> Create Stripe onboarding link
439
+ connect onboard <id> Create Stripe onboarding link (--country required first time)
233
440
  connect status <id> Check Stripe account status
234
441
  escrow release <id> Release card escrow funds
235
442
  escrow refund <id> Refund card escrow
236
443
 
444
+ ${colors.bright}finances${colors.reset} (also: coinpay money)
445
+ [tui] Live dashboard: earnings, commission, refunds,
446
+ bank & credit-card feeds, ledger, invoices, escrow
447
+ --days <n> Window for earnings/cashflow (7, 30, 90, 365; default 30)
448
+ --interval <s> Refresh every s seconds (default 30)
449
+ --business-id <id> Narrow payments to one business
450
+ summary Plain-text headline numbers (--json for machines)
451
+ position Debt vs income, credits vs debits, recurring bills
452
+ (also: coinpay finances debt)
453
+ accounts Linked bank and card accounts with balances
454
+ ledger Bank/card transactions (--limit, --search, --category, --account)
455
+ connections Linked institutions and their last sync
456
+ sync Pull fresh balances from the bank bridge (--days)
457
+
237
458
  ${colors.bright}escrow${colors.reset}
238
459
  create Create a new escrow
239
460
  get <id> Get escrow status
@@ -259,6 +480,7 @@ ${colors.cyan}Commands:${colors.reset}
259
480
  badge [did] Get embeddable reputation badge URL
260
481
  verify <id> Verify a credential
261
482
  revocations List revoked credentials
483
+ did setup Use existing DID or create one automatically
262
484
  did claim Claim (auto-generate) a new DID
263
485
  did me Show your current DID
264
486
  issuer register Register platform issuer (--name, --domain)
@@ -270,6 +492,12 @@ ${colors.cyan}Commands:${colors.reset}
270
492
  logs <business-id> Get webhook logs
271
493
  test <business-id> Send test webhook
272
494
 
495
+ ${colors.bright}self${colors.reset}
496
+ update Update the coinpay CLI to the latest version
497
+ upgrade Alias for update
498
+ remove Uninstall the coinpay CLI
499
+ uninstall Alias for remove
500
+
273
501
  ${colors.cyan}Wallet Options:${colors.reset}
274
502
  --words <12|24> Number of mnemonic words (default: 12)
275
503
  --chains <BTC,ETH,...> Chains to derive (default: BTC,ETH,SOL,POL,BCH)
@@ -326,6 +554,12 @@ ${colors.cyan}Examples:${colors.reset}
326
554
  coinpay swap quote --from BTC --to ETH --amount 0.1
327
555
  coinpay swap create --from BTC --to ETH --amount 0.1 --settle 0x...
328
556
 
557
+ # Connect Stripe to a business (country is locked at Stripe account creation)
558
+ coinpay card connect onboard <business-id> --country CA --email merchant@example.com
559
+
560
+ # List payment tokens for a business
561
+ coinpay tokens list --business-id <business-id> --active-only
562
+
329
563
  ${colors.cyan}Environment Variables:${colors.reset}
330
564
  COINPAY_API_KEY API key (overrides config)
331
565
  COINPAY_BASE_URL Custom API URL
@@ -388,6 +622,73 @@ async function promptYesNo(question, defaultYes = true) {
388
622
  return answer.toLowerCase().startsWith('y');
389
623
  }
390
624
 
625
+ async function promptYesNoOnStderr(question) {
626
+ const answer = await new Promise((resolve) => {
627
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
628
+ rl.question(question + ' [y/N] ', (value) => {
629
+ rl.close();
630
+ resolve(value.trim());
631
+ });
632
+ });
633
+ return answer.toLowerCase().startsWith('y');
634
+ }
635
+
636
+ async function confirmInvoiceMutation(flags, question) {
637
+ if (flags.yes === true) return true;
638
+ if (flags.json) {
639
+ throw new Error('--json requires --yes for invoice publish/send/delete');
640
+ }
641
+ if (!process.stdin.isTTY) {
642
+ throw new Error('Confirmation requires an interactive terminal; rerun with --yes');
643
+ }
644
+ return promptYesNoOnStderr(question);
645
+ }
646
+
647
+ /**
648
+ * Run gpg with the passphrase on a dedicated file descriptor and the plaintext
649
+ * on stdin, so neither ever touches the filesystem.
650
+ *
651
+ * This replaces a pair of temporary files in the system temp directory that
652
+ * held the wallet MNEMONIC and the password in plaintext for the duration of
653
+ * the gpg call. Three things made that dangerous:
654
+ *
655
+ * 1. The names were predictable — built from Date.now() — so another process
656
+ * could poll the temp directory for `coinpay-wallet-*`.
657
+ * 2. Mode 0600 keeps out other OS users. It does not keep out anything
658
+ * running AS the same user, which is the realistic threat on a developer
659
+ * machine: a malicious npm postinstall, a compromised editor extension.
660
+ * 3. Overwrite-then-unlink does not reliably erase data on journalling or
661
+ * copy-on-write filesystems, or on SSDs that remap blocks.
662
+ *
663
+ * The mnemonic derives every private key on every chain, so this window was
664
+ * worth removing rather than narrowing. Using fd 3 also keeps the passphrase
665
+ * off the command line, where it would be visible in `ps`.
666
+ */
667
+ function runGpg(args, { stdinData, passphrase }) {
668
+ return new Promise((resolve, reject) => {
669
+ const child = spawn('gpg', args, { stdio: ['pipe', 'pipe', 'pipe', 'pipe'] });
670
+
671
+ const stdout = [];
672
+ let stderr = '';
673
+
674
+ child.stdout.on('data', (d) => stdout.push(d));
675
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
676
+ child.on('error', reject);
677
+ child.on('close', (code) => {
678
+ if (code === 0) resolve(Buffer.concat(stdout));
679
+ else reject(new Error(stderr.trim() || `gpg exited with code ${code}`));
680
+ });
681
+
682
+ const passFd = child.stdio[3];
683
+ passFd.on('error', () => {});
684
+ passFd.end(passphrase);
685
+
686
+ child.stdin.on('error', () => {});
687
+ if (stdinData !== undefined) child.stdin.end(stdinData);
688
+ else child.stdin.end();
689
+ });
690
+ }
691
+
391
692
  /**
392
693
  * Check if gpg is available
393
694
  */
@@ -403,7 +704,39 @@ function hasGpg() {
403
704
  /**
404
705
  * Encrypt mnemonic with GPG and save to file
405
706
  */
707
+
708
+ /**
709
+ * Minimum strength for a passphrase that protects an exported wallet.
710
+ *
711
+ * F5-L1-07 / L6B-05: there was no minimum at all here, while the sibling
712
+ * `generate-hd-wallets` backup asks for eight characters and the web wallet's
713
+ * create/import flow requires length >= 8 AND a strength score. A one-character
714
+ * passphrase was accepted on the path that produces a file an attacker can walk
715
+ * away with — and GPG's default KDF is far cheaper to attack on a GPU than the
716
+ * scrypt used by the other backup, so the passphrase is doing most of the work.
717
+ */
718
+ function assertStrongPassphrase(password) {
719
+ const problems = [];
720
+ if (!password || password.length < 12) {
721
+ problems.push('at least 12 characters');
722
+ }
723
+ if (!/[a-z]/.test(password ?? '') || !/[A-Z]/.test(password ?? '')) {
724
+ problems.push('both upper and lower case');
725
+ }
726
+ if (!/\d/.test(password ?? '') && !/[^A-Za-z0-9]/.test(password ?? '')) {
727
+ problems.push('a digit or a symbol');
728
+ }
729
+ if (problems.length > 0) {
730
+ throw new Error(
731
+ 'Passphrase too weak — this is the only thing protecting your wallet file. ' +
732
+ 'It needs ' + problems.join(', ') + '.'
733
+ );
734
+ }
735
+ }
736
+
406
737
  async function saveEncryptedWallet(mnemonic, walletId, password, walletFile) {
738
+ assertStrongPassphrase(password);
739
+
407
740
  if (!hasGpg()) {
408
741
  throw new Error('GPG is required for wallet encryption. Install: apt install gnupg');
409
742
  }
@@ -415,24 +748,38 @@ async function saveEncryptedWallet(mnemonic, walletId, password, walletFile) {
415
748
  createdAt: new Date().toISOString(),
416
749
  });
417
750
 
418
- const tmpFile = join(tmpdir(), `coinpay-wallet-${Date.now()}.json`);
419
- const passFile = join(tmpdir(), `coinpay-pass-${Date.now()}`);
420
-
751
+ // The mnemonic goes to gpg on stdin and the passphrase on fd 3. Neither is
752
+ // written to disk, and neither appears in the process arguments.
753
+ await runGpg(
754
+ [
755
+ '--batch',
756
+ '--yes',
757
+ '--passphrase-fd', '3',
758
+ '--pinentry-mode', 'loopback',
759
+ '--symmetric',
760
+ '--cipher-algo', 'AES256',
761
+ '--output', walletFile,
762
+ ],
763
+ { stdinData: content, passphrase: password }
764
+ );
765
+
766
+ // Restrict the file gpg just created (F-L7-01 / F5-L1-02).
767
+ //
768
+ // `--output` means GPG creates the file itself, at the process umask — so it
769
+ // typically lands world-readable, and every other local user can copy an
770
+ // encrypted wallet at their leisure and attack the passphrase offline.
771
+ // Sibling writes in this same CLI pass `{ mode: 0o600 }`, but that option
772
+ // only applies when Node does the writing.
421
773
  try {
422
- writeFileSync(tmpFile, content, { mode: 0o600 });
423
- writeFileSync(passFile, password, { mode: 0o600 });
424
-
425
- execSync(
426
- `gpg --batch --yes --passphrase-file "${passFile}" --pinentry-mode loopback --symmetric --cipher-algo AES256 --output "${walletFile}" "${tmpFile}"`,
427
- { stdio: ['pipe', 'pipe', 'pipe'] }
774
+ chmodSync(walletFile, 0o600);
775
+ } catch (err) {
776
+ console.warn(
777
+ `Warning: could not restrict permissions on ${walletFile} — ` +
778
+ 'other local users may be able to read it. ' + (err?.message ?? err)
428
779
  );
429
-
430
- return true;
431
- } finally {
432
- // Secure cleanup
433
- try { writeFileSync(tmpFile, Buffer.alloc(content.length, 0)); unlinkSync(tmpFile); } catch {}
434
- try { writeFileSync(passFile, Buffer.alloc(password.length, 0)); unlinkSync(passFile); } catch {}
435
780
  }
781
+
782
+ return true;
436
783
  }
437
784
 
438
785
  /**
@@ -447,21 +794,18 @@ async function loadEncryptedWallet(password, walletFile) {
447
794
  throw new Error(`Wallet file not found: ${walletFile}`);
448
795
  }
449
796
 
450
- const passFile = join(tmpdir(), `coinpay-pass-${Date.now()}`);
451
-
452
- try {
453
- writeFileSync(passFile, password, { mode: 0o600 });
454
-
455
- const result = execSync(
456
- `gpg --batch --yes --passphrase-file "${passFile}" --pinentry-mode loopback --decrypt "${walletFile}"`,
457
- { stdio: ['pipe', 'pipe', 'pipe'] }
458
- );
459
-
460
- const data = JSON.parse(result.toString());
461
- return data;
462
- } finally {
463
- try { writeFileSync(passFile, Buffer.alloc(password.length, 0)); unlinkSync(passFile); } catch {}
464
- }
797
+ const result = await runGpg(
798
+ [
799
+ '--batch',
800
+ '--yes',
801
+ '--passphrase-fd', '3',
802
+ '--pinentry-mode', 'loopback',
803
+ '--decrypt', walletFile,
804
+ ],
805
+ { passphrase: password }
806
+ );
807
+
808
+ return JSON.parse(result.toString());
465
809
  }
466
810
 
467
811
  /**
@@ -607,33 +951,521 @@ async function handlePayment(subcommand, args, flags) {
607
951
  print.error('Required: --business-id');
608
952
  return;
609
953
  }
610
-
611
- const payments = await client.listPayments({
612
- businessId,
613
- status,
614
- limit: limit ? parseInt(limit, 10) : undefined,
615
- });
616
-
617
- print.json(payments);
618
- break;
619
- }
620
-
621
- case 'qr': {
622
- const paymentId = args[0];
623
-
624
- if (!paymentId) {
625
- print.error('Payment ID required');
626
- return;
954
+
955
+ const payments = await client.listPayments({
956
+ businessId,
957
+ status,
958
+ limit: limit ? parseInt(limit, 10) : undefined,
959
+ });
960
+
961
+ print.json(payments);
962
+ break;
963
+ }
964
+
965
+ case 'qr': {
966
+ const paymentId = args[0];
967
+
968
+ if (!paymentId) {
969
+ print.error('Payment ID required');
970
+ return;
971
+ }
972
+
973
+ const url = client.getPaymentQRUrl(paymentId);
974
+ print.info(`QR Code URL: ${url}`);
975
+ break;
976
+ }
977
+
978
+ default:
979
+ print.error(`Unknown payment command: ${subcommand}`);
980
+ showHelp();
981
+ }
982
+ }
983
+
984
+ function printInvoiceDetails(invoice, { shareUrl } = {}) {
985
+ const label = invoice.invoiceNumber || invoice.id;
986
+ console.log('\n' + colors.bright + label + colors.reset);
987
+ print.info('ID: ' + invoice.id);
988
+ print.info('Status: ' + (invoice.status || 'unknown'));
989
+ print.info('Amount: ' + invoice.amount + ' ' + (invoice.currency || ''));
990
+ if (invoice.businessId) print.info('Business: ' + invoice.businessId);
991
+ if (invoice.clientId) print.info('Client: ' + invoice.clientId);
992
+ if (invoice.cryptoCurrency) print.info('Crypto: ' + invoice.cryptoCurrency);
993
+ if (invoice.walletId) print.info('Wallet reference: ' + invoice.walletId);
994
+ if (invoice.merchantWalletAddress) {
995
+ print.info('Settlement address: ' + invoice.merchantWalletAddress);
996
+ }
997
+ if (invoice.paymentAddress) print.info('Payment address: ' + invoice.paymentAddress);
998
+ if (invoice.dueDate) print.info('Due: ' + invoice.dueDate);
999
+ if (invoice.notes) print.info('Notes: ' + invoice.notes);
1000
+ if (shareUrl) {
1001
+ print.info('Share link: ' + shareUrl);
1002
+ }
1003
+ console.log();
1004
+ }
1005
+
1006
+ /**
1007
+ * Invoice commands
1008
+ */
1009
+ async function handleInvoice(subcommand, args, flags) {
1010
+ const client = createClient();
1011
+
1012
+ switch (subcommand) {
1013
+ case 'create': {
1014
+ requireFlagValues(flags, [
1015
+ 'amount',
1016
+ 'business-id',
1017
+ 'client-id',
1018
+ 'currency',
1019
+ 'crypto-currency',
1020
+ 'due-date',
1021
+ 'notes',
1022
+ 'wallet-id',
1023
+ 'merchant-wallet-address',
1024
+ ]);
1025
+
1026
+ const amount = parsePositiveAmount(flags.amount);
1027
+ if (amount === null) {
1028
+ print.error('--amount must be a positive decimal with at most two decimal places');
1029
+ print.info('Example: coinpay invoice create --amount 125 --currency USD --notes "Consulting"');
1030
+ process.exit(1);
1031
+ }
1032
+
1033
+ const currency = flags.currency || 'USD';
1034
+ if (!/^[A-Za-z]{3}$/.test(currency)) {
1035
+ print.error('--currency must be a three-letter currency code');
1036
+ process.exit(1);
1037
+ }
1038
+
1039
+ const dueDate = flags['due-date'];
1040
+ if (dueDate !== undefined && !isValidDate(dueDate)) {
1041
+ print.error('--due-date must be a valid date in YYYY-MM-DD format');
1042
+ process.exit(1);
1043
+ }
1044
+
1045
+ const cryptoCurrency = flags['crypto-currency'];
1046
+ const invoice = await createInvoice(client, {
1047
+ businessId: flags['business-id'],
1048
+ clientId: flags['client-id'],
1049
+ currency: currency.toUpperCase(),
1050
+ amount,
1051
+ cryptoCurrency: cryptoCurrency?.toUpperCase(),
1052
+ dueDate,
1053
+ notes: flags.notes,
1054
+ walletId: flags['wallet-id'],
1055
+ merchantWalletAddress: flags['merchant-wallet-address'],
1056
+ });
1057
+ if (flags.json) {
1058
+ print.json(invoice);
1059
+ break;
1060
+ }
1061
+
1062
+ print.success('Draft invoice created');
1063
+ printInvoiceDetails(invoice);
1064
+ break;
1065
+ }
1066
+
1067
+ case 'list': {
1068
+ requireFlagValues(flags, ['business-id', 'status', 'client-id', 'date-from', 'date-to']);
1069
+
1070
+ const status = flags.status?.toLowerCase();
1071
+ const validStatuses = new Set(Object.values(InvoiceStatus));
1072
+ if (status !== undefined && !validStatuses.has(status)) {
1073
+ print.error('--status must be one of: ' + [...validStatuses].join(', '));
1074
+ process.exit(1);
1075
+ }
1076
+
1077
+ const dateFrom = flags['date-from'];
1078
+ const dateTo = flags['date-to'];
1079
+ if (dateFrom !== undefined && !isValidDate(dateFrom)) {
1080
+ print.error('--date-from must be a valid date in YYYY-MM-DD format');
1081
+ process.exit(1);
1082
+ }
1083
+ if (dateTo !== undefined && !isValidDate(dateTo)) {
1084
+ print.error('--date-to must be a valid date in YYYY-MM-DD format');
1085
+ process.exit(1);
1086
+ }
1087
+ if (dateFrom && dateTo && dateFrom > dateTo) {
1088
+ print.error('--date-from must not be later than --date-to');
1089
+ process.exit(1);
1090
+ }
1091
+
1092
+ const result = await listInvoices(client, {
1093
+ businessId: flags['business-id'],
1094
+ status,
1095
+ clientId: flags['client-id'],
1096
+ dateFrom,
1097
+ dateTo,
1098
+ });
1099
+
1100
+ if (flags.json) {
1101
+ print.json(result);
1102
+ break;
1103
+ }
1104
+
1105
+ if (result.invoices.length === 0) {
1106
+ print.info('No invoices found');
1107
+ break;
1108
+ }
1109
+
1110
+ console.log('\n' + colors.bright + 'Invoices' + colors.reset + ' (' + (result.total ?? result.invoices.length) + ')\n');
1111
+ for (const invoice of result.invoices) {
1112
+ const label = invoice.invoiceNumber || invoice.id;
1113
+ const created = invoice.createdAt ? invoice.createdAt.slice(0, 10) : '-';
1114
+ console.log(
1115
+ ' ' + colors.bright + label + colors.reset +
1116
+ ' ' + (invoice.status || 'unknown') +
1117
+ ' ' + invoice.amount + ' ' + (invoice.currency || '') +
1118
+ ' ' + created
1119
+ );
1120
+ if (invoice.invoiceNumber && invoice.id) console.log(' ' + invoice.id);
1121
+ }
1122
+ console.log();
1123
+ break;
1124
+ }
1125
+
1126
+ case 'get': {
1127
+ rejectUnsupportedFlags(flags, []);
1128
+ const id = requireSingleInvoiceId(args, 'get');
1129
+
1130
+ const invoice = await getInvoice(client, id);
1131
+ if (flags.json) {
1132
+ print.json(invoice);
1133
+ break;
1134
+ }
1135
+
1136
+ printInvoiceDetails(invoice);
1137
+ break;
1138
+ }
1139
+
1140
+ case 'update': {
1141
+ const updateFlags = [
1142
+ 'amount',
1143
+ 'currency',
1144
+ 'crypto-currency',
1145
+ 'due-date',
1146
+ 'notes',
1147
+ 'client-id',
1148
+ 'wallet-id',
1149
+ 'merchant-wallet-address',
1150
+ ];
1151
+ rejectUnsupportedFlags(flags, updateFlags);
1152
+ if (flags.notes === true) {
1153
+ print.error('--notes requires a value; use --notes= to clear existing notes');
1154
+ process.exit(1);
1155
+ }
1156
+ requireFlagValues(flags, updateFlags);
1157
+ const id = requireSingleInvoiceId(args, 'update');
1158
+ const updates = {};
1159
+
1160
+ if (flags.amount !== undefined) {
1161
+ const amount = parsePositiveAmount(flags.amount);
1162
+ if (amount === null) {
1163
+ print.error('--amount must be a positive decimal with at most two decimal places');
1164
+ process.exit(1);
1165
+ }
1166
+ updates.amount = amount;
1167
+ }
1168
+
1169
+ if (flags.currency !== undefined) {
1170
+ if (!/^[A-Za-z]{3}$/.test(flags.currency)) {
1171
+ print.error('--currency must be a three-letter currency code');
1172
+ process.exit(1);
1173
+ }
1174
+ updates.currency = flags.currency.toUpperCase();
1175
+ }
1176
+
1177
+ if (flags['crypto-currency'] !== undefined) {
1178
+ const cryptoCurrency = flags['crypto-currency'].trim();
1179
+ if (!cryptoCurrency) {
1180
+ print.error('--crypto-currency must not be empty');
1181
+ process.exit(1);
1182
+ }
1183
+ updates.cryptoCurrency = cryptoCurrency.toUpperCase();
1184
+ }
1185
+
1186
+ if (flags['due-date'] !== undefined) {
1187
+ if (!isValidDate(flags['due-date'])) {
1188
+ print.error('--due-date must be a valid date in YYYY-MM-DD format');
1189
+ process.exit(1);
1190
+ }
1191
+ updates.dueDate = flags['due-date'];
1192
+ }
1193
+
1194
+ if (flags.notes !== undefined) updates.notes = flags.notes;
1195
+
1196
+ const stringUpdates = [
1197
+ ['client-id', 'clientId'],
1198
+ ['wallet-id', 'walletId'],
1199
+ ['merchant-wallet-address', 'merchantWalletAddress'],
1200
+ ];
1201
+ for (const [flag, field] of stringUpdates) {
1202
+ if (flags[flag] === undefined) continue;
1203
+ const value = flags[flag].trim();
1204
+ if (!value) {
1205
+ print.error('--' + flag + ' must not be empty');
1206
+ process.exit(1);
1207
+ }
1208
+ updates[field] = value;
1209
+ }
1210
+
1211
+ if (Object.keys(updates).length === 0) {
1212
+ print.error('Provide at least one editable field for invoice update');
1213
+ print.info('Example: coinpay invoice update <id> --amount 125 --notes "Updated scope"');
1214
+ process.exit(1);
1215
+ }
1216
+
1217
+ const current = await getInvoice(client, id);
1218
+ if (current.status !== InvoiceStatus.DRAFT) {
1219
+ throw new Error('Only draft invoices can be updated (current: ' +
1220
+ (current.status || 'unknown') + ')');
1221
+ }
1222
+
1223
+ const updated = await updateInvoice(client, id, updates);
1224
+ assertInvoiceUpdatesApplied(updated, updates);
1225
+
1226
+ if (flags.json) {
1227
+ print.json(updated);
1228
+ break;
1229
+ }
1230
+
1231
+ print.success('Draft invoice updated');
1232
+ const routingFields = ['cryptoCurrency', 'merchantWalletAddress'];
1233
+ if (routingFields.some((field) => updates[field] !== undefined)) {
1234
+ print.info('Payment routing: ' + describeInvoiceRouting(current) +
1235
+ ' => ' + describeInvoiceRouting(updated));
1236
+ }
1237
+ if (updates.walletId !== undefined) {
1238
+ print.info('Wallet reference: ' + (current.walletId || 'unset') +
1239
+ ' => ' + updated.walletId);
1240
+ }
1241
+ printInvoiceDetails(updated);
1242
+ break;
1243
+ }
1244
+
1245
+ case 'publish': {
1246
+ rejectUnsupportedFlags(flags, ['yes']);
1247
+ const id = requireSingleInvoiceId(args, 'publish');
1248
+ const current = await getInvoice(client, id);
1249
+ const existingShareUrl = getInvoiceShareUrl(id);
1250
+
1251
+ if (current.status === InvoiceStatus.SENT) {
1252
+ if (!current.paymentAddress) {
1253
+ throw new Error(
1254
+ 'Published invoice has no active payment address; refresh it in the web app'
1255
+ );
1256
+ }
1257
+ if (flags.json) {
1258
+ print.json({
1259
+ ...current,
1260
+ shareUrl: existingShareUrl,
1261
+ emailAttempted: false,
1262
+ idempotentReplay: true,
1263
+ });
1264
+ break;
1265
+ }
1266
+ print.success('Invoice is already published; no email sent');
1267
+ printInvoiceDetails(current, { shareUrl: existingShareUrl });
1268
+ break;
1269
+ }
1270
+
1271
+ if (current.status !== InvoiceStatus.DRAFT) {
1272
+ throw new Error(
1273
+ 'Only draft invoices can be published (current: ' + (current.status || 'unknown') + ')'
1274
+ );
1275
+ }
1276
+ if (!current.cryptoCurrency) {
1277
+ throw new Error('Set --crypto-currency before publishing this invoice');
1278
+ }
1279
+
1280
+ const label = current.invoiceNumber || current.id;
1281
+ const confirmed = await confirmInvoiceMutation(
1282
+ flags,
1283
+ 'Publish ' +
1284
+ label +
1285
+ ' for ' +
1286
+ current.amount +
1287
+ ' ' +
1288
+ (current.currency || '') +
1289
+ ' using ' +
1290
+ current.cryptoCurrency +
1291
+ '? This creates live payment details but does not email the client.'
1292
+ );
1293
+ if (!confirmed) {
1294
+ print.info('Aborted');
1295
+ break;
1296
+ }
1297
+
1298
+ const result = await publishInvoice(client, id);
1299
+ const published = invoiceForOutput(result, id);
1300
+ if (
1301
+ result?.success === false ||
1302
+ published.status !== InvoiceStatus.SENT ||
1303
+ !published.paymentAddress
1304
+ ) {
1305
+ throw new Error('Invoice publish was not confirmed by the server');
1306
+ }
1307
+ const shareUrl =
1308
+ typeof result?.paymentLink === 'string'
1309
+ ? result.paymentLink
1310
+ : getInvoiceShareUrl(published.id);
1311
+
1312
+ if (flags.json) {
1313
+ print.json({
1314
+ ...published,
1315
+ shareUrl,
1316
+ emailAttempted: false,
1317
+ idempotentReplay: result?.idempotentReplay === true,
1318
+ });
1319
+ break;
1320
+ }
1321
+
1322
+ print.success('Invoice published; no email sent');
1323
+ printInvoiceDetails(published, { shareUrl });
1324
+ break;
1325
+ }
1326
+
1327
+ case 'send': {
1328
+ rejectUnsupportedFlags(flags, ['yes']);
1329
+ const id = requireSingleInvoiceId(args, 'send');
1330
+ const current = await getInvoice(client, id);
1331
+ const sendable = new Set([InvoiceStatus.DRAFT, InvoiceStatus.OVERDUE]);
1332
+ if (!sendable.has(current.status)) {
1333
+ throw new Error('Only draft or overdue invoices can be sent (current: ' +
1334
+ (current.status || 'unknown') + ')');
1335
+ }
1336
+ if (!current.cryptoCurrency) {
1337
+ throw new Error('Set --crypto-currency before sending this invoice');
1338
+ }
1339
+
1340
+ const label = current.invoiceNumber || current.id;
1341
+ const recipient = current.clientId ? 'client ' + current.clientId : 'the configured client';
1342
+ const amount = current.amount + ' ' + (current.currency || '');
1343
+ const resendWarning = current.status === InvoiceStatus.OVERDUE
1344
+ ? ' This will issue new payment details and email the client again.'
1345
+ : ' This will create payment details and email the client.';
1346
+ const confirmed = await confirmInvoiceMutation(
1347
+ flags,
1348
+ 'Send ' + label + ' to ' + recipient + ' for ' + amount +
1349
+ ' using ' + current.cryptoCurrency + '?' + resendWarning
1350
+ );
1351
+ if (!confirmed) {
1352
+ print.info('Aborted');
1353
+ break;
1354
+ }
1355
+
1356
+ const result = await sendInvoice(client, id);
1357
+ let sent = invoiceForOutput(result, id);
1358
+ const legacySuccess = result?.success === true &&
1359
+ !Object.prototype.hasOwnProperty.call(result, 'invoice') &&
1360
+ sent.status === undefined;
1361
+
1362
+ if (legacySuccess) {
1363
+ sent = invoiceForOutput({
1364
+ ...current,
1365
+ ...result,
1366
+ status: InvoiceStatus.SENT,
1367
+ }, id);
1368
+ } else if (result?.success === false || sent.status !== InvoiceStatus.SENT) {
1369
+ throw new Error('Invoice send was not confirmed by the server');
1370
+ }
1371
+ const shareUrl = getInvoiceShareUrl(sent.id);
1372
+
1373
+ if (flags.json) {
1374
+ const delivery = {};
1375
+ for (const field of ['emailAccepted', 'emailTrackingSaved', 'warning', 'emailError']) {
1376
+ if (Object.prototype.hasOwnProperty.call(result ?? {}, field)) {
1377
+ delivery[field] = result[field];
1378
+ }
1379
+ }
1380
+ print.json({ ...sent, shareUrl, ...delivery });
1381
+ break;
1382
+ }
1383
+
1384
+ if (result?.emailAccepted === false) {
1385
+ print.warn('Invoice payment link created, but the email provider did not accept the message');
1386
+ } else if (result?.emailAccepted === true) {
1387
+ print.success('Invoice sent; email accepted by provider');
1388
+ } else {
1389
+ print.success('Invoice sent; email status was not reported by the server');
1390
+ }
1391
+ if (legacySuccess) {
1392
+ print.warn('The server returned limited invoice details; run coinpay invoice get ' +
1393
+ id + ' to refresh them.');
1394
+ }
1395
+ printInvoiceDetails(sent, { shareUrl });
1396
+ break;
1397
+ }
1398
+
1399
+ case 'delete': {
1400
+ rejectUnsupportedFlags(flags, ['yes']);
1401
+ const id = requireSingleInvoiceId(args, 'delete');
1402
+ const current = await getInvoice(client, id);
1403
+ if (current.status !== InvoiceStatus.DRAFT) {
1404
+ throw new Error('Only draft invoices can be deleted (current: ' +
1405
+ (current.status || 'unknown') + ')');
1406
+ }
1407
+
1408
+ const label = current.invoiceNumber || current.id;
1409
+ const confirmed = await confirmInvoiceMutation(
1410
+ flags,
1411
+ 'Permanently delete draft invoice ' + label + ' for ' + current.amount +
1412
+ ' ' + (current.currency || '') + '?'
1413
+ );
1414
+ if (!confirmed) {
1415
+ print.info('Aborted');
1416
+ break;
1417
+ }
1418
+
1419
+ const result = await deleteInvoice(client, id);
1420
+ if (result?.success === false) {
1421
+ throw new Error('Invoice deletion was not confirmed by the server');
1422
+ }
1423
+
1424
+ if (flags.json) {
1425
+ print.json({ id, deleted: true });
1426
+ break;
1427
+ }
1428
+ print.success('Draft invoice deleted: ' + id);
1429
+ break;
1430
+ }
1431
+
1432
+ default:
1433
+ print.error('Unknown invoice command: ' + subcommand);
1434
+ print.info('Available: create, list, get, update, publish, send, delete');
1435
+ process.exit(1);
1436
+ }
1437
+ }
1438
+
1439
+ /**
1440
+ * Token discovery commands
1441
+ */
1442
+ async function handleTokens(subcommand, args, flags) {
1443
+ const client = createClient();
1444
+
1445
+ switch (subcommand) {
1446
+ case 'list': {
1447
+ const businessId = flags['business-id'];
1448
+ const activeOnly = flags['active-only'] === true || flags.active === true;
1449
+ const result = await client.getTokens({ businessId, activeOnly });
1450
+
1451
+ if (flags.json) {
1452
+ print.json(result);
1453
+ break;
1454
+ }
1455
+
1456
+ const tokens = result.tokens || [];
1457
+ print.info(`Payment tokens (${tokens.length})`);
1458
+ for (const token of tokens) {
1459
+ const chain = token.chain ? ` on ${token.chain}` : '';
1460
+ const source = token.wallet_source ? ` (${token.wallet_source})` : '';
1461
+ console.log(` ${colors.bright}${token.code}${colors.reset} ${token.ticker || token.symbol}${chain}${source}`);
627
1462
  }
628
-
629
- const url = client.getPaymentQRUrl(paymentId);
630
- print.info(`QR Code URL: ${url}`);
631
1463
  break;
632
1464
  }
633
-
1465
+
634
1466
  default:
635
- print.error(`Unknown payment command: ${subcommand}`);
636
- showHelp();
1467
+ print.error(`Unknown tokens command: ${subcommand}`);
1468
+ print.info('Available: list');
637
1469
  }
638
1470
  }
639
1471
 
@@ -645,18 +1477,43 @@ async function handleBusiness(subcommand, args, flags) {
645
1477
 
646
1478
  switch (subcommand) {
647
1479
  case 'create': {
648
- const { name, 'webhook-url': webhookUrl } = flags;
649
-
650
- if (!name) {
651
- print.error('Required: --name');
1480
+ const {
1481
+ name,
1482
+ category,
1483
+ description,
1484
+ 'webhook-url': webhookUrl,
1485
+ 'webhook-secret': webhookSecret,
1486
+ } = flags;
1487
+
1488
+ if (!name || !category) {
1489
+ // The API has required a category since businesses started being
1490
+ // classified on create, and this command had no way to send one -- so it
1491
+ // failed with "Select a valid business category" and no route to fixing
1492
+ // it. Naming the flag here is cheaper than reading the taxonomy.
1493
+ print.error('Required: --name <name> --category <slug>');
1494
+ print.info('Categories: coinpay business categories');
652
1495
  return;
653
1496
  }
654
-
655
- const business = await client.createBusiness({ name, webhookUrl });
1497
+
1498
+ const business = await client.createBusiness({
1499
+ name,
1500
+ category,
1501
+ description,
1502
+ webhookUrl,
1503
+ webhookSecret,
1504
+ });
656
1505
  print.success('Business created');
657
1506
  print.json(business);
658
1507
  break;
659
1508
  }
1509
+
1510
+ case 'categories': {
1511
+ // Fetched rather than hardcoded: the taxonomy is server-side and a stale
1512
+ // copy in the CLI is how "Select a valid business category" happens twice.
1513
+ const result = await client.listBusinessCategories();
1514
+ print.json(result);
1515
+ break;
1516
+ }
660
1517
 
661
1518
  case 'get': {
662
1519
  const businessId = args[0];
@@ -1836,17 +2693,18 @@ async function handleAuth(subcommand, args, flags) {
1836
2693
  const client = new CoinPayClient({ apiKey: jwtToken, baseUrl: getBaseUrl() });
1837
2694
  const { getMe } = await import('../src/auth.js');
1838
2695
  const result = await getMe(client);
1839
-
2696
+ const me = result?.merchant || result;
2697
+
1840
2698
  print.success('Current merchant:');
1841
- print.info(`ID: ${result.id}`);
1842
- print.info(`Email: ${result.email}`);
1843
- if (result.name) {
1844
- print.info(`Name: ${result.name}`);
2699
+ print.info(`ID: ${me.id}`);
2700
+ print.info(`Email: ${me.email}`);
2701
+ if (me.name) {
2702
+ print.info(`Name: ${me.name}`);
1845
2703
  }
1846
- print.info(`Created: ${result.created_at}`);
1847
-
2704
+ if (me.created_at) print.info(`Created: ${me.created_at}`);
2705
+
1848
2706
  if (flags.json) {
1849
- print.json(result);
2707
+ print.json(me);
1850
2708
  }
1851
2709
  } catch (error) {
1852
2710
  if (error.message.includes('401') || error.message.includes('403')) {
@@ -2013,15 +2871,28 @@ async function handleReputation(subcommand, args, flags) {
2013
2871
  const client = createClient();
2014
2872
 
2015
2873
  if (didSubcommand === 'claim') {
2016
- const { claimDid } = await import('../src/reputation.js');
2017
- const result = await claimDid(client);
2874
+ const { claimDid, getMyDid } = await import('../src/reputation.js');
2875
+
2876
+ // Idempotent: a merchant can only hold one principal DID, so if one
2877
+ // already exists just show it (exit 0) instead of erroring with
2878
+ // "already has a principal DID". This lets automated setup (e.g.
2879
+ // c0mpute worker onboarding) call `did claim` unconditionally.
2880
+ let existing = null;
2881
+ try {
2882
+ existing = await getMyDid(client);
2883
+ } catch {
2884
+ existing = null; // no DID yet — fall through to claim
2885
+ }
2886
+
2887
+ const result = existing?.did ? existing : await claimDid(client);
2018
2888
 
2019
2889
  if (result.did) {
2020
- print.success(`DID claimed: ${result.did}`);
2890
+ print.success(`${existing?.did ? 'DID ready' : 'DID claimed'}: ${result.did}`);
2021
2891
  console.log(` Public Key: ${result.public_key}`);
2022
2892
  console.log(` Verified: ${result.verified}`);
2023
2893
  } else {
2024
2894
  print.error(result.error || 'Failed to claim DID');
2895
+ process.exitCode = 1;
2025
2896
  }
2026
2897
 
2027
2898
  if (flags.json) print.json(result);
@@ -2061,6 +2932,31 @@ async function handleReputation(subcommand, args, flags) {
2061
2932
  print.info('No DID found. Run: coinpay reputation did claim');
2062
2933
  }
2063
2934
 
2935
+ if (flags.json) print.json(result);
2936
+ } else if (didSubcommand === 'setup') {
2937
+ // Ensure the merchant has a payable DID, hands-free: use the existing
2938
+ // one if there is one, otherwise create it automatically. No prompts —
2939
+ // worker onboarding (c0mpute worker register) must never block.
2940
+ const { getMyDid, claimDid } = await import('../src/reputation.js');
2941
+
2942
+ let existing = null;
2943
+ try {
2944
+ existing = await getMyDid(client);
2945
+ } catch {
2946
+ existing = null; // none yet — create below
2947
+ }
2948
+
2949
+ const result = existing?.did ? existing : await claimDid(client);
2950
+
2951
+ if (result.did) {
2952
+ print.success(`${existing?.did ? 'Using existing DID' : 'Created DID'}: ${result.did}`);
2953
+ console.log(` Public Key: ${result.public_key}`);
2954
+ console.log(` Verified: ${result.verified}`);
2955
+ } else {
2956
+ print.error(result.error || 'Failed to set up DID');
2957
+ process.exitCode = 1;
2958
+ }
2959
+
2064
2960
  if (flags.json) print.json(result);
2065
2961
  } else {
2066
2962
  // Default: show current DID
@@ -2291,9 +3187,9 @@ async function handlePayout(subcommand, args, flags) {
2291
3187
 
2292
3188
  switch (subcommand) {
2293
3189
  case 'create': {
2294
- const amount = parseInt(flags.amount);
2295
- if (!amount || amount <= 0) {
2296
- print.error('--amount is required (in cents, e.g., 5000 for $50.00)');
3190
+ const amount = parsePositiveInteger(flags.amount);
3191
+ if (amount === null) {
3192
+ print.error('--amount must be a positive integer in cents (e.g., 5000 for $50.00)');
2297
3193
  print.info('Example: coinpay payout create --amount 5000 --description "Weekly payout"');
2298
3194
  process.exit(1);
2299
3195
  }
@@ -2435,7 +3331,8 @@ async function handleCard(subcommand, args, flags) {
2435
3331
  case 'onboard': {
2436
3332
  if (!merchantId) {
2437
3333
  print.error('Merchant ID required');
2438
- print.info('Usage: coinpay card connect onboard <merchantId>');
3334
+ print.info('Usage: coinpay card connect onboard <merchantId> --country <ISO-2> [--email <email>]');
3335
+ print.info('Country is required on first onboarding (e.g. US, CA, GB, AU, DE) and is locked once the Stripe account is created.');
2439
3336
  return;
2440
3337
  }
2441
3338
 
@@ -2559,15 +3456,740 @@ async function handleCard(subcommand, args, flags) {
2559
3456
  /**
2560
3457
  * Main entry point
2561
3458
  */
3459
+ // Top-level commands offered by the interactive menu (shown when `coinpay` is
3460
+ // run with no arguments in a TTY). Self-manage commands are intentionally omitted.
3461
+ const MENU_COMMANDS = [
3462
+ ['login', 'Log in headlessly — approve in a browser on any device'],
3463
+ ['whoami', 'Show the signed-in merchant'],
3464
+ ['logout', 'Sign out on this device'],
3465
+ ['config', 'API key & endpoint (set-key, set-url, show)'],
3466
+ ['auth', 'Merchant account (register, login, me)'],
3467
+ ['finances', 'Live money dashboard (earnings, cards, bank feeds, ledger)'],
3468
+ ['payment', 'Payments (create, get, list, qr)'],
3469
+ ['invoice', 'Invoices (create, list, get, update, publish, send, delete)'],
3470
+ ['tokens', 'List checkout tokens'],
3471
+ ['business', 'Businesses (create, get, list, update)'],
3472
+ ['rates', 'Exchange rates (get, list)'],
3473
+ ['wallet', 'Local encrypted wallet (create, import, balance, send, history…)'],
3474
+ ['swap', 'Cross-chain swaps'],
3475
+ ['escrow', 'Escrow & series operations'],
3476
+ ['ln', 'Lightning wallet'],
3477
+ ['webhook', 'Webhook management'],
3478
+ ['payout', 'Payouts'],
3479
+ ['card', 'Card payments & issuing'],
3480
+ ['reputation', 'DID, credentials, receipts, issuer'],
3481
+ ['x402', 'x402 protocol operations'],
3482
+ ['oauth', 'OAuth operations'],
3483
+ ];
3484
+
3485
+ // Subcommands per command group (name → [[subcommand, description], …]).
3486
+ const SUBCOMMANDS = {
3487
+ config: [['set-key', 'Set your API key'], ['set-url', 'Set custom API URL'], ['show', 'Show current configuration']],
3488
+ 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']],
3490
+ payment: [['create', 'Create a new payment'], ['get', 'Get payment details <id>'], ['list', 'List payments'], ['qr', 'Get payment QR code <id>']],
3491
+ 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
+ tokens: [['list', 'List checkout tokens']],
3493
+ business: [['create', 'Create a new business'], ['get', 'Get business details <id>'], ['list', 'List businesses'], ['update', 'Update business <id>']],
3494
+ rates: [['get', 'Get exchange rate <crypto>'], ['list', 'Get all exchange rates']],
3495
+ wallet: [['create', 'Create a new wallet'], ['import', 'Import wallet <mnemonic>'], ['unlock', 'Decrypt wallet and show info'], ['info', 'Show wallet info'], ['addresses', 'List all addresses'], ['derive', 'Derive new address <chain>'], ['derive-missing', 'Derive missing chains'], ['balance', 'Get balance(s) [chain]'], ['send', 'Send a transaction'], ['history', 'Transaction history'], ['backup', 'Export encrypted backup'], ['delete', 'Delete local wallet file']],
3496
+ swap: [['coins', 'List supported coins'], ['quote', 'Get swap quote'], ['create', 'Create swap transaction'], ['status', 'Check swap status <swap-id>'], ['history', 'Swap history']],
3497
+ escrow: [['create', 'Create a new escrow'], ['get', 'Get escrow status <id>'], ['list', 'List escrows'], ['release', 'Release funds <id>'], ['refund', 'Refund funds <id>'], ['dispute', 'Open a dispute <id>'], ['events', 'Get escrow audit log <id>'], ['auth', 'Authenticate with escrow token <id>'], ['series', 'Series operations']],
3498
+ ln: [['enable', 'Enable Lightning wallet'], ['address', 'Lightning address'], ['invoice', 'Create an invoice'], ['send', 'Send a payment'], ['balance', 'Lightning balance'], ['payments', 'List payments']],
3499
+ webhook: [['logs', 'Get webhook logs <business-id>'], ['test', 'Send test webhook <business-id>']],
3500
+ payout: [['create', 'Create a payout'], ['list', 'List payouts'], ['get', 'Get payout details <id>']],
3501
+ card: [['create', 'Create a card payment'], ['get', 'Get card payment status <id>'], ['list', 'List card payments'], ['connect', 'Stripe onboarding / status'], ['escrow', 'Release / refund card escrow']],
3502
+ reputation: [['submit', 'Submit a task receipt'], ['query', 'Query agent reputation <did>'], ['credential', 'Get credential details <id>'], ['credentials', 'List credentials [did]'], ['receipts', 'List task receipts [did]'], ['badge', 'Reputation badge URL [did]'], ['verify', 'Verify a credential <id>'], ['revocations', 'List revoked credentials'], ['did', 'DID claim / me'], ['issuer', 'Issuer register / list / rotate']],
3503
+ x402: [
3504
+ ['status', 'x402 status'],
3505
+ ['test', 'x402 test'],
3506
+ ['pay', 'pay for a URL, approving in your browser'],
3507
+ ],
3508
+ oauth: [['list', 'List OAuth clients'], ['create', 'Create an OAuth client'], ['get', 'Get client details <id>'], ['delete', 'Delete a client <id>']],
3509
+ };
3510
+
3511
+ function askLine(prompt) {
3512
+ return new Promise((resolve) => {
3513
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
3514
+ rl.question(prompt, (answer) => { rl.close(); resolve(answer); });
3515
+ });
3516
+ }
3517
+
3518
+ // Re-invoke this CLI as a child so the real parser/handlers run unchanged.
3519
+ function runChild(argv) {
3520
+ return new Promise((resolve) => {
3521
+ const child = spawn(process.execPath, [process.argv[1], ...argv], { stdio: 'inherit' });
3522
+ child.on('exit', () => resolve());
3523
+ child.on('error', () => resolve());
3524
+ });
3525
+ }
3526
+
3527
+ // An authenticated client (session token or API key) for the guided menu flows
3528
+ // that need to fetch your data. Returns null if not authenticated.
3529
+ function authedClientOrNull() {
3530
+ const apiKey = getApiKey() || loadConfig().jwtToken;
3531
+ return apiKey ? new CoinPayClient({ apiKey, baseUrl: getBaseUrl() }) : null;
3532
+ }
3533
+
3534
+ function unwrapList(res) {
3535
+ const list = res?.data ?? res?.businesses ?? res?.payments ?? res;
3536
+ return Array.isArray(list) ? list : [];
3537
+ }
3538
+
3539
+ // Fetch the merchant's businesses and let them pick one; returns a business id.
3540
+ async function pickBusiness(io) {
3541
+ const client = authedClientOrNull();
3542
+ if (!client) { print.error('Not signed in — run `coinpay login` first.'); return null; }
3543
+ let list;
3544
+ try { list = unwrapList(await client.listBusinesses()); }
3545
+ catch (e) { print.error('Could not fetch businesses: ' + e.message); return null; }
3546
+ if (!list.length) { print.info('No businesses yet — create one first (business → create).'); return null; }
3547
+ if (list.length === 1) return list[0].id; // only one — no need to ask
3548
+ return io.select({
3549
+ message: 'Which business?',
3550
+ choices: list.map((b) => ({ name: `${b.name || b.id} ${colors.reset}(${b.id})`, value: b.id })),
3551
+ pageSize: 15,
3552
+ });
3553
+ }
3554
+
3555
+ // Fetch payments for a business and let them pick one; returns a payment id.
3556
+ async function pickPayment(io, businessId) {
3557
+ const client = authedClientOrNull();
3558
+ if (!client) return null;
3559
+ let list;
3560
+ try { list = unwrapList(await client.listPayments({ businessId })); }
3561
+ catch (e) { print.error('Could not fetch payments: ' + e.message); return null; }
3562
+ if (!list.length) { print.info('No payments for that business yet.'); return null; }
3563
+ return io.select({
3564
+ message: 'Which payment?',
3565
+ choices: list.map((p) => ({
3566
+ name: `${p.id} ${[p.amount, p.currency, p.status].filter(Boolean).join(' ')}`.trim(),
3567
+ value: p.id,
3568
+ })),
3569
+ pageSize: 15,
3570
+ });
3571
+ }
3572
+
3573
+ // Guided interactive flows keyed by "command subcommand": fetch your data + ask
3574
+ // for inputs, then return the argv to run (or null to abort). Used by the menu.
3575
+ const GUIDED = {
3576
+ 'business get': async (io) => {
3577
+ const id = await pickBusiness(io);
3578
+ return id ? ['business', 'get', id] : null;
3579
+ },
3580
+ 'business update': async (io) => {
3581
+ const id = await pickBusiness(io);
3582
+ if (!id) return null;
3583
+ const name = (await io.input({ message: 'New name (blank to keep):' })).trim();
3584
+ const url = (await io.input({ message: 'New webhook URL (blank to keep):' })).trim();
3585
+ const argv = ['business', 'update', id];
3586
+ if (name) argv.push('--name', name);
3587
+ if (url) argv.push('--webhook-url', url);
3588
+ if (argv.length === 3) { print.info('Nothing to update.'); return null; }
3589
+ return argv;
3590
+ },
3591
+ 'business create': async (io) => {
3592
+ const name = (await io.input({ message: 'Business name:' })).trim();
3593
+ if (!name) { print.error('Name is required.'); return null; }
3594
+ const url = (await io.input({ message: 'Webhook URL (optional):' })).trim();
3595
+ const argv = ['business', 'create', '--name', name];
3596
+ if (url) argv.push('--webhook-url', url);
3597
+ return argv;
3598
+ },
3599
+ 'payment create': async (io) => {
3600
+ const businessId = await pickBusiness(io);
3601
+ if (!businessId) return null;
3602
+ const amount = (await io.input({ message: 'Amount:' })).trim();
3603
+ if (!amount) { print.error('Amount is required.'); return null; }
3604
+ const currency = (await io.input({ message: 'Currency:', default: 'USD' })).trim() || 'USD';
3605
+ const blockchain = await io.select({
3606
+ message: 'Blockchain:',
3607
+ choices: ['BTC', 'ETH', 'SOL', 'POL', 'BCH'].map((c) => ({ name: c, value: c })),
3608
+ });
3609
+ const description = (await io.input({ message: 'Description (optional):' })).trim();
3610
+ const argv = ['payment', 'create', '--business-id', businessId, '--amount', amount, '--currency', currency, '--blockchain', blockchain];
3611
+ if (description) argv.push('--description', description);
3612
+ return argv;
3613
+ },
3614
+ 'payment get': async (io) => {
3615
+ const b = await pickBusiness(io);
3616
+ if (!b) return null;
3617
+ const p = await pickPayment(io, b);
3618
+ return p ? ['payment', 'get', p] : null;
3619
+ },
3620
+ 'payment qr': async (io) => {
3621
+ const b = await pickBusiness(io);
3622
+ if (!b) return null;
3623
+ const p = await pickPayment(io, b);
3624
+ return p ? ['payment', 'qr', p] : null;
3625
+ },
3626
+ 'payment list': async (io) => {
3627
+ const b = await pickBusiness(io);
3628
+ return b ? ['payment', 'list', '--business-id', b] : null;
3629
+ },
3630
+ };
3631
+
3632
+ // Fallback menu (used only if @inquirer/prompts can't be loaded): a numbered
3633
+ // list, then a prompt for the subcommand + args.
3634
+ async function numberedMenu() {
3635
+ console.log('');
3636
+ console.log(` ${colors.bright}${colors.cyan}coinpay${colors.reset} · interactive menu (v${VERSION})`);
3637
+ for (;;) {
3638
+ console.log('');
3639
+ MENU_COMMANDS.forEach(([name, desc], i) => {
3640
+ const num = colors.yellow + String(i + 1).padStart(2) + colors.reset;
3641
+ console.log(` ${num} ${colors.bright}${name.padEnd(11)}${colors.reset}${desc}`);
3642
+ });
3643
+ console.log('');
3644
+ const answer = (await askLine(' pick a number, type a command (with args), or q to quit: ')).trim();
3645
+ if (!answer || answer === 'q' || answer === 'quit' || answer === 'exit') break;
3646
+
3647
+ let argv;
3648
+ if (/^\d+$/.test(answer)) {
3649
+ const idx = parseInt(answer, 10) - 1;
3650
+ if (idx < 0 || idx >= MENU_COMMANDS.length) { print.error(' invalid choice'); continue; }
3651
+ const name = MENU_COMMANDS[idx][0];
3652
+ const hint = (SUBCOMMANDS[name] || []).map((s) => s[0]).join(', ');
3653
+ const rest = (await askLine(
3654
+ ` coinpay ${colors.bright}${name}${colors.reset} ▸ subcommand + args${hint ? ` (${hint})` : ''} — Enter to list: `
3655
+ )).trim();
3656
+ argv = [name, ...(rest ? rest.split(/\s+/) : [])];
3657
+ } else {
3658
+ argv = answer.split(/\s+/);
3659
+ }
3660
+ console.log('');
3661
+ await runChild(argv);
3662
+ }
3663
+ }
3664
+
3665
+ // Arrow-key drill-down menu (parity with the ugig CLI): pick a command, pick a
3666
+ // subcommand, fill in args, run — looping until you quit. Falls back to the
3667
+ // numbered menu if @inquirer/prompts isn't installed.
3668
+ async function runInteractiveMenu() {
3669
+ let select, input;
3670
+ try { ({ select, input } = await import('@inquirer/prompts')); }
3671
+ catch { return numberedMenu(); }
3672
+
3673
+ const BACK = '\x00back';
3674
+ const TYPE = '\x00type';
3675
+ const splitArgs = (s) => (s ? s.trim().split(/\s+/).filter(Boolean) : []);
3676
+
3677
+ // Who's signed in (refreshed after login/logout). Shown in the menu header.
3678
+ let me = await fetchMe();
3679
+
3680
+ for (;;) {
3681
+ let cmd;
3682
+ try {
3683
+ cmd = await select({
3684
+ message: `coinpay — ${me ? `signed in as ${me.email}` : 'not signed in (pick login)'} — pick a command:`,
3685
+ choices: MENU_COMMANDS.map(([name, desc]) => ({ name: `${name.padEnd(11)} ${desc}`, value: name })),
3686
+ pageSize: 20,
3687
+ });
3688
+ } catch { break; } // Ctrl-C / ESC quits
3689
+
3690
+ const subs = SUBCOMMANDS[cmd] || [];
3691
+ let argv;
3692
+ if (subs.length) {
3693
+ let sub;
3694
+ try {
3695
+ sub = await select({
3696
+ message: `coinpay ${cmd} — pick a subcommand:`,
3697
+ choices: [
3698
+ ...subs.map(([n, d]) => ({ name: `${n.padEnd(14)} ${d || ''}`.trimEnd(), value: n })),
3699
+ { name: '✎ type subcommand + args…', value: TYPE },
3700
+ { name: '↩ back', value: BACK },
3701
+ ],
3702
+ pageSize: 20,
3703
+ });
3704
+ } catch { continue; }
3705
+ if (sub === BACK) continue;
3706
+ if (sub === TYPE) {
3707
+ let line; try { line = await input({ message: `coinpay ${cmd}` }); } catch { continue; }
3708
+ argv = [cmd, ...splitArgs(line)];
3709
+ } else if (GUIDED[`${cmd} ${sub}`]) {
3710
+ // Guided flow: fetch your data + ask for the inputs, then run.
3711
+ let g; try { g = await GUIDED[`${cmd} ${sub}`]({ select, input }); } catch { continue; }
3712
+ if (!g) continue;
3713
+ argv = g;
3714
+ } else {
3715
+ let extra; try { extra = await input({ message: `args/flags for 'coinpay ${cmd} ${sub}' (optional):` }); } catch { continue; }
3716
+ argv = [cmd, sub, ...splitArgs(extra)];
3717
+ }
3718
+ } else {
3719
+ let line; try { line = await input({ message: `coinpay ${cmd}` }); } catch { continue; }
3720
+ argv = [cmd, ...splitArgs(line)];
3721
+ }
3722
+ console.log('');
3723
+ await runChild(argv);
3724
+ console.log('');
3725
+ // login/logout change who's signed in — refresh the header.
3726
+ if (argv[0] === 'login' || argv[0] === 'logout') me = await fetchMe();
3727
+ }
3728
+ }
3729
+
3730
+ // Headless login: ask the server for a device code, show a URL to approve on any
3731
+ // device, and poll until a merchant session token is minted and stored. No
3732
+ // password on this machine — works over SSH / on a server.
3733
+ async function handleLogin() {
3734
+ const cfg = loadConfig();
3735
+ const base = (cfg.baseUrl || 'https://coinpayportal.com/api').replace(/\/$/, '');
3736
+
3737
+ let start;
3738
+ try {
3739
+ const res = await fetch(`${base}/cli-auth/start`, {
3740
+ method: 'POST',
3741
+ headers: { 'content-type': 'application/json' },
3742
+ body: JSON.stringify({ client_name: hostname() }),
3743
+ });
3744
+ if (!res.ok) throw new Error(`server returned ${res.status}`);
3745
+ start = await res.json();
3746
+ } catch (err) {
3747
+ print.error(`Couldn't start login: ${err.message}`);
3748
+ process.exit(1);
3749
+ }
3750
+
3751
+ // G-1.2-10: there is no pre-filled approval link any more. A link that filled
3752
+ // in the code turned a phishing message into one-click account takeover —
3753
+ // anyone could start a device authorization and send the resulting link to a
3754
+ // signed-in merchant. Entering the code by hand is what ties the approval to
3755
+ // a terminal the person is actually sitting at.
3756
+ print.info('');
3757
+ console.log(' To log in, open this URL on any device (e.g. your desktop):');
3758
+ console.log('');
3759
+ console.log(' ' + colors.cyan + start.verification_uri + colors.reset);
3760
+ console.log('');
3761
+ console.log(' and enter this code: ' + colors.bright + start.user_code + colors.reset);
3762
+ console.log('');
3763
+ console.log(colors.yellow + ' Only enter a code your own terminal printed.' + colors.reset);
3764
+ console.log('');
3765
+ print.info('Waiting for approval… (Ctrl-C to cancel)');
3766
+
3767
+ const deadline = Date.now() + (start.expires_in || 600) * 1000;
3768
+ const intervalMs = Math.max(1, start.interval || 5) * 1000;
3769
+ while (Date.now() < deadline) {
3770
+ await new Promise((r) => setTimeout(r, intervalMs));
3771
+ let data = {};
3772
+ try {
3773
+ const res = await fetch(`${base}/cli-auth/poll`, {
3774
+ method: 'POST',
3775
+ headers: { 'content-type': 'application/json' },
3776
+ body: JSON.stringify({ device_code: start.device_code }),
3777
+ });
3778
+ if (res.status === 202) continue; // authorization_pending
3779
+ data = await res.json().catch(() => ({}));
3780
+ } catch {
3781
+ continue; // transient network error — keep polling
3782
+ }
3783
+ if (data.status === 'complete' && data.token) {
3784
+ cfg.jwtToken = data.token;
3785
+ saveConfig(cfg);
3786
+ const me = await fetchMe();
3787
+ print.success(me ? `Logged in as ${meLabel(me)}` : 'Logged in! Session saved to ' + CONFIG_FILE);
3788
+ return;
3789
+ }
3790
+ if (data.status === 'denied') { print.error('Request denied.'); process.exit(1); }
3791
+ if (data.status === 'expired') { print.error('Login request expired — run `coinpay login` again.'); process.exit(1); }
3792
+ }
3793
+ print.error('Timed out waiting for approval.');
3794
+ process.exit(1);
3795
+ }
3796
+
3797
+ function handleLogout() {
3798
+ const cfg = loadConfig();
3799
+ delete cfg.jwtToken;
3800
+ saveConfig(cfg);
3801
+ print.success('Logged out — session removed from ' + CONFIG_FILE);
3802
+ }
3803
+
3804
+ // Fetch the signed-in merchant (via the stored session token), or null if not
3805
+ // logged in / the token is stale. Used to greet after login, for `whoami`, and
3806
+ // to show who you are in the interactive menu.
3807
+ async function fetchMe() {
3808
+ const cfg = loadConfig();
3809
+ if (!cfg.jwtToken) return null;
3810
+ try {
3811
+ const client = new CoinPayClient({ apiKey: cfg.jwtToken, baseUrl: getBaseUrl() });
3812
+ const { getMe } = await import('../src/auth.js');
3813
+ const result = await getMe(client);
3814
+ return result?.merchant || result || null;
3815
+ } catch {
3816
+ return null;
3817
+ }
3818
+ }
3819
+
3820
+ function meLabel(me) {
3821
+ if (!me) return null;
3822
+ return me.name ? `${me.name} <${me.email}>` : me.email;
3823
+ }
3824
+
3825
+ async function handleWhoami(flags) {
3826
+ const me = await fetchMe();
3827
+ if (!me) {
3828
+ print.error('Not signed in. Run: coinpay login');
3829
+ process.exitCode = 1;
3830
+ return;
3831
+ }
3832
+ print.success(`Signed in as ${meLabel(me)}`);
3833
+ if (me.id) print.info(`Merchant ID: ${me.id}`);
3834
+ if (flags?.json) print.json(me);
3835
+ }
3836
+
3837
+
3838
+ /**
3839
+ * Finances — the merchant's money in one place (bank feeds via SimpleFIN or
3840
+ * Plaid, crypto and card earnings, commission paid, refunds, invoices, escrow).
3841
+ *
3842
+ * Needs the merchant session from `coinpay login`; the bank-data routes
3843
+ * refuse business API keys on purpose.
3844
+ */
3845
+ function financesClient() {
3846
+ const cfg = loadConfig();
3847
+ const token = process.env.COINPAY_SESSION_TOKEN || cfg.jwtToken;
3848
+ if (!token) {
3849
+ print.error('Finances need your merchant session, not an API key. Run: coinpay login');
3850
+ process.exit(1);
3851
+ }
3852
+ return { client: new CoinPayClient({ apiKey: token, baseUrl: getBaseUrl(), timeout: 120000 }), token, baseUrl: getBaseUrl() };
3853
+ }
3854
+
3855
+ function pad(value, width, align = 'left') {
3856
+ const s = String(value ?? '');
3857
+ if (s.length >= width) return s;
3858
+ return align === 'right' ? s.padStart(width) : s.padEnd(width);
3859
+ }
3860
+
3861
+ function printTable(rows, columns) {
3862
+ if (!rows.length) return;
3863
+ const widths = columns.map((c) => Math.max(c.title.length, ...rows.map((r) => String(c.render(r) ?? '').length)));
3864
+ console.log(colors.bright + columns.map((c, i) => pad(c.title, widths[i], c.align)).join(' ') + colors.reset);
3865
+ for (const r of rows) {
3866
+ console.log(columns.map((c, i) => pad(c.render(r) ?? '', widths[i], c.align)).join(' '));
3867
+ }
3868
+ }
3869
+
3870
+ function printFinanceSummary(snapshot, fmt) {
3871
+ const { money, ago } = fmt;
3872
+ const e = snapshot.earnings;
3873
+ const b = snapshot.bank;
3874
+ const cur = b.currency || 'USD';
3875
+ const win = `${snapshot.windowDays}d`;
3876
+ const line = (label, value, color = '') => console.log(` ${pad(label, 26)} ${color}${value}${colors.reset}`);
3877
+
3878
+ console.log(`\n${colors.bright}Earnings · ${win}${colors.reset}${snapshot.plan ? colors.cyan + ' (' + snapshot.plan.commission_percent + ' plan)' + colors.reset : ''}`);
3879
+ line('Gross volume', money(e.grossVolumeUsd), colors.bright);
3880
+ line(' crypto', money(e.cryptoVolumeUsd));
3881
+ line(' cards', money(e.cardVolumeUsd));
3882
+ line('Commission paid', `-${money(e.commissionUsd)}`, colors.yellow);
3883
+ line('Card processor fees', `-${money(e.stripeFeesUsd)}`, colors.yellow);
3884
+ line('Refunds', `-${money(e.refundsUsd)}`, e.refundsUsd > 0 ? colors.red : '');
3885
+ line('Net earnings', money(e.netUsd), e.netUsd >= 0 ? colors.green : colors.red);
3886
+ line('Paid transactions', `${e.transactions} (${e.failed} failed, ${e.failureRate}%)`);
3887
+
3888
+ console.log(`\n${colors.bright}Bank & cards${colors.reset}`);
3889
+ if (!b.connections.length) {
3890
+ console.log(' Nothing linked. Connect a bank at ' + new URL('/finances', getBaseUrl()).toString());
3891
+ } else {
3892
+ line('Cash & assets', money(b.assets, cur), colors.green);
3893
+ line('Cards & loans owed', money(b.liabilities, cur), colors.red);
3894
+ line('Net position', money(b.net, cur), b.net >= 0 ? colors.green : colors.red);
3895
+ line(`Cash in · ${win}`, money(b.cashflow.moneyIn, cur), colors.green);
3896
+ line(`Cash out · ${win}`, money(b.cashflow.moneyOut, cur), colors.red);
3897
+ line('Cashflow net', money(b.cashflow.net, cur), b.cashflow.net >= 0 ? colors.green : colors.red);
3898
+ line('Accounts', `${b.accountCount} (${b.creditCards.length} credit cards)`);
3899
+ line('Last bank sync', `${ago(b.connections[0]?.last_synced_at)} · ${b.connections[0]?.last_sync_status || '—'}`);
3900
+ }
3901
+
3902
+ console.log(`\n${colors.bright}Pipeline${colors.reset}`);
3903
+ const inv = snapshot.invoices;
3904
+ const esc = snapshot.escrow;
3905
+ line('Invoices outstanding', `${money(inv.totals.outstanding)} (${inv.counts.outstanding})`, colors.yellow);
3906
+ line('Invoices overdue', `${money(inv.totals.overdue)} (${inv.counts.overdue})`, inv.counts.overdue ? colors.red : '');
3907
+ line(`Invoices paid · ${win}`, `${money(inv.totals.paid)} (${inv.counts.paid})`, colors.green);
3908
+ line('Escrow held', `${money(esc.heldUsd)} (${esc.held})`, colors.cyan);
3909
+ line(`Escrow released · ${win}`, `${money(esc.releasedUsd)} (${esc.released})`);
3910
+ line(`Escrow refunded · ${win}`, `${money(esc.refundedUsd)} (${esc.refunded})`, esc.refunded ? colors.red : '');
3911
+ line('Card payouts pending', money(snapshot.payout.pendingUsd));
3912
+ line(`Card payouts paid · ${win}`, money(snapshot.payout.paidUsd), colors.green);
3913
+
3914
+ if (snapshot.position) printPosition(snapshot.position, fmt, { heading: true });
3915
+
3916
+ const failed = Object.keys(snapshot.errors);
3917
+ if (failed.length) {
3918
+ console.log('');
3919
+ print.warn(`Unavailable: ${failed.map((k) => `${k} (${snapshot.errors[k]})`).join('; ')}`);
3920
+ }
3921
+ console.log('');
3922
+ }
3923
+
3924
+ /**
3925
+ * Debt against income as text.
3926
+ *
3927
+ * Shared by `finances summary` (as one more section) and `finances position`
3928
+ * (on its own, with the account and recurring detail the summary omits).
3929
+ */
3930
+ function printPosition(position, fmt, { heading = false, detail = false } = {}) {
3931
+ const { money, pct } = fmt;
3932
+ const cur = position.currency || 'USD';
3933
+ const look = `${Math.round(position.observedDays ?? position.lookbackDays)}d`;
3934
+ const line = (label, value, color = '') => console.log(` ${pad(label, 26)} ${color}${value}${colors.reset}`);
3935
+
3936
+ if (heading) console.log(`\n${colors.bright}Debt & income · ${look}${colors.reset}${colors.cyan} (${position.monthsObserved} months)${colors.reset}`);
3937
+
3938
+ const inc = position.income;
3939
+ const spend = position.spending;
3940
+ const debt = position.debt;
3941
+ const r = position.ratios;
3942
+
3943
+ line('Income', `${money(inc.total, cur)} (${money(inc.perMonth, cur)}/mo)`, colors.green);
3944
+ line('Spending', `${money(spend.total, cur)} (${money(spend.perMonth, cur)}/mo)`, colors.red);
3945
+ line('Net', `${money(position.net.total, cur)} (${money(position.net.perMonth, cur)}/mo)`, position.net.total >= 0 ? colors.green : colors.red);
3946
+ line('Kept of income', pct(position.net.savingsRate, 1));
3947
+ line('Gross credits / debits', `${money(inc.grossCredits, cur)} / ${money(spend.grossDebits, cur)}`);
3948
+ line('Total owed', money(debt.total, cur), colors.red);
3949
+ line(' revolving / instalment', `${money(debt.revolving, cur)} / ${money(debt.instalment, cur)}`);
3950
+ line('Debt paid per month', money(debt.servicePerMonth, cur), colors.green);
3951
+ line('Fixed bills per month', money(position.recurring.monthlyTotal, cur), colors.yellow);
3952
+ line('Clear in', debt.payoffMonths === null ? 'never at this rate' : `${debt.payoffMonths} months (${debt.payoffDate ? debt.payoffDate.slice(0, 10) : '—'})`, debt.payoffMonths === null ? colors.red : '');
3953
+ line('Debt to income', r.debtToIncome === null ? '—' : `${r.debtToIncome.toFixed(2)}x`, (r.debtToIncome ?? 0) > 1 ? colors.red : colors.green);
3954
+ line('Debt service ratio', pct(r.debtServiceRatio, 1), (r.debtServiceRatio ?? 0) > 0.36 ? colors.red : colors.green);
3955
+ line('Months of cover', r.monthsOfCover === null ? '—' : r.monthsOfCover.toFixed(1));
3956
+ line('Card utilisation', pct(r.creditUtilisation, 0));
3957
+
3958
+ for (const sc of position.scopes) {
3959
+ line(`${sc.scope} (${sc.accounts} acct)`, `${money(sc.income, cur)} in · ${money(sc.spending, cur)} out · owes ${money(sc.debt, cur)}`);
3960
+ }
3961
+
3962
+ if (position.confidence.noLiabilityAccounts) {
3963
+ console.log('');
3964
+ print.warn('No card or loan account is linked, so the debt figures are blind.');
3965
+ }
3966
+ if (position.confidence.uncategorisedShare > 0.25) {
3967
+ console.log('');
3968
+ print.warn(`${pct(position.confidence.uncategorisedShare, 0)} of transactions are uncategorised — the income/spending split is rough.`);
3969
+ }
3970
+
3971
+ if (!detail) return;
3972
+
3973
+ if (position.months.length) {
3974
+ console.log(`\n${colors.bright}Month by month${colors.reset}`);
3975
+ printTable(position.months, [
3976
+ { title: 'Month', align: 'left', render: (m) => (m.partial ? `${m.month} *` : m.month) },
3977
+ { title: 'Income', align: 'right', render: (m) => money(m.income, cur) },
3978
+ { title: 'Spending', align: 'right', render: (m) => money(m.spending, cur) },
3979
+ { title: 'Net', align: 'right', render: (m) => money(m.net, cur) },
3980
+ { title: 'Debt paid', align: 'right', render: (m) => money(m.debtService, cur) },
3981
+ { title: 'Rows', align: 'right', render: (m) => String(m.transactions) },
3982
+ ]);
3983
+ console.log(` ${colors.cyan}* partial month${colors.reset}`);
3984
+ }
3985
+
3986
+ if (debt.accounts.length) {
3987
+ console.log(`\n${colors.bright}Owed by account${colors.reset}`);
3988
+ printTable(debt.accounts, [
3989
+ { title: 'Institution', align: 'left', render: (d) => d.org || '—' },
3990
+ { title: 'Account', align: 'left', render: (d) => d.name },
3991
+ { title: 'Kind', align: 'left', render: (d) => d.kind },
3992
+ { title: 'Side', align: 'left', render: (d) => d.scope },
3993
+ { title: 'Owed', align: 'right', render: (d) => money(d.owed, cur) },
3994
+ { title: 'Share', align: 'right', render: (d) => pct(d.share, 0) },
3995
+ { title: `Paid ${look}`, align: 'right', render: (d) => money(d.paid, cur) },
3996
+ { title: 'Clear in', align: 'right', render: (d) => (d.payoffMonths === null ? 'never' : `${d.payoffMonths} mo`) },
3997
+ ]);
3998
+ }
3999
+
4000
+ if (position.recurring.charges.length) {
4001
+ console.log(`\n${colors.bright}Recurring${colors.reset} ${colors.cyan}${money(position.recurring.monthlyTotal, cur)}/mo, of which ${money(position.recurring.monthlyDebtService, cur)} is debt${colors.reset}`);
4002
+ printTable(position.recurring.charges, [
4003
+ { title: 'Payee', align: 'left', render: (c) => c.payee },
4004
+ { title: 'Amount', align: 'right', render: (c) => money(c.amount, cur) },
4005
+ { title: 'Every', align: 'left', render: (c) => c.cadence },
4006
+ { title: 'Seen', align: 'right', render: (c) => String(c.occurrences) },
4007
+ { title: 'Last', align: 'left', render: (c) => c.lastSeen.slice(0, 10) },
4008
+ { title: 'Next', align: 'left', render: (c) => c.nextExpected.slice(0, 10) },
4009
+ { title: 'Per month', align: 'right', render: (c) => money(c.monthlyEquivalent, cur) },
4010
+ { title: 'Kind', align: 'left', render: (c) => (c.isDebtService ? 'debt' : 'bill') },
4011
+ ]);
4012
+ }
4013
+ console.log('');
4014
+ }
4015
+
4016
+ async function handleFinances(subcommand, args, flags) {
4017
+ const parsedDays = Number.parseInt(String(flags.days ?? flags.window ?? ''), 10);
4018
+ const days = Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : 30;
4019
+ const fin = await import('../src/finances.js');
4020
+
4021
+ switch (subcommand || 'tui') {
4022
+ case 'tui':
4023
+ case 'dashboard':
4024
+ case 'watch': {
4025
+ const { client, token, baseUrl } = financesClient();
4026
+ const wantsText = flags.plain || flags.json || !process.stdout.isTTY || !process.stdin.isTTY;
4027
+ if (wantsText) {
4028
+ const snapshot = await fin.collectFinanceSnapshot(client, { days, businessId: flags['business-id'] });
4029
+ if (flags.json) { print.json(snapshot); return; }
4030
+ const fmt = await import('../src/finances-tui.js');
4031
+ printFinanceSummary(snapshot, fmt);
4032
+ return;
4033
+ }
4034
+ const parsedInterval = Number.parseInt(String(flags.interval ?? ''), 10);
4035
+ const { runFinancesTui } = await import('../src/finances-tui.js');
4036
+ await runFinancesTui({
4037
+ client,
4038
+ baseUrl,
4039
+ token: flags['no-stream'] ? null : token,
4040
+ days,
4041
+ interval: Number.isFinite(parsedInterval) ? parsedInterval : 30,
4042
+ businessId: flags['business-id'],
4043
+ theme: flags.theme,
4044
+ });
4045
+ return;
4046
+ }
4047
+
4048
+ case 'summary': {
4049
+ const { client } = financesClient();
4050
+ const snapshot = await fin.collectFinanceSnapshot(client, { days, businessId: flags['business-id'] });
4051
+ if (flags.json) { print.json(snapshot); return; }
4052
+ const fmt = await import('../src/finances-tui.js');
4053
+ printFinanceSummary(snapshot, fmt);
4054
+ return;
4055
+ }
4056
+
4057
+ case 'position':
4058
+ case 'debt':
4059
+ case 'income': {
4060
+ // Reads the summary route alone: everything on this screen comes from
4061
+ // there, so there is no reason to fan out to eleven sources for it.
4062
+ const { client } = financesClient();
4063
+ const summary = await fin.getFinanceSummary(client, { days, includeHidden: Boolean(flags.hidden) });
4064
+ const position = summary?.position;
4065
+ if (!position) {
4066
+ print.error('This CoinPay server does not report a debt-and-income position yet.');
4067
+ process.exit(1);
4068
+ }
4069
+ if (flags.json) { print.json(position); return; }
4070
+ const fmt = await import('../src/finances-tui.js');
4071
+ console.log('');
4072
+ printPosition(position, fmt, { heading: true, detail: true });
4073
+ return;
4074
+ }
4075
+
4076
+ case 'accounts': {
4077
+ const { client } = financesClient();
4078
+ const accounts = await fin.listFinanceAccounts(client, { includeHidden: Boolean(flags.hidden) });
4079
+ if (flags.json) { print.json({ accounts }); return; }
4080
+ if (!accounts.length) { print.info('No linked accounts. Connect a bank at ' + new URL('/finances', getBaseUrl()).toString()); return; }
4081
+ const { money, shortDate } = await import('../src/finances-tui.js');
4082
+ console.log('');
4083
+ printTable(accounts, [
4084
+ { title: 'Institution', render: (a) => a.org_name || '—' },
4085
+ { title: 'Account', render: (a) => a.name },
4086
+ { title: 'Kind', render: (a) => a.effective_kind || a.kind },
4087
+ { title: 'Balance', align: 'right', render: (a) => money(a.display_balance ?? a.balance ?? 0, a.currency || 'USD') },
4088
+ { title: 'Available', align: 'right', render: (a) => (a.available_balance == null ? '—' : money(a.available_balance, a.currency || 'USD')) },
4089
+ { title: 'As of', render: (a) => shortDate(a.balance_date) },
4090
+ { title: 'Id', render: (a) => a.id },
4091
+ ]);
4092
+ console.log('');
4093
+ return;
4094
+ }
4095
+
4096
+ case 'ledger':
4097
+ case 'transactions': {
4098
+ const { client } = financesClient();
4099
+ const parsedLimit = Number.parseInt(String(flags.limit ?? ''), 10);
4100
+ const page = await fin.listFinanceTransactions(client, {
4101
+ limit: Number.isFinite(parsedLimit) ? parsedLimit : 50,
4102
+ offset: Number.parseInt(String(flags.offset ?? '0'), 10) || 0,
4103
+ search: flags.search,
4104
+ category: flags.category,
4105
+ accountId: flags.account,
4106
+ startDate: flags.start || flags.since,
4107
+ endDate: flags.end || flags.until,
4108
+ includePending: !flags['no-pending'],
4109
+ });
4110
+ if (flags.json) { print.json(page); return; }
4111
+ if (!page.rows?.length) { print.info('No transactions match.'); return; }
4112
+ const { money, shortDate } = await import('../src/finances-tui.js');
4113
+ console.log('');
4114
+ printTable(page.rows, [
4115
+ { title: 'Date', render: (r) => shortDate(r.transacted_at || r.posted) },
4116
+ { title: 'Account', render: (r) => `${r.org_name ? r.org_name.split(' ')[0] + ' ' : ''}${r.account_name}` },
4117
+ { title: 'Payee / description', render: (r) => (r.payee || r.description || r.memo || '—').slice(0, 40) },
4118
+ { title: 'Category', render: (r) => r.category || '—' },
4119
+ { title: 'Amount', align: 'right', render: (r) => money(r.amount, r.currency || 'USD') },
4120
+ { title: '', render: (r) => (r.pending ? 'pending' : '') },
4121
+ ]);
4122
+ console.log(`\n${page.rows.length} of ${page.total}` + (page.total > page.offset + page.rows.length ? ` (--offset ${page.offset + page.rows.length} for more)` : ''));
4123
+ console.log('');
4124
+ return;
4125
+ }
4126
+
4127
+ case 'connections': {
4128
+ const { client } = financesClient();
4129
+ const data = await fin.listFinanceConnections(client);
4130
+ if (flags.json) { print.json(data); return; }
4131
+ const list = data.connections || [];
4132
+ if (!list.length) {
4133
+ print.info('No bank connections. Link one at ' + new URL('/finances', getBaseUrl()).toString() + (data.plaidEnabled ? ' (Plaid or SimpleFIN)' : ' (SimpleFIN)'));
4134
+ return;
4135
+ }
4136
+ const { ago } = await import('../src/finances-tui.js');
4137
+ console.log('');
4138
+ for (const c of list) {
4139
+ console.log(`${colors.bright}${c.label || c.id}${colors.reset} ${colors.cyan}${c.provider}${colors.reset} ${c.is_active ? colors.green + 'active' : colors.yellow + 'inactive'}${colors.reset}`);
4140
+ console.log(` id ${c.id}`);
4141
+ console.log(` last sync ${ago(c.last_synced_at)} · ${c.last_sync_status || '—'} · ${c.last_sync_accounts ?? 0} accounts / ${c.last_sync_transactions ?? 0} transactions`);
4142
+ if (c.last_sync_error) console.log(` ${colors.yellow}note${colors.reset} ${c.last_sync_error}`);
4143
+ }
4144
+ console.log('');
4145
+ return;
4146
+ }
4147
+
4148
+ case 'sync': {
4149
+ const { client } = financesClient();
4150
+ print.info('Pulling fresh balances and transactions from the bank bridge…');
4151
+ const parsedSyncDays = Number.parseInt(String(flags.days ?? ''), 10);
4152
+ const result = await fin.syncFinances(client, {
4153
+ days: Number.isFinite(parsedSyncDays) ? parsedSyncDays : undefined,
4154
+ connectionId: flags.connection || flags['connection-id'],
4155
+ });
4156
+ if (flags.json) { print.json(result); return; }
4157
+ const t = result.totals || {};
4158
+ print.success(`Synced ${t.accounts ?? 0} accounts: ${t.transactionsNew ?? 0} new of ${t.transactionsSeen ?? 0} transactions (${result.status}).`);
4159
+ for (const r of result.results || []) {
4160
+ if (r.status === 'partial' || r.error) print.warn(`${r.connectionId || r.id || 'connection'}: ${r.error || r.status}`);
4161
+ }
4162
+ return;
4163
+ }
4164
+
4165
+ default:
4166
+ print.error(`Unknown finances command: ${subcommand}`);
4167
+ console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync]');
4168
+ process.exit(1);
4169
+ }
4170
+ }
4171
+
2562
4172
  async function main() {
2563
4173
  const { command, subcommand, args, flags } = parseArgs(process.argv.slice(2));
4174
+ validateBooleanFlags(flags);
2564
4175
 
2565
4176
  if (flags.version || flags.v) {
2566
4177
  console.log(VERSION);
2567
4178
  return;
2568
4179
  }
2569
4180
 
2570
- if (flags.help || flags.h || !command) {
4181
+ if (flags.help || flags.h) {
4182
+ showHelp();
4183
+ return;
4184
+ }
4185
+
4186
+ if (!command) {
4187
+ // No command in an interactive terminal → browse-and-run menu instead of a
4188
+ // bare help dump. Piped/non-TTY invocations keep printing help.
4189
+ if (process.stdin.isTTY && process.stdout.isTTY) {
4190
+ await runInteractiveMenu();
4191
+ return;
4192
+ }
2571
4193
  showHelp();
2572
4194
  return;
2573
4195
  }
@@ -2584,10 +4206,12 @@ async function handleLightning(subcommand, args, flags) {
2584
4206
  console.error(colors.red + 'Error: --wallet-id required' + colors.reset);
2585
4207
  process.exit(1);
2586
4208
  }
2587
- const mnemonic = await getDecryptedMnemonic(flags);
4209
+ // NEW-20: this used to decrypt the stored seed — prompting for the
4210
+ // passphrase — purely to post it to the server, which validated it and
4211
+ // threw it away. Provisioning is a custodial LNbits wallet, so nothing
4212
+ // is derived or signed here. The seed never leaves the machine now.
2588
4213
  const result = await client.lightning.enableWallet({
2589
4214
  wallet_id: walletId,
2590
- mnemonic,
2591
4215
  business_id: flags['business-id'],
2592
4216
  });
2593
4217
  console.log(colors.green + '⚡ Lightning wallet enabled!' + colors.reset);
@@ -2715,6 +4339,18 @@ async function handleLightning(subcommand, args, flags) {
2715
4339
  }
2716
4340
 
2717
4341
  switch (command) {
4342
+ case 'login':
4343
+ await handleLogin();
4344
+ break;
4345
+
4346
+ case 'logout':
4347
+ handleLogout();
4348
+ break;
4349
+
4350
+ case 'whoami':
4351
+ await handleWhoami(flags);
4352
+ break;
4353
+
2718
4354
  case 'config':
2719
4355
  await handleConfig(subcommand, args);
2720
4356
  break;
@@ -2722,6 +4358,14 @@ async function handleLightning(subcommand, args, flags) {
2722
4358
  case 'payment':
2723
4359
  await handlePayment(subcommand, args, flags);
2724
4360
  break;
4361
+
4362
+ case 'invoice':
4363
+ await handleInvoice(subcommand, args, flags);
4364
+ break;
4365
+
4366
+ case 'tokens':
4367
+ await handleTokens(subcommand, args, flags);
4368
+ break;
2725
4369
 
2726
4370
  case 'business':
2727
4371
  await handleBusiness(subcommand, args, flags);
@@ -2775,14 +4419,37 @@ async function handleLightning(subcommand, args, flags) {
2775
4419
  case 'oauth':
2776
4420
  await handleOAuth(subcommand, args, flags);
2777
4421
  break;
2778
-
4422
+
4423
+ case 'finances':
4424
+ case 'finance':
4425
+ case 'money':
4426
+ await handleFinances(subcommand, args, flags);
4427
+ break;
4428
+
4429
+ case 'tui':
4430
+ case 'dashboard':
4431
+ await handleFinances('tui', [subcommand, ...args].filter(Boolean), flags);
4432
+ break;
4433
+
4434
+ case 'update':
4435
+ case 'upgrade':
4436
+ case 'self-update':
4437
+ handleSelfManage('update');
4438
+ break;
4439
+
4440
+ case 'remove':
4441
+ case 'uninstall':
4442
+ handleSelfManage('remove');
4443
+ break;
4444
+
2779
4445
  default:
2780
4446
  print.error(`Unknown command: ${command}`);
2781
4447
  showHelp();
2782
4448
  process.exit(1);
2783
4449
  }
2784
4450
  } catch (error) {
2785
- print.error(error.message);
4451
+ const code = error?.response?.code;
4452
+ print.error(error.message + (code ? ' (' + code + ')' : ''));
2786
4453
  if (flags.debug) {
2787
4454
  console.error(error);
2788
4455
  }
@@ -2896,6 +4563,108 @@ async function handleOAuth(subcommand, args, flags) {
2896
4563
  }
2897
4564
  }
2898
4565
 
4566
+ /**
4567
+ * Open the platform's browser at a URL.
4568
+ *
4569
+ * Failure is not fatal: the URL is printed either way, so a headless box or an
4570
+ * unusual desktop just means the user opens it themselves.
4571
+ */
4572
+ async function openInBrowser(url) {
4573
+ const { spawn } = await import('node:child_process');
4574
+ const command =
4575
+ process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
4576
+ try {
4577
+ spawn(command, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' })
4578
+ .on('error', () => {})
4579
+ .unref();
4580
+ return true;
4581
+ } catch {
4582
+ return false;
4583
+ }
4584
+ }
4585
+
4586
+ /**
4587
+ * `coinpay x402 pay <url>` — fetch a paid resource, approving in the browser.
4588
+ *
4589
+ * The terminal cannot sign: browser wallets expose themselves to pages, not to
4590
+ * processes. So this serves a one-shot approval page on loopback, opens it,
4591
+ * and waits for the signed `X-PAYMENT` header to come back before retrying the
4592
+ * original request.
4593
+ */
4594
+ async function handleX402Pay(args, flags) {
4595
+ const target = args[0] || flags.url;
4596
+ if (!target) {
4597
+ print.error('Usage: coinpay x402 pay <url>');
4598
+ process.exit(1);
4599
+ }
4600
+
4601
+ const { startPaymentServer } = await import('../src/x402-pay-server.js');
4602
+
4603
+ print.info(`Fetching ${target}`);
4604
+ const first = await fetch(target);
4605
+
4606
+ if (first.status !== 402) {
4607
+ // Nothing to pay for. Still the useful answer, so print it rather than
4608
+ // treating "free" as an error.
4609
+ print.success(`No payment required (HTTP ${first.status})`);
4610
+ const body = await first.text();
4611
+ if (flags.output) {
4612
+ await (await import('node:fs/promises')).writeFile(flags.output, body);
4613
+ print.info(`Wrote ${flags.output}`);
4614
+ } else {
4615
+ console.log(body);
4616
+ }
4617
+ return;
4618
+ }
4619
+
4620
+ let paymentRequired;
4621
+ try {
4622
+ paymentRequired = await first.clone().json();
4623
+ } catch {
4624
+ print.error('Resource returned 402 but its body was not valid x402 JSON');
4625
+ process.exit(1);
4626
+ }
4627
+
4628
+ const server = await startPaymentServer({
4629
+ paymentRequired,
4630
+ resourceUrl: target,
4631
+ port: flags.port ? Number(flags.port) : 0,
4632
+ });
4633
+
4634
+ try {
4635
+ if (flags['no-open']) {
4636
+ print.info(`Open this to approve:\n ${server.url}`);
4637
+ } else {
4638
+ await openInBrowser(server.url);
4639
+ print.info(`Approve in your browser:\n ${server.url}`);
4640
+ }
4641
+ print.info('Waiting for approval…');
4642
+
4643
+ const header = await server.waitForPayment();
4644
+ print.success('Payment approved');
4645
+
4646
+ const paid = await fetch(target, { headers: { 'X-PAYMENT': header } });
4647
+ if (paid.status === 402) {
4648
+ // Never sign again automatically — a second 402 means the payment was
4649
+ // refused rather than missing, and retrying spends the user's money on a
4650
+ // resource that just said no.
4651
+ print.error('Payment was rejected by the resource. Not retrying.');
4652
+ process.exit(1);
4653
+ }
4654
+
4655
+ const body = await paid.text();
4656
+ if (flags.output) {
4657
+ await (await import('node:fs/promises')).writeFile(flags.output, body);
4658
+ print.success(`HTTP ${paid.status} — wrote ${flags.output}`);
4659
+ } else {
4660
+ print.success(`HTTP ${paid.status}`);
4661
+ console.log(body);
4662
+ }
4663
+ } finally {
4664
+ await server.close();
4665
+ }
4666
+ }
4667
+
2899
4668
  /**
2900
4669
  * Handle x402 commands
2901
4670
  */
@@ -2911,16 +4680,23 @@ Usage: coinpay x402 <command> [options]
2911
4680
  Commands:
2912
4681
  status Check x402 facilitator status
2913
4682
  test Test x402 flow against an endpoint
4683
+ pay <url> Fetch a paid URL, approving payment in your browser
2914
4684
 
2915
4685
  Options:
2916
4686
  --url <url> Endpoint URL for testing
2917
4687
  --network <net> Network: base, ethereum, polygon (default: base)
2918
4688
  --amount <amt> Amount in USDC smallest unit (default: 1000000 = 1 USDC)
4689
+ --output <file> (pay) Write the paid response body to a file
4690
+ --port <n> (pay) Port for the local approval page (default: random)
4691
+ --no-open (pay) Print the URL instead of opening a browser
2919
4692
  `);
2920
4693
  return;
2921
4694
  }
2922
4695
 
2923
4696
  switch (subcommand) {
4697
+ case 'pay':
4698
+ return handleX402Pay(args, flags);
4699
+
2924
4700
  case 'status': {
2925
4701
  const apiKey = flags['api-key'] || config.apiKey;
2926
4702
  const baseUrl = flags['base-url'] || config.baseUrl || 'https://coinpayportal.com';