@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/CHANGELOG.md +66 -0
- package/ERRORS.md +252 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +317 -0
- package/README.md +411 -0
- package/index.d.ts +680 -0
- package/package.json +55 -0
- package/src/cli.js +635 -0
- package/src/client.js +799 -0
- package/src/mcp.js +434 -0
- package/src/webhooks.js +191 -0
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();
|