@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/src/client.js
ADDED
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The payouts client. Everything the CLI and the MCP server do goes through
|
|
5
|
+
* here, so there is one place where auth, idempotency and error shape live.
|
|
6
|
+
*
|
|
7
|
+
* No dependencies, on purpose. This package ends up inside a payments partner's
|
|
8
|
+
* infrastructure holding a credential that moves money, and "what is in this
|
|
9
|
+
* dependency tree" is a question their security review will ask. The answer
|
|
10
|
+
* being "nothing" is worth more than any convenience a library would buy.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { createHash, randomUUID } = require('node:crypto');
|
|
14
|
+
|
|
15
|
+
/** Thrown for any non-2xx. Carries enough to act on without parsing prose. */
|
|
16
|
+
class PayoutsError extends Error {
|
|
17
|
+
constructor(status, body, requestId) {
|
|
18
|
+
const type = (body && (body.type || body.error)) || 'error';
|
|
19
|
+
const message =
|
|
20
|
+
(body && body.message) || `Request failed with status ${status}`;
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'PayoutsError';
|
|
23
|
+
this.status = status;
|
|
24
|
+
this.type = type;
|
|
25
|
+
this.requestId = requestId;
|
|
26
|
+
this.body = body;
|
|
27
|
+
// Recovery hints, when the server sent them. On DUPLICATE_REQUEST_DETECTED
|
|
28
|
+
// these are the whole point: they name the key to retry with and the payout
|
|
29
|
+
// that already exists.
|
|
30
|
+
if (body && body.originalIdempotencyKey)
|
|
31
|
+
this.originalIdempotencyKey = body.originalIdempotencyKey;
|
|
32
|
+
if (body && body.originalPayoutId)
|
|
33
|
+
this.originalPayoutId = body.originalPayoutId;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Is retrying this exact request, unchanged, worth doing?
|
|
38
|
+
*
|
|
39
|
+
* A 409 in flight is the one non-obvious yes: it means an identical request
|
|
40
|
+
* is already running, and the right response is to back off and ask again,
|
|
41
|
+
* not to mint a new idempotency key and send a second payment.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* Make the error survive `JSON.stringify` and structured logging.
|
|
45
|
+
*
|
|
46
|
+
* `retryable` is a getter and `message` is non-enumerable on Error, so the
|
|
47
|
+
* README told people to branch on `err.retryable` while the object they piped
|
|
48
|
+
* to their logger contained neither. An error whose most important field
|
|
49
|
+
* disappears the moment you log it is the error you cannot debug at 3am.
|
|
50
|
+
*/
|
|
51
|
+
toJSON() {
|
|
52
|
+
return {
|
|
53
|
+
name: this.name,
|
|
54
|
+
type: this.type,
|
|
55
|
+
message: this.message,
|
|
56
|
+
status: this.status,
|
|
57
|
+
retryable: this.retryable,
|
|
58
|
+
requestId: this.requestId,
|
|
59
|
+
idempotencyKey: this.idempotencyKey,
|
|
60
|
+
...(this.originalIdempotencyKey
|
|
61
|
+
? { originalIdempotencyKey: this.originalIdempotencyKey }
|
|
62
|
+
: {}),
|
|
63
|
+
...(this.originalPayoutId
|
|
64
|
+
? { originalPayoutId: this.originalPayoutId }
|
|
65
|
+
: {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get retryable() {
|
|
70
|
+
if (this.type === 'IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS') return true;
|
|
71
|
+
// NOT retryable as-is, deliberately. It means an identical request just went
|
|
72
|
+
// out under a different key, and blindly retrying is either a second payment
|
|
73
|
+
// or a pointless loop. It needs a decision: reuse `originalIdempotencyKey`,
|
|
74
|
+
// or declare the duplicate intentional. Auto-resolving it here would be the
|
|
75
|
+
// same silent guess the server refused to make.
|
|
76
|
+
if (this.type === 'DUPLICATE_REQUEST_DETECTED') return false;
|
|
77
|
+
if (this.status === 429) return true;
|
|
78
|
+
return this.status >= 500;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A drift ceiling as a fraction (0.02 = 2%) -> basis points.
|
|
84
|
+
*
|
|
85
|
+
* Refuses rather than clamping. Passing 100000 — a plausible misreading of "in
|
|
86
|
+
* basis points" — produced a server-side VALIDATION_ERROR that named neither the
|
|
87
|
+
* unit nor the limit, and the three surfaces spell this differently
|
|
88
|
+
* (`maxRateDrift` fractional here, `maxDriftBps` on the wire,
|
|
89
|
+
* `--max-drift-bps` in the CLI), which is exactly how someone reaches for the
|
|
90
|
+
* wrong one.
|
|
91
|
+
*/
|
|
92
|
+
function toDriftBps(fraction) {
|
|
93
|
+
const n = Number(fraction);
|
|
94
|
+
if (!Number.isFinite(n) || n < 0 || n > 1) {
|
|
95
|
+
throw new PayoutsError(
|
|
96
|
+
400,
|
|
97
|
+
{
|
|
98
|
+
type: 'VALIDATION_ERROR',
|
|
99
|
+
message:
|
|
100
|
+
`maxRateDrift must be a fraction between 0 and 1 (0.02 = 2%). ` +
|
|
101
|
+
`Got ${fraction}. If you meant basis points, divide by 10000.`,
|
|
102
|
+
},
|
|
103
|
+
undefined,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return Math.round(n * 10_000);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A deterministic idempotency key, derived from what makes the payment unique.
|
|
111
|
+
*
|
|
112
|
+
* stableKey(orgId, payrollRunId, employeeId)
|
|
113
|
+
*
|
|
114
|
+
* `randomUUID()` is the right default for a single call and the wrong one for
|
|
115
|
+
* the exact failure the docs tell partners to retry through: a job that crashes
|
|
116
|
+
* mid-payroll and requeues mints a NEW key for the same wage, so the key-based
|
|
117
|
+
* replay that was supposed to protect it never fires and the worker is paid
|
|
118
|
+
* twice. Persisting the key before sending is the other answer, and it needs a
|
|
119
|
+
* durable write on the hot path; this one needs nothing remembered at all,
|
|
120
|
+
* because the key is recomputed rather than recalled.
|
|
121
|
+
*
|
|
122
|
+
* Formatted as a UUID because the server accepts one everywhere a key is
|
|
123
|
+
* allowed, and because a value that lands in someone's logs and database
|
|
124
|
+
* columns should look like what it is.
|
|
125
|
+
*/
|
|
126
|
+
function stableKey(...parts) {
|
|
127
|
+
// Both refusals below are the same failure wearing different clothes: a key
|
|
128
|
+
// that does not depend on WHO is being paid is one key for the whole run, and
|
|
129
|
+
// after the first payout every later one replays it — so nine people are
|
|
130
|
+
// silently not paid while nine ledger rows say they were. Refusing is the only
|
|
131
|
+
// safe answer; there is nothing sensible to substitute.
|
|
132
|
+
if (!parts.length) {
|
|
133
|
+
throw new PayoutsError(
|
|
134
|
+
400,
|
|
135
|
+
{
|
|
136
|
+
type: 'VALIDATION_ERROR',
|
|
137
|
+
message:
|
|
138
|
+
'stableKey() needs at least one part identifying THIS payment, e.g. ' +
|
|
139
|
+
'stableKey(orgId, payrollRunId, employeeId). With no parts every ' +
|
|
140
|
+
'payout would share one key and only the first would ever be sent.',
|
|
141
|
+
},
|
|
142
|
+
undefined,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const empty = parts.findIndex((p) => p === undefined || p === null || p === '');
|
|
146
|
+
if (empty !== -1) {
|
|
147
|
+
throw new PayoutsError(
|
|
148
|
+
400,
|
|
149
|
+
{
|
|
150
|
+
type: 'VALIDATION_ERROR',
|
|
151
|
+
message:
|
|
152
|
+
`stableKey() part ${empty} is empty. An undefined id collapses ` +
|
|
153
|
+
'distinct payments onto one key, and every payment after the first ' +
|
|
154
|
+
'replays instead of sending.',
|
|
155
|
+
},
|
|
156
|
+
undefined,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// NUL-separated, not concatenated: ('a','bc') and ('ab','c') are different
|
|
161
|
+
// payments and must not hash alike. A separator that can appear inside an id
|
|
162
|
+
// would rebuild the same collision, and NUL cannot appear in one.
|
|
163
|
+
const hex = createHash('sha256')
|
|
164
|
+
.update(parts.map(String).join('\u0000'))
|
|
165
|
+
.digest('hex');
|
|
166
|
+
const bytes = Buffer.from(hex.slice(0, 32), 'hex');
|
|
167
|
+
// Stamp version 8 (custom/derived) and the RFC 4122 variant, so this is a
|
|
168
|
+
// well-formed UUID rather than a string that merely looks like one — and so
|
|
169
|
+
// anything parsing it can see it was derived, not drawn at random.
|
|
170
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x80;
|
|
171
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
172
|
+
const h = bytes.toString('hex');
|
|
173
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
177
|
+
|
|
178
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Full jitter: random between 0 and the exponential ceiling.
|
|
182
|
+
*
|
|
183
|
+
* Not fixed backoff — a payroll batch that fails together would retry together
|
|
184
|
+
* and rebuild the spike that caused it. `Retry-After` wins when the server sent
|
|
185
|
+
* one, because it knows when the window actually resets.
|
|
186
|
+
*/
|
|
187
|
+
function backoffMs(attempt, retryAfterSeconds) {
|
|
188
|
+
if (retryAfterSeconds) return Math.min(retryAfterSeconds * 1000, 30_000);
|
|
189
|
+
const ceiling = Math.min(500 * 2 ** attempt, 8_000);
|
|
190
|
+
return Math.random() * ceiling;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
class PayoutsClient {
|
|
194
|
+
/**
|
|
195
|
+
* @param {object} opts
|
|
196
|
+
* @param {string} opts.apiKey Server-side only. Never ship this to a browser or a phone.
|
|
197
|
+
* @param {string} opts.orgId Your organization id.
|
|
198
|
+
* @param {string} [opts.baseUrl] Defaults to the AVVIO_BASE_URL env var.
|
|
199
|
+
* @param {number} [opts.timeoutMs]
|
|
200
|
+
*/
|
|
201
|
+
constructor(opts = {}) {
|
|
202
|
+
this.apiKey = opts.apiKey || process.env.AVVIO_API_KEY;
|
|
203
|
+
this.orgId = opts.orgId || process.env.AVVIO_ORG_ID;
|
|
204
|
+
// `business/api/v1`, NOT `api/v1`. The bare `api/v1` prefix on this host is
|
|
205
|
+
// the CONSUMER backend that the mobile app talks to — a different service.
|
|
206
|
+
// Cloudflare routes only `/business/*` to this one. Worse than a 404: the
|
|
207
|
+
// consumer host sits behind a bot challenge, so the default used to answer
|
|
208
|
+
// a partner's server-to-server call with an HTML interstitial rather than
|
|
209
|
+
// JSON, which reads as "your key is broken" rather than "wrong host".
|
|
210
|
+
this.baseUrl = (
|
|
211
|
+
opts.baseUrl ||
|
|
212
|
+
process.env.AVVIO_BASE_URL ||
|
|
213
|
+
'https://api.avvio.xyz/business/api/v1'
|
|
214
|
+
).replace(/\/+$/, '');
|
|
215
|
+
this.timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
216
|
+
// Attempts AFTER the first, so 2 means up to three calls. Set 0 to disable.
|
|
217
|
+
this.maxRetries = opts.maxRetries === undefined ? 2 : opts.maxRetries;
|
|
218
|
+
|
|
219
|
+
if (!this.apiKey) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
'Missing API key. Set AVVIO_API_KEY, or pass { apiKey } to the client.',
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
if (!this.orgId) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
'Missing organization id. Set AVVIO_ORG_ID, or pass { orgId } to the client.',
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* `'test'` or `'live'`, from the key prefix — so a test suite can assert it
|
|
232
|
+
* is not about to pay real people, before it sends anything.
|
|
233
|
+
*
|
|
234
|
+
* Anything that is not recognisably a test key is reported as LIVE. The two
|
|
235
|
+
* wrong answers are not symmetrical: calling a live key "test" is how a
|
|
236
|
+
* suite happily runs a payroll against real money, while calling a test key
|
|
237
|
+
* "live" only makes someone check. `doctor` reads this rather than deciding
|
|
238
|
+
* again, because two places deciding is two places to get it wrong.
|
|
239
|
+
*/
|
|
240
|
+
this.mode = this.apiKey.startsWith('ak_test_') ? 'test' : 'live';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ─── transport ───
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Send, with retries.
|
|
247
|
+
*
|
|
248
|
+
* ── Why the client retries and the caller does not ──
|
|
249
|
+
*
|
|
250
|
+
* `err.retryable` was computed, documented, and told to partners to branch
|
|
251
|
+
* on — and then never acted on, so every partner wrote the same loop, and
|
|
252
|
+
* each one had to independently work out the rule that makes retrying a
|
|
253
|
+
* PAYMENT safe. That rule is subtle enough that it should not be redistributed
|
|
254
|
+
* as prose.
|
|
255
|
+
*
|
|
256
|
+
* The rule: a mutation may only be retried while REUSING its idempotency key.
|
|
257
|
+
* By the time execution reaches here the key is already fixed — it was minted
|
|
258
|
+
* by the calling method or supplied by the caller — so a retry reuses it by
|
|
259
|
+
* construction and cannot become a second payment. A mutation with no key is
|
|
260
|
+
* never retried, because there is nothing making it safe.
|
|
261
|
+
*
|
|
262
|
+
* `DUPLICATE_REQUEST_DETECTED` is never retried at any level: it means an
|
|
263
|
+
* identical body arrived under a DIFFERENT key, and retrying is exactly the
|
|
264
|
+
* behaviour it exists to refuse.
|
|
265
|
+
*/
|
|
266
|
+
async request(method, path, opts = {}) {
|
|
267
|
+
const idempotent = method === 'GET' || Boolean(opts.idempotencyKey);
|
|
268
|
+
let attempt = 0;
|
|
269
|
+
|
|
270
|
+
for (;;) {
|
|
271
|
+
try {
|
|
272
|
+
return await this._send(method, path, opts);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
const canRetry =
|
|
275
|
+
idempotent &&
|
|
276
|
+
attempt < this.maxRetries &&
|
|
277
|
+
err instanceof PayoutsError &&
|
|
278
|
+
err.retryable &&
|
|
279
|
+
err.type !== 'DUPLICATE_REQUEST_DETECTED';
|
|
280
|
+
|
|
281
|
+
if (!canRetry) throw err;
|
|
282
|
+
|
|
283
|
+
await sleep(backoffMs(attempt, err.retryAfterSeconds));
|
|
284
|
+
attempt += 1;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async _send(method, path, { body, idempotencyKey, query, headers: extra } = {}) {
|
|
290
|
+
// Every failure from a mutation carries the key it used. Without this, the
|
|
291
|
+
// instruction "retry with the same idempotency key" names a value the
|
|
292
|
+
// caller has no way to obtain — which is worse than saying nothing, because
|
|
293
|
+
// they retry by calling us again and we mint a fresh one.
|
|
294
|
+
const tag = (err) => {
|
|
295
|
+
if (idempotencyKey) err.idempotencyKey = idempotencyKey;
|
|
296
|
+
return err;
|
|
297
|
+
};
|
|
298
|
+
const url = new URL(this.baseUrl + path);
|
|
299
|
+
for (const [k, v] of Object.entries(query || {})) {
|
|
300
|
+
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const headers = { 'x-api-key': this.apiKey };
|
|
304
|
+
if (body) headers['content-type'] = 'application/json';
|
|
305
|
+
// Mutations always carry one. Generating it here rather than making the
|
|
306
|
+
// caller remember means the safe thing is the default thing; a caller that
|
|
307
|
+
// wants retry-across-process-restarts passes their own stable value.
|
|
308
|
+
if (idempotencyKey) headers['idempotency-key'] = idempotencyKey;
|
|
309
|
+
Object.assign(headers, extra || {});
|
|
310
|
+
|
|
311
|
+
let res;
|
|
312
|
+
try {
|
|
313
|
+
res = await fetch(url, {
|
|
314
|
+
method,
|
|
315
|
+
headers,
|
|
316
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
317
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
318
|
+
});
|
|
319
|
+
} catch (err) {
|
|
320
|
+
// A timeout is an UNKNOWN outcome, not a failure. If this was a send, the
|
|
321
|
+
// payout may well have been accepted — so the instruction is to retry the
|
|
322
|
+
// same idempotency key, never to start again.
|
|
323
|
+
const isTimeout = err && err.name === 'TimeoutError';
|
|
324
|
+
const e = new PayoutsError(
|
|
325
|
+
isTimeout ? 504 : 502,
|
|
326
|
+
{
|
|
327
|
+
type: isTimeout ? 'TIMEOUT' : 'NETWORK_ERROR',
|
|
328
|
+
message: isTimeout
|
|
329
|
+
? `No response within ${this.timeoutMs}ms. The outcome is unknown — retry with the SAME idempotency key.`
|
|
330
|
+
: `Could not reach ${url.host}: ${err && err.message}`,
|
|
331
|
+
},
|
|
332
|
+
undefined,
|
|
333
|
+
);
|
|
334
|
+
throw tag(e);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const requestId = res.headers.get('x-request-id') || undefined;
|
|
338
|
+
const replayed = res.headers.get('idempotency-replayed') === 'true';
|
|
339
|
+
const text = await res.text();
|
|
340
|
+
let parsed;
|
|
341
|
+
try {
|
|
342
|
+
parsed = text ? JSON.parse(text) : undefined;
|
|
343
|
+
} catch {
|
|
344
|
+
parsed = { message: text.slice(0, 500) };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!res.ok) {
|
|
348
|
+
const err = new PayoutsError(res.status, parsed, requestId);
|
|
349
|
+
// The server tells us when its window resets; guessing is worse. Only
|
|
350
|
+
// present on a 429, which is the one case where the wait is knowable.
|
|
351
|
+
const after = Number(res.headers.get('retry-after'));
|
|
352
|
+
if (Number.isFinite(after) && after > 0) err.retryAfterSeconds = after;
|
|
353
|
+
throw tag(err);
|
|
354
|
+
}
|
|
355
|
+
if (idempotencyKey && parsed && typeof parsed === 'object') {
|
|
356
|
+
// Non-enumerable so it never pollutes a partner's stored payload, but
|
|
357
|
+
// reachable for logging and for a retry after a later failure.
|
|
358
|
+
Object.defineProperty(parsed, 'idempotencyKey', {
|
|
359
|
+
value: idempotencyKey,
|
|
360
|
+
enumerable: false,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
if (replayed && parsed && typeof parsed === 'object') {
|
|
364
|
+
// Surfaced so a caller reconciling a retry storm can tell "we already did
|
|
365
|
+
// this" from "we just did this".
|
|
366
|
+
Object.defineProperty(parsed, 'replayed', { value: true, enumerable: false });
|
|
367
|
+
}
|
|
368
|
+
return parsed;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ─── discovery ───
|
|
372
|
+
|
|
373
|
+
/** Every currency you can pay out to, with the fields each one needs. */
|
|
374
|
+
corridors() {
|
|
375
|
+
return this.request('GET', `/recipients/${this.orgId}/corridors`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* What a beneficiary in this currency requires.
|
|
380
|
+
*
|
|
381
|
+
* Read this rather than hardcoding a form: the field set and the field NAMES
|
|
382
|
+
* depend on how your organization is routed, and we may re-route you.
|
|
383
|
+
*/
|
|
384
|
+
async requirements(currency) {
|
|
385
|
+
const { corridors } = await this.corridors();
|
|
386
|
+
const want = String(currency).toUpperCase();
|
|
387
|
+
const match = (corridors || []).find(
|
|
388
|
+
(c) => String(c.currency || '').toUpperCase() === want,
|
|
389
|
+
);
|
|
390
|
+
if (!match) {
|
|
391
|
+
const available = (corridors || []).map((c) => c.currency).join(', ');
|
|
392
|
+
// A PayoutsError, not a bare Error. The docs say "branch on `type`,
|
|
393
|
+
// always" — and this was the one failure in the package that had no
|
|
394
|
+
// `type`, `status` or `retryable`, so code written exactly as instructed
|
|
395
|
+
// fell straight through the branch it was told to write.
|
|
396
|
+
throw new PayoutsError(
|
|
397
|
+
400,
|
|
398
|
+
{
|
|
399
|
+
type: 'CORRIDOR_UNAVAILABLE',
|
|
400
|
+
message: `No ${want} corridor is available for this organization. Available: ${available}`,
|
|
401
|
+
},
|
|
402
|
+
undefined,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
return match;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ─── pricing ───
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Indicative price for a corridor, with no beneficiary in existence.
|
|
412
|
+
*
|
|
413
|
+
* This is what you show while someone is still typing an amount. It is an
|
|
414
|
+
* estimate: the binding number is locked when you quote against a real
|
|
415
|
+
* beneficiary.
|
|
416
|
+
*/
|
|
417
|
+
quote({ amount, to, from = 'USD' }) {
|
|
418
|
+
return this.request('GET', `/payments/organizations/${this.orgId}/rates`, {
|
|
419
|
+
query: { from, to, amount },
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ─── beneficiaries ───
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* @param {object} p
|
|
427
|
+
* @param {string} p.endUserId YOUR id for the person sending. Scopes the
|
|
428
|
+
* beneficiary to them, so listing for that id returns only what they added.
|
|
429
|
+
* @param {object} p.details The corridor's fields, from requirements().
|
|
430
|
+
*/
|
|
431
|
+
createBeneficiary(p) {
|
|
432
|
+
const body = {
|
|
433
|
+
type: p.type || 'individual',
|
|
434
|
+
name: p.name,
|
|
435
|
+
email: p.email,
|
|
436
|
+
country: p.country,
|
|
437
|
+
externalId: p.externalId,
|
|
438
|
+
endUserId: p.endUserId,
|
|
439
|
+
method: {
|
|
440
|
+
kind: 'fiat',
|
|
441
|
+
currency: p.currency,
|
|
442
|
+
recipientDetails: p.details || {},
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
return this.request('POST', `/recipients/${this.orgId}`, {
|
|
446
|
+
body,
|
|
447
|
+
idempotencyKey: p.idempotencyKey || randomUUID(),
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
listBeneficiaries({ endUserId, limit, cursor } = {}) {
|
|
452
|
+
return this.request('GET', `/recipients/${this.orgId}`, {
|
|
453
|
+
query: { endUserId, limit, cursor },
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ─── paying ───
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Price a payout against a real beneficiary. Nothing moves.
|
|
461
|
+
*
|
|
462
|
+
* The returned snapshot is what `send` executes; it has an expiry, so quote
|
|
463
|
+
* and send close together.
|
|
464
|
+
*/
|
|
465
|
+
pricePayout({ amount, destinationAccountId, purposeOfPayment }) {
|
|
466
|
+
return this.request(
|
|
467
|
+
'POST',
|
|
468
|
+
`/payments/organizations/${this.orgId}/quotes/offramp`,
|
|
469
|
+
{ body: { amount, destinationAccountId, purposeOfPayment } },
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Execute. This is the step that moves money.
|
|
475
|
+
*
|
|
476
|
+
* If it times out, retry with the SAME idempotencyKey — a replay returns the
|
|
477
|
+
* original payout. Re-quoting instead is a second payment.
|
|
478
|
+
*/
|
|
479
|
+
send({ snapshotId, quoteId, endUser, reference, idempotencyKey }) {
|
|
480
|
+
return this.request(
|
|
481
|
+
'POST',
|
|
482
|
+
`/payments/organizations/${this.orgId}/quotes/accept`,
|
|
483
|
+
{
|
|
484
|
+
body: {
|
|
485
|
+
snapshotId,
|
|
486
|
+
quoteId,
|
|
487
|
+
type: 'OFFRAMP',
|
|
488
|
+
reference,
|
|
489
|
+
endUser,
|
|
490
|
+
},
|
|
491
|
+
idempotencyKey: idempotencyKey || randomUUID(),
|
|
492
|
+
},
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Send a payout. One request.
|
|
498
|
+
*
|
|
499
|
+
* Deliberately NOT price-then-send from here. That sequence is not
|
|
500
|
+
* retry-safe: a retry re-prices, produces a different quote, and therefore a
|
|
501
|
+
* different request body — so the idempotency key that was supposed to
|
|
502
|
+
* protect the retry conflicts with itself instead. The server prices and
|
|
503
|
+
* sends in one call, which makes the body a partner can retry a stable one.
|
|
504
|
+
*
|
|
505
|
+
* `expectDestination` is the guard: pass what you told the payer they would
|
|
506
|
+
* receive and the send is refused if the binding quote moved further than
|
|
507
|
+
* `maxRateDrift` from it. Enforced server-side, so it protects every caller
|
|
508
|
+
* rather than only the ones using this package.
|
|
509
|
+
*/
|
|
510
|
+
payout(p) {
|
|
511
|
+
const expect =
|
|
512
|
+
p.expectDestination !== undefined
|
|
513
|
+
? p.expectDestination
|
|
514
|
+
: p.expectDestinationAmount;
|
|
515
|
+
return this.request(
|
|
516
|
+
'POST',
|
|
517
|
+
`/payments/organizations/${this.orgId}/payouts`,
|
|
518
|
+
{
|
|
519
|
+
body: {
|
|
520
|
+
amount: p.amount,
|
|
521
|
+
destinationAccountId: p.destinationAccountId,
|
|
522
|
+
...(expect !== undefined ? { expectDestination: String(expect) } : {}),
|
|
523
|
+
...(p.maxRateDrift !== undefined
|
|
524
|
+
? { maxDriftBps: toDriftBps(p.maxRateDrift) }
|
|
525
|
+
: {}),
|
|
526
|
+
...(p.reference ? { reference: p.reference } : {}),
|
|
527
|
+
...(p.purposeOfPayment
|
|
528
|
+
? { purposeOfPayment: p.purposeOfPayment }
|
|
529
|
+
: {}),
|
|
530
|
+
...(p.endUser ? { endUser: p.endUser } : {}),
|
|
531
|
+
},
|
|
532
|
+
idempotencyKey: p.idempotencyKey || randomUUID(),
|
|
533
|
+
// Says "I know this looks like the last one, and I mean it". Only ever
|
|
534
|
+
// set deliberately: it switches off the guard that catches a retry
|
|
535
|
+
// arriving under a fresh key.
|
|
536
|
+
...(p.allowDuplicate ? { headers: { 'x-allow-duplicate': 'true' } } : {}),
|
|
537
|
+
},
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ─── reading ───
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Everything that has happened to your payouts, in order.
|
|
545
|
+
*
|
|
546
|
+
* The reconciliation primitive. Events are written once and never change, so
|
|
547
|
+
* carrying `nextSince` gives you exactly what is new — including a bank return
|
|
548
|
+
* that lands days after you booked the payout as settled.
|
|
549
|
+
*
|
|
550
|
+
* At-least-once: dedupe on `id`.
|
|
551
|
+
*/
|
|
552
|
+
listEvents({ since, limit, payoutId } = {}) {
|
|
553
|
+
return this.request('GET', `/payments/organizations/${this.orgId}/events`, {
|
|
554
|
+
query: { since, limit, payoutId },
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Every event since a watermark, paged for you.
|
|
560
|
+
*
|
|
561
|
+
* for await (const e of avvio.eachEvent({ since: saved })) { … }
|
|
562
|
+
*
|
|
563
|
+
* The two list endpoints page differently — payouts use `nextCursor`, events
|
|
564
|
+
* use `nextSince` — and the reconciliation loop the docs tell partners to
|
|
565
|
+
* build is the one place that difference bites. Every partner writes the same
|
|
566
|
+
* while-loop, and the one that gets it subtly wrong stops reconciling
|
|
567
|
+
* silently. Written once, here.
|
|
568
|
+
*
|
|
569
|
+
* At-least-once by design: `since` is inclusive, so a resumed run re-reads the
|
|
570
|
+
* row at your watermark. Dedupe on `id`.
|
|
571
|
+
*/
|
|
572
|
+
async *eachEvent({ since, limit = 100, payoutId } = {}) {
|
|
573
|
+
let cursor = since;
|
|
574
|
+
for (;;) {
|
|
575
|
+
const page = await this.listEvents({ since: cursor, limit, payoutId });
|
|
576
|
+
for (const event of page.data || []) yield event;
|
|
577
|
+
if (!page.hasMore || !page.nextSince || page.nextSince === cursor) return;
|
|
578
|
+
cursor = page.nextSince;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Every payout matching a filter, paged for you.
|
|
584
|
+
*
|
|
585
|
+
* for await (const p of avvio.eachPayout({ status: 'failed' })) { … }
|
|
586
|
+
*
|
|
587
|
+
* Stops when the server says there is no more. It does NOT stop on an empty
|
|
588
|
+
* page alone — a cursor we did not issue is a 400, not an empty page, so an
|
|
589
|
+
* empty page with `hasMore` is a real state and swallowing it would rebuild
|
|
590
|
+
* the silent-truncation bug the API was fixed to remove.
|
|
591
|
+
*/
|
|
592
|
+
async *eachPayout({ limit = 50, status, endUserId, reference, updatedSince } = {}) {
|
|
593
|
+
let cursor;
|
|
594
|
+
for (;;) {
|
|
595
|
+
const page = await this.listPayouts({
|
|
596
|
+
limit,
|
|
597
|
+
cursor,
|
|
598
|
+
status,
|
|
599
|
+
endUserId,
|
|
600
|
+
reference,
|
|
601
|
+
updatedSince,
|
|
602
|
+
});
|
|
603
|
+
for (const payout of page.data || []) yield payout;
|
|
604
|
+
if (!page.hasMore || !page.nextCursor || page.nextCursor === cursor) return;
|
|
605
|
+
cursor = page.nextCursor;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* How to fund a payout that came back with `requiresFunding: true`.
|
|
611
|
+
*
|
|
612
|
+
* Where to send, how much, on which network, and by when. Your funds stay in
|
|
613
|
+
* your wallet until you move them — we hold no key and cannot move them.
|
|
614
|
+
*/
|
|
615
|
+
getFunding(payoutId) {
|
|
616
|
+
return this.request(
|
|
617
|
+
'GET',
|
|
618
|
+
`/payments/organizations/${this.orgId}/payouts/${encodeURIComponent(payoutId)}/funding`,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Proof you sent the funds: a `transactionHash` you broadcast, or
|
|
624
|
+
* `signedOperations` you signed from `getFunding()`. Which one depends on how
|
|
625
|
+
* you hold the money, not on anything we prefer.
|
|
626
|
+
*/
|
|
627
|
+
confirmFunding(payoutId, proof = {}) {
|
|
628
|
+
return this.request(
|
|
629
|
+
'POST',
|
|
630
|
+
`/payments/organizations/${this.orgId}/payouts/${encodeURIComponent(payoutId)}/funding/confirm`,
|
|
631
|
+
{
|
|
632
|
+
body: {
|
|
633
|
+
...(proof.transactionHash
|
|
634
|
+
? { transactionHash: proof.transactionHash }
|
|
635
|
+
: {}),
|
|
636
|
+
...(proof.signedOperations
|
|
637
|
+
? { signedOperations: proof.signedOperations }
|
|
638
|
+
: {}),
|
|
639
|
+
...(proof.tamperProofSignature
|
|
640
|
+
? { tamperProofSignature: proof.tamperProofSignature }
|
|
641
|
+
: {}),
|
|
642
|
+
},
|
|
643
|
+
idempotencyKey: proof.idempotencyKey || randomUUID(),
|
|
644
|
+
},
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
getPayout(payoutId) {
|
|
649
|
+
return this.request(
|
|
650
|
+
'GET',
|
|
651
|
+
`/payments/organizations/${this.orgId}/orders/${encodeURIComponent(payoutId)}`,
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Stop a payout that has NOT been funded yet.
|
|
657
|
+
*
|
|
658
|
+
* The API has documented this since the beginning and the SDK never exposed
|
|
659
|
+
* it, so a Node partner who created a `requiresFunding` payout by mistake had
|
|
660
|
+
* no supported way to stop it — the one recovery an operator actually reaches
|
|
661
|
+
* for.
|
|
662
|
+
*
|
|
663
|
+
* Once a payout is funded it cannot be cancelled: you get
|
|
664
|
+
* `PAYOUT_NOT_CANCELABLE`, which is the honest answer rather than a
|
|
665
|
+
* cancellation that does not happen.
|
|
666
|
+
*/
|
|
667
|
+
cancelPayout(payoutId, { idempotencyKey } = {}) {
|
|
668
|
+
return this.request(
|
|
669
|
+
'POST',
|
|
670
|
+
`/payments/organizations/${this.orgId}/payouts/${encodeURIComponent(payoutId)}/cancel`,
|
|
671
|
+
{ idempotencyKey },
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* A PAGE of payouts: `{ data, hasMore, nextCursor }`, not an array. The type
|
|
677
|
+
* declaration used to claim an array, so code written off it threw on the
|
|
678
|
+
* first call.
|
|
679
|
+
*/
|
|
680
|
+
listPayouts({ limit, cursor, status, endUserId, reference, updatedSince } = {}) {
|
|
681
|
+
return this.request('GET', `/payments/organizations/${this.orgId}/orders`, {
|
|
682
|
+
query: { limit, cursor, status, endUserId, reference, updatedSince },
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Sandbox only: credit the test balance so you can send. */
|
|
687
|
+
/**
|
|
688
|
+
* Register a sandbox webhook endpoint and get its signing secret.
|
|
689
|
+
*
|
|
690
|
+
* The secret is returned ONCE and is not retrievable afterwards, which is why
|
|
691
|
+
* this returns it rather than storing it for you. Live endpoints are managed
|
|
692
|
+
* from the dashboard on purpose: a credential that could repoint its own
|
|
693
|
+
* webhook URL could redirect every payout notification.
|
|
694
|
+
*/
|
|
695
|
+
createWebhookEndpoint({ url, events, idempotencyKey } = {}) {
|
|
696
|
+
return this.request(
|
|
697
|
+
'POST',
|
|
698
|
+
`/payments/organizations/${this.orgId}/sandbox/webhook-endpoints`,
|
|
699
|
+
{
|
|
700
|
+
body: { url, ...(events ? { events } : {}) },
|
|
701
|
+
idempotencyKey: idempotencyKey || randomUUID(),
|
|
702
|
+
},
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** What we sent, what came back, and what we retried. */
|
|
707
|
+
webhookDeliveries(endpointId) {
|
|
708
|
+
return this.request(
|
|
709
|
+
'GET',
|
|
710
|
+
`/payments/organizations/${this.orgId}/sandbox/webhook-endpoints/${encodeURIComponent(endpointId)}/deliveries`,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Mint a one-time link for the person being paid.
|
|
716
|
+
*
|
|
717
|
+
* Use this instead of `createBeneficiary` + `payout()` when you would rather
|
|
718
|
+
* not collect bank details yourself — the recipient enters their own, so the
|
|
719
|
+
* details never touch your systems or your compliance surface.
|
|
720
|
+
*
|
|
721
|
+
* The idempotency key is carried onto the link, so the payout it eventually
|
|
722
|
+
* creates deduplicates against YOUR retry rather than only against ours.
|
|
723
|
+
*/
|
|
724
|
+
createPayoutLink(p = {}) {
|
|
725
|
+
return this.request(
|
|
726
|
+
'POST',
|
|
727
|
+
`/payments/organizations/${this.orgId}/payout-links`,
|
|
728
|
+
{
|
|
729
|
+
body: {
|
|
730
|
+
amount: p.amount,
|
|
731
|
+
destinationCurrency: p.destinationCurrency ?? p.to,
|
|
732
|
+
endUserId: p.endUserId,
|
|
733
|
+
...(p.reference ? { reference: p.reference } : {}),
|
|
734
|
+
...(p.expiresInMinutes
|
|
735
|
+
? { expiresInMinutes: p.expiresInMinutes }
|
|
736
|
+
: {}),
|
|
737
|
+
},
|
|
738
|
+
idempotencyKey: p.idempotencyKey || randomUUID(),
|
|
739
|
+
},
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Why the balance is what it is: every movement, with the running balance
|
|
745
|
+
* after each. Reconcile against this rather than trusting a single number —
|
|
746
|
+
* a balance you cannot explain is one you cannot build a ledger on.
|
|
747
|
+
*/
|
|
748
|
+
balanceHistory(limit) {
|
|
749
|
+
return this.request(
|
|
750
|
+
'GET',
|
|
751
|
+
`/payments/organizations/${this.orgId}/balance/history`,
|
|
752
|
+
{ query: { limit } },
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
fund(amount = '5000.00', idempotencyKey) {
|
|
757
|
+
return this.request(
|
|
758
|
+
'POST',
|
|
759
|
+
`/payments/organizations/${this.orgId}/sandbox/fund`,
|
|
760
|
+
{ body: { amount }, idempotencyKey: idempotencyKey || randomUUID() },
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** What you can currently send. */
|
|
765
|
+
balance() {
|
|
766
|
+
return this.request(
|
|
767
|
+
'GET',
|
|
768
|
+
`/payments/organizations/${this.orgId}/balance`,
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** Where to wire funds to top up the balance payouts debit. */
|
|
773
|
+
fundingAccounts() {
|
|
774
|
+
return this.request(
|
|
775
|
+
'GET',
|
|
776
|
+
`/payments/organizations/${this.orgId}/payin-accounts`,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const {
|
|
782
|
+
verifyWebhook,
|
|
783
|
+
// Declared in index.d.ts and documented, and until now exported only from
|
|
784
|
+
// `./webhooks` — so `require('@avvio/payments').createWebhookHandler` was
|
|
785
|
+
// undefined against a type declaration that promised a function. That is the
|
|
786
|
+
// same declared-but-missing failure this package has been bitten by three
|
|
787
|
+
// times, in the surface the docs tell you to build first.
|
|
788
|
+
createWebhookHandler,
|
|
789
|
+
WebhookVerificationError,
|
|
790
|
+
} = require('./webhooks');
|
|
791
|
+
|
|
792
|
+
module.exports = {
|
|
793
|
+
PayoutsClient,
|
|
794
|
+
PayoutsError,
|
|
795
|
+
stableKey,
|
|
796
|
+
verifyWebhook,
|
|
797
|
+
createWebhookHandler,
|
|
798
|
+
WebhookVerificationError,
|
|
799
|
+
};
|