@profullstack/coinpay 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/coinpay.js CHANGED
@@ -15,8 +15,17 @@ import {
15
15
  DEFAULT_CHAINS,
16
16
  } from '../src/wallet.js';
17
17
  import { SwapClient, SwapCoins } from '../src/swap.js';
18
- import { createInvoice, getInvoice, listInvoices, InvoiceStatus } from '../src/invoices.js';
19
- import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
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';
20
29
  import { execSync, spawn } from 'child_process';
21
30
  import { createInterface } from 'readline';
22
31
  import { homedir, hostname } from 'os';
@@ -150,6 +159,34 @@ function createUnauthenticatedClient() {
150
159
  return new CoinPayClient({ baseUrl: getBaseUrl() });
151
160
  }
152
161
 
162
+ const BOOLEAN_FLAGS = new Set([
163
+ 'active',
164
+ // finances reports / statements
165
+ 'wait',
166
+ 'strict',
167
+ 'setup-token-stdin',
168
+ 'overwrite',
169
+ 'no-pending',
170
+ 'liability-positive',
171
+ 'acknowledge',
172
+ 'plain',
173
+ 'no-stream',
174
+ 'hidden',
175
+ 'active-only',
176
+ 'debug',
177
+ 'escrow',
178
+ 'escrow-mode',
179
+ 'h',
180
+ 'help',
181
+ 'json',
182
+ 'no-open',
183
+ 'no-save',
184
+ 'show',
185
+ 'v',
186
+ 'version',
187
+ 'yes',
188
+ ]);
189
+
153
190
  /**
154
191
  * Parse command line arguments
155
192
  */
