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