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