@avvio/payments 0.1.0 → 0.5.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 CHANGED
@@ -10,7 +10,13 @@
10
10
  * being "nothing" is worth more than any convenience a library would buy.
11
11
  */
12
12
 
13
- const { createHash, randomUUID } = require('node:crypto');
13
+ const {
14
+ createHash,
15
+ createPrivateKey,
16
+ createSign,
17
+ generateKeyPairSync,
18
+ randomUUID,
19
+ } = require('node:crypto');
14
20
 
15
21
  /** Thrown for any non-2xx. Carries enough to act on without parsing prose. */
16
22
  class PayoutsError extends Error {
@@ -142,7 +148,9 @@ function stableKey(...parts) {
142
148
  undefined,
143
149
  );
144
150
  }
145
- const empty = parts.findIndex((p) => p === undefined || p === null || p === '');
151
+ const empty = parts.findIndex(
152
+ (p) => p === undefined || p === null || p === '',
153
+ );
146
154
  if (empty !== -1) {
147
155
  throw new PayoutsError(
148
156
  400,
@@ -193,7 +201,7 @@ function backoffMs(attempt, retryAfterSeconds) {
193
201
  class PayoutsClient {
194
202
  /**
195
203
  * @param {object} opts
196
- * @param {string} opts.apiKey Server-side only. Never ship this to a browser or a phone.
204
+ * @param {string} opts.apiKey Complete `avvio_live_*` or `avvio_test_*` bearer key.
197
205
  * @param {string} opts.orgId Your organization id.
198
206
  * @param {string} [opts.baseUrl] Defaults to the AVVIO_BASE_URL env var.
199
207
  * @param {number} [opts.timeoutMs]
@@ -201,6 +209,8 @@ class PayoutsClient {
201
209
  constructor(opts = {}) {
202
210
  this.apiKey = opts.apiKey || process.env.AVVIO_API_KEY;
203
211
  this.orgId = opts.orgId || process.env.AVVIO_ORG_ID;
212
+ // PARKED request-signing setup. Restore with the signing block in `_send`.
213
+ // const privatePem = opts.privateKeyPem || process.env.AVVIO_PRIVATE_KEY;
204
214
  // `business/api/v1`, NOT `api/v1`. The bare `api/v1` prefix on this host is
205
215
  // the CONSUMER backend that the mobile app talks to — a different service.
206
216
  // Cloudflare routes only `/business/*` to this one. Worse than a 404: the
@@ -211,7 +221,12 @@ class PayoutsClient {
211
221
  opts.baseUrl ||
212
222
  process.env.AVVIO_BASE_URL ||
213
223
  'https://api.avvio.xyz/business/api/v1'
214
- ).replace(/\/+$/, '');
224
+ );
225
+ // Trailing slashes, trimmed without `/\/+$/`: that pattern is quadratic on
226
+ // a long run of slashes, and this value can come from an env var.
227
+ let baseEnd = this.baseUrl.length;
228
+ while (baseEnd > 0 && this.baseUrl[baseEnd - 1] === '/') baseEnd--;
229
+ this.baseUrl = this.baseUrl.slice(0, baseEnd);
215
230
  this.timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
216
231
  // Attempts AFTER the first, so 2 means up to three calls. Set 0 to disable.
217
232
  this.maxRetries = opts.maxRetries === undefined ? 2 : opts.maxRetries;
@@ -226,18 +241,32 @@ class PayoutsClient {
226
241
  'Missing organization id. Set AVVIO_ORG_ID, or pass { orgId } to the client.',
227
242
  );
228
243
  }
244
+ if (
245
+ !/^avvio_(?:live|test)_[0-9a-f]{32}_[A-Za-z0-9_-]{43}$/.test(this.apiKey)
246
+ ) {
247
+ throw new Error(
248
+ 'Unsupported API credential. Copy the complete avvio_live_* or avvio_test_* API key shown at issuance.',
249
+ );
250
+ }
251
+ // PARKED request-signing constructor behavior. The bearer API key is the
252
+ // complete credential; AVVIO_PRIVATE_KEY is intentionally ignored.
253
+ // if (!privatePem) {
254
+ // throw new Error(
255
+ // 'This signed API key id requires a private signing key. Set AVVIO_PRIVATE_KEY, or pass { privateKeyPem } to the client.',
256
+ // );
257
+ // }
258
+ // this.privateKey = privatePem ? createPrivateKey(privatePem) : null;
229
259
 
230
260
  /**
231
261
  * `'test'` or `'live'`, from the key prefix — so a test suite can assert it
232
262
  * is not about to pay real people, before it sends anything.
233
263
  *
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.
264
+ * The constructor has already rejected every other credential shape, so
265
+ * this cannot silently interpret an unknown value as live. `doctor` reads
266
+ * this rather than deciding again, because two places deciding is two
267
+ * places to get it wrong.
239
268
  */
240
- this.mode = this.apiKey.startsWith('ak_test_') ? 'test' : 'live';
269
+ this.mode = this.apiKey.startsWith('avvio_test_') ? 'test' : 'live';
241
270
  }
242
271
 
243
272
  // ─── transport ───
@@ -254,16 +283,20 @@ class PayoutsClient {
254
283
  * as prose.
255
284
  *
256
285
  * 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.
286
+ * Every mutation carries one supplied by the caller, or minted here once
287
+ * per logical call and reused across its attemptsbecause the server
288
+ * honours the header on every POST, PATCH and DELETE. So a retry reuses the
289
+ * key by construction and cannot become a second payment; a mutation that
290
+ * somehow has no key is never retried, because nothing makes it safe.
261
291
  *
262
292
  * `DUPLICATE_REQUEST_DETECTED` is never retried at any level: it means an
263
293
  * identical body arrived under a DIFFERENT key, and retrying is exactly the
264
294
  * behaviour it exists to refuse.
265
295
  */
266
296
  async request(method, path, opts = {}) {
297
+ if (method !== 'GET' && !opts.idempotencyKey) {
298
+ opts = { ...opts, idempotencyKey: randomUUID() };
299
+ }
267
300
  const idempotent = method === 'GET' || Boolean(opts.idempotencyKey);
268
301
  let attempt = 0;
269
302
 
@@ -286,7 +319,11 @@ class PayoutsClient {
286
319
  }
287
320
  }
288
321
 
289
- async _send(method, path, { body, idempotencyKey, query, headers: extra } = {}) {
322
+ async _send(
323
+ method,
324
+ path,
325
+ { body, idempotencyKey, query, headers: extra } = {},
326
+ ) {
290
327
  // Every failure from a mutation carries the key it used. Without this, the
291
328
  // instruction "retry with the same idempotency key" names a value the
292
329
  // caller has no way to obtain — which is worse than saying nothing, because
@@ -308,6 +345,30 @@ class PayoutsClient {
308
345
  if (idempotencyKey) headers['idempotency-key'] = idempotencyKey;
309
346
  Object.assign(headers, extra || {});
310
347
 
348
+ // PARKED request-signing block. Restore only with server verification and
349
+ // the matching public documentation.
350
+ // if (this.privateKey) {
351
+ // const serialized = body ? JSON.stringify(body) : '';
352
+ // const timestamp = String(Math.floor(Date.now() / 1000));
353
+ // const nonce = randomUUID();
354
+ // const canonical = [
355
+ // this.apiKey,
356
+ // method.toUpperCase(),
357
+ // url.pathname + url.search,
358
+ // createHash('sha256').update(serialized, 'utf8').digest('hex'),
359
+ // idempotencyKey || '',
360
+ // timestamp,
361
+ // nonce,
362
+ // ].join('\n');
363
+ // headers['x-anzo-signature'] = createSign('sha256')
364
+ // .update(canonical)
365
+ // .end()
366
+ // .sign(this.privateKey)
367
+ // .toString('base64');
368
+ // headers['x-anzo-timestamp'] = timestamp;
369
+ // headers['x-anzo-nonce'] = nonce;
370
+ // }
371
+
311
372
  let res;
312
373
  try {
313
374
  res = await fetch(url, {
@@ -344,6 +405,13 @@ class PayoutsClient {
344
405
  parsed = { message: text.slice(0, 500) };
345
406
  }
346
407
 
408
+ // A generic HTTP client considers every 2xx successful. This payload is
409
+ // authentication work, not a payout response, so fail closed if a signed
410
+ // client ever receives one instead of quietly handing it to business code.
411
+ if (res.status === 202 && parsed?.type === 'SIGNATURE_CHALLENGE') {
412
+ throw tag(new PayoutsError(res.status, parsed, requestId));
413
+ }
414
+
347
415
  if (!res.ok) {
348
416
  const err = new PayoutsError(res.status, parsed, requestId);
349
417
  // The server tells us when its window resets; guessing is worse. Only
@@ -363,16 +431,22 @@ class PayoutsClient {
363
431
  if (replayed && parsed && typeof parsed === 'object') {
364
432
  // Surfaced so a caller reconciling a retry storm can tell "we already did
365
433
  // this" from "we just did this".
366
- Object.defineProperty(parsed, 'replayed', { value: true, enumerable: false });
434
+ Object.defineProperty(parsed, 'replayed', {
435
+ value: true,
436
+ enumerable: false,
437
+ });
367
438
  }
368
439
  return parsed;
369
440
  }
370
441
 
371
442
  // ─── discovery ───
372
443
 
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`);
444
+ /** Every currency you can pay out to, or the exact routed currency requested. */
445
+ corridors(currency) {
446
+ const query = currency
447
+ ? `?currency=${encodeURIComponent(String(currency).toUpperCase())}`
448
+ : '';
449
+ return this.request('GET', `/recipients/${this.orgId}/corridors${query}`);
376
450
  }
377
451
 
378
452
  /**
@@ -382,8 +456,8 @@ class PayoutsClient {
382
456
  * depend on how your organization is routed, and we may re-route you.
383
457
  */
384
458
  async requirements(currency) {
385
- const { corridors } = await this.corridors();
386
459
  const want = String(currency).toUpperCase();
460
+ const { corridors } = await this.corridors(want);
387
461
  const match = (corridors || []).find(
388
462
  (c) => String(c.currency || '').toUpperCase() === want,
389
463
  );
@@ -405,6 +479,21 @@ class PayoutsClient {
405
479
  return match;
406
480
  }
407
481
 
482
+ /**
483
+ * The stated payment reasons this organization may use.
484
+ *
485
+ * Some corridors require one — `purposeOfPayment` on a payout, `paymentReason`
486
+ * on a quote you accept — and this is the list they are validated against.
487
+ * Read it rather than guessing: a rejected value is a 400 on a payout you have
488
+ * already promised somebody.
489
+ */
490
+ paymentReasons() {
491
+ return this.request(
492
+ 'GET',
493
+ `/payments/organizations/${this.orgId}/payment-reasons`,
494
+ );
495
+ }
496
+
408
497
  // ─── pricing ───
409
498
 
410
499
  /**
@@ -454,6 +543,99 @@ class PayoutsClient {
454
543
  });
455
544
  }
456
545
 
546
+ /** One beneficiary, by the id we returned. */
547
+ getBeneficiary(recipientId) {
548
+ return this.request(
549
+ 'GET',
550
+ `/recipients/${this.orgId}/${encodeURIComponent(recipientId)}`,
551
+ );
552
+ }
553
+
554
+ /**
555
+ * One beneficiary, by YOUR id for them.
556
+ *
557
+ * `externalId` was already the key that makes creation idempotent; this reads
558
+ * it back, so "worker 4471" resolves to a `destinationAccountId` in one call
559
+ * rather than paging your whole book and filtering client-side. Unique per
560
+ * organization, so this is exactly one beneficiary or a 404.
561
+ */
562
+ getBeneficiaryByExternalId(externalId) {
563
+ return this.request(
564
+ 'GET',
565
+ `/recipients/${this.orgId}/external/${encodeURIComponent(externalId)}`,
566
+ );
567
+ }
568
+
569
+ /**
570
+ * Correct a beneficiary's own details: name, email, phone, country, and
571
+ * whether they are an individual or a business.
572
+ *
573
+ * **The bank account behind a payment method is not editable, by design.**
574
+ * The rail validated that account, and swapping it underneath would send the
575
+ * next payout somewhere you never registered. A wrong account is a NEW
576
+ * payment method, and the old one is deleted.
577
+ */
578
+ updateBeneficiary(recipientId, patch = {}, { idempotencyKey } = {}) {
579
+ return this.request(
580
+ 'PATCH',
581
+ `/recipients/${this.orgId}/${encodeURIComponent(recipientId)}`,
582
+ {
583
+ idempotencyKey,
584
+ // Only what was named. Sending `email: undefined` as an explicit null
585
+ // would ask the server to clear a field the caller never mentioned.
586
+ body: {
587
+ ...(patch.type !== undefined ? { type: patch.type } : {}),
588
+ ...(patch.name !== undefined ? { name: patch.name } : {}),
589
+ ...(patch.email !== undefined ? { email: patch.email } : {}),
590
+ ...(patch.phone !== undefined ? { phone: patch.phone } : {}),
591
+ ...(patch.country !== undefined ? { country: patch.country } : {}),
592
+ },
593
+ },
594
+ );
595
+ }
596
+
597
+ /**
598
+ * Remove a beneficiary and every payment method on it.
599
+ *
600
+ * Payouts already sent are history, not references — deleting cancels
601
+ * nothing in flight.
602
+ */
603
+ deleteBeneficiary(recipientId, { idempotencyKey } = {}) {
604
+ return this.request(
605
+ 'DELETE',
606
+ `/recipients/${this.orgId}/${encodeURIComponent(recipientId)}`,
607
+ { idempotencyKey },
608
+ );
609
+ }
610
+
611
+ /**
612
+ * Remove one way of paying a beneficiary — an account that closed, or one
613
+ * entered wrong. The beneficiary and their other methods are untouched, and
614
+ * the `destinationAccountId` this method carried stops being payable.
615
+ */
616
+ deleteBeneficiaryMethod(recipientId, methodId, { idempotencyKey } = {}) {
617
+ return this.request(
618
+ 'DELETE',
619
+ `/recipients/${this.orgId}/${encodeURIComponent(recipientId)}/methods/${encodeURIComponent(methodId)}`,
620
+ { idempotencyKey },
621
+ );
622
+ }
623
+
624
+ /**
625
+ * The full account details behind one payment method.
626
+ *
627
+ * Lists carry only `last4`. This returns what was actually registered, so you
628
+ * can show someone the account on file before they authorize a payout, or
629
+ * confirm what you stored matches what we hold. Deliberately per-method: bulk
630
+ * reads do not move full account numbers around.
631
+ */
632
+ getBeneficiaryMethodDetails(recipientId, methodId) {
633
+ return this.request(
634
+ 'GET',
635
+ `/recipients/${this.orgId}/${encodeURIComponent(recipientId)}/methods/${encodeURIComponent(methodId)}/details`,
636
+ );
637
+ }
638
+
457
639
  // ─── paying ───
458
640
 
459
641
  /**
@@ -506,6 +688,13 @@ class PayoutsClient {
506
688
  * receive and the send is refused if the binding quote moved further than
507
689
  * `maxRateDrift` from it. Enforced server-side, so it protects every caller
508
690
  * rather than only the ones using this package.
691
+ *
692
+ * `amountLeg: 'destination'` changes what `amount` MEANS: the beneficiary
693
+ * receives exactly that figure, in THEIR currency, and the fees are added to
694
+ * what you are debited rather than taken out of it. Measured on a live
695
+ * corridor, naming 3400 MXN debited 201.879397 USDC and paid out 3400.00.
696
+ * Refused with `EXACT_OUTPUT_UNSUPPORTED` where the routing cannot lock it —
697
+ * check `capabilities.exactOutput` on the corridors call first.
509
698
  */
510
699
  payout(p) {
511
700
  const expect =
@@ -519,7 +708,10 @@ class PayoutsClient {
519
708
  body: {
520
709
  amount: p.amount,
521
710
  destinationAccountId: p.destinationAccountId,
522
- ...(expect !== undefined ? { expectDestination: String(expect) } : {}),
711
+ ...(p.amountLeg ? { amountLeg: p.amountLeg } : {}),
712
+ ...(expect !== undefined
713
+ ? { expectDestination: String(expect) }
714
+ : {}),
523
715
  ...(p.maxRateDrift !== undefined
524
716
  ? { maxDriftBps: toDriftBps(p.maxRateDrift) }
525
717
  : {}),
@@ -533,7 +725,9 @@ class PayoutsClient {
533
725
  // Says "I know this looks like the last one, and I mean it". Only ever
534
726
  // set deliberately: it switches off the guard that catches a retry
535
727
  // arriving under a fresh key.
536
- ...(p.allowDuplicate ? { headers: { 'x-allow-duplicate': 'true' } } : {}),
728
+ ...(p.allowDuplicate
729
+ ? { headers: { 'x-allow-duplicate': 'true' } }
730
+ : {}),
537
731
  },
538
732
  );
539
733
  }
@@ -549,9 +743,17 @@ class PayoutsClient {
549
743
  *
550
744
  * At-least-once: dedupe on `id`.
551
745
  */
552
- listEvents({ since, limit, payoutId } = {}) {
746
+ listEvents({ since, limit, payoutId, type } = {}) {
553
747
  return this.request('GET', `/payments/organizations/${this.orgId}/events`, {
554
- query: { since, limit, payoutId },
748
+ // `type` narrows the feed to the families you book: a string
749
+ // ('payout.completed,payout.returned') or an array. Unknown values are a
750
+ // 400, never an empty page.
751
+ query: {
752
+ since,
753
+ limit,
754
+ payoutId,
755
+ type: Array.isArray(type) ? type.join(',') : type,
756
+ },
555
757
  });
556
758
  }
557
759
 
@@ -569,10 +771,10 @@ class PayoutsClient {
569
771
  * At-least-once by design: `since` is inclusive, so a resumed run re-reads the
570
772
  * row at your watermark. Dedupe on `id`.
571
773
  */
572
- async *eachEvent({ since, limit = 100, payoutId } = {}) {
774
+ async *eachEvent({ since, limit = 100, payoutId, type } = {}) {
573
775
  let cursor = since;
574
776
  for (;;) {
575
- const page = await this.listEvents({ since: cursor, limit, payoutId });
777
+ const page = await this.listEvents({ since: cursor, limit, payoutId, type });
576
778
  for (const event of page.data || []) yield event;
577
779
  if (!page.hasMore || !page.nextSince || page.nextSince === cursor) return;
578
780
  cursor = page.nextSince;
@@ -589,7 +791,13 @@ class PayoutsClient {
589
791
  * empty page with `hasMore` is a real state and swallowing it would rebuild
590
792
  * the silent-truncation bug the API was fixed to remove.
591
793
  */
592
- async *eachPayout({ limit = 50, status, endUserId, reference, updatedSince } = {}) {
794
+ async *eachPayout({
795
+ limit = 50,
796
+ status,
797
+ endUserId,
798
+ reference,
799
+ updatedSince,
800
+ } = {}) {
593
801
  let cursor;
594
802
  for (;;) {
595
803
  const page = await this.listPayouts({
@@ -601,7 +809,8 @@ class PayoutsClient {
601
809
  updatedSince,
602
810
  });
603
811
  for (const payout of page.data || []) yield payout;
604
- if (!page.hasMore || !page.nextCursor || page.nextCursor === cursor) return;
812
+ if (!page.hasMore || !page.nextCursor || page.nextCursor === cursor)
813
+ return;
605
814
  cursor = page.nextCursor;
606
815
  }
607
816
  }
@@ -609,8 +818,18 @@ class PayoutsClient {
609
818
  /**
610
819
  * How to fund a payout that came back with `requiresFunding: true`.
611
820
  *
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.
821
+ * Where to send, how much, on which network. Your funds stay in your wallet
822
+ * until you move them — we hold no key and cannot move them.
823
+ *
824
+ * A pure read: poll it as often as you like. `expiresAt` is normally `null`,
825
+ * because there is no countdown on the deposit address — the rail decides
826
+ * when an unfunded payout is over and reports that as the payout's own
827
+ * status. Before sending against instructions you fetched a while ago, read
828
+ * `getPayout()` rather than watching a clock.
829
+ *
830
+ * `signableOperations` is no longer returned here; it moved to
831
+ * a dashboard action: they are operations for the organization's own wallet,
832
+ * which an API key cannot sign.
614
833
  */
615
834
  getFunding(payoutId) {
616
835
  return this.request(
@@ -619,11 +838,7 @@ class PayoutsClient {
619
838
  );
620
839
  }
621
840
 
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
- */
841
+ /** Proof you sent the funds: the transaction hash you broadcast. */
627
842
  confirmFunding(payoutId, proof = {}) {
628
843
  return this.request(
629
844
  'POST',
@@ -633,12 +848,6 @@ class PayoutsClient {
633
848
  ...(proof.transactionHash
634
849
  ? { transactionHash: proof.transactionHash }
635
850
  : {}),
636
- ...(proof.signedOperations
637
- ? { signedOperations: proof.signedOperations }
638
- : {}),
639
- ...(proof.tamperProofSignature
640
- ? { tamperProofSignature: proof.tamperProofSignature }
641
- : {}),
642
851
  },
643
852
  idempotencyKey: proof.idempotencyKey || randomUUID(),
644
853
  },
@@ -677,12 +886,128 @@ class PayoutsClient {
677
886
  * declaration used to claim an array, so code written off it threw on the
678
887
  * first call.
679
888
  */
680
- listPayouts({ limit, cursor, status, endUserId, reference, updatedSince } = {}) {
889
+ listPayouts({
890
+ limit,
891
+ cursor,
892
+ status,
893
+ endUserId,
894
+ reference,
895
+ updatedSince,
896
+ } = {}) {
681
897
  return this.request('GET', `/payments/organizations/${this.orgId}/orders`, {
682
898
  query: { limit, cursor, status, endUserId, reference, updatedSince },
683
899
  });
684
900
  }
685
901
 
902
+ // ─── mass payouts ───
903
+
904
+ /**
905
+ * Submit up to 1,000 payouts as one run. Each item is exactly a `payout()`
906
+ * body (the wire shape — `expectDestination`, `maxDriftBps`).
907
+ *
908
+ * The `202` means RECEIVED, not paid: every line is validated first (nothing
909
+ * priced, nothing debited), then the valid lines become ordinary payouts.
910
+ * With `autoCommit: true` — the default — a run with zero validation errors
911
+ * proceeds straight to creation; otherwise the batch holds at
912
+ * `awaiting_confirmation` for `confirmPayoutBatch` or `cancelPayoutBatch`.
913
+ *
914
+ * The idempotency key covers the RUN: a submit loop that dies and resubmits
915
+ * the same file under the same key gets the same batch back — never a second
916
+ * payroll. Persist your own key before you send; the generated fallback only
917
+ * protects retries inside this process.
918
+ *
919
+ * `externalReferenceId` is required and unique per organization: it is the
920
+ * guard the key cannot be, against the same file re-run under a FRESH key.
921
+ * A repeat is refused with `PAYOUT_BATCH_DUPLICATE_REFERENCE` naming the
922
+ * original batch. Sent as-is; the server validates it, so a missing id is
923
+ * the server's 400, not a silent omission here.
924
+ */
925
+ createPayoutBatch(p = {}) {
926
+ return this.request(
927
+ 'POST',
928
+ `/payments/organizations/${this.orgId}/payouts/batches`,
929
+ {
930
+ body: {
931
+ externalReferenceId: p.externalReferenceId,
932
+ items: p.items,
933
+ ...(p.autoCommit !== undefined ? { autoCommit: p.autoCommit } : {}),
934
+ },
935
+ idempotencyKey: p.idempotencyKey || randomUUID(),
936
+ // Same meaning as on a single payout: "I know this run looks like the
937
+ // last one, and I mean it".
938
+ ...(p.allowDuplicate ? { headers: { 'x-allow-duplicate': 'true' } } : {}),
939
+ },
940
+ );
941
+ }
942
+
943
+ /** A PAGE of batches, newest first: `{ data, hasMore, nextCursor }`. */
944
+ listPayoutBatches({ status, externalReferenceId, limit, cursor } = {}) {
945
+ return this.request(
946
+ 'GET',
947
+ `/payments/organizations/${this.orgId}/payouts/batches`,
948
+ { query: { status, externalReferenceId, limit, cursor } },
949
+ );
950
+ }
951
+
952
+ /**
953
+ * One run, with its counts. A batch tracks CREATION, not settlement:
954
+ * `completed` means every line either became a payout or was refused. Once a
955
+ * line is `created`, watch the payout, not the item.
956
+ */
957
+ getPayoutBatch(batchId) {
958
+ return this.request(
959
+ 'GET',
960
+ `/payments/organizations/${this.orgId}/payouts/batches/${encodeURIComponent(batchId)}`,
961
+ );
962
+ }
963
+
964
+ /**
965
+ * The lines of a run, in submitted order, with the instruction you sent
966
+ * echoed back verbatim — join errors to your own file by content, not by
967
+ * counting rows. `status: 'invalid'` is the review screen; `'created'` joins
968
+ * the run to the payout ledger; `'requires_review'` are lines whose outcome
969
+ * could not be established and were deliberately NOT retried — contact
970
+ * support with the batchId rather than resubmitting them.
971
+ *
972
+ * The CSV export (`?format=csv`) is a plain file download, not a JSON page —
973
+ * fetch it over HTTP directly. This method returns the JSON page.
974
+ */
975
+ listPayoutBatchItems(batchId, { status, limit, cursor } = {}) {
976
+ return this.request(
977
+ 'GET',
978
+ `/payments/organizations/${this.orgId}/payouts/batches/${encodeURIComponent(batchId)}/items`,
979
+ { query: { status, limit, cursor } },
980
+ );
981
+ }
982
+
983
+ /**
984
+ * Proceed with the valid lines of a held run. Only legal while the batch is
985
+ * `awaiting_confirmation`; anything else is `PAYOUT_BATCH_NOT_CONFIRMABLE`,
986
+ * naming where it actually is. Invalid lines stay refused — correct them and
987
+ * resubmit as a new batch.
988
+ */
989
+ confirmPayoutBatch(batchId, { idempotencyKey } = {}) {
990
+ return this.request(
991
+ 'POST',
992
+ `/payments/organizations/${this.orgId}/payouts/batches/${encodeURIComponent(batchId)}/confirm`,
993
+ { idempotencyKey: idempotencyKey || randomUUID() },
994
+ );
995
+ }
996
+
997
+ /**
998
+ * Stop a run before any payout exists. Honoured in `received`, `validating`
999
+ * and `awaiting_confirmation` only; once creation begins the run is
1000
+ * committed and this is `PAYOUT_BATCH_NOT_CANCELABLE` — cancel individual
1001
+ * payouts while they are still `pending` via `cancelPayout` instead.
1002
+ */
1003
+ cancelPayoutBatch(batchId, { idempotencyKey } = {}) {
1004
+ return this.request(
1005
+ 'POST',
1006
+ `/payments/organizations/${this.orgId}/payouts/batches/${encodeURIComponent(batchId)}/cancel`,
1007
+ { idempotencyKey: idempotencyKey || randomUUID() },
1008
+ );
1009
+ }
1010
+
686
1011
  /** Sandbox only: credit the test balance so you can send. */
687
1012
  /**
688
1013
  * Register a sandbox webhook endpoint and get its signing secret.
@@ -691,19 +1016,61 @@ class PayoutsClient {
691
1016
  * this returns it rather than storing it for you. Live endpoints are managed
692
1017
  * from the dashboard on purpose: a credential that could repoint its own
693
1018
  * webhook URL could redirect every payout notification.
1019
+ *
1020
+ * No `Idempotency-Key`, and so no retry: the server ignores the header here
1021
+ * because a stored replay would keep the secret for seven days. A timeout
1022
+ * means "register again"; an extra endpoint is a delete, a leaked secret is
1023
+ * not.
694
1024
  */
695
- createWebhookEndpoint({ url, events, idempotencyKey } = {}) {
696
- return this.request(
1025
+ createWebhookEndpoint({ url, events } = {}) {
1026
+ return this._send(
697
1027
  'POST',
698
1028
  `/payments/organizations/${this.orgId}/sandbox/webhook-endpoints`,
699
- {
700
- body: { url, ...(events ? { events } : {}) },
701
- idempotencyKey: idempotencyKey || randomUUID(),
702
- },
1029
+ { body: { url, ...(events ? { events } : {}) } },
1030
+ );
1031
+ }
1032
+
1033
+ /**
1034
+ * The webhook endpoints registered for your organization.
1035
+ *
1036
+ * Read-only, and it stays that way. Registering, pausing, deleting and
1037
+ * replaying are dashboard actions: a credential that could repoint its own
1038
+ * webhook URL could quietly redirect every payout notification you receive.
1039
+ * The signing secret is never returned here — you are shown it once, when the
1040
+ * endpoint is created.
1041
+ *
1042
+ * Note the path: `/organizations/{orgId}/…`, not the `/payments/…` prefix the
1043
+ * payout routes use.
1044
+ */
1045
+ webhookEndpoints() {
1046
+ return this.request(
1047
+ 'GET',
1048
+ `/organizations/${this.orgId}/webhook-endpoints`,
703
1049
  );
704
1050
  }
705
1051
 
706
- /** What we sent, what came back, and what we retried. */
1052
+ /**
1053
+ * The 50 most recent delivery attempts for one endpoint, newest first —
1054
+ * "did you send me that event", without opening a dashboard.
1055
+ *
1056
+ * `eventId` is the `svix-id` we sent, so it is what your dedupe keys on.
1057
+ * `lastError` is the STATUS your server answered with on the last failed
1058
+ * attempt (`HTTP 500`) or the transport error, never your response body — we
1059
+ * do not store one. `nextAttemptAt` is when we try again; retries back off
1060
+ * over roughly 70 hours, after which only the event feed still has it.
1061
+ * Payloads are not returned.
1062
+ *
1063
+ * This is the endpoint from `webhookEndpoints()`. The sandbox registration
1064
+ * view is `webhookDeliveries()`, below.
1065
+ */
1066
+ webhookEndpointDeliveries(endpointId) {
1067
+ return this.request(
1068
+ 'GET',
1069
+ `/organizations/${this.orgId}/webhook-endpoints/${encodeURIComponent(endpointId)}/deliveries`,
1070
+ );
1071
+ }
1072
+
1073
+ /** What we sent, what came back, and what we retried. Sandbox endpoints. */
707
1074
  webhookDeliveries(endpointId) {
708
1075
  return this.request(
709
1076
  'GET',
@@ -711,6 +1078,84 @@ class PayoutsClient {
711
1078
  );
712
1079
  }
713
1080
 
1081
+ /**
1082
+ * Payouts and batch runs waiting on your approvers: `{ data }`.
1083
+ *
1084
+ * When your organization requires M-of-N approval on API payouts, `payout()`
1085
+ * and `confirmPayoutBatch()` above the threshold answer 202 with
1086
+ * `{ status: 'pending_approval', approvalId, … }` instead of a payout. This is
1087
+ * that queue. Approving and rejecting are human actions in the dashboard and
1088
+ * deliberately have no method here — a key cannot approve.
1089
+ */
1090
+ listApprovals({ status, limit } = {}) {
1091
+ return this.request(
1092
+ 'GET',
1093
+ `/payments/organizations/${this.orgId}/payouts/approvals`,
1094
+ { query: { status, limit } },
1095
+ );
1096
+ }
1097
+
1098
+ /**
1099
+ * One approval, by the `approvalId` a 202 gave you. Once `status` is
1100
+ * `executed`, `payoutId` is the payout it became.
1101
+ */
1102
+ getApproval(approvalId) {
1103
+ return this.request(
1104
+ 'GET',
1105
+ `/payments/organizations/${this.orgId}/payouts/approvals/${encodeURIComponent(approvalId)}`,
1106
+ );
1107
+ }
1108
+
1109
+ /**
1110
+ * A PAGE of audit events, newest first: `{ data, hasMore, nextCursor }`.
1111
+ *
1112
+ * Who did what, from where, with which credential — every audited mutation on
1113
+ * your organization, refused attempts included. Rows carry the key PREFIX you
1114
+ * named it by, never the internal id. A read-only key may read this.
1115
+ */
1116
+ listAuditEvents({
1117
+ cursor,
1118
+ limit,
1119
+ action,
1120
+ resourceId,
1121
+ apiKey,
1122
+ actorUserId,
1123
+ createdAfter,
1124
+ createdBefore,
1125
+ } = {}) {
1126
+ return this.request(
1127
+ 'GET',
1128
+ `/payments/organizations/${this.orgId}/audit-events`,
1129
+ {
1130
+ query: {
1131
+ cursor,
1132
+ limit,
1133
+ action,
1134
+ resourceId,
1135
+ apiKey,
1136
+ actorUserId,
1137
+ createdAfter,
1138
+ createdBefore,
1139
+ },
1140
+ },
1141
+ );
1142
+ }
1143
+
1144
+ /**
1145
+ * Every audit event matching a filter, newest first, paged for you. Same
1146
+ * stop rule as `eachPayout`: ends when the server says there is no more, or
1147
+ * when the cursor stops advancing.
1148
+ */
1149
+ async *eachAuditEvent({ limit = 100, ...filters } = {}) {
1150
+ let cursor;
1151
+ for (;;) {
1152
+ const page = await this.listAuditEvents({ ...filters, limit, cursor });
1153
+ for (const row of page.data || []) yield row;
1154
+ if (!page.hasMore || !page.nextCursor || page.nextCursor === cursor) return;
1155
+ cursor = page.nextCursor;
1156
+ }
1157
+ }
1158
+
714
1159
  /**
715
1160
  * Mint a one-time link for the person being paid.
716
1161
  *
@@ -741,18 +1186,60 @@ class PayoutsClient {
741
1186
  }
742
1187
 
743
1188
  /**
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.
1189
+ * A PAGE of balance transactions, newest first: `{ data, hasMore, nextCursor }`.
1190
+ *
1191
+ * Every change to what you can spend funding, payouts, returns, holds and
1192
+ * their release, adjustments — each with `balanceAfter`. Reconcile your
1193
+ * ledger row by row against this; `id` is the cursor and the dedupe key.
1194
+ * `type` takes a string (`'payout,funding'`) or an array.
1195
+ *
1196
+ * Served on every environment; an empty page means no rows yet, not an
1197
+ * error.
747
1198
  */
748
- balanceHistory(limit) {
1199
+ listBalanceTransactions({
1200
+ cursor,
1201
+ limit,
1202
+ type,
1203
+ orderId,
1204
+ currency,
1205
+ createdAfter,
1206
+ createdBefore,
1207
+ } = {}) {
749
1208
  return this.request(
750
1209
  'GET',
751
- `/payments/organizations/${this.orgId}/balance/history`,
752
- { query: { limit } },
1210
+ `/payments/organizations/${this.orgId}/balance_transactions`,
1211
+ {
1212
+ query: {
1213
+ cursor,
1214
+ limit,
1215
+ type: Array.isArray(type) ? type.join(',') : type,
1216
+ orderId,
1217
+ currency,
1218
+ createdAfter,
1219
+ createdBefore,
1220
+ },
1221
+ },
753
1222
  );
754
1223
  }
755
1224
 
1225
+ /**
1226
+ * Every balance transaction matching a filter, newest first, paged for you.
1227
+ *
1228
+ * for await (const t of avvio.eachBalanceTransaction({ type: 'payout' })) { … }
1229
+ *
1230
+ * Same stop rule as `eachPayout`: ends when the server says there is no
1231
+ * more, or when the cursor stops advancing.
1232
+ */
1233
+ async *eachBalanceTransaction({ limit = 100, ...filters } = {}) {
1234
+ let cursor;
1235
+ for (;;) {
1236
+ const page = await this.listBalanceTransactions({ ...filters, limit, cursor });
1237
+ for (const row of page.data || []) yield row;
1238
+ if (!page.hasMore || !page.nextCursor || page.nextCursor === cursor) return;
1239
+ cursor = page.nextCursor;
1240
+ }
1241
+ }
1242
+
756
1243
  fund(amount = '5000.00', idempotencyKey) {
757
1244
  return this.request(
758
1245
  'POST',
@@ -761,14 +1248,24 @@ class PayoutsClient {
761
1248
  );
762
1249
  }
763
1250
 
764
- /** What you can currently send. */
765
- balance() {
1251
+ /**
1252
+ * What your organization is bound by, read live: payout caps (`null` = no
1253
+ * cap), the approval threshold and M, effective features, rate limits per
1254
+ * minute, idempotency windows, the currencies that need a `purposeOfPayment`,
1255
+ * and where to read corridors, events and the ledger. Read it first.
1256
+ */
1257
+ getPolicy() {
766
1258
  return this.request(
767
1259
  'GET',
768
- `/payments/organizations/${this.orgId}/balance`,
1260
+ `/payments/organizations/${this.orgId}/policy`,
769
1261
  );
770
1262
  }
771
1263
 
1264
+ /** What you can currently send. */
1265
+ balance() {
1266
+ return this.request('GET', `/payments/organizations/${this.orgId}/balance`);
1267
+ }
1268
+
772
1269
  /** Where to wire funds to top up the balance payouts debit. */
773
1270
  fundingAccounts() {
774
1271
  return this.request(
@@ -789,7 +1286,28 @@ const {
789
1286
  WebhookVerificationError,
790
1287
  } = require('./webhooks');
791
1288
 
1289
+ /**
1290
+ * Generate a signing pair. The private half is yours and must never be sent to
1291
+ * us — register `publicKeyPem` on the key, keep `privateKeyPem` in your secret
1292
+ * store, and pass it to the client.
1293
+ *
1294
+ * Here rather than in the docs as an openssl incantation, because a partner who
1295
+ * has to look up `openssl ecparam` is a partner who might paste the wrong half.
1296
+ */
1297
+ function generateSigningKeyPair() {
1298
+ const { publicKey, privateKey } = generateKeyPairSync('ec', {
1299
+ namedCurve: 'prime256v1',
1300
+ });
1301
+ return {
1302
+ publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
1303
+ privateKeyPem: privateKey
1304
+ .export({ type: 'pkcs8', format: 'pem' })
1305
+ .toString(),
1306
+ };
1307
+ }
1308
+
792
1309
  module.exports = {
1310
+ generateSigningKeyPair,
793
1311
  PayoutsClient,
794
1312
  PayoutsError,
795
1313
  stableKey,