@mandateops/audit 0.0.0-stage → 0.2.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.
@@ -0,0 +1,594 @@
1
+ import { CREATED_AT, CREATED_UNIX, undocumented } from "./common.js";
2
+ /** The currencies these formats are tried with; any other is refused. */
3
+ const CURRENCIES = [
4
+ "AUD",
5
+ "BHD",
6
+ "BRL",
7
+ "CAD",
8
+ "CHF",
9
+ "DKK",
10
+ "EUR",
11
+ "GBP",
12
+ "GHS",
13
+ "HKD",
14
+ "HUF",
15
+ "INR",
16
+ "JPY",
17
+ "KES",
18
+ "KRW",
19
+ "KWD",
20
+ "MXN",
21
+ "NGN",
22
+ "NOK",
23
+ "NZD",
24
+ "OMR",
25
+ "PLN",
26
+ "SEK",
27
+ "SGD",
28
+ "TWD",
29
+ "USD",
30
+ "ZAR",
31
+ ];
32
+ /** ISO 4217 minor digits, as the runtime's own currency data states them. */
33
+ const minorDigitsOf = (currency) => new Intl.NumberFormat("en", {
34
+ style: "currency",
35
+ currency,
36
+ }).resolvedOptions().maximumFractionDigits ?? 0;
37
+ const CURRENCY_DIGITS = Object.fromEntries(CURRENCIES.map((currency) => [currency, minorDigitsOf(currency)]));
38
+ const DECIMAL = { scaleTerm: "currency", scales: CURRENCY_DIGITS };
39
+ /**
40
+ * `POST /transfer`, from Paystack's published OpenAPI description. The amount
41
+ * is in kobo or pesewas, and a reference is lowercase alphanumeric with `-` and
42
+ * `_`. Reuse of a reference is unverified: its docs both reject one and advise
43
+ * retrying with it. `currency` is optional there, defaulting to NGN.
44
+ */
45
+ export const paystack = (terms) => ({
46
+ path: "/transfer",
47
+ key: { field: "reference", shape: "lowercase_slug" },
48
+ fields: {
49
+ amount: "amount",
50
+ currency: "currency",
51
+ destination: "recipient",
52
+ },
53
+ defaults: { currency: "NGN" },
54
+ fixed: terms,
55
+ replies: {
56
+ paid: {
57
+ status: 200,
58
+ body: {
59
+ status: true,
60
+ message: "Transfer has been queued",
61
+ data: {
62
+ amount: "{{amount:number}}",
63
+ currency: "{{currency}}",
64
+ reference: "{{key}}",
65
+ source: "balance",
66
+ status: "success",
67
+ transfer_code: "TRF_{{effectId}}",
68
+ },
69
+ },
70
+ },
71
+ declined: {
72
+ status: 400,
73
+ body: { status: false, message: "Transfer declined" },
74
+ },
75
+ conflict: {
76
+ status: 400,
77
+ body: { status: false, message: "Duplicate Transaction Reference" },
78
+ },
79
+ },
80
+ });
81
+ const stripe = (path, terms, paid) => ({
82
+ path,
83
+ encoding: "form",
84
+ key: { header: "idempotency-key", maxLength: 255 },
85
+ fields: {
86
+ amount: "amount",
87
+ currency: "currency",
88
+ destination: "destination",
89
+ },
90
+ fixed: terms,
91
+ replies: {
92
+ paid: {
93
+ status: 200,
94
+ body: {
95
+ amount: "{{amount:number}}",
96
+ created: CREATED_UNIX,
97
+ currency: "{{currency}}",
98
+ livemode: false,
99
+ ...paid,
100
+ },
101
+ },
102
+ // The status of both failures is unverified.
103
+ declined: {
104
+ status: 400,
105
+ body: {
106
+ error: {
107
+ type: "invalid_request_error",
108
+ code: "balance_insufficient",
109
+ message: "Insufficient funds",
110
+ },
111
+ },
112
+ },
113
+ conflict: {
114
+ status: 400,
115
+ body: {
116
+ error: {
117
+ type: "idempotency_error",
118
+ message: "Keys can only be used with the same parameters",
119
+ },
120
+ },
121
+ },
122
+ },
123
+ });
124
+ /** `POST /v1/payouts`. A request without `destination` is refused here. */
125
+ export const stripePayouts = (terms) => stripe("/v1/payouts", terms, {
126
+ id: "po_{{effectId}}",
127
+ object: "payout",
128
+ arrival_date: CREATED_UNIX,
129
+ automatic: false,
130
+ method: "standard",
131
+ reconciliation_status: "not_applicable",
132
+ source_type: "card",
133
+ status: "pending",
134
+ type: "bank_account",
135
+ });
136
+ /** `POST /v1/transfers`, to a connected account. */
137
+ export const stripeTransfers = (terms) => stripe("/v1/transfers", terms, {
138
+ id: "tr_{{effectId}}",
139
+ object: "transfer",
140
+ amount_reversed: 0,
141
+ destination: "{{destination}}",
142
+ metadata: {},
143
+ reversals: { object: "list", data: [], has_more: false },
144
+ reversed: false,
145
+ });
146
+ /**
147
+ * `POST /v3/transfers`. Keyed on the unique `reference`, as v3 coverage of
148
+ * `X-Idempotency-Key` and v3 reference reuse are unverified. The amount is a
149
+ * required integer whose unit is not stated, though samples look major; it is
150
+ * read as given. A synchronous refusal is unverified. The destination is
151
+ * `bank_account`, so a run authorises the two joined.
152
+ */
153
+ export const flutterwave = (terms) => ({
154
+ path: "/v3/transfers",
155
+ key: { field: "reference" },
156
+ fields: { amount: "amount", currency: "currency" },
157
+ joined: { destination: ["account_bank", "account_number"] },
158
+ fixed: terms,
159
+ replies: {
160
+ paid: {
161
+ status: 200,
162
+ body: {
163
+ status: "success",
164
+ message: "Transfer Queued Successfully",
165
+ data: {
166
+ id: "{{effectId}}",
167
+ currency: "{{currency}}",
168
+ amount: "{{amount:number}}",
169
+ status: "NEW",
170
+ reference: "{{key}}",
171
+ },
172
+ },
173
+ },
174
+ declined: undocumented(400),
175
+ conflict: undocumented(400),
176
+ },
177
+ });
178
+ /**
179
+ * `POST /transfers` of the Transfers API, to a transfer instrument. A refusal
180
+ * is a 202, as the published specification shows, and 422 is its validation
181
+ * status. A processed retry gets the first response; a reused key with a
182
+ * different body is unverified.
183
+ */
184
+ export const adyen = (terms) => ({
185
+ path: "/transfers",
186
+ credential: { header: "x-api-key", prefix: "" },
187
+ key: { header: "idempotency-key" },
188
+ fields: {
189
+ account: "balanceAccountId",
190
+ amount: "amount.value",
191
+ currency: "amount.currency",
192
+ destination: "counterparty.transferInstrumentId",
193
+ },
194
+ fixed: terms,
195
+ replies: {
196
+ paid: {
197
+ status: 202,
198
+ body: {
199
+ id: "{{effectId}}",
200
+ amount: { value: "{{amount:number}}", currency: "{{currency}}" },
201
+ category: "bank",
202
+ counterparty: { transferInstrumentId: "{{destination}}" },
203
+ status: "authorised",
204
+ },
205
+ },
206
+ declined: {
207
+ status: 202,
208
+ body: {
209
+ amount: { value: "{{amount:number}}", currency: "{{currency}}" },
210
+ category: "bank",
211
+ counterparty: { transferInstrumentId: "{{destination}}" },
212
+ status: "refused",
213
+ reason: "notEnoughBalance",
214
+ },
215
+ },
216
+ conflict: undocumented(422),
217
+ },
218
+ });
219
+ /**
220
+ * `POST /transfers` between entities; the sample names no currency. Idempotency
221
+ * is documented for this route: only 2xx replies are cached, and a concurrent
222
+ * repeat gets 409. The field names come from non-canonical sources, and the
223
+ * amount's unit, the credential scheme and all failures are unverified.
224
+ */
225
+ export const checkout = (terms) => ({
226
+ path: "/transfers",
227
+ key: { header: "cko-idempotency-key" },
228
+ fields: {
229
+ account: "source.id",
230
+ amount: "source.amount",
231
+ destination: "destination.id",
232
+ },
233
+ fixed: terms,
234
+ replies: {
235
+ paid: {
236
+ status: 201,
237
+ body: {
238
+ id: "tra_{{effectId}}",
239
+ status: "pending",
240
+ _links: { self: { href: "/transfers/tra_{{effectId}}" } },
241
+ },
242
+ },
243
+ declined: undocumented(422),
244
+ conflict: undocumented(422),
245
+ },
246
+ });
247
+ /** PayPal writes these whole, whatever ISO 4217 says. */
248
+ const PAYPAL_DIGITS = { ...CURRENCY_DIGITS, HUF: 0, TWD: 0 };
249
+ /**
250
+ * `POST /v1/payments/payouts`, one item per batch. An email receiver is not an
251
+ * identifier the lab can hold. A repeated batch id is rejected with a link to
252
+ * the original batch, within 30 days; that status is unverified. Insufficient
253
+ * funds is documented per item, so a synchronous refusal is unverified.
254
+ */
255
+ export const paypal = (terms) => ({
256
+ path: "/v1/payments/payouts",
257
+ key: { field: "sender_batch_header.sender_batch_id" },
258
+ singleEntry: ["items"],
259
+ fields: {
260
+ amount: "items.0.amount.value",
261
+ currency: "items.0.amount.currency",
262
+ destination: "items.0.receiver",
263
+ },
264
+ fixed: terms,
265
+ amount: { scaleTerm: "currency", scales: PAYPAL_DIGITS },
266
+ replay: "duplicate_reply",
267
+ replies: {
268
+ paid: {
269
+ status: 201,
270
+ body: {
271
+ batch_header: {
272
+ payout_batch_id: "{{effectId}}",
273
+ batch_status: "PENDING",
274
+ sender_batch_header: { sender_batch_id: "{{key}}" },
275
+ time_created: CREATED_AT,
276
+ },
277
+ links: [],
278
+ },
279
+ },
280
+ declined: undocumented(400),
281
+ conflict: undocumented(400),
282
+ duplicate: {
283
+ status: 400,
284
+ body: {
285
+ links: [{ href: "/v1/payments/payouts/{{effectId}}", rel: "self" }],
286
+ },
287
+ },
288
+ },
289
+ });
290
+ /**
291
+ * `POST /v1/payouts`. A repeated key returns the first response; a changed body
292
+ * is unverified. Low balance is a payout state, so a refusal is a reply whose
293
+ * status says so, and every HTTP status here is unverified.
294
+ */
295
+ export const razorpayx = (terms) => ({
296
+ path: "/v1/payouts",
297
+ credential: { basic: true },
298
+ key: { header: "x-payout-idempotency" },
299
+ fields: {
300
+ account: "account_number",
301
+ amount: "amount",
302
+ currency: "currency",
303
+ destination: "fund_account_id",
304
+ },
305
+ fixed: terms,
306
+ replies: {
307
+ paid: {
308
+ status: 200,
309
+ body: {
310
+ id: "pout_{{effectId}}",
311
+ entity: "payout",
312
+ amount: "{{amount:number}}",
313
+ currency: "{{currency}}",
314
+ status: "queued",
315
+ },
316
+ },
317
+ declined: {
318
+ status: 200,
319
+ body: {
320
+ id: "pout_{{payment}}",
321
+ entity: "payout",
322
+ amount: "{{amount:number}}",
323
+ currency: "{{currency}}",
324
+ status: "rejected",
325
+ },
326
+ },
327
+ conflict: undocumented(400),
328
+ },
329
+ });
330
+ const HAL_JSON = { "content-type": "application/hal+json" };
331
+ const omitCurrency = ({ currency: _currency, ...rest }) => rest;
332
+ const mollieError = (status, detail) => ({
333
+ status,
334
+ body: { status, title: detail, detail },
335
+ headers: HAL_JSON,
336
+ });
337
+ /**
338
+ * `POST /v2/payouts`, to the balance's configured bank account. `balanceId` is
339
+ * optional; `account` is what its absence means. Omitting `amount` pays out the
340
+ * whole balance, which the suite states as `wholeBalance`. The reply's
341
+ * `statusReason` is left out, as its codes are unverified.
342
+ */
343
+ export const molliePayouts = ({ account, wholeBalance, ...terms }) => ({
344
+ path: "/v2/payouts",
345
+ key: { header: "idempotency-key" },
346
+ fields: {
347
+ account: "balanceId",
348
+ amount: "amount.value",
349
+ currency: "amount.currency",
350
+ },
351
+ defaults: { account, currency: terms.currency },
352
+ amountDefault: wholeBalance,
353
+ fixed: omitCurrency(terms),
354
+ amount: DECIMAL,
355
+ replies: {
356
+ paid: {
357
+ status: 201,
358
+ headers: HAL_JSON,
359
+ body: {
360
+ resource: "payout",
361
+ id: "{{effectId}}",
362
+ status: "requested",
363
+ createdAt: CREATED_AT,
364
+ mode: "test",
365
+ },
366
+ },
367
+ declined: mollieError(422, "Insufficient funds"),
368
+ conflict: mollieError(400, "Idempotency key reused"),
369
+ },
370
+ });
371
+ /**
372
+ * `POST /api/merchants/{merchantId}/payouts`, one payout per request. Failures
373
+ * are documented as `{ title, message, code }`; which code fits each refusal is
374
+ * unverified, so their bodies stay empty. The merchant is the account, and the
375
+ * destination is `branchCode--accountNumber`. A payout without `payoutId` has no
376
+ * idempotency, so each such request is its own payment.
377
+ */
378
+ export const peach = (terms) => ({
379
+ path: "/api/merchants/:merchantId/payouts",
380
+ key: { field: "payouts.0.payoutId", optional: true },
381
+ singleEntry: ["payouts"],
382
+ fields: {
383
+ account: ":merchantId",
384
+ amount: "payouts.0.amount",
385
+ currency: "payouts.0.currency",
386
+ },
387
+ joined: {
388
+ destination: ["payouts.0.branchCode", "payouts.0.accountNumber"],
389
+ },
390
+ fixed: terms,
391
+ replay: "duplicate_reply",
392
+ replies: {
393
+ paid: {
394
+ status: 201,
395
+ body: {
396
+ payoutRequestId: "{{effectId}}",
397
+ created: CREATED_AT,
398
+ payouts: [
399
+ { payoutId: "{{key}}", status: "pending", created: CREATED_AT },
400
+ ],
401
+ },
402
+ },
403
+ declined: undocumented(400),
404
+ conflict: undocumented(400),
405
+ duplicate: undocumented(400),
406
+ },
407
+ });
408
+ /**
409
+ * `POST /v2/disbursements`, ZAR only. The amount is read as decimal, and a
410
+ * repeated nonce gets 409. Insufficient funds is documented as a later `error`
411
+ * status, so a refusal is a reply in that status. The destination is
412
+ * `bankId--accountNumber`.
413
+ */
414
+ export const stitch = (terms) => ({
415
+ path: "/v2/disbursements",
416
+ key: { field: "nonce" },
417
+ fields: {
418
+ amount: "amount.quantity",
419
+ currency: "amount.currency",
420
+ },
421
+ joined: { destination: ["beneficiary.bankId", "beneficiary.accountNumber"] },
422
+ fixed: terms,
423
+ amount: { scaleTerm: "currency", scales: { ZAR: 2 } },
424
+ replay: "duplicate_reply",
425
+ replies: {
426
+ paid: {
427
+ status: 201,
428
+ body: { id: "{{effectId}}", status: "pending", createdAt: CREATED_AT },
429
+ },
430
+ declined: {
431
+ status: 201,
432
+ body: { id: "{{payment}}", status: "error", createdAt: CREATED_AT },
433
+ },
434
+ conflict: undocumented(409),
435
+ duplicate: undocumented(409),
436
+ },
437
+ });
438
+ /**
439
+ * `POST /pay`, from `account_id` to a counterparty's account, per Revolut's
440
+ * OpenAPI description. A reused `request_id` makes no second transaction and
441
+ * answers the documented 400 for two weeks. The id is at most 40 characters.
442
+ */
443
+ export const revolut = (terms) => {
444
+ const duplicate = {
445
+ status: 400,
446
+ body: { code: 3020, message: "Duplicate request: {{key}}" },
447
+ };
448
+ return {
449
+ path: "/pay",
450
+ key: { field: "request_id", maxLength: 40 },
451
+ fields: {
452
+ account: "account_id",
453
+ amount: "amount",
454
+ currency: "currency",
455
+ destination: "receiver.account_id",
456
+ },
457
+ fixed: terms,
458
+ amount: DECIMAL,
459
+ replay: "duplicate_reply",
460
+ replies: {
461
+ paid: {
462
+ status: 200,
463
+ body: { id: "{{effectId}}", state: "pending", created_at: CREATED_AT },
464
+ },
465
+ declined: undocumented(400),
466
+ conflict: duplicate,
467
+ duplicate,
468
+ },
469
+ };
470
+ };
471
+ /**
472
+ * `POST /api/payment_orders`. A key is at most 180 characters, and only
473
+ * successes are cached, with a concurrent repeat getting 409. The reply holds a
474
+ * few of the object's fields; its status, a changed body and the failures are
475
+ * unverified. `currency` is optional
476
+ * there, taken from the originating account, so pass it in `defaults`.
477
+ */
478
+ export const modernTreasury = (terms, defaults = {}) => ({
479
+ path: "/api/payment_orders",
480
+ credential: { basic: true },
481
+ key: { header: "idempotency-key", maxLength: 180 },
482
+ fields: {
483
+ account: "originating_account_id",
484
+ amount: "amount",
485
+ currency: "currency",
486
+ destination: "receiving_account_id",
487
+ },
488
+ defaults,
489
+ fixed: terms,
490
+ replies: {
491
+ paid: {
492
+ status: 201,
493
+ body: {
494
+ id: "{{effectId}}",
495
+ object: "payment_order",
496
+ amount: "{{amount:number}}",
497
+ currency: "{{currency}}",
498
+ direction: "credit",
499
+ status: "approved",
500
+ },
501
+ },
502
+ declined: undocumented(422),
503
+ conflict: undocumented(422),
504
+ },
505
+ });
506
+ /**
507
+ * `POST /api/v1/transfers/create`. The amount is a string in the schema and a
508
+ * number in the example; either is read, as decimal, which is unverified, as
509
+ * is the `transfer_amount` name. Both failure codes are from Airwallex's
510
+ * transfer error list.
511
+ */
512
+ export const airwallex = (terms) => {
513
+ const duplicate = {
514
+ status: 400,
515
+ body: { code: "duplicate_request_id", message: "duplicate_request_id" },
516
+ };
517
+ return {
518
+ path: "/api/v1/transfers/create",
519
+ key: { field: "request_id" },
520
+ fields: {
521
+ amount: "transfer_amount",
522
+ currency: "transfer_currency",
523
+ destination: "beneficiary_id",
524
+ },
525
+ fixed: terms,
526
+ amount: DECIMAL,
527
+ replay: "duplicate_reply",
528
+ replies: {
529
+ paid: {
530
+ status: 201,
531
+ body: {
532
+ id: "{{effectId}}",
533
+ request_id: "{{key}}",
534
+ status: "NEW",
535
+ created_at: CREATED_AT,
536
+ },
537
+ },
538
+ declined: {
539
+ status: 400,
540
+ body: { code: "balance_insufficient", message: "balance_insufficient" },
541
+ },
542
+ conflict: duplicate,
543
+ duplicate,
544
+ },
545
+ };
546
+ };
547
+ const DWOLLA_FUNDING = "/funding-sources/";
548
+ /**
549
+ * `POST /transfers`, answered by a `Location`, relative so a client following
550
+ * it stays in the lab. A repeated key gets 201 with the same `Location`; a 409
551
+ * there means the original is still processing, so it is not used for a changed
552
+ * body, whose status is unverified, as are failure bodies.
553
+ */
554
+ export const dwolla = (terms) => {
555
+ const location = { location: "/transfers/{{effectId}}" };
556
+ return {
557
+ path: "/transfers",
558
+ jsonType: "application/vnd.dwolla.v1.hal+json",
559
+ key: { header: "idempotency-key" },
560
+ fields: {
561
+ account: "_links.source.href",
562
+ amount: "amount.value",
563
+ currency: "amount.currency",
564
+ destination: "_links.destination.href",
565
+ },
566
+ urlFields: { account: DWOLLA_FUNDING, destination: DWOLLA_FUNDING },
567
+ fixed: terms,
568
+ amount: DECIMAL,
569
+ replies: {
570
+ paid: { status: 201, body: null, headers: location },
571
+ declined: undocumented(400),
572
+ conflict: undocumented(400),
573
+ },
574
+ };
575
+ };
576
+ const WISE_ROUTES = {
577
+ quotes: "/v3/profiles/:profileId/quotes",
578
+ transfers: "/v1/transfers",
579
+ fund: "/v3/profiles/:profileId/transfers/:transferId/payments",
580
+ };
581
+ /**
582
+ * Quote, transfer, then fund, from Wise's API reference. Only the fund call
583
+ * moves money. The account is the quote's profile, the currency its source and
584
+ * the asset its target; `customerTransactionId` is the key, and a repeat makes
585
+ * no duplicate, though its reply body and quote reuse are unverified. Wise also
586
+ * serves dated bases such as `/2026Q4`, so a client may override the routes.
587
+ * Recipient creation is not imitated.
588
+ */
589
+ export const wise = (terms, routes = WISE_ROUTES) => ({
590
+ flow: "wise",
591
+ fixed: terms,
592
+ scales: CURRENCY_DIGITS,
593
+ routes,
594
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Provider formats the stand-in can speak, so a customer's provider client can
3
+ * run against it within the subset `contracts/synthetic-provider.md` states per
4
+ * format. Each imitates one payout call's shape; none of them establishes how
5
+ * that provider behaves.
6
+ */
7
+ export { bridge, circleMint, fireblocks } from "./crypto.js";
8
+ export { adyen, airwallex, checkout, dwolla, flutterwave, modernTreasury, molliePayouts, paypal, paystack, peach, razorpayx, revolut, stitch, stripePayouts, stripeTransfers, wise, } from "./fiat.js";
9
+ export type * from "./types.js";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Provider formats the stand-in can speak, so a customer's provider client can
3
+ * run against it within the subset `contracts/synthetic-provider.md` states per
4
+ * format. Each imitates one payout call's shape; none of them establishes how
5
+ * that provider behaves.
6
+ */
7
+ export { bridge, circleMint, fireblocks } from "./crypto.js";
8
+ export { adyen, airwallex, checkout, dwolla, flutterwave, modernTreasury, molliePayouts, paypal, paystack, peach, razorpayx, revolut, stitch, stripePayouts, stripeTransfers, wise, } from "./fiat.js";