@avvio/payments 0.1.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/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@avvio/payments",
3
+ "version": "0.1.0",
4
+ "description": "Pay out to your own customers from your Avvio balance. CLI, MCP server, and Node client — zero dependencies.",
5
+ "license": "MIT",
6
+ "engines": {
7
+ "node": ">=18"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/anzolabs/anzolabs-B2B-backend.git",
12
+ "directory": "packages/avvio-payments"
13
+ },
14
+ "homepage": "https://avvio-docs.pages.dev",
15
+ "bugs": {
16
+ "url": "https://github.com/anzolabs/anzolabs-B2B-backend/issues",
17
+ "email": "support@avvio.xyz"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "types": "./index.d.ts",
25
+ "default": "./src/client.js"
26
+ },
27
+ "./webhooks": {
28
+ "types": "./index.d.ts",
29
+ "default": "./src/webhooks.js"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "main": "src/client.js",
34
+ "bin": {
35
+ "avvio-payments": "src/cli.js"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "index.d.ts",
40
+ "LICENSE",
41
+ "README.md",
42
+ "QUICKSTART.md",
43
+ "ERRORS.md",
44
+ "CHANGELOG.md"
45
+ ],
46
+ "scripts": {
47
+ "test": "node --test test/*.test.js"
48
+ },
49
+ "keywords": [
50
+ "payouts",
51
+ "cross-border",
52
+ "mcp"
53
+ ],
54
+ "types": "index.d.ts"
55
+ }
package/src/cli.js ADDED
@@ -0,0 +1,635 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * The CLI. Same operations as the MCP server, same client underneath.
6
+ *
7
+ * Design rule throughout: every failure says what to do next. An integrator
8
+ * hitting an error at 2am should not have to read our source to find out
9
+ * whether to retry, change something, or call us.
10
+ */
11
+
12
+ const { PayoutsClient, PayoutsError } = require('./client');
13
+
14
+ const USAGE = `
15
+ avvio-payments — pay out to your customers from your balance
16
+
17
+ Setup
18
+ guide The whole flow, as commands you can paste
19
+ doctor Check your credentials, connectivity and balance
20
+ mcp Run as an MCP server over stdio (for agents)
21
+
22
+ Discovery
23
+ corridors Currencies you can pay out to
24
+ requirements <CCY> Fields a beneficiary in that currency needs
25
+
26
+ Pricing
27
+ quote --amount 200 --to MXN What the recipient gets, and the fee
28
+
29
+ Beneficiaries
30
+ beneficiary create --name "Maria Gonzalez" --email maria@example.com \\
31
+ --currency MXN --end-user emp_42 \\
32
+ --external-id emp42_maria \\
33
+ --field clabeNumber=012345678901234567
34
+ [--country MX] [--external-id <your id>] external-id makes a repeat
35
+ create return the existing
36
+ beneficiary instead of
37
+ registering a second account
38
+ beneficiary list [--end-user emp_42] [--limit 50] [--cursor <id>]
39
+
40
+ Sandbox
41
+ fund [--amount 5000] Credit your test balance
42
+ balance [--history] What you can send; --history explains every change
43
+
44
+ Paying
45
+ pay --amount 200 --to <destinationAccountId> --end-user emp_42 \\
46
+ [--expect <destinationAmount from quote>] [--end-user-name "Ana Lopez"] \\
47
+ [--reference ZZ-1] [--idempotency-key k1] [--max-drift-bps 200]
48
+ cancel <payoutId> stop a payout that has not been funded yet
49
+ status <payoutId> [--watch] --watch polls until it stops moving
50
+ payouts Recent payouts
51
+
52
+ Reconciliation
53
+ events [--since <sequence>] [--limit N] [--payout-id ID] [--follow]
54
+ Every transition, in order. Carry the returned
55
+ nextSince back as --since; --follow polls and
56
+ prints new rows as they land (--interval SECONDS)
57
+
58
+ Funding
59
+ funding Where to wire money to top up your balance
60
+ funding <payoutId> Deposit instructions for a payout you fund
61
+ yourself (requiresFunding: true)
62
+ funding confirm <payoutId> --tx <hash>
63
+ Report the transfer you already sent
64
+
65
+ Payout links
66
+ link create --amount --to --end-user
67
+ Mint a one-time link; the recipient enters their
68
+ own bank details (--reference, --expires MINUTES)
69
+
70
+ Webhooks (sandbox)
71
+ webhook create --url <url> Register an endpoint, print its signing secret
72
+ (--events a,b to filter; localhost ok in sandbox)
73
+ webhook deliveries <id> What we sent, what came back, what we retried
74
+
75
+ Configuration (environment)
76
+ AVVIO_API_KEY required Server-side only. Never ship it to a browser or a phone.
77
+ AVVIO_ORG_ID required
78
+ AVVIO_BASE_URL optional
79
+
80
+ Every command takes --json for machine-readable output.
81
+ `;
82
+
83
+ function parseArgs(argv) {
84
+ const positional = [];
85
+ const flags = {};
86
+ const fields = {};
87
+ for (let i = 0; i < argv.length; i++) {
88
+ const a = argv[i];
89
+ if (a === '--field') {
90
+ const [k, ...rest] = String(argv[++i] || '').split('=');
91
+ if (k) fields[k] = rest.join('=');
92
+ } else if (a.startsWith('--')) {
93
+ const key = a.slice(2);
94
+ const next = argv[i + 1];
95
+ if (next === undefined || next.startsWith('--')) flags[key] = true;
96
+ else flags[key] = argv[++i];
97
+ } else {
98
+ positional.push(a);
99
+ }
100
+ }
101
+ return { positional, flags, fields };
102
+ }
103
+
104
+ /**
105
+ * Every byte this CLI prints goes through here.
106
+ *
107
+ * Not an abstraction for its own sake: it is the only way a test can read what
108
+ * a command printed without replacing `process.stdout.write`, which the test
109
+ * runner is also using — patching it globally silently swallowed the runner's
110
+ * own results and made a third of the suite invisible.
111
+ */
112
+ const io = {
113
+ out: (s) => process.stdout.write(s),
114
+ err: (s) => process.stderr.write(s),
115
+ };
116
+
117
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
118
+
119
+ /** A --interval flag in seconds. `0` is honoured; a missing flag is not. */
120
+ const seconds = (flag, fallback) => {
121
+ const n = Number(flag);
122
+ return (Number.isFinite(n) && n >= 0 ? n : fallback) * 1000;
123
+ };
124
+
125
+ /**
126
+ * Where a payout stops moving on its own. `completed` is in here because
127
+ * nothing further happens without a bank return — which is a NEW event days
128
+ * later, not a state this poll will ever see. Waiting for it here would hang
129
+ * forever; reading the event feed is how you catch it.
130
+ */
131
+ const TERMINAL = new Set(['completed', 'failed', 'canceled']);
132
+
133
+ const out = (v, json) =>
134
+ io.out(
135
+ (json ? JSON.stringify(v) : JSON.stringify(v, null, 2)) + '\n',
136
+ );
137
+
138
+ /**
139
+ * Preflight. This is the first command an integrator should run, and it exists
140
+ * because "it doesn't work" is otherwise four different problems that look the
141
+ * same: wrong key, wrong org, wrong URL, or an account not yet able to pay.
142
+ */
143
+ async function doctor(json) {
144
+ const checks = [];
145
+ const add = (name, ok, detail) => checks.push({ name, ok, detail });
146
+
147
+ const key = process.env.AVVIO_API_KEY;
148
+ const org = process.env.AVVIO_ORG_ID;
149
+ const base = process.env.AVVIO_BASE_URL || '(default)';
150
+
151
+ add('AVVIO_API_KEY set', !!key, key ? `${key.slice(0, 16)}…` : 'missing');
152
+ add('AVVIO_ORG_ID set', !!org, org || 'missing');
153
+ add('AVVIO_BASE_URL', true, base);
154
+ if (!key || !org) {
155
+ report(checks, json);
156
+ process.exitCode = 1;
157
+ return;
158
+ }
159
+
160
+ const client = new PayoutsClient();
161
+ // Read off the client rather than re-deriving it here. This was the same
162
+ // prefix test written twice, and the version a partner can assert on in their
163
+ // own suite is the one that has to be right.
164
+ add(
165
+ 'mode',
166
+ true,
167
+ client.mode === 'test'
168
+ ? 'TEST — no real money can move'
169
+ : 'LIVE — payouts move real money',
170
+ );
171
+ try {
172
+ const { corridors } = await client.corridors();
173
+ add(
174
+ 'credentials accepted',
175
+ true,
176
+ `${(corridors || []).length} payout corridors available`,
177
+ );
178
+ } catch (err) {
179
+ const e = err instanceof PayoutsError ? err : null;
180
+ add(
181
+ 'credentials accepted',
182
+ false,
183
+ e
184
+ ? e.status === 401
185
+ ? 'Key rejected. Check it was copied whole, and has not been revoked.'
186
+ : e.status === 403
187
+ ? 'Key is valid but not for this organization. Check AVVIO_ORG_ID.'
188
+ : `${e.type}: ${e.message}`
189
+ : String(err && err.message),
190
+ );
191
+ report(checks, json);
192
+ process.exitCode = 1;
193
+ return;
194
+ }
195
+
196
+ // In test mode the useful question is "can I send right now", not "where do
197
+ // I wire money" — a sandbox balance is one command away, and reporting wire
198
+ // instructions there sends someone off to do something pointless.
199
+ if (client.mode === 'test') {
200
+ try {
201
+ const bal = await client.balance();
202
+ const amount = Number(bal && bal.amount);
203
+ add(
204
+ 'sandbox balance',
205
+ amount > 0,
206
+ amount > 0
207
+ ? `${bal.amount} ${bal.currency}`
208
+ : 'empty — run: avvio-payments fund',
209
+ );
210
+ } catch (err) {
211
+ add('sandbox balance', false, String(err && err.message));
212
+ }
213
+ } else {
214
+ try {
215
+ const funding = await client.fundingAccounts();
216
+ const n = Array.isArray(funding)
217
+ ? funding.length
218
+ : (funding && funding.accounts && funding.accounts.length) || 0;
219
+ add(
220
+ 'funding instructions available',
221
+ n > 0,
222
+ n > 0 ? `${n} account(s)` : 'none yet — your account may still be in review',
223
+ );
224
+ } catch (err) {
225
+ add('funding instructions available', false, String(err && err.message));
226
+ }
227
+ }
228
+
229
+ report(checks, json);
230
+ if (checks.some((c) => !c.ok)) process.exitCode = 1;
231
+ else if (!json) {
232
+ // The point of a preflight is to say what to do next, not to award a pass.
233
+ io.out("Next: avvio-payments guide\n");
234
+ }
235
+ }
236
+
237
+ function report(checks, json) {
238
+ if (json) return out({ checks }, true);
239
+ for (const c of checks) {
240
+ io.out(
241
+ ` ${c.ok ? 'ok ' : 'FAIL'} ${c.name.padEnd(32)} ${c.detail}\n`,
242
+ );
243
+ }
244
+ const bad = checks.filter((c) => !c.ok);
245
+ io.out(
246
+ bad.length
247
+ ? `\n${bad.length} check(s) failed. Fix the first one — the rest usually follow.\n`
248
+ : '\nReady to pay out.\n',
249
+ );
250
+ }
251
+
252
+ /**
253
+ * The flow, as commands rather than prose.
254
+ *
255
+ * Exists because the docs live somewhere else and an integrator's first
256
+ * question is "what do I type next". Reading a page and translating it into
257
+ * five commands is work we can do once instead of every partner doing it.
258
+ */
259
+ function guide() {
260
+ io.out(`
261
+ Send your first payout — five commands, about two minutes.
262
+
263
+ 1. Check your setup
264
+ avvio-payments doctor
265
+
266
+ 2. Give your sandbox a balance
267
+ avvio-payments fund --amount 5000
268
+
269
+ 3. See what Mexico needs
270
+ avvio-payments requirements MXN
271
+
272
+ 4. Create who you're paying
273
+ avvio-payments beneficiary create --name "Maria Gonzalez" \\
274
+ --email maria@example.com --currency MXN --end-user employee_42 \\
275
+ --field clabeNumber=012345678901234567
276
+
277
+ 5. Price it, then send it
278
+ avvio-payments quote --amount 200 --to MXN
279
+ avvio-payments pay --amount 200 --to <destinationAccountId> \\
280
+ --end-user employee_42 --expect <the destinationAmount from the quote>
281
+
282
+ 6. Watch it settle
283
+ avvio-payments status <payoutId>
284
+
285
+ Before you go live, run the case most integrations get wrong: create a
286
+ beneficiary whose account number ends 0003, pay it, and keep polling past
287
+ "completed". It reverses to failed with returned_by_bank — a settled payout is
288
+ not always final, and this is how you see it happen rather than take our word.
289
+
290
+ Full walkthrough: QUICKSTART.md in this package.
291
+ Every error code and what to do: ERRORS.md.
292
+ `);
293
+ }
294
+
295
+ async function run(argv) {
296
+ const { positional, flags, fields } = parseArgs(argv);
297
+ const cmd = positional[0];
298
+ const json = !!flags.json;
299
+
300
+ if (!cmd || cmd === 'help' || flags.help) {
301
+ io.out(USAGE);
302
+ return;
303
+ }
304
+ if (cmd === 'mcp') return require('./mcp').main();
305
+ if (cmd === 'doctor') return doctor(json);
306
+ if (cmd === 'guide') return guide();
307
+
308
+ const client = new PayoutsClient();
309
+
310
+ switch (cmd) {
311
+ case 'corridors':
312
+ return out(await client.corridors(), json);
313
+
314
+ case 'requirements': {
315
+ const ccy = positional[1] || flags.currency;
316
+ if (!ccy) throw new Error('Usage: requirements <CURRENCY>');
317
+ return out(await client.requirements(ccy), json);
318
+ }
319
+
320
+ case 'quote': {
321
+ if (!flags.amount || !flags.to) {
322
+ throw new Error('Usage: quote --amount 200 --to MXN');
323
+ }
324
+ return out(
325
+ await client.quote({
326
+ amount: flags.amount,
327
+ to: flags.to,
328
+ from: flags.from,
329
+ }),
330
+ json,
331
+ );
332
+ }
333
+
334
+ case 'beneficiary': {
335
+ const sub = positional[1];
336
+ if (sub === 'list') {
337
+ return out(
338
+ await client.listBeneficiaries({
339
+ endUserId: flags['end-user'],
340
+ limit: flags.limit ? Number(flags.limit) : undefined,
341
+ cursor: flags.cursor,
342
+ }),
343
+ json,
344
+ );
345
+ }
346
+ if (sub === 'create') {
347
+ if (!flags.name || !flags.currency || !flags.email) {
348
+ throw new Error(
349
+ 'Usage: beneficiary create --name "..." --email "..." --currency MXN --field <name>=<value>',
350
+ );
351
+ }
352
+ if (!Object.keys(fields).length) {
353
+ throw new Error(
354
+ `No --field given. Run "requirements ${flags.currency}" to see what this corridor needs.`,
355
+ );
356
+ }
357
+ return out(
358
+ await client.createBeneficiary({
359
+ name: flags.name,
360
+ email: flags.email,
361
+ country: flags.country,
362
+ currency: flags.currency,
363
+ endUserId: flags['end-user'],
364
+ externalId: flags['external-id'],
365
+ details: fields,
366
+ }),
367
+ json,
368
+ );
369
+ }
370
+ throw new Error('Usage: beneficiary <create|list>');
371
+ }
372
+
373
+ case 'pay': {
374
+ if (!flags.amount || !flags.to) {
375
+ throw new Error(
376
+ 'Usage: pay --amount 200 --to <destinationAccountId> [--expect 3410.00]',
377
+ );
378
+ }
379
+ return out(
380
+ await client.payout({
381
+ amount: flags.amount,
382
+ destinationAccountId: flags.to,
383
+ reference: flags.reference,
384
+ purposeOfPayment: flags.purpose,
385
+ expectDestination: flags.expect,
386
+ ...(flags['max-drift-bps']
387
+ ? { maxRateDrift: Number(flags['max-drift-bps']) / 10_000 }
388
+ : {}),
389
+ idempotencyKey: flags['idempotency-key'],
390
+ endUser: flags['end-user']
391
+ ? {
392
+ id: flags['end-user'],
393
+ ...(flags['end-user-name']
394
+ ? { name: flags['end-user-name'] }
395
+ : {}),
396
+ }
397
+ : undefined,
398
+ }),
399
+ json,
400
+ );
401
+ }
402
+
403
+ case 'status': {
404
+ const id = positional[1];
405
+ if (!id) throw new Error('Usage: status <payoutId> [--watch]');
406
+ if (!flags.watch) return out(await client.getPayout(id), json);
407
+
408
+ // Polling by hand is what people already do, so do it here and get the
409
+ // caveat printed with it. Progress goes to stderr so stdout stays a single
410
+ // parseable payout — a --watch that interleaves progress into --json
411
+ // output would break the pipe it exists to serve.
412
+ const interval = seconds(flags.interval, 3);
413
+ let last;
414
+ for (;;) {
415
+ const payout = await client.getPayout(id);
416
+ if (payout.status !== last) {
417
+ last = payout.status;
418
+ io.err(
419
+ `${new Date().toISOString()} ${payout.status}${payout.failureCode ? ` (${payout.failureCode})` : ''}\n`,
420
+ );
421
+ }
422
+ if (TERMINAL.has(payout.status)) {
423
+ if (payout.status === 'completed') {
424
+ // The whole package says this and it matters most right here: the
425
+ // person watching just saw "completed" and is about to close the
426
+ // terminal and mark a wage settled.
427
+ io.err(
428
+ 'completed is not final — a bank can return a settled payout days later.\n' +
429
+ 'Keep reading: avvio-payments events --payout-id ' + id + '\n',
430
+ );
431
+ }
432
+ return out(payout, json);
433
+ }
434
+ await sleep(interval);
435
+ }
436
+ }
437
+
438
+ case 'events': {
439
+ // The reconciliation primitive. `since` is INCLUSIVE, so the row at your
440
+ // watermark comes back again on every resume — dedupe on `id` rather than
441
+ // assuming a page starts after it.
442
+ const args = {
443
+ since: flags.since,
444
+ limit: flags.limit ? Number(flags.limit) : undefined,
445
+ payoutId: flags['payout-id'],
446
+ };
447
+ if (!flags.follow) return out(await client.listEvents(args), json);
448
+
449
+ // --follow holds the watermark in MEMORY. It is a tail, not a reconciler:
450
+ // when it stops, everything that happened while it was down is only ever
451
+ // read again if you persisted a `nextSince` and pass it back as --since.
452
+ // One row per line, so it pipes into something that does persist.
453
+ const interval = seconds(flags.interval, 5);
454
+ let since = args.since;
455
+ let previous = new Set();
456
+ for (;;) {
457
+ const page = await client.listEvents({ ...args, since });
458
+ const current = new Set();
459
+ for (const event of page.data || []) {
460
+ current.add(event.id);
461
+ // The inclusive boundary re-delivers the row at the watermark on the
462
+ // next poll. Suppressing just the previous page's ids keeps the tail
463
+ // readable without pretending the feed is exactly-once — anything
464
+ // consuming this still has to dedupe on `id`.
465
+ if (!previous.has(event.id)) {
466
+ io.out(JSON.stringify(event) + '\n');
467
+ }
468
+ }
469
+ previous = current;
470
+ if (page.nextSince) since = page.nextSince;
471
+ // A backlog is drained at once; only a caught-up tail waits. Sleeping
472
+ // after a page the server said has more behind it means a tail that
473
+ // never catches up.
474
+ if (!page.hasMore) await sleep(interval);
475
+ }
476
+ }
477
+
478
+ case 'payouts':
479
+ return out(await client.listPayouts(), json);
480
+
481
+ case 'cancel': {
482
+ // The recovery an operator actually reaches for: a payout created for the
483
+ // wrong amount, before any money has been sent.
484
+ const id = positional[1];
485
+ if (!id) throw new Error('Usage: cancel <payoutId>');
486
+ return out(await client.cancelPayout(id), json);
487
+ }
488
+
489
+ case 'fund': {
490
+ // Sandbox only. Kept in the CLI so the whole first-payout flow is one
491
+ // tool — an integrator dropping to curl for one step is where a
492
+ // quickstart stops being a quickstart.
493
+ const res = await client.fund(flags.amount ?? '5000.00');
494
+ return out(res, json);
495
+ }
496
+
497
+ case 'balance':
498
+ // `--history` answers "why did my balance change", which a single number
499
+ // never can.
500
+ return out(
501
+ flags.history
502
+ ? await client.balanceHistory(flags.limit ? Number(flags.limit) : undefined)
503
+ : await client.balance(),
504
+ json,
505
+ );
506
+
507
+ case 'funding': {
508
+ const sub = positional[1];
509
+ if (sub === 'confirm') {
510
+ const id = positional[2];
511
+ const tx = flags.tx;
512
+ if (!id || !tx || tx === true) {
513
+ throw new Error(
514
+ 'Usage: funding confirm <payoutId> --tx <transactionHash>',
515
+ );
516
+ }
517
+ // Checked here rather than left to the server, because by the time
518
+ // anyone runs this the money has already left their wallet. A truncated
519
+ // paste should come back naming the hash, not as a rejection of a
520
+ // transfer that really did happen — the difference between "fix your
521
+ // argument" and "did my transfer fail?".
522
+ if (!/^0x[0-9a-fA-F]{64}$/.test(tx)) {
523
+ throw new Error(
524
+ `--tx must be a 0x-prefixed 32-byte transaction hash. Got: ${tx}`,
525
+ );
526
+ }
527
+ return out(
528
+ await client.confirmFunding(id, {
529
+ transactionHash: tx,
530
+ // A confirm that times out has an unknown outcome like any other
531
+ // mutation, and the same key is what makes asking again a replay.
532
+ idempotencyKey: flags['idempotency-key'],
533
+ }),
534
+ json,
535
+ );
536
+ }
537
+ // `funding <payoutId>` is "how do I fund THIS payout"; bare `funding` is
538
+ // "where do I wire a top-up". Different questions, and the second one was
539
+ // here first — so it stays the bare verb.
540
+ if (sub) return out(await client.getFunding(sub), json);
541
+ return out(await client.fundingAccounts(), json);
542
+ }
543
+
544
+ case 'link': {
545
+ if (positional[1] !== 'create') {
546
+ throw new Error('link create --amount <usd> --to <CCY> --end-user <id>');
547
+ }
548
+ const res = await client.createPayoutLink({
549
+ amount: flags.amount,
550
+ destinationCurrency: flags.to,
551
+ endUserId: flags['end-user'],
552
+ reference: flags.reference,
553
+ expiresInMinutes: flags.expires ? Number(flags.expires) : undefined,
554
+ });
555
+ if (!json) {
556
+ io.out(
557
+ `\nSend this to the person being paid:\n ${res.url}\n\n` +
558
+ `Expires ${res.expiresAt}. It can be spent once.\n`,
559
+ );
560
+ }
561
+ return out(res, json);
562
+ }
563
+
564
+ case 'webhook': {
565
+ const sub = positional[1];
566
+ if (sub === 'create') {
567
+ if (!flags.url) {
568
+ throw new Error(
569
+ 'webhook create needs --url <https endpoint>\n' +
570
+ 'Testing locally? http://localhost:PORT is accepted in sandbox.',
571
+ );
572
+ }
573
+ const res = await client.createWebhookEndpoint({
574
+ url: flags.url,
575
+ events: flags.events ? String(flags.events).split(',') : undefined,
576
+ });
577
+ if (!json && res.secret) {
578
+ io.out(
579
+ `\nSigning secret (shown once — store it now):\n ${res.secret}\n\n` +
580
+ ` export AVVIO_WEBHOOK_SECRET=${res.secret}\n\n` +
581
+ 'Verify with verifyWebhook({ body, headers, secret }) over the RAW bytes.\n',
582
+ );
583
+ return out({ id: res.id, url: res.url, events: res.events }, json);
584
+ }
585
+ return out(res, json);
586
+ }
587
+ if (sub === 'deliveries') {
588
+ const id = positional[2];
589
+ if (!id) throw new Error('webhook deliveries needs an endpoint id');
590
+ return out(await client.webhookDeliveries(id), json);
591
+ }
592
+ throw new Error('webhook create --url <url> | webhook deliveries <id>');
593
+ }
594
+
595
+ default:
596
+ throw new Error(`Unknown command: ${cmd}\n${USAGE}`);
597
+ }
598
+ }
599
+
600
+ function main() {
601
+ run(process.argv.slice(2)).catch((err) => {
602
+ if (err instanceof PayoutsError) {
603
+ io.err(`\n${err.type}: ${err.message}\n`);
604
+ // The API says exactly which fields failed. Printing only the summary —
605
+ // "1 field(s) failed validation" — turns a two-second fix into a guess.
606
+ const fields = err.body && err.body.errors;
607
+ if (Array.isArray(fields)) {
608
+ for (const f of fields) io.err(` - ${f}\n`);
609
+ }
610
+ if (err.body && err.body.resolution) {
611
+ io.err(`${err.body.resolution}\n`);
612
+ }
613
+ if (err.requestId) {
614
+ io.err(`request id: ${err.requestId}\n`);
615
+ }
616
+ if (err.idempotencyKey) {
617
+ // Naming the actual value matters: telling someone to "reuse the same
618
+ // key" when they never passed one is advice that double-pays.
619
+ io.err(
620
+ `retry with: --idempotency-key ${err.idempotencyKey}\n`,
621
+ );
622
+ }
623
+ if (err.retryable) {
624
+ io.err('This is safe to retry unchanged.\n');
625
+ }
626
+ } else {
627
+ io.err(`\n${(err && err.message) || err}\n`);
628
+ }
629
+ process.exitCode = 1;
630
+ });
631
+ }
632
+
633
+ module.exports = { run, parseArgs, io };
634
+
635
+ if (require.main === module) main();