@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/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
+ };