@profullstack/coinpay 0.8.0 → 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,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,26 @@ function createUnauthenticatedClient() {
150
159
  return new CoinPayClient({ baseUrl: getBaseUrl() });
151
160
  }
152
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
+
153
182
  /**
154
183
  * Parse command line arguments
155
184
  */
@@ -164,16 +193,20 @@ function parseArgs(args) {
164
193
  if (eqIdx !== -1) {
165
194
  result.flags[arg.slice(2, eqIdx)] = arg.slice(eqIdx + 1);
166
195
  } else {
196
+ const name = arg.slice(2);
167
197
  const next = args[i + 1];
168
- if (next && !next.startsWith('-')) {
169
- 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;
170
202
  i++;
171
203
  } else {
172
- result.flags[arg.slice(2)] = true;
204
+ result.flags[name] = true;
173
205
  }
174
206
  }
175
207
  } else if (arg.startsWith('-')) {
176
- 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;
177
210
  } else if (!result.command) {
178
211
  result.command = arg;
179
212
  } else if (!result.subcommand) {
@@ -186,6 +219,15 @@ function parseArgs(args) {
186
219
  return result;
187
220
  }
188
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
+
189
231
  function parsePositiveInteger(value) {
190
232
  if (typeof value !== 'string' || !/^\d+$/.test(value.trim())) {
191
233
  return null;
@@ -232,6 +274,79 @@ function requireFlagValues(flags, names) {
232
274
  }
233
275
  }
234
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
+
235
350
  /**
236
351
  * Show help
237
352
  */
@@ -266,12 +381,23 @@ ${colors.cyan}Commands:${colors.reset}
266
381
  Route settlement to a direct address
267
382
  get <id> Get invoice details
268
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
269
389
 
270
390
  ${colors.bright}tokens${colors.reset}
271
391
  list List checkout tokens (--business-id, --active-only)
272
392
 
273
393
  ${colors.bright}business${colors.reset}
274
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
275
401
  get <id> Get business details
276
402
  list List businesses
277
403
  update <id> Update business
@@ -315,6 +441,20 @@ ${colors.cyan}Commands:${colors.reset}
315
441
  escrow release <id> Release card escrow funds
316
442
  escrow refund <id> Refund card escrow
317
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
+
318
458
  ${colors.bright}escrow${colors.reset}
319
459
  create Create a new escrow
320
460
  get <id> Get escrow status
@@ -482,6 +622,28 @@ async function promptYesNo(question, defaultYes = true) {
482
622
  return answer.toLowerCase().startsWith('y');
483
623
  }
484
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
+
485
647
  /**
486
648
  * Run gpg with the passphrase on a dedicated file descriptor and the plaintext
487
649
  * on stdin, so neither ever touches the filesystem.
@@ -542,7 +704,39 @@ function hasGpg() {
542
704
  /**
543
705
  * Encrypt mnemonic with GPG and save to file
544
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
+
545
737
  async function saveEncryptedWallet(mnemonic, walletId, password, walletFile) {
738
+ assertStrongPassphrase(password);
739
+
546
740
  if (!hasGpg()) {
547
741
  throw new Error('GPG is required for wallet encryption. Install: apt install gnupg');
548
742
  }
@@ -569,6 +763,22 @@ async function saveEncryptedWallet(mnemonic, walletId, password, walletFile) {
569
763
  { stdinData: content, passphrase: password }
570
764
  );
571
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.
773
+ try {
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)
779
+ );
780
+ }
781
+
572
782
  return true;
573
783
  }
574
784
 
@@ -780,9 +990,16 @@ function printInvoiceDetails(invoice, { shareUrl } = {}) {
780
990
  if (invoice.businessId) print.info('Business: ' + invoice.businessId);
781
991
  if (invoice.clientId) print.info('Client: ' + invoice.clientId);
782
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);
783
998
  if (invoice.dueDate) print.info('Due: ' + invoice.dueDate);
784
999
  if (invoice.notes) print.info('Notes: ' + invoice.notes);
785
- if (shareUrl) print.info('Share link (opens after send): ' + shareUrl);
1000
+ if (shareUrl) {
1001
+ print.info('Share link: ' + shareUrl);
1002
+ }
786
1003
  console.log();
787
1004
  }
788
1005
 
@@ -837,15 +1054,13 @@ async function handleInvoice(subcommand, args, flags) {
837
1054
  walletId: flags['wallet-id'],
838
1055
  merchantWalletAddress: flags['merchant-wallet-address'],
839
1056
  });
840
- const shareUrl = invoice.id ? getInvoiceShareUrl(invoice.id) : undefined;
841
-
842
1057
  if (flags.json) {
843
- print.json(shareUrl ? { ...invoice, shareUrl } : invoice);
1058
+ print.json(invoice);
844
1059
  break;
845
1060
  }
846
1061
 
847
1062
  print.success('Draft invoice created');
848
- printInvoiceDetails(invoice, { shareUrl });
1063
+ printInvoiceDetails(invoice);
849
1064
  break;
850
1065
  }
851
1066
 
@@ -909,11 +1124,8 @@ async function handleInvoice(subcommand, args, flags) {
909
1124
  }
910
1125
 
911
1126
  case 'get': {
912
- const id = args[0];
913
- if (!id) {
914
- print.error('Usage: coinpay invoice get <id>');
915
- process.exit(1);
916
- }
1127
+ rejectUnsupportedFlags(flags, []);
1128
+ const id = requireSingleInvoiceId(args, 'get');
917
1129
 
918
1130
  const invoice = await getInvoice(client, id);
919
1131
  if (flags.json) {
@@ -925,9 +1137,301 @@ async function handleInvoice(subcommand, args, flags) {
925
1137
  break;
926
1138
  }
927
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
+
928
1432
  default:
929
1433
  print.error('Unknown invoice command: ' + subcommand);
930
- print.info('Available: create, list, get');
1434
+ print.info('Available: create, list, get, update, publish, send, delete');
931
1435
  process.exit(1);
932
1436
  }
933
1437
  }
@@ -973,18 +1477,43 @@ async function handleBusiness(subcommand, args, flags) {
973
1477
 
974
1478
  switch (subcommand) {
975
1479
  case 'create': {
976
- const { name, 'webhook-url': webhookUrl } = flags;
977
-
978
- if (!name) {
979
- 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');
980
1495
  return;
981
1496
  }
982
-
983
- 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
+ });
984
1505
  print.success('Business created');
985
1506
  print.json(business);
986
1507
  break;
987
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
+ }
988
1517
 
989
1518
  case 'get': {
990
1519
  const businessId = args[0];
@@ -2935,7 +3464,9 @@ const MENU_COMMANDS = [
2935
3464
  ['logout', 'Sign out on this device'],
2936
3465
  ['config', 'API key & endpoint (set-key, set-url, show)'],
2937
3466
  ['auth', 'Merchant account (register, login, me)'],
3467
+ ['finances', 'Live money dashboard (earnings, cards, bank feeds, ledger)'],
2938
3468
  ['payment', 'Payments (create, get, list, qr)'],
3469
+ ['invoice', 'Invoices (create, list, get, update, publish, send, delete)'],
2939
3470
  ['tokens', 'List checkout tokens'],
2940
3471
  ['business', 'Businesses (create, get, list, update)'],
2941
3472
  ['rates', 'Exchange rates (get, list)'],
@@ -2955,7 +3486,9 @@ const MENU_COMMANDS = [
2955
3486
  const SUBCOMMANDS = {
2956
3487
  config: [['set-key', 'Set your API key'], ['set-url', 'Set custom API URL'], ['show', 'Show current configuration']],
2957
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']],
2958
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>']],
2959
3492
  tokens: [['list', 'List checkout tokens']],
2960
3493
  business: [['create', 'Create a new business'], ['get', 'Get business details <id>'], ['list', 'List businesses'], ['update', 'Update business <id>']],
2961
3494
  rates: [['get', 'Get exchange rate <crypto>'], ['list', 'Get all exchange rates']],
@@ -3215,13 +3748,19 @@ async function handleLogin() {
3215
3748
  process.exit(1);
3216
3749
  }
3217
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.
3218
3756
  print.info('');
3219
- console.log(' To log in, open this URL on any device (e.g. your desktop) and approve:');
3757
+ console.log(' To log in, open this URL on any device (e.g. your desktop):');
3220
3758
  console.log('');
3221
- console.log(' ' + colors.cyan + start.verification_uri_complete + colors.reset);
3759
+ console.log(' ' + colors.cyan + start.verification_uri + colors.reset);
3222
3760
  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);
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);
3225
3764
  console.log('');
3226
3765
  print.info('Waiting for approval… (Ctrl-C to cancel)');
3227
3766
 
@@ -3295,8 +3834,344 @@ async function handleWhoami(flags) {
3295
3834
  if (flags?.json) print.json(me);
3296
3835
  }
3297
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
+
3298
4172
  async function main() {
3299
4173
  const { command, subcommand, args, flags } = parseArgs(process.argv.slice(2));
4174
+ validateBooleanFlags(flags);
3300
4175
 
3301
4176
  if (flags.version || flags.v) {
3302
4177
  console.log(VERSION);
@@ -3331,10 +4206,12 @@ async function handleLightning(subcommand, args, flags) {
3331
4206
  console.error(colors.red + 'Error: --wallet-id required' + colors.reset);
3332
4207
  process.exit(1);
3333
4208
  }
3334
- 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.
3335
4213
  const result = await client.lightning.enableWallet({
3336
4214
  wallet_id: walletId,
3337
- mnemonic,
3338
4215
  business_id: flags['business-id'],
3339
4216
  });
3340
4217
  console.log(colors.green + '⚡ Lightning wallet enabled!' + colors.reset);
@@ -3543,6 +4420,17 @@ async function handleLightning(subcommand, args, flags) {
3543
4420
  await handleOAuth(subcommand, args, flags);
3544
4421
  break;
3545
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
+
3546
4434
  case 'update':
3547
4435
  case 'upgrade':
3548
4436
  case 'self-update':
@@ -3560,7 +4448,8 @@ async function handleLightning(subcommand, args, flags) {
3560
4448
  process.exit(1);
3561
4449
  }
3562
4450
  } catch (error) {
3563
- print.error(error.message);
4451
+ const code = error?.response?.code;
4452
+ print.error(error.message + (code ? ' (' + code + ')' : ''));
3564
4453
  if (flags.debug) {
3565
4454
  console.error(error);
3566
4455
  }