@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/CHANGELOG.md +39 -0
- package/README.md +128 -3
- package/bin/coinpay.js +919 -30
- package/package.json +24 -9
- package/src/card-payments.js +23 -14
- package/src/client.js +40 -2
- package/src/finances-tui.js +1005 -0
- package/src/finances.d.ts +333 -0
- package/src/finances.js +445 -0
- package/src/index.d.ts +3 -0
- package/src/index.js +35 -0
- package/src/invoices.d.ts +1 -0
- package/src/invoices.js +14 -1
- package/src/lightning.js +8 -3
- package/src/swap.js +36 -5
- package/src/taskmarket.js +329 -0
- package/src/wallet.js +54 -24
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 {
|
|
19
|
-
|
|
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 (
|
|
169
|
-
result.flags[
|
|
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[
|
|
204
|
+
result.flags[name] = true;
|
|
173
205
|
}
|
|
174
206
|
}
|
|
175
207
|
} else if (arg.startsWith('-')) {
|
|
176
|
-
|
|
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)
|
|
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(
|
|
1058
|
+
print.json(invoice);
|
|
844
1059
|
break;
|
|
845
1060
|
}
|
|
846
1061
|
|
|
847
1062
|
print.success('Draft invoice created');
|
|
848
|
-
printInvoiceDetails(invoice
|
|
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
|
-
|
|
913
|
-
|
|
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 {
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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({
|
|
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)
|
|
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.
|
|
3759
|
+
console.log(' ' + colors.cyan + start.verification_uri + colors.reset);
|
|
3222
3760
|
console.log('');
|
|
3223
|
-
console.log('
|
|
3224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|