@@ -164,16 +201,20 @@ function parseArgs(args) {
164
201
  if (eqIdx !== -1) {
165
202
  result.flags[arg.slice(2, eqIdx)] = arg.slice(eqIdx + 1);
166
203
  } else {
204
+ const name = arg.slice(2);
167
205
  const next = args[i + 1];
168
- if (next && !next.startsWith('-')) {
169
- result.flags[arg.slice(2)] = next;
206
+ if (BOOLEAN_FLAGS.has(name)) {
207
+ result.flags[name] = true;
208
+ } else if (next && !next.startsWith('-')) {
209
+ result.flags[name] = next;
170
210
  i++;
171
211
  } else {
172
- result.flags[arg.slice(2)] = true;
212
+ result.flags[name] = true;
173
213
  }
174
214
  }
175
215
  } else if (arg.startsWith('-')) {
176
- result.flags[arg.slice(1)] = args[++i] ?? true;
216
+ const name = arg.slice(1);
217
+ result.flags[name] = BOOLEAN_FLAGS.has(name) ? true : args[++i] ?? true;
177
218
  } else if (!result.command) {
178
219
  result.command = arg;
179
220
  } else if (!result.subcommand) {
@@ -186,6 +227,15 @@ function parseArgs(args) {
186
227
  return result;
187
228
  }
188
229
 
230
+ function validateBooleanFlags(flags) {
231
+ for (const name of BOOLEAN_FLAGS) {
232
+ if (flags[name] !== undefined && flags[name] !== true) {
233
+ print.error('--' + name + ' does not take a value');
234
+ process.exit(1);
235
+ }
236
+ }
237
+ }
238
+
189
239
  function parsePositiveInteger(value) {
190
240
  if (typeof value !== 'string' || !/^\d+$/.test(value.trim())) {
191
241
  return null;
@@ -232,6 +282,79 @@ function requireFlagValues(flags, names) {
232
282
  }
233
283
  }
234
284
 
285
+ function requireSingleInvoiceId(args, subcommand) {
286
+ if (args.length !== 1) {
287
+ print.error('Usage: coinpay invoice ' + subcommand + ' <id>');
288
+ process.exit(1);
289
+ }
290
+ return args[0];
291
+ }
292
+
293
+ function rejectUnsupportedFlags(flags, allowed) {
294
+ const allowedFlags = new Set([...allowed, 'debug', 'json']);
295
+ const unsupported = Object.keys(flags).filter((name) => !allowedFlags.has(name));
296
+ if (unsupported.length > 0) {
297
+ print.error('Unsupported option' + (unsupported.length > 1 ? 's' : '') + ': ' +
298
+ unsupported.map((name) => '--' + name).join(', '));
299
+ process.exit(1);
300
+ }
301
+ }
302
+
303
+ function invoiceForOutput(data, fallbackId) {
304
+ const invoice = data?.invoice ?? data ?? {};
305
+ const output = {
306
+ id: invoice.id ?? fallbackId,
307
+ invoiceNumber: invoice.invoiceNumber ?? invoice.invoice_number,
308
+ businessId: invoice.businessId ?? invoice.business_id,
309
+ clientId: invoice.clientId ?? invoice.client_id,
310
+ currency: invoice.currency,
311
+ amount: invoice.amount,
312
+ cryptoCurrency: invoice.cryptoCurrency ?? invoice.crypto_currency,
313
+ cryptoAmount: invoice.cryptoAmount ?? invoice.crypto_amount,
314
+ status: invoice.status,
315
+ dueDate: invoice.dueDate ?? invoice.due_date,
316
+ notes: invoice.notes,
317
+ walletId: invoice.walletId ?? invoice.wallet_id,
318
+ merchantWalletAddress:
319
+ invoice.merchantWalletAddress ?? invoice.merchant_wallet_address,
320
+ paymentAddress: invoice.paymentAddress ?? invoice.payment_address,
321
+ stripeCheckoutUrl: invoice.stripeCheckoutUrl ?? invoice.stripe_checkout_url,
322
+ paidAt: invoice.paidAt ?? invoice.paid_at,
323
+ sentAt: invoice.sentAt ?? invoice.sent_at,
324
+ createdAt: invoice.createdAt ?? invoice.created_at,
325
+ updatedAt: invoice.updatedAt ?? invoice.updated_at,
326
+ };
327
+
328
+ return Object.fromEntries(
329
+ Object.entries(output).filter(([, value]) => value !== undefined)
330
+ );
331
+ }
332
+
333
+ function assertInvoiceUpdatesApplied(invoice, updates) {
334
+ if (invoice.status !== InvoiceStatus.DRAFT) {
335
+ throw new Error('Invoice changed state before the update completed; no draft update was confirmed');
336
+ }
337
+
338
+ const mismatches = Object.entries(updates).filter(([field, expected]) => {
339
+ const actual = invoice[field];
340
+ if (field === 'amount') return Number(actual) !== Number(expected);
341
+ if (field === 'dueDate') return String(actual || '').slice(0, 10) !== expected;
342
+ return actual !== expected;
343
+ });
344
+
345
+ if (mismatches.length > 0) {
346
+ throw new Error(
347
+ 'Invoice update was not confirmed for: ' + mismatches.map(([field]) => field).join(', ')
348
+ );
349
+ }
350
+ }
351
+
352
+ function describeInvoiceRouting(invoice) {
353
+ const crypto = invoice.cryptoCurrency || 'unset crypto';
354
+ const destination = invoice.merchantWalletAddress || 'resolved payee';
355
+ return crypto + ' -> ' + destination;
356
+ }
357
+
235
358
  /**
236
359
  * Show help
237
360
  */
@@ -266,12 +389,23 @@ ${colors.cyan}Commands:${colors.reset}
266
389
  Route settlement to a direct address
267
390
  get <id> Get invoice details
268
391
  list List invoices with optional filters
392
+ update <id> Update editable fields on a draft invoice
393
+ publish <id> Create payment details without emailing the client
394
+ send <id> Create payment details and email the client
395
+ delete <id> Permanently delete a draft invoice
396
+ --yes Skip confirmation for publish/send/delete
269
397
 
270
398
  ${colors.bright}tokens${colors.reset}
271
399
  list List checkout tokens (--business-id, --active-only)
272
400
 
273
401
  ${colors.bright}business${colors.reset}
274
402
  create Create a new business
403
+ --name <name> Required
404
+ --category <slug> Required; see: coinpay business categories
405
+ --description <s> Optional
406
+ --webhook-url <url> Optional; enables webhooks and a generated secret
407
+ --webhook-secret <s> Optional; supply your own instead
408
+ categories List the valid business category slugs
275
409
  get <id> Get business details
276
410
  list List businesses
277
411
  update <id> Update business
@@ -315,6 +449,36 @@ ${colors.cyan}Commands:${colors.reset}
315
449
  escrow release <id> Release card escrow funds
316
450
  escrow refund <id> Refund card escrow
317
451
 
452
+ ${colors.bright}finances${colors.reset} (also: coinpay money)
453
+ [tui] Live dashboard: earnings, commission, refunds,
454
+ bank & credit-card feeds, ledger, invoices, escrow
455
+ --days <n> Window for earnings/cashflow (7, 30, 90, 365; default 30)
456
+ --interval <s> Refresh every s seconds (default 30)
457
+ --business-id <id> Narrow payments to one business
458
+ summary Plain-text headline numbers (--json for machines)
459
+ position Debt vs income, credits vs debits, recurring bills
460
+ (also: coinpay finances debt)
461
+ scope <id> <side> Put an account on the business or personal books
462
+ (side: business | personal | clear)
463
+ accounts Linked bank and card accounts with balances
464
+ ledger Bank/card transactions (--limit, --search, --category, --account)
465
+ connections Linked institutions and their last sync
466
+ sync Pull fresh balances from the bank bridge (--days)
467
+ connect Link SimpleFIN: token prompted without echo, or --setup-token-stdin
468
+ disconnect <id> Stop syncs and drop the credential; history stays (--yes)
469
+ consent <id> on|off Opt a connection into a once-daily background sync
470
+ backfill Fetch a calendar period in safe chunks (--period 2026-Q2 |
471
+ --from/--to, --connection, --timezone, --wait, --json)
472
+ jobs [get|cancel <id>] Persisted sync/report jobs; reading one costs no request
473
+ coverage What part of a period was ever fetched (--period, --account)
474
+ report Monthly/quarterly/custom activity report (--period 2026-08 |
475
+ --from/--to; --account, --scope, --timezone, --format pdf|csv|html|json,
476
+ --output <file>, --strict, --wait, --json)
477
+ reports [get|download|delete <id>] Report history and downloads
478
+ statements import <pdf> Keep an original bank statement (--account, --period|--from/--to)
479
+ statements list|get|download|reconcile|delete
480
+ Statement library; reconcile takes --report --opening --closing --currency
481
+
318
482
  ${colors.bright}escrow${colors.reset}
319
483
  create Create a new escrow
320
484
  get <id> Get escrow status
@@ -482,6 +646,28 @@ async function promptYesNo(question, defaultYes = true) {
482
646
  return answer.toLowerCase().startsWith('y');
483
647
  }
484
648
 
649
+ async function promptYesNoOnStderr(question) {
650
+ const answer = await new Promise((resolve) => {
651
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
652
+ rl.question(question + ' [y/N] ', (value) => {
653
+ rl.close();
654
+ resolve(value.trim());
655
+ });
656
+ });
657
+ return answer.toLowerCase().startsWith('y');
658
+ }
659
+
660
+ async function confirmInvoiceMutation(flags, question) {
661
+ if (flags.yes === true) return true;
662
+ if (flags.json) {
663
+ throw new Error('--json requires --yes for invoice publish/send/delete');
664
+ }
665
+ if (!process.stdin.isTTY) {
666
+ throw new Error('Confirmation requires an interactive terminal; rerun with --yes');
667
+ }
668
+ return promptYesNoOnStderr(question);
669
+ }
670
+
485
671
  /**
486
672
  * Run gpg with the passphrase on a dedicated file descriptor and the plaintext
487
673
  * on stdin, so neither ever touches the filesystem.
@@ -542,7 +728,39 @@ function hasGpg() {
542
728
  /**
543
729
  * Encrypt mnemonic with GPG and save to file
544
730
  */
731
+
732
+ /**
733
+ * Minimum strength for a passphrase that protects an exported wallet.
734
+ *
735
+ * F5-L1-07 / L6B-05: there was no minimum at all here, while the sibling
736
+ * `generate-hd-wallets` backup asks for eight characters and the web wallet's
737
+ * create/import flow requires length >= 8 AND a strength score. A one-character
738
+ * passphrase was accepted on the path that produces a file an attacker can walk
739
+ * away with — and GPG's default KDF is far cheaper to attack on a GPU than the
740
+ * scrypt used by the other backup, so the passphrase is doing most of the work.
741
+ */
742
+ function assertStrongPassphrase(password) {
743
+ const problems = [];
744
+ if (!password || password.length < 12) {
745
+ problems.push('at least 12 characters');
746
+ }
747
+ if (!/[a-z]/.test(password ?? '') || !/[A-Z]/.test(password ?? '')) {
748
+ problems.push('both upper and lower case');
749
+ }
750
+ if (!/\d/.test(password ?? '') && !/[^A-Za-z0-9]/.test(password ?? '')) {
751
+ problems.push('a digit or a symbol');
752
+ }
753
+ if (problems.length > 0) {
754
+ throw new Error(
755
+ 'Passphrase too weak — this is the only thing protecting your wallet file. ' +
756
+ 'It needs ' + problems.join(', ') + '.'
757
+ );
758
+ }
759
+ }
760
+
545
761
  async function saveEncryptedWallet(mnemonic, walletId, password, walletFile) {
762
+ assertStrongPassphrase(password);
763
+
546
764
  if (!hasGpg()) {
547
765
  throw new Error('GPG is required for wallet encryption. Install: apt install gnupg');
548
766
  }
@@ -569,6 +787,22 @@ async function saveEncryptedWallet(mnemonic, walletId, password, walletFile) {
569
787
  { stdinData: content, passphrase: password }
570
788
  );
571
789
 
790
+ // Restrict the file gpg just created (F-L7-01 / F5-L1-02).
791
+ //
792
+ // `--output` means GPG creates the file itself, at the process umask — so it
793
+ // typically lands world-readable, and every other local user can copy an
794
+ // encrypted wallet at their leisure and attack the passphrase offline.
795
+ // Sibling writes in this same CLI pass `{ mode: 0o600 }`, but that option
796
+ // only applies when Node does the writing.
797
+ try {
798
+ chmodSync(walletFile, 0o600);
799
+ } catch (err) {
800
+ console.warn(
801
+ `Warning: could not restrict permissions on ${walletFile} — ` +
802
+ 'other local users may be able to read it. ' + (err?.message ?? err)
803
+ );
804
+ }
805
+
572
806
  return true;
573
807
  }
574
808
 
@@ -780,9 +1014,16 @@ function printInvoiceDetails(invoice, { shareUrl } = {}) {
780
1014
  if (invoice.businessId) print.info('Business: ' + invoice.businessId);
781
1015
  if (invoice.clientId) print.info('Client: ' + invoice.clientId);
782
1016
  if (invoice.cryptoCurrency) print.info('Crypto: ' + invoice.cryptoCurrency);
1017
+ if (invoice.walletId) print.info('Wallet reference: ' + invoice.walletId);
1018
+ if (invoice.merchantWalletAddress) {
1019
+ print.info('Settlement address: ' + invoice.merchantWalletAddress);
1020
+ }
1021
+ if (invoice.paymentAddress) print.info('Payment address: ' + invoice.paymentAddress);
783
1022
  if (invoice.dueDate) print.info('Due: ' + invoice.dueDate);
784
1023
  if (invoice.notes) print.info('Notes: ' + invoice.notes);
785
- if (shareUrl) print.info('Share link (opens after send): ' + shareUrl);
1024
+ if (shareUrl) {
1025
+ print.info('Share link: ' + shareUrl);
1026
+ }
786
1027
  console.log();
787
1028
  }
788
1029
 
@@ -837,15 +1078,13 @@ async function handleInvoice(subcommand, args, flags) {
837
1078
  walletId: flags['wallet-id'],
838
1079
  merchantWalletAddress: flags['merchant-wallet-address'],
839
1080
  });
840
- const shareUrl = invoice.id ? getInvoiceShareUrl(invoice.id) : undefined;
841
-
842
1081
  if (flags.json) {
843
- print.json(shareUrl ? { ...invoice, shareUrl } : invoice);
1082
+ print.json(invoice);
844
1083
  break;
845
1084
  }
846
1085
 
847
1086
  print.success('Draft invoice created');
848
- printInvoiceDetails(invoice, { shareUrl });
1087
+ printInvoiceDetails(invoice);
849
1088
  break;
850
1089
  }
851
1090
 
@@ -909,11 +1148,8 @@ async function handleInvoice(subcommand, args, flags) {
909
1148
  }
910
1149
 
911
1150
  case 'get': {
912
- const id = args[0];
913
- if (!id) {
914
- print.error('Usage: coinpay invoice get <id>');
915
- process.exit(1);
916
- }
1151
+ rejectUnsupportedFlags(flags, []);
1152
+ const id = requireSingleInvoiceId(args, 'get');
917
1153
 
918
1154
  const invoice = await getInvoice(client, id);
919
1155
  if (flags.json) {
@@ -925,9 +1161,301 @@ async function handleInvoice(subcommand, args, flags) {
925
1161
  break;
926
1162
  }
927
1163
 
1164
+ case 'update': {
1165
+ const updateFlags = [
1166
+ 'amount',
1167
+ 'currency',
1168
+ 'crypto-currency',
1169
+ 'due-date',
1170
+ 'notes',
1171
+ 'client-id',
1172
+ 'wallet-id',
1173
+ 'merchant-wallet-address',
1174
+ ];
1175
+ rejectUnsupportedFlags(flags, updateFlags);
1176
+ if (flags.notes === true) {
1177
+ print.error('--notes requires a value; use --notes= to clear existing notes');
1178
+ process.exit(1);
1179
+ }
1180
+ requireFlagValues(flags, updateFlags);
1181
+ const id = requireSingleInvoiceId(args, 'update');
1182
+ const updates = {};
1183
+
1184
+ if (flags.amount !== undefined) {
1185
+ const amount = parsePositiveAmount(flags.amount);
1186
+ if (amount === null) {
1187
+ print.error('--amount must be a positive decimal with at most two decimal places');
1188
+ process.exit(1);
1189
+ }
1190
+ updates.amount = amount;
1191
+ }
1192
+
1193
+ if (flags.currency !== undefined) {
1194
+ if (!/^[A-Za-z]{3}$/.test(flags.currency)) {
1195
+ print.error('--currency must be a three-letter currency code');
1196
+ process.exit(1);
1197
+ }
1198
+ updates.currency = flags.currency.toUpperCase();
1199
+ }
1200
+
1201
+ if (flags['crypto-currency'] !== undefined) {
1202
+ const cryptoCurrency = flags['crypto-currency'].trim();
1203
+ if (!cryptoCurrency) {
1204
+ print.error('--crypto-currency must not be empty');
1205
+ process.exit(1);
1206
+ }
1207
+ updates.cryptoCurrency = cryptoCurrency.toUpperCase();
1208
+ }
1209
+
1210
+ if (flags['due-date'] !== undefined) {
1211
+ if (!isValidDate(flags['due-date'])) {
1212
+ print.error('--due-date must be a valid date in YYYY-MM-DD format');
1213
+ process.exit(1);
1214
+ }
1215
+ updates.dueDate = flags['due-date'];
1216
+ }
1217
+
1218
+ if (flags.notes !== undefined) updates.notes = flags.notes;
1219
+
1220
+ const stringUpdates = [
1221
+ ['client-id', 'clientId'],
1222
+ ['wallet-id', 'walletId'],
1223
+ ['merchant-wallet-address', 'merchantWalletAddress'],
1224
+ ];
1225
+ for (const [flag, field] of stringUpdates) {
1226
+ if (flags[flag] === undefined) continue;
1227
+ const value = flags[flag].trim();
1228
+ if (!value) {
1229
+ print.error('--' + flag + ' must not be empty');
1230
+ process.exit(1);
1231
+ }
1232
+ updates[field] = value;
1233
+ }
1234
+
1235
+ if (Object.keys(updates).length === 0) {
1236
+ print.error('Provide at least one editable field for invoice update');
1237
+ print.info('Example: coinpay invoice update <id> --amount 125 --notes "Updated scope"');
1238
+ process.exit(1);
1239
+ }
1240
+
1241
+ const current = await getInvoice(client, id);
1242
+ if (current.status !== InvoiceStatus.DRAFT) {
1243
+ throw new Error('Only draft invoices can be updated (current: ' +
1244
+ (current.status || 'unknown') + ')');
1245
+ }
1246
+
1247
+ const updated = await updateInvoice(client, id, updates);
1248
+ assertInvoiceUpdatesApplied(updated, updates);
1249
+
1250
+ if (flags.json) {
1251
+ print.json(updated);
1252
+ break;
1253
+ }
1254
+
1255
+ print.success('Draft invoice updated');
1256
+ const routingFields = ['cryptoCurrency', 'merchantWalletAddress'];
1257
+ if (routingFields.some((field) => updates[field] !== undefined)) {
1258
+ print.info('Payment routing: ' + describeInvoiceRouting(current) +
1259
+ ' => ' + describeInvoiceRouting(updated));
1260
+ }
1261
+ if (updates.walletId !== undefined) {
1262
+ print.info('Wallet reference: ' + (current.walletId || 'unset') +
1263
+ ' => ' + updated.walletId);
1264
+ }
1265
+ printInvoiceDetails(updated);
1266
+ break;
1267
+ }
1268
+
1269
+ case 'publish': {
1270
+ rejectUnsupportedFlags(flags, ['yes']);
1271
+ const id = requireSingleInvoiceId(args, 'publish');
1272
+ const current = await getInvoice(client, id);
1273
+ const existingShareUrl = getInvoiceShareUrl(id);
1274
+
1275
+ if (current.status === InvoiceStatus.SENT) {
1276
+ if (!current.paymentAddress) {
1277
+ throw new Error(
1278
+ 'Published invoice has no active payment address; refresh it in the web app'
1279
+ );
1280
+ }
1281
+ if (flags.json) {
1282
+ print.json({
1283
+ ...current,
1284
+ shareUrl: existingShareUrl,
1285
+ emailAttempted: false,
1286
+ idempotentReplay: true,
1287
+ });
1288
+ break;
1289
+ }
1290
+ print.success('Invoice is already published; no email sent');
1291
+ printInvoiceDetails(current, { shareUrl: existingShareUrl });
1292
+ break;
1293
+ }
1294
+
1295
+ if (current.status !== InvoiceStatus.DRAFT) {
1296
+ throw new Error(
1297
+ 'Only draft invoices can be published (current: ' + (current.status || 'unknown') + ')'
1298
+ );
1299
+ }
1300
+ if (!current.cryptoCurrency) {
1301
+ throw new Error('Set --crypto-currency before publishing this invoice');
1302
+ }
1303
+
1304
+ const label = current.invoiceNumber || current.id;
1305
+ const confirmed = await confirmInvoiceMutation(
1306
+ flags,
1307
+ 'Publish ' +
1308
+ label +
1309
+ ' for ' +
1310
+ current.amount +
1311
+ ' ' +
1312
+ (current.currency || '') +
1313
+ ' using ' +
1314
+ current.cryptoCurrency +
1315
+ '? This creates live payment details but does not email the client.'
1316
+ );
1317
+ if (!confirmed) {
1318
+ print.info('Aborted');
1319
+ break;
1320
+ }
1321
+
1322
+ const result = await publishInvoice(client, id);
1323
+ const published = invoiceForOutput(result, id);
1324
+ if (
1325
+ result?.success === false ||
1326
+ published.status !== InvoiceStatus.SENT ||
1327
+ !published.paymentAddress
1328
+ ) {
1329
+ throw new Error('Invoice publish was not confirmed by the server');
1330
+ }
1331
+ const shareUrl =
1332
+ typeof result?.paymentLink === 'string'
1333
+ ? result.paymentLink
1334
+ : getInvoiceShareUrl(published.id);
1335
+
1336
+ if (flags.json) {
1337
+ print.json({
1338
+ ...published,
1339
+ shareUrl,
1340
+ emailAttempted: false,
1341
+ idempotentReplay: result?.idempotentReplay === true,
1342
+ });
1343
+ break;
1344
+ }
1345
+
1346
+ print.success('Invoice published; no email sent');
1347
+ printInvoiceDetails(published, { shareUrl });
1348
+ break;
1349
+ }
1350
+
1351
+ case 'send': {
1352
+ rejectUnsupportedFlags(flags, ['yes']);
1353
+ const id = requireSingleInvoiceId(args, 'send');
1354
+ const current = await getInvoice(client, id);
1355
+ const sendable = new Set([InvoiceStatus.DRAFT, InvoiceStatus.OVERDUE]);
1356
+ if (!sendable.has(current.status)) {
1357
+ throw new Error('Only draft or overdue invoices can be sent (current: ' +
1358
+ (current.status || 'unknown') + ')');
1359
+ }
1360
+ if (!current.cryptoCurrency) {
1361
+ throw new Error('Set --crypto-currency before sending this invoice');
1362
+ }
1363
+
1364
+ const label = current.invoiceNumber || current.id;
1365
+ const recipient = current.clientId ? 'client ' + current.clientId : 'the configured client';
1366
+ const amount = current.amount + ' ' + (current.currency || '');
1367
+ const resendWarning = current.status === InvoiceStatus.OVERDUE
1368
+ ? ' This will issue new payment details and email the client again.'
1369
+ : ' This will create payment details and email the client.';
1370
+ const confirmed = await confirmInvoiceMutation(
1371
+ flags,
1372
+ 'Send ' + label + ' to ' + recipient + ' for ' + amount +
1373
+ ' using ' + current.cryptoCurrency + '?' + resendWarning
1374
+ );
1375
+ if (!confirmed) {
1376
+ print.info('Aborted');
1377
+ break;
1378
+ }
1379
+
1380
+ const result = await sendInvoice(client, id);
1381
+ let sent = invoiceForOutput(result, id);
1382
+ const legacySuccess = result?.success === true &&
1383
+ !Object.prototype.hasOwnProperty.call(result, 'invoice') &&
1384
+ sent.status === undefined;
1385
+
1386
+ if (legacySuccess) {
1387
+ sent = invoiceForOutput({
1388
+ ...current,
1389
+ ...result,
1390
+ status: InvoiceStatus.SENT,
1391
+ }, id);
1392
+ } else if (result?.success === false || sent.status !== InvoiceStatus.SENT) {
1393
+ throw new Error('Invoice send was not confirmed by the server');
1394
+ }
1395
+ const shareUrl = getInvoiceShareUrl(sent.id);
1396
+
1397
+ if (flags.json) {
1398
+ const delivery = {};
1399
+ for (const field of ['emailAccepted', 'emailTrackingSaved', 'warning', 'emailError']) {
1400
+ if (Object.prototype.hasOwnProperty.call(result ?? {}, field)) {
1401
+ delivery[field] = result[field];
1402
+ }
1403
+ }
1404
+ print.json({ ...sent, shareUrl, ...delivery });
1405
+ break;
1406
+ }
1407
+
1408
+ if (result?.emailAccepted === false) {
1409
+ print.warn('Invoice payment link created, but the email provider did not accept the message');
1410
+ } else if (result?.emailAccepted === true) {
1411
+ print.success('Invoice sent; email accepted by provider');
1412
+ } else {
1413
+ print.success('Invoice sent; email status was not reported by the server');
1414
+ }
1415
+ if (legacySuccess) {
1416
+ print.warn('The server returned limited invoice details; run coinpay invoice get ' +
1417
+ id + ' to refresh them.');
1418
+ }
1419
+ printInvoiceDetails(sent, { shareUrl });
1420
+ break;
1421
+ }
1422
+
1423
+ case 'delete': {
1424
+ rejectUnsupportedFlags(flags, ['yes']);
1425
+ const id = requireSingleInvoiceId(args, 'delete');
1426
+ const current = await getInvoice(client, id);
1427
+ if (current.status !== InvoiceStatus.DRAFT) {
1428
+ throw new Error('Only draft invoices can be deleted (current: ' +
1429
+ (current.status || 'unknown') + ')');
1430
+ }
1431
+
1432
+ const label = current.invoiceNumber || current.id;
1433
+ const confirmed = await confirmInvoiceMutation(
1434
+ flags,
1435
+ 'Permanently delete draft invoice ' + label + ' for ' + current.amount +
1436
+ ' ' + (current.currency || '') + '?'
1437
+ );
1438
+ if (!confirmed) {
1439
+ print.info('Aborted');
1440
+ break;
1441
+ }
1442
+
1443
+ const result = await deleteInvoice(client, id);
1444
+ if (result?.success === false) {
1445
+ throw new Error('Invoice deletion was not confirmed by the server');
1446
+ }
1447
+
1448
+ if (flags.json) {
1449
+ print.json({ id, deleted: true });
1450
+ break;
1451
+ }
1452
+ print.success('Draft invoice deleted: ' + id);
1453
+ break;
1454
+ }
1455
+
928
1456
  default:
929
1457
  print.error('Unknown invoice command: ' + subcommand);
930
- print.info('Available: create, list, get');
1458
+ print.info('Available: create, list, get, update, publish, send, delete');
931
1459
  process.exit(1);
932
1460
  }
933
1461
  }
@@ -973,18 +1501,43 @@ async function handleBusiness(subcommand, args, flags) {
973
1501
 
974
1502
  switch (subcommand) {
975
1503
  case 'create': {
976
- const { name, 'webhook-url': webhookUrl } = flags;
977
-
978
- if (!name) {
979
- print.error('Required: --name');
1504
+ const {
1505
+ name,
1506
+ category,
1507
+ description,
1508
+ 'webhook-url': webhookUrl,
1509
+ 'webhook-secret': webhookSecret,
1510
+ } = flags;
1511
+
1512
+ if (!name || !category) {
1513
+ // The API has required a category since businesses started being
1514
+ // classified on create, and this command had no way to send one -- so it
1515
+ // failed with "Select a valid business category" and no route to fixing
1516
+ // it. Naming the flag here is cheaper than reading the taxonomy.
1517
+ print.error('Required: --name <name> --category <slug>');
1518
+ print.info('Categories: coinpay business categories');
980
1519
  return;
981
1520
  }
982
-
983
- const business = await client.createBusiness({ name, webhookUrl });
1521
+
1522
+ const business = await client.createBusiness({
1523
+ name,
1524
+ category,
1525
+ description,
1526
+ webhookUrl,
1527
+ webhookSecret,
1528
+ });
984
1529
  print.success('Business created');
985
1530
  print.json(business);
986
1531
  break;
987
1532
  }
1533
+
1534
+ case 'categories': {
1535
+ // Fetched rather than hardcoded: the taxonomy is server-side and a stale
1536
+ // copy in the CLI is how "Select a valid business category" happens twice.
1537
+ const result = await client.listBusinessCategories();
1538
+ print.json(result);
1539
+ break;
1540
+ }
988
1541
 
989
1542
  case 'get': {
990
1543
  const businessId = args[0];
@@ -2935,7 +3488,9 @@ const MENU_COMMANDS = [
2935
3488
  ['logout', 'Sign out on this device'],
2936
3489
  ['config', 'API key & endpoint (set-key, set-url, show)'],
2937
3490
  ['auth', 'Merchant account (register, login, me)'],
3491
+ ['finances', 'Live money dashboard (earnings, cards, bank feeds, ledger)'],
2938
3492
  ['payment', 'Payments (create, get, list, qr)'],
3493
+ ['invoice', 'Invoices (create, list, get, update, publish, send, delete)'],
2939
3494
  ['tokens', 'List checkout tokens'],
2940
3495
  ['business', 'Businesses (create, get, list, update)'],
2941
3496
  ['rates', 'Exchange rates (get, list)'],
@@ -2955,7 +3510,9 @@ const MENU_COMMANDS = [
2955
3510
  const SUBCOMMANDS = {
2956
3511
  config: [['set-key', 'Set your API key'], ['set-url', 'Set custom API URL'], ['show', 'Show current configuration']],
2957
3512
  auth: [['register', 'Register new merchant account'], ['login', 'Login to merchant account'], ['me', 'Show current merchant info']],
3513
+ finances: [['tui', 'Live dashboard (default)'], ['summary', 'Headline numbers as text'], ['position', 'Debt vs income, credits vs debits'], ['scope', 'Set an account business or personal'], ['accounts', 'Bank & card accounts'], ['ledger', 'Bank/card transactions'], ['connections', 'Linked institutions'], ['sync', 'Pull fresh bank data'], ['report', 'Monthly/quarterly activity report'], ['backfill', 'Fetch a calendar period'], ['statements', 'Original bank statements'], ['jobs', 'Sync and report jobs']],
2958
3514
  payment: [['create', 'Create a new payment'], ['get', 'Get payment details <id>'], ['list', 'List payments'], ['qr', 'Get payment QR code <id>']],
3515
+ invoice: [['create', 'Create a draft invoice'], ['list', 'List invoices'], ['get', 'Get invoice details <id>'], ['update', 'Update a draft invoice <id>'], ['publish', 'Publish an invoice <id>'], ['send', 'Send an invoice <id>'], ['delete', 'Delete a draft invoice <id>']],
2959
3516
  tokens: [['list', 'List checkout tokens']],
2960
3517
  business: [['create', 'Create a new business'], ['get', 'Get business details <id>'], ['list', 'List businesses'], ['update', 'Update business <id>']],
2961
3518
  rates: [['get', 'Get exchange rate <crypto>'], ['list', 'Get all exchange rates']],
@@ -3215,13 +3772,19 @@ async function handleLogin() {
3215
3772
  process.exit(1);
3216
3773
  }
3217
3774
 
3775
+ // G-1.2-10: there is no pre-filled approval link any more. A link that filled
3776
+ // in the code turned a phishing message into one-click account takeover —
3777
+ // anyone could start a device authorization and send the resulting link to a
3778
+ // signed-in merchant. Entering the code by hand is what ties the approval to
3779
+ // a terminal the person is actually sitting at.
3218
3780
  print.info('');
3219
- console.log(' To log in, open this URL on any device (e.g. your desktop) and approve:');
3781
+ console.log(' To log in, open this URL on any device (e.g. your desktop):');
3782
+ console.log('');
3783
+ console.log(' ' + colors.cyan + start.verification_uri + colors.reset);
3220
3784
  console.log('');
3221
- console.log(' ' + colors.cyan + start.verification_uri_complete + colors.reset);
3785
+ console.log(' and enter this code: ' + colors.bright + start.user_code + colors.reset);
3222
3786
  console.log('');
3223
- console.log(' …or go to ' + colors.cyan + start.verification_uri + colors.reset +
3224
- ' and enter code ' + colors.bright + start.user_code + colors.reset);
3787
+ console.log(colors.yellow + ' Only enter a code your own terminal printed.' + colors.reset);
3225
3788
  console.log('');
3226
3789
  print.info('Waiting for approval… (Ctrl-C to cancel)');
3227
3790
 
@@ -3295,8 +3858,441 @@ async function handleWhoami(flags) {
3295
3858
  if (flags?.json) print.json(me);
3296
3859
  }
3297
3860
 
3861
+
3862
+ /**
3863
+ * Finances — the merchant's money in one place (bank feeds via SimpleFIN or
3864
+ * Plaid, crypto and card earnings, commission paid, refunds, invoices, escrow).
3865
+ *
3866
+ * Needs the merchant session from `coinpay login`; the bank-data routes
3867
+ * refuse business API keys on purpose.
3868
+ */
3869
+ function financesClient() {
3870
+ const cfg = loadConfig();
3871
+ const token = process.env.COINPAY_SESSION_TOKEN || cfg.jwtToken;
3872
+ if (!token) {
3873
+ print.error('Finances need your merchant session, not an API key. Run: coinpay login');
3874
+ process.exit(1);
3875
+ }
3876
+ return { client: new CoinPayClient({ apiKey: token, baseUrl: getBaseUrl(), timeout: 120000 }), token, baseUrl: getBaseUrl() };
3877
+ }
3878
+
3879
+ function pad(value, width, align = 'left') {
3880
+ const s = String(value ?? '');
3881
+ if (s.length >= width) return s;
3882
+ return align === 'right' ? s.padStart(width) : s.padEnd(width);
3883
+ }
3884
+
3885
+ function printTable(rows, columns) {
3886
+ if (!rows.length) return;
3887
+ const widths = columns.map((c) => Math.max(c.title.length, ...rows.map((r) => String(c.render(r) ?? '').length)));
3888
+ console.log(colors.bright + columns.map((c, i) => pad(c.title, widths[i], c.align)).join(' ') + colors.reset);
3889
+ for (const r of rows) {
3890
+ console.log(columns.map((c, i) => pad(c.render(r) ?? '', widths[i], c.align)).join(' '));
3891
+ }
3892
+ }
3893
+
3894
+ function printFinanceSummary(snapshot, fmt) {
3895
+ const { money, ago } = fmt;
3896
+ const e = snapshot.earnings;
3897
+ const b = snapshot.bank;
3898
+ const cur = b.currency || 'USD';
3899
+ const win = `${snapshot.windowDays}d`;
3900
+ const line = (label, value, color = '') => console.log(` ${pad(label, 26)} ${color}${value}${colors.reset}`);
3901
+
3902
+ console.log(`\n${colors.bright}Earnings · ${win}${colors.reset}${snapshot.plan ? colors.cyan + ' (' + snapshot.plan.commission_percent + ' plan)' + colors.reset : ''}`);
3903
+ line('Gross volume', money(e.grossVolumeUsd), colors.bright);
3904
+ line(' crypto', money(e.cryptoVolumeUsd));
3905
+ line(' cards', money(e.cardVolumeUsd));
3906
+ line('Commission paid', `-${money(e.commissionUsd)}`, colors.yellow);
3907
+ line('Card processor fees', `-${money(e.stripeFeesUsd)}`, colors.yellow);
3908
+ line('Refunds', `-${money(e.refundsUsd)}`, e.refundsUsd > 0 ? colors.red : '');
3909
+ line('Net earnings', money(e.netUsd), e.netUsd >= 0 ? colors.green : colors.red);
3910
+ line('Paid transactions', `${e.transactions} (${e.failed} failed, ${e.failureRate}%)`);
3911
+
3912
+ console.log(`\n${colors.bright}Bank & cards${colors.reset}`);
3913
+ if (!b.connections.length) {
3914
+ console.log(' Nothing linked. Connect a bank at ' + new URL('/finances', getBaseUrl()).toString());
3915
+ } else {
3916
+ line('Cash & assets', money(b.assets, cur), colors.green);
3917
+ line('Cards & loans owed', money(b.liabilities, cur), colors.red);
3918
+ line('Net position', money(b.net, cur), b.net >= 0 ? colors.green : colors.red);
3919
+ line(`Cash in · ${win}`, money(b.cashflow.moneyIn, cur), colors.green);
3920
+ line(`Cash out · ${win}`, money(b.cashflow.moneyOut, cur), colors.red);
3921
+ line('Cashflow net', money(b.cashflow.net, cur), b.cashflow.net >= 0 ? colors.green : colors.red);
3922
+ line('Accounts', `${b.accountCount} (${b.creditCards.length} credit cards)`);
3923
+ line('Last bank sync', `${ago(b.connections[0]?.last_synced_at)} · ${b.connections[0]?.last_sync_status || '—'}`);
3924
+ }
3925
+
3926
+ console.log(`\n${colors.bright}Pipeline${colors.reset}`);
3927
+ const inv = snapshot.invoices;
3928
+ const esc = snapshot.escrow;
3929
+ line('Invoices outstanding', `${money(inv.totals.outstanding)} (${inv.counts.outstanding})`, colors.yellow);
3930
+ line('Invoices overdue', `${money(inv.totals.overdue)} (${inv.counts.overdue})`, inv.counts.overdue ? colors.red : '');
3931
+ line(`Invoices paid · ${win}`, `${money(inv.totals.paid)} (${inv.counts.paid})`, colors.green);
3932
+ line('Escrow held', `${money(esc.heldUsd)} (${esc.held})`, colors.cyan);
3933
+ line(`Escrow released · ${win}`, `${money(esc.releasedUsd)} (${esc.released})`);
3934
+ line(`Escrow refunded · ${win}`, `${money(esc.refundedUsd)} (${esc.refunded})`, esc.refunded ? colors.red : '');
3935
+ line('Card payouts pending', money(snapshot.payout.pendingUsd));
3936
+ line(`Card payouts paid · ${win}`, money(snapshot.payout.paidUsd), colors.green);
3937
+
3938
+ if (snapshot.position) printPosition(snapshot.position, fmt, { heading: true });
3939
+
3940
+ const failed = Object.keys(snapshot.errors);
3941
+ if (failed.length) {
3942
+ console.log('');
3943
+ print.warn(`Unavailable: ${failed.map((k) => `${k} (${snapshot.errors[k]})`).join('; ')}`);
3944
+ }
3945
+ console.log('');
3946
+ }
3947
+
3948
+ /**
3949
+ * Debt against income as text.
3950
+ *
3951
+ * Shared by `finances summary` (as one more section) and `finances position`
3952
+ * (on its own, with the account and recurring detail the summary omits).
3953
+ */
3954
+ function printPosition(position, fmt, { heading = false, detail = false } = {}) {
3955
+ const { money, pct } = fmt;
3956
+ const cur = position.currency || 'USD';
3957
+ const look = `${Math.round(position.observedDays ?? position.lookbackDays)}d`;
3958
+ const line = (label, value, color = '') => console.log(` ${pad(label, 26)} ${color}${value}${colors.reset}`);
3959
+
3960
+ if (heading) console.log(`\n${colors.bright}Debt & income · ${look}${colors.reset}${colors.cyan} (${position.monthsObserved} months)${colors.reset}`);
3961
+
3962
+ const inc = position.income;
3963
+ const spend = position.spending;
3964
+ const debt = position.debt;
3965
+ const r = position.ratios;
3966
+
3967
+ line('Income', `${money(inc.total, cur)} (${money(inc.perMonth, cur)}/mo)`, colors.green);
3968
+ line('Spending', `${money(spend.total, cur)} (${money(spend.perMonth, cur)}/mo)`, colors.red);
3969
+ line('Net', `${money(position.net.total, cur)} (${money(position.net.perMonth, cur)}/mo)`, position.net.total >= 0 ? colors.green : colors.red);
3970
+ line('Kept of income', pct(position.net.savingsRate, 1));
3971
+ line('Gross credits / debits', `${money(inc.grossCredits, cur)} / ${money(spend.grossDebits, cur)}`);
3972
+ line('Total owed', money(debt.total, cur), colors.red);
3973
+ line(' revolving / instalment', `${money(debt.revolving, cur)} / ${money(debt.instalment, cur)}`);
3974
+ line('Debt paid per month', money(debt.servicePerMonth, cur), colors.green);
3975
+ line('Fixed bills per month', money(position.recurring.monthlyTotal, cur), colors.yellow);
3976
+ 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 : '');
3977
+ line('Debt to income', r.debtToIncome === null ? '—' : `${r.debtToIncome.toFixed(2)}x`, (r.debtToIncome ?? 0) > 1 ? colors.red : colors.green);
3978
+ line('Debt service ratio', pct(r.debtServiceRatio, 1), (r.debtServiceRatio ?? 0) > 0.36 ? colors.red : colors.green);
3979
+ line('Months of cover', r.monthsOfCover === null ? '—' : r.monthsOfCover.toFixed(1));
3980
+ line('Card utilisation', pct(r.creditUtilisation, 0));
3981
+
3982
+ for (const sc of position.scopes) {
3983
+ line(`${sc.scope} (${sc.accounts} acct)`, `${money(sc.income, cur)} in · ${money(sc.spending, cur)} out · owes ${money(sc.debt, cur)}`);
3984
+ }
3985
+
3986
+ if (position.confidence.noLiabilityAccounts) {
3987
+ console.log('');
3988
+ print.warn('No card or loan account is linked, so the debt figures are blind.');
3989
+ }
3990
+ if (position.confidence.uncategorisedShare > 0.25) {
3991
+ console.log('');
3992
+ print.warn(`${pct(position.confidence.uncategorisedShare, 0)} of transactions are uncategorised — the income/spending split is rough.`);
3993
+ }
3994
+
3995
+ if (!detail) return;
3996
+
3997
+ if (position.months.length) {
3998
+ console.log(`\n${colors.bright}Month by month${colors.reset}`);
3999
+ printTable(position.months, [
4000
+ { title: 'Month', align: 'left', render: (m) => (m.partial ? `${m.month} *` : m.month) },
4001
+ { title: 'Income', align: 'right', render: (m) => money(m.income, cur) },
4002
+ { title: 'Spending', align: 'right', render: (m) => money(m.spending, cur) },
4003
+ { title: 'Net', align: 'right', render: (m) => money(m.net, cur) },
4004
+ { title: 'Debt paid', align: 'right', render: (m) => money(m.debtService, cur) },
4005
+ { title: 'Rows', align: 'right', render: (m) => String(m.transactions) },
4006
+ ]);
4007
+ console.log(` ${colors.cyan}* partial month${colors.reset}`);
4008
+ }
4009
+
4010
+ if (debt.accounts.length) {
4011
+ console.log(`\n${colors.bright}Owed by account${colors.reset}`);
4012
+ printTable(debt.accounts, [
4013
+ { title: 'Institution', align: 'left', render: (d) => d.org || '—' },
4014
+ { title: 'Account', align: 'left', render: (d) => d.name },
4015
+ { title: 'Kind', align: 'left', render: (d) => d.kind },
4016
+ { title: 'Side', align: 'left', render: (d) => d.scope },
4017
+ { title: 'Owed', align: 'right', render: (d) => money(d.owed, cur) },
4018
+ { title: 'Share', align: 'right', render: (d) => pct(d.share, 0) },
4019
+ { title: `Paid ${look}`, align: 'right', render: (d) => money(d.paid, cur) },
4020
+ { title: 'Clear in', align: 'right', render: (d) => (d.payoffMonths === null ? 'never' : `${d.payoffMonths} mo`) },
4021
+ ]);
4022
+ }
4023
+
4024
+ if (position.recurring.charges.length) {
4025
+ 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}`);
4026
+ printTable(position.recurring.charges, [
4027
+ { title: 'Payee', align: 'left', render: (c) => c.payee },
4028
+ { title: 'Amount', align: 'right', render: (c) => money(c.amount, cur) },
4029
+ { title: 'Every', align: 'left', render: (c) => c.cadence },
4030
+ { title: 'Seen', align: 'right', render: (c) => String(c.occurrences) },
4031
+ { title: 'Last', align: 'left', render: (c) => c.lastSeen.slice(0, 10) },
4032
+ { title: 'Next', align: 'left', render: (c) => c.nextExpected.slice(0, 10) },
4033
+ { title: 'Per month', align: 'right', render: (c) => money(c.monthlyEquivalent, cur) },
4034
+ { title: 'Kind', align: 'left', render: (c) => (c.isDebtService ? 'debt' : 'bill') },
4035
+ ]);
4036
+ }
4037
+ console.log('');
4038
+ }
4039
+
4040
+ async function handleFinances(subcommand, args, flags) {
4041
+ const parsedDays = Number.parseInt(String(flags.days ?? flags.window ?? ''), 10);
4042
+ const days = Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : 30;
4043
+ const fin = await import('../src/finances.js');
4044
+
4045
+ switch (subcommand || 'tui') {
4046
+ case 'tui':
4047
+ case 'dashboard':
4048
+ case 'watch': {
4049
+ const { client, token, baseUrl } = financesClient();
4050
+
4051
+ // A document for somebody who bills by the hour. Every question it
4052
+ // answers up front is a question nobody pays to have asked, so it
4053
+ // defaults to the whole transaction list and a year of history.
4054
+ if (flags.pdf || flags.report) {
4055
+ const reportDays = Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : 365;
4056
+ const limit = Number.parseInt(String(flags.limit ?? ''), 10);
4057
+ const snapshot = await fin.collectFinanceSnapshot(client, {
4058
+ days: reportDays,
4059
+ limit: Number.isFinite(limit) && limit > 0 ? limit : 5000,
4060
+ businessId: flags['business-id'],
4061
+ // Page the whole window. Asking for 5000 returns 500, and a report
4062
+ // built on one page understates the period without saying so.
4063
+ allTransactions: true,
4064
+ });
4065
+ const { buildFinanceReportHtml } = await import('../src/finances-report.js');
4066
+ const html = buildFinanceReportHtml(snapshot, {
4067
+ preparedFor: typeof flags.for === 'string' ? flags.for : '',
4068
+ preparedBy: typeof flags.by === 'string' ? flags.by : '',
4069
+ entity: typeof flags.entity === 'string' ? flags.entity : '',
4070
+ includeTransactions: !flags['no-transactions'],
4071
+ transactionLimit: 0,
4072
+ title: typeof flags.title === 'string' ? flags.title : 'Financial summary',
4073
+ });
4074
+
4075
+ const stamp = new Date().toISOString().slice(0, 10);
4076
+ const base = typeof flags.pdf === 'string' && flags.pdf
4077
+ ? String(flags.pdf).replace(/\.pdf$/i, '')
4078
+ : `finances-${stamp}`;
4079
+ const pdfPath = `${base}.pdf`;
4080
+ const htmlOut = `${base}.html`;
4081
+
4082
+ const { writeFileSync } = await import('node:fs');
4083
+ writeFileSync(htmlOut, html, 'utf8');
4084
+
4085
+ const { htmlToPdf, installHint } = await import('../src/pdf.js');
4086
+ const result = await htmlToPdf(html, pdfPath);
4087
+ if (result.ok) {
4088
+ console.log(`Wrote ${pdfPath}`);
4089
+ console.log(` ${htmlOut}`);
4090
+ } else {
4091
+ // The HTML is the report. Losing the PDF is a downgrade, not a failure.
4092
+ console.log(`Wrote ${htmlOut}`);
4093
+ console.log('');
4094
+ console.log('Could not render a PDF on this machine.');
4095
+ console.log(installHint(result.tried));
4096
+ console.log('');
4097
+ console.log('Or open the HTML in a browser and print it to PDF.');
4098
+ }
4099
+ return;
4100
+ }
4101
+
4102
+ const wantsText = flags.plain || flags.json || !process.stdout.isTTY || !process.stdin.isTTY;
4103
+ if (wantsText) {
4104
+ const snapshot = await fin.collectFinanceSnapshot(client, { days, businessId: flags['business-id'] });
4105
+ if (flags.json) { print.json(snapshot); return; }
4106
+ const fmt = await import('../src/finances-tui.js');
4107
+ printFinanceSummary(snapshot, fmt);
4108
+ return;
4109
+ }
4110
+ const parsedInterval = Number.parseInt(String(flags.interval ?? ''), 10);
4111
+ const { runFinancesTui } = await import('../src/finances-tui.js');
4112
+ await runFinancesTui({
4113
+ client,
4114
+ baseUrl,
4115
+ token: flags['no-stream'] ? null : token,
4116
+ days,
4117
+ interval: Number.isFinite(parsedInterval) ? parsedInterval : 30,
4118
+ businessId: flags['business-id'],
4119
+ theme: flags.theme,
4120
+ });
4121
+ return;
4122
+ }
4123
+
4124
+ case 'summary': {
4125
+ const { client } = financesClient();
4126
+ const snapshot = await fin.collectFinanceSnapshot(client, { days, businessId: flags['business-id'] });
4127
+ if (flags.json) { print.json(snapshot); return; }
4128
+ const fmt = await import('../src/finances-tui.js');
4129
+ printFinanceSummary(snapshot, fmt);
4130
+ return;
4131
+ }
4132
+
4133
+ case 'position':
4134
+ case 'debt':
4135
+ case 'income': {
4136
+ // Reads the summary route alone: everything on this screen comes from
4137
+ // there, so there is no reason to fan out to eleven sources for it.
4138
+ const { client } = financesClient();
4139
+ const summary = await fin.getFinanceSummary(client, { days, includeHidden: Boolean(flags.hidden) });
4140
+ const position = summary?.position;
4141
+ if (!position) {
4142
+ print.error('This CoinPay server does not report a debt-and-income position yet.');
4143
+ process.exit(1);
4144
+ }
4145
+ if (flags.json) { print.json(position); return; }
4146
+ const fmt = await import('../src/finances-tui.js');
4147
+ console.log('');
4148
+ printPosition(position, fmt, { heading: true, detail: true });
4149
+ return;
4150
+ }
4151
+
4152
+ case 'scope': {
4153
+ // Which set of books an account belongs to. The name-derived guess is
4154
+ // right about "Business Checking" and blind to a personal card carrying
4155
+ // company spend, so the correction is stored and always wins.
4156
+ const { client } = financesClient();
4157
+ const accountId = args[0];
4158
+ const value = (args[1] || '').toLowerCase();
4159
+ if (!accountId) {
4160
+ print.error('Usage: coinpay finances scope <account-id> <business|personal|clear> (ids: coinpay finances accounts)');
4161
+ process.exit(1);
4162
+ }
4163
+ const scope = value === 'clear' || value === 'auto' || value === '' ? null : value;
4164
+ if (scope !== null && scope !== 'business' && scope !== 'personal') {
4165
+ print.error(`Unknown scope '${value}'. Use business, personal, or clear.`);
4166
+ process.exit(1);
4167
+ }
4168
+ const account = await fin.updateFinanceAccount(client, accountId, { scope });
4169
+ if (flags.json) { print.json({ account }); return; }
4170
+ print.success(
4171
+ `${account.name} → ${account.effective_scope}${scope === null ? ' (derived from the name again)' : ''}`,
4172
+ );
4173
+ return;
4174
+ }
4175
+
4176
+ case 'accounts': {
4177
+ const { client } = financesClient();
4178
+ const accounts = await fin.listFinanceAccounts(client, { includeHidden: Boolean(flags.hidden) });
4179
+ if (flags.json) { print.json({ accounts }); return; }
4180
+ if (!accounts.length) { print.info('No linked accounts. Connect a bank at ' + new URL('/finances', getBaseUrl()).toString()); return; }
4181
+ const { money, shortDate } = await import('../src/finances-tui.js');
4182
+ console.log('');
4183
+ printTable(accounts, [
4184
+ { title: 'Institution', render: (a) => a.org_name || '—' },
4185
+ { title: 'Account', render: (a) => a.name },
4186
+ { title: 'Kind', render: (a) => a.effective_kind || a.kind },
4187
+ { title: 'Side', render: (a) => `${a.effective_scope || '—'}${a.scope_override ? '*' : ''}` },
4188
+ { title: 'Balance', align: 'right', render: (a) => money(a.display_balance ?? a.balance ?? 0, a.currency || 'USD') },
4189
+ { title: 'Available', align: 'right', render: (a) => (a.available_balance == null ? '—' : money(a.available_balance, a.currency || 'USD')) },
4190
+ { title: 'As of', render: (a) => shortDate(a.balance_date) },
4191
+ { title: 'Id', render: (a) => a.id },
4192
+ ]);
4193
+ console.log('');
4194
+ return;
4195
+ }
4196
+
4197
+ case 'ledger':
4198
+ case 'transactions': {
4199
+ const { client } = financesClient();
4200
+ const parsedLimit = Number.parseInt(String(flags.limit ?? ''), 10);
4201
+ const page = await fin.listFinanceTransactions(client, {
4202
+ limit: Number.isFinite(parsedLimit) ? parsedLimit : 50,
4203
+ offset: Number.parseInt(String(flags.offset ?? '0'), 10) || 0,
4204
+ search: flags.search,
4205
+ category: flags.category,
4206
+ accountId: flags.account,
4207
+ startDate: flags.start || flags.since,
4208
+ endDate: flags.end || flags.until,
4209
+ includePending: !flags['no-pending'],
4210
+ });
4211
+ if (flags.json) { print.json(page); return; }
4212
+ if (!page.rows?.length) { print.info('No transactions match.'); return; }
4213
+ const { money, shortDate } = await import('../src/finances-tui.js');
4214
+ console.log('');
4215
+ printTable(page.rows, [
4216
+ { title: 'Date', render: (r) => shortDate(r.transacted_at || r.posted) },
4217
+ { title: 'Account', render: (r) => `${r.org_name ? r.org_name.split(' ')[0] + ' ' : ''}${r.account_name}` },
4218
+ { title: 'Payee / description', render: (r) => (r.payee || r.description || r.memo || '—').slice(0, 40) },
4219
+ { title: 'Category', render: (r) => r.category || '—' },
4220
+ { title: 'Amount', align: 'right', render: (r) => money(r.amount, r.currency || 'USD') },
4221
+ { title: '', render: (r) => (r.pending ? 'pending' : '') },
4222
+ ]);
4223
+ console.log(`\n${page.rows.length} of ${page.total}` + (page.total > page.offset + page.rows.length ? ` (--offset ${page.offset + page.rows.length} for more)` : ''));
4224
+ console.log('');
4225
+ return;
4226
+ }
4227
+
4228
+ case 'connections': {
4229
+ const { client } = financesClient();
4230
+ const data = await fin.listFinanceConnections(client);
4231
+ if (flags.json) { print.json(data); return; }
4232
+ const list = data.connections || [];
4233
+ if (!list.length) {
4234
+ print.info('No bank connections. Link one at ' + new URL('/finances', getBaseUrl()).toString() + (data.plaidEnabled ? ' (Plaid or SimpleFIN)' : ' (SimpleFIN)'));
4235
+ return;
4236
+ }
4237
+ const { ago } = await import('../src/finances-tui.js');
4238
+ console.log('');
4239
+ for (const c of list) {
4240
+ 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}`);
4241
+ console.log(` id ${c.id}`);
4242
+ 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`);
4243
+ if (c.last_sync_error) console.log(` ${colors.yellow}note${colors.reset} ${c.last_sync_error}`);
4244
+ }
4245
+ console.log('');
4246
+ return;
4247
+ }
4248
+
4249
+ case 'sync': {
4250
+ const { client } = financesClient();
4251
+ print.info('Pulling fresh balances and transactions from the bank bridge…');
4252
+ const parsedSyncDays = Number.parseInt(String(flags.days ?? ''), 10);
4253
+ const result = await fin.syncFinances(client, {
4254
+ days: Number.isFinite(parsedSyncDays) ? parsedSyncDays : undefined,
4255
+ connectionId: flags.connection || flags['connection-id'],
4256
+ });
4257
+ if (flags.json) { print.json(result); return; }
4258
+ const t = result.totals || {};
4259
+ print.success(`Synced ${t.accounts ?? 0} accounts: ${t.transactionsNew ?? 0} new of ${t.transactionsSeen ?? 0} transactions (${result.status}).`);
4260
+ for (const r of result.results || []) {
4261
+ if (r.status === 'partial' || r.error) print.warn(`${r.connectionId || r.id || 'connection'}: ${r.error || r.status}`);
4262
+ }
4263
+ return;
4264
+ }
4265
+
4266
+ case 'connect':
4267
+ case 'disconnect':
4268
+ case 'consent':
4269
+ case 'backfill':
4270
+ case 'jobs':
4271
+ case 'coverage':
4272
+ case 'report':
4273
+ case 'reports':
4274
+ case 'statements': {
4275
+ const { client } = financesClient();
4276
+ const { runFinancesCommand } = await import('../src/finances-commands.js');
4277
+ const code = await runFinancesCommand(subcommand, args, flags, {
4278
+ client,
4279
+ out: (line) => console.log(line),
4280
+ err: (line) => console.error(line),
4281
+ });
4282
+ if (code !== 0) process.exit(code);
4283
+ return;
4284
+ }
4285
+
4286
+ default:
4287
+ print.error(`Unknown finances command: ${subcommand}`);
4288
+ console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync|connect|disconnect|backfill|jobs|coverage|report|reports|statements]');
4289
+ process.exit(1);
4290
+ }
4291
+ }
4292
+
3298
4293
  async function main() {
3299
4294
  const { command, subcommand, args, flags } = parseArgs(process.argv.slice(2));
4295
+ validateBooleanFlags(flags);
3300
4296
 
3301
4297
  if (flags.version || flags.v) {
3302
4298
  console.log(VERSION);
@@ -3331,10 +4327,12 @@ async function handleLightning(subcommand, args, flags) {
3331
4327
  console.error(colors.red + 'Error: --wallet-id required' + colors.reset);
3332
4328
  process.exit(1);
3333
4329
  }
3334
- const mnemonic = await getDecryptedMnemonic(flags);
4330
+ // NEW-20: this used to decrypt the stored seed — prompting for the
4331
+ // passphrase — purely to post it to the server, which validated it and
4332
+ // threw it away. Provisioning is a custodial LNbits wallet, so nothing
4333
+ // is derived or signed here. The seed never leaves the machine now.
3335
4334
  const result = await client.lightning.enableWallet({
3336
4335
  wallet_id: walletId,
3337
- mnemonic,
3338
4336
  business_id: flags['business-id'],
3339
4337
  });
3340
4338
  console.log(colors.green + '⚡ Lightning wallet enabled!' + colors.reset);
@@ -3543,6 +4541,17 @@ async function handleLightning(subcommand, args, flags) {
3543
4541
  await handleOAuth(subcommand, args, flags);
3544
4542
  break;
3545
4543
 
4544
+ case 'finances':
4545
+ case 'finance':
4546
+ case 'money':
4547
+ await handleFinances(subcommand, args, flags);
4548
+ break;
4549
+
4550
+ case 'tui':
4551
+ case 'dashboard':
4552
+ await handleFinances('tui', [subcommand, ...args].filter(Boolean), flags);
4553
+ break;
4554
+
3546
4555
  case 'update':
3547
4556
  case 'upgrade':
3548
4557
  case 'self-update':
@@ -3560,7 +4569,8 @@ async function handleLightning(subcommand, args, flags) {
3560
4569
  process.exit(1);
3561
4570
  }
3562
4571
  } catch (error) {
3563
- print.error(error.message);
4572
+ const code = error?.response?.code;
4573
+ print.error(error.message + (code ? ' (' + code + ')' : ''));
3564
4574
  if (flags.debug) {
3565
4575
  console.error(error);
3566
4576
  }
@@ -3619,12 +4629,16 @@ async function handleOAuth(subcommand, args, flags) {
3619
4629
  description,
3620
4630
  });
3621
4631
 
4632
+ // The API wraps the record: { success, client: { ..., client_secret }, warning }.
4633
+ // Accept a bare record too so either shape prints.
4634
+ const created = result.client ?? result;
4635
+
3622
4636
  print.success('OAuth client created');
3623
- if (result.client_id) {
3624
- console.log(`\n ${colors.bright}Client ID:${colors.reset} ${result.client_id}`);
4637
+ if (created.client_id) {
4638
+ console.log(`\n ${colors.bright}Client ID:${colors.reset} ${created.client_id}`);
3625
4639
  }
3626
- if (result.client_secret) {
3627
- console.log(` ${colors.bright}Client Secret:${colors.reset} ${colors.yellow}${result.client_secret}${colors.reset}`);
4640
+ if (created.client_secret) {
4641
+ console.log(` ${colors.bright}Client Secret:${colors.reset} ${colors.yellow}${created.client_secret}${colors.reset}`);
3628
4642
  console.log();
3629
4643
  print.warn('Save the client secret — it is only shown once!');
3630
4644
  }
@@ -3642,13 +4656,15 @@ async function handleOAuth(subcommand, args, flags) {
3642
4656
  }
3643
4657
 
3644
4658
  const result = await client.getOAuthClient(id);
3645
-
3646
- print.success(`OAuth Client: ${result.name || id}`);
3647
- if (result.client_id) print.info(` Client ID: ${result.client_id}`);
3648
- if (result.description) print.info(` Description: ${result.description}`);
3649
- if (result.redirect_uris) print.info(` Redirect URIs: ${result.redirect_uris.join(', ')}`);
3650
- if (result.scopes) print.info(` Scopes: ${Array.isArray(result.scopes) ? result.scopes.join(', ') : result.scopes}`);
3651
- if (result.created_at) print.info(` Created: ${result.created_at}`);
4659
+ // GET /oauth/clients/:id also wraps the record as { success, client }.
4660
+ const found = result.client ?? result;
4661
+
4662
+ print.success(`OAuth Client: ${found.name || id}`);
4663
+ if (found.client_id) print.info(` Client ID: ${found.client_id}`);
4664
+ if (found.description) print.info(` Description: ${found.description}`);
4665
+ if (found.redirect_uris) print.info(` Redirect URIs: ${found.redirect_uris.join(', ')}`);
4666
+ if (found.scopes) print.info(` Scopes: ${Array.isArray(found.scopes) ? found.scopes.join(', ') : found.scopes}`);
4667
+ if (found.created_at) print.info(` Created: ${found.created_at}`);
3652
4668
 
3653
4669
  if (flags.json) print.json(result);
3654
4670
  break;