@kuvarpay/sdk 1.4.2 → 1.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/README.md +132 -12
- package/index.d.ts +202 -5
- package/index.js +485 -44
- package/index.mjs +430 -35
- package/package.json +4 -1
package/index.js
CHANGED
|
@@ -15,6 +15,46 @@ const DEFAULT_BASE = (process.env.KUVARPAY_API_BASE_URL
|
|
|
15
15
|
|| 'https://payment.kuvarpay.com'
|
|
16
16
|
).replace(/\/$/, '');
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Decode an ISO-3166 alpha-2 code from a flag emoji. Flags are two regional
|
|
20
|
+
* indicator symbols (U+1F1E6 = 'A'), so this is an exact inverse, not a guess.
|
|
21
|
+
*/
|
|
22
|
+
function iso2FromFlag(flag) {
|
|
23
|
+
if (typeof flag !== 'string') return null;
|
|
24
|
+
const points = Array.from(flag).map((ch) => ch.codePointAt(0));
|
|
25
|
+
if (points.length !== 2) return null;
|
|
26
|
+
const letters = points.map((cp) =>
|
|
27
|
+
cp >= 0x1f1e6 && cp <= 0x1f1ff ? String.fromCharCode(65 + (cp - 0x1f1e6)) : null
|
|
28
|
+
);
|
|
29
|
+
return letters.every(Boolean) ? letters.join('') : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Guarantee a `countryCode` (ISO-2) on every corridor.
|
|
34
|
+
*
|
|
35
|
+
* `/send/countries` returns `code` as a CURRENCY and `country` as a display
|
|
36
|
+
* name, so neither is the ISO-2 that `/fiat/banks?country=` and the FIAT rail
|
|
37
|
+
* on `/transactions/create` require. Newer gateways return `countryCode`
|
|
38
|
+
* directly; for older ones we recover it from the flag emoji. Everything else
|
|
39
|
+
* on the payload is passed through untouched.
|
|
40
|
+
*/
|
|
41
|
+
function normalizeFiatCountries(data) {
|
|
42
|
+
const list = data && Array.isArray(data.countries) ? data.countries : null;
|
|
43
|
+
if (!list) return data;
|
|
44
|
+
return Object.assign({}, data, {
|
|
45
|
+
countries: list.map((entry) => {
|
|
46
|
+
if (!entry || typeof entry !== 'object') return entry;
|
|
47
|
+
const declared = typeof entry.countryCode === 'string' ? entry.countryCode.toUpperCase() : null;
|
|
48
|
+
const fromCountry = /^[A-Za-z]{2}$/.test(String(entry.country || ''))
|
|
49
|
+
? String(entry.country).toUpperCase()
|
|
50
|
+
: null;
|
|
51
|
+
return Object.assign({}, entry, {
|
|
52
|
+
countryCode: declared || fromCountry || iso2FromFlag(entry.flag) || null,
|
|
53
|
+
});
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
18
58
|
class KuvarPayServer {
|
|
19
59
|
/**
|
|
20
60
|
* @param {Object} config
|
|
@@ -67,7 +107,14 @@ class KuvarPayServer {
|
|
|
67
107
|
let json;
|
|
68
108
|
try { json = text ? JSON.parse(text) : null; } catch (e) { json = null; }
|
|
69
109
|
if (!res.ok) {
|
|
70
|
-
|
|
110
|
+
// The gateway returns errors as either { error: 'msg' }, { message: 'msg' },
|
|
111
|
+
// or { error: { code, message, status } }. Flatten to a readable string so
|
|
112
|
+
// err.message is never '[object Object]'. Full payload stays on err.body.
|
|
113
|
+
const errField = json && json.error;
|
|
114
|
+
const msg =
|
|
115
|
+
(errField && typeof errField === 'object' ? (errField.message || errField.code) : errField) ||
|
|
116
|
+
(json && json.message) ||
|
|
117
|
+
`HTTP ${res.status}`;
|
|
71
118
|
const err = new Error(msg);
|
|
72
119
|
err.status = res.status;
|
|
73
120
|
err.body = json || text;
|
|
@@ -88,6 +135,10 @@ class KuvarPayServer {
|
|
|
88
135
|
return this._request('POST', path, body, extraHeaders);
|
|
89
136
|
}
|
|
90
137
|
|
|
138
|
+
async _patch(path, body, extraHeaders = {}) {
|
|
139
|
+
return this._request('PATCH', path, body, extraHeaders);
|
|
140
|
+
}
|
|
141
|
+
|
|
91
142
|
/**
|
|
92
143
|
* Fetch checkout session status by sessionId
|
|
93
144
|
* GET {Payment API Base}/api/v1/checkout-sessions/{sessionId}
|
|
@@ -96,15 +147,30 @@ class KuvarPayServer {
|
|
|
96
147
|
*/
|
|
97
148
|
/**
|
|
98
149
|
* Signature Verification for Webhooks
|
|
99
|
-
*
|
|
150
|
+
*
|
|
151
|
+
* The gateway sends `X-KuvarPay-Signature: sha256=<hex>`. The prefix is
|
|
152
|
+
* stripped here, and bare-hex signatures still verify, so this works
|
|
153
|
+
* whichever format the header arrives in.
|
|
154
|
+
*
|
|
155
|
+
* Always returns a boolean — never throws. A handler that throws on a bad
|
|
156
|
+
* signature returns 5xx, which the gateway treats as a failed delivery and
|
|
157
|
+
* retries, so malformed input must fail closed rather than blow up.
|
|
158
|
+
*
|
|
159
|
+
* @param {string|Buffer} payload - Raw request body (pre-JSON.parse)
|
|
100
160
|
* @param {string} signature - Value from X-KuvarPay-Signature header
|
|
101
161
|
* @param {string} secret - Your Webhook Secret from the dashboard
|
|
162
|
+
* @returns {boolean}
|
|
102
163
|
*/
|
|
103
164
|
verifyWebhookSignature(payload, signature, secret) {
|
|
104
|
-
if (!signature || !secret) return false;
|
|
105
|
-
const
|
|
106
|
-
const digest =
|
|
107
|
-
|
|
165
|
+
if (!signature || typeof signature !== 'string' || !secret) return false;
|
|
166
|
+
const received = signature.startsWith('sha256=') ? signature.slice(7) : signature;
|
|
167
|
+
const digest = crypto.createHmac('sha256', secret).update(payload).digest('hex');
|
|
168
|
+
// Decoding as hex is case-insensitive and silently truncates on any
|
|
169
|
+
// non-hex character, so the length check below also rejects junk input.
|
|
170
|
+
const expectedBuf = Buffer.from(digest, 'hex');
|
|
171
|
+
const receivedBuf = Buffer.from(received, 'hex');
|
|
172
|
+
if (receivedBuf.length !== expectedBuf.length) return false;
|
|
173
|
+
return crypto.timingSafeEqual(expectedBuf, receivedBuf);
|
|
108
174
|
}
|
|
109
175
|
|
|
110
176
|
async getSessionStatus(sessionId) {
|
|
@@ -113,11 +179,24 @@ class KuvarPayServer {
|
|
|
113
179
|
}
|
|
114
180
|
const data = await this._get(`/api/v1/checkout-sessions/${encodeURIComponent(sessionId)}`);
|
|
115
181
|
const status = data?.status || data?.checkoutSession?.status || data?.data?.status;
|
|
116
|
-
|
|
182
|
+
// The transaction may be exposed either as a flat `transactionId` or, for
|
|
183
|
+
// fiat/crypto checkouts, nested in a `transactions` array on the session
|
|
184
|
+
// body — fall back through both so this is populated for fiat sessions too.
|
|
185
|
+
const transactionId = data?.transactionId
|
|
186
|
+
|| data?.data?.transactionId
|
|
187
|
+
|| data?.data?.transactions?.[0]?.id
|
|
188
|
+
|| data?.transactions?.[0]?.id
|
|
189
|
+
|| null;
|
|
190
|
+
// Fiat checkouts: the linked deposit projection (depositId, status,
|
|
191
|
+
// nextAction, paymentInstructions, quote) is attached to the session so a
|
|
192
|
+
// server-side/headless integrator can recover it by polling — no browser
|
|
193
|
+
// SSE required. Null for crypto checkouts or before a fiat tx is created.
|
|
194
|
+
const fiatDeposit = data?.data?.fiatDeposit ?? data?.fiatDeposit ?? null;
|
|
117
195
|
return {
|
|
118
196
|
sessionId: data?.id || data?.sessionId || sessionId,
|
|
119
197
|
status,
|
|
120
198
|
transactionId,
|
|
199
|
+
fiatDeposit,
|
|
121
200
|
metadata: data?.metadata || data?.checkoutSession?.metadata || data?.data?.metadata || null,
|
|
122
201
|
raw: data,
|
|
123
202
|
};
|
|
@@ -134,10 +213,13 @@ class KuvarPayServer {
|
|
|
134
213
|
throw new Error('transactionId is required');
|
|
135
214
|
}
|
|
136
215
|
const data = await this._get(`/api/v1/transactions/${encodeURIComponent(transactionId)}`);
|
|
216
|
+
// The gateway wraps the transaction in a `{ success, data }` envelope, so
|
|
217
|
+
// read fields off `data.data` (falling back to the root for older shapes).
|
|
218
|
+
const body = data?.data || data;
|
|
137
219
|
return {
|
|
138
|
-
transactionId:
|
|
139
|
-
status:
|
|
140
|
-
metadata:
|
|
220
|
+
transactionId: body?.id || transactionId,
|
|
221
|
+
status: body?.status,
|
|
222
|
+
metadata: body?.metadata || data?.metadata || null,
|
|
141
223
|
raw: data,
|
|
142
224
|
};
|
|
143
225
|
}
|
|
@@ -275,7 +357,7 @@ class KuvarPayServer {
|
|
|
275
357
|
|
|
276
358
|
const session = await this.createCheckoutSession(sessionData);
|
|
277
359
|
|
|
278
|
-
// 2. Create Transaction using the session. We omit toAmount/toCurrency
|
|
360
|
+
// 2. Create Transaction using the session. We omit toAmount/toCurrency
|
|
279
361
|
// to let the API use the values already locked into the session.
|
|
280
362
|
const transactionData = {
|
|
281
363
|
checkoutSessionId: session.sessionId,
|
|
@@ -283,18 +365,18 @@ class KuvarPayServer {
|
|
|
283
365
|
fromNetwork: data.fromNetwork,
|
|
284
366
|
description: data.description,
|
|
285
367
|
metadata: data.metadata,
|
|
368
|
+
// FIAT rail context. The gateway hard-rejects a FIAT transaction with no
|
|
369
|
+
// country, so these must be forwarded — dropping them silently produced
|
|
370
|
+
// `400 country is required for FIAT transactions`. For a fiat collection
|
|
371
|
+
// prefer createFiatPayment(), which also waits for the deposit.
|
|
372
|
+
...(data.country ? { country: data.country } : {}),
|
|
373
|
+
...(data.methodId ? { methodId: data.methodId } : {}),
|
|
374
|
+
...(data.phone ? { phone: data.phone } : {}),
|
|
286
375
|
};
|
|
287
376
|
|
|
288
377
|
return this.createTransaction(transactionData);
|
|
289
378
|
}
|
|
290
379
|
|
|
291
|
-
/**
|
|
292
|
-
* Email an invoice to a customer
|
|
293
|
-
*/
|
|
294
|
-
async sendInvoiceEmail(sessionId) {
|
|
295
|
-
return this._post(`/api/v1/invoices/${encodeURIComponent(sessionId)}/send-email`, {});
|
|
296
|
-
}
|
|
297
|
-
|
|
298
380
|
// --- Subaccount Management ---
|
|
299
381
|
|
|
300
382
|
/**
|
|
@@ -436,29 +518,34 @@ class KuvarPayServer {
|
|
|
436
518
|
// --- Banks & Account Verification ---
|
|
437
519
|
|
|
438
520
|
/**
|
|
439
|
-
* List supported banks
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
521
|
+
* List supported payment methods (banks / mobile-money operators) for a
|
|
522
|
+
* corridor, from the fiat gateway. Returns ONLY the corridor's primary-rail
|
|
523
|
+
* methods (e.g. an NGN corridor routed via Yellow Card returns YC methods
|
|
524
|
+
* only), each as an opaque `methodId`.
|
|
525
|
+
*
|
|
526
|
+
* Pass EITHER `country` (ISO-3166 alpha-2, e.g. 'NG') OR `currency`
|
|
527
|
+
* (ISO-4217, e.g. 'NGN') — a currency is resolved to its corridor country
|
|
528
|
+
* server-side, and when both are given `currency` further filters the list.
|
|
529
|
+
* A bare string is treated as a currency for backward compatibility.
|
|
530
|
+
* GET /api/v1/fiat/banks
|
|
531
|
+
* @param {string|Object} params - Currency string, or { country?, currency?, type?, direction? }
|
|
532
|
+
* @param {Object} [options] - Extra params when the first arg is a currency string
|
|
533
|
+
* @returns {Promise<{ methods: Array<{ methodId: string, name: string, currency: string, type: 'bank'|'mobile_money' }> }>}
|
|
443
534
|
*/
|
|
444
535
|
async getBanks(params = {}, options = {}) {
|
|
445
|
-
//
|
|
446
|
-
if (typeof params === 'string') {
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
if (
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
const query = new URLSearchParams(rest).toString();
|
|
460
|
-
const path = query ? `/api/v1/banks?${query}` : '/api/v1/banks';
|
|
461
|
-
return this._get(path);
|
|
536
|
+
// Back-compat: getBanks('NGN', { type }) → treat the string as a currency.
|
|
537
|
+
if (typeof params === 'string') params = Object.assign({ currency: params }, options);
|
|
538
|
+
// Default to the collection (deposit) rail — getBanks is used to populate a
|
|
539
|
+
// checkout's "pay with" method picker. Pass direction:'outbound' for payout
|
|
540
|
+
// (settlement) methods.
|
|
541
|
+
const { country, currency, type, direction = 'inbound' } = params || {};
|
|
542
|
+
if (!country && !currency) throw new Error('country or currency is required');
|
|
543
|
+
const qs = new URLSearchParams();
|
|
544
|
+
if (country) qs.set('country', country);
|
|
545
|
+
if (currency) qs.set('currency', currency);
|
|
546
|
+
if (type) qs.set('type', type);
|
|
547
|
+
if (direction) qs.set('direction', direction);
|
|
548
|
+
return this._get(`/api/v1/fiat/banks?${qs.toString()}`);
|
|
462
549
|
}
|
|
463
550
|
|
|
464
551
|
/**
|
|
@@ -470,6 +557,349 @@ class KuvarPayServer {
|
|
|
470
557
|
return this._post('/api/v1/banks/resolve', data);
|
|
471
558
|
}
|
|
472
559
|
|
|
560
|
+
// --- Fiat collection (Pay with Local Currency) ---
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Supported fiat collection corridors (public).
|
|
564
|
+
* GET /api/v1/send/countries
|
|
565
|
+
*/
|
|
566
|
+
async getFiatCountries() {
|
|
567
|
+
const data = await this._get('/api/v1/send/countries');
|
|
568
|
+
return normalizeFiatCountries(data);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Payment methods (banks / MoMo operators) for a corridor.
|
|
573
|
+
* GET /api/v1/fiat/banks?country=KE&type=mobile_money&direction=inbound
|
|
574
|
+
*
|
|
575
|
+
* Accepts an ISO-3166 alpha-2 country ("KE") or an ISO-4217 currency
|
|
576
|
+
* ("KES") — the gateway resolves either, but they go in DIFFERENT query
|
|
577
|
+
* params. `getFiatCountries()` returns its corridor identifier as `code`,
|
|
578
|
+
* which is a CURRENCY, so passing that straight through as `country=KES`
|
|
579
|
+
* silently returned `{ methods: [] }`. Route by length instead: 2 chars →
|
|
580
|
+
* country, 3 chars → currency. Pass `currency` explicitly to be unambiguous.
|
|
581
|
+
*
|
|
582
|
+
* @param {Object} params - { country?, currency?, type?, sandbox?, direction? }
|
|
583
|
+
*/
|
|
584
|
+
async getFiatMethods(params = {}) {
|
|
585
|
+
const { country, currency, type, sandbox, direction = 'inbound' } = params;
|
|
586
|
+
if (!country && !currency) throw new Error('country or currency is required');
|
|
587
|
+
|
|
588
|
+
const qs = new URLSearchParams({ direction });
|
|
589
|
+
if (currency) qs.set('currency', String(currency).toUpperCase());
|
|
590
|
+
if (country) {
|
|
591
|
+
const code = String(country).toUpperCase();
|
|
592
|
+
// A 3-letter code is a currency, not a country.
|
|
593
|
+
if (!currency && code.length === 3) qs.set('currency', code);
|
|
594
|
+
else qs.set('country', code);
|
|
595
|
+
}
|
|
596
|
+
if (type) qs.set('type', type);
|
|
597
|
+
if (sandbox) qs.set('sandbox', 'true');
|
|
598
|
+
return this._get(`/api/v1/fiat/banks?${qs.toString()}`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Preview the local amount a customer must pay to settle a checkout amount
|
|
603
|
+
* (inverse deposit pricing — the customer bears spread + gate fee).
|
|
604
|
+
* GET /api/v1/fiat/checkout-quote
|
|
605
|
+
* @param {Object} params - { amount, baseCurrency, currency, country }
|
|
606
|
+
*/
|
|
607
|
+
async getFiatCheckoutQuote(params = {}) {
|
|
608
|
+
const { amount, baseCurrency, currency, country } = params;
|
|
609
|
+
if (!amount || !baseCurrency || !currency || !country) {
|
|
610
|
+
throw new Error('amount, baseCurrency, currency and country are required');
|
|
611
|
+
}
|
|
612
|
+
const qs = new URLSearchParams({
|
|
613
|
+
amount: String(amount), baseCurrency, currency, country,
|
|
614
|
+
});
|
|
615
|
+
return this._get(`/api/v1/fiat/checkout-quote?${qs.toString()}`);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Create a FIAT collection transaction on a checkout session. Async: the
|
|
620
|
+
* gateway initiates the provider collection in a worker; recover the
|
|
621
|
+
* deposit (id, nextAction, paymentInstructions, quote) from
|
|
622
|
+
* getSessionStatus(sessionId).raw.data.fiatDeposit or the
|
|
623
|
+
* `transaction.ready` session SSE.
|
|
624
|
+
* POST /api/v1/transactions/create (fromNetwork=FIAT)
|
|
625
|
+
* @param {Object} data - { checkoutSessionId, fromCurrency, country, methodId?, phone?, customerEmail?, customerName?, ... }
|
|
626
|
+
*/
|
|
627
|
+
async createFiatTransaction(data = {}) {
|
|
628
|
+
const { checkoutSessionId, fromCurrency, country } = data;
|
|
629
|
+
if (!checkoutSessionId || !fromCurrency || !country) {
|
|
630
|
+
throw new Error('checkoutSessionId, fromCurrency and country are required');
|
|
631
|
+
}
|
|
632
|
+
return this._post('/api/v1/transactions/create', {
|
|
633
|
+
...data,
|
|
634
|
+
fromNetwork: 'FIAT',
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Poll a fiat deposit (public; self-healing reconcile server-side).
|
|
640
|
+
* GET /api/v1/fiat/deposits/:id
|
|
641
|
+
*/
|
|
642
|
+
async getFiatDeposit(depositId) {
|
|
643
|
+
if (!depositId) throw new Error('depositId is required');
|
|
644
|
+
return this._get(`/api/v1/fiat/deposits/${encodeURIComponent(depositId)}`);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Drive the next required step on a fiat deposit.
|
|
649
|
+
* POST /api/v1/fiat/deposits/:id/action
|
|
650
|
+
* @param {string} depositId
|
|
651
|
+
* @param {'CONFIRM_QUOTE'|'SUBMIT_OTP'} action
|
|
652
|
+
* @param {string} [otp]
|
|
653
|
+
*/
|
|
654
|
+
async submitFiatDepositAction(depositId, action, otp) {
|
|
655
|
+
if (!depositId) throw new Error('depositId is required');
|
|
656
|
+
if (!action) throw new Error('action is required');
|
|
657
|
+
return this._post(`/api/v1/fiat/deposits/${encodeURIComponent(depositId)}/action`, {
|
|
658
|
+
action, ...(otp ? { otp } : {}),
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* SANDBOX-ONLY: force-complete a stuck fiat deposit (drives the full
|
|
664
|
+
* completion pipeline incl. session COMPLETED + merchant webhooks).
|
|
665
|
+
* POST /api/v1/fiat/deposits/:id/simulate-complete
|
|
666
|
+
*/
|
|
667
|
+
async simulateFiatDepositComplete(depositId) {
|
|
668
|
+
if (!depositId) throw new Error('depositId is required');
|
|
669
|
+
return this._post(`/api/v1/fiat/deposits/${encodeURIComponent(depositId)}/simulate-complete`, {});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Wait for the fiat deposit projection to appear on a checkout session.
|
|
674
|
+
*
|
|
675
|
+
* `createFiatTransaction` returns immediately with status INITIALIZING — a
|
|
676
|
+
* worker initiates the provider collection and only then does the deposit
|
|
677
|
+
* (depositId, nextAction, quote) attach to the session. This polls until it
|
|
678
|
+
* shows up, with a bounded deadline and gentle backoff so a stalled worker
|
|
679
|
+
* degrades into a clean timeout instead of a hot loop.
|
|
680
|
+
*
|
|
681
|
+
* @param {string} sessionId
|
|
682
|
+
* @param {Object} [options]
|
|
683
|
+
* @param {number} [options.timeoutMs=30000] - Total time budget.
|
|
684
|
+
* @param {number} [options.pollIntervalMs=1000] - First delay between polls.
|
|
685
|
+
* @param {number} [options.maxIntervalMs=5000] - Backoff ceiling.
|
|
686
|
+
* @param {(deposit: any) => boolean} [options.until] - Custom stop condition.
|
|
687
|
+
* Defaults to "the deposit exists". Pass e.g.
|
|
688
|
+
* `d => d.status === 'CREDITED'` to wait for settlement instead.
|
|
689
|
+
* @returns {Promise<any>} The deposit projection.
|
|
690
|
+
* @throws {Error} code `FIAT_DEPOSIT_TIMEOUT` with `.lastDeposit` attached.
|
|
691
|
+
*/
|
|
692
|
+
async waitForFiatDeposit(sessionId, options = {}) {
|
|
693
|
+
if (!sessionId) throw new Error('sessionId is required');
|
|
694
|
+
const {
|
|
695
|
+
timeoutMs = 30000,
|
|
696
|
+
pollIntervalMs = 1000,
|
|
697
|
+
maxIntervalMs = 5000,
|
|
698
|
+
until,
|
|
699
|
+
} = options;
|
|
700
|
+
const isReady = typeof until === 'function'
|
|
701
|
+
? until
|
|
702
|
+
: (d) => Boolean(d && d.depositId);
|
|
703
|
+
|
|
704
|
+
const deadline = Date.now() + timeoutMs;
|
|
705
|
+
let interval = pollIntervalMs;
|
|
706
|
+
let lastDeposit = null;
|
|
707
|
+
|
|
708
|
+
for (;;) {
|
|
709
|
+
let deposit = null;
|
|
710
|
+
try {
|
|
711
|
+
const session = await this.getSessionStatus(sessionId);
|
|
712
|
+
deposit = session.fiatDeposit || null;
|
|
713
|
+
} catch (err) {
|
|
714
|
+
// A transient read failure shouldn't abort the wait, but don't swallow
|
|
715
|
+
// it either — if the budget is spent, the real error is the useful one.
|
|
716
|
+
if (Date.now() >= deadline) throw err;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (deposit) lastDeposit = deposit;
|
|
720
|
+
if (deposit && isReady(deposit)) return deposit;
|
|
721
|
+
|
|
722
|
+
const remaining = deadline - Date.now();
|
|
723
|
+
if (remaining <= 0) {
|
|
724
|
+
const err = new Error(
|
|
725
|
+
`Timed out after ${timeoutMs}ms waiting for the fiat deposit on session ${sessionId}`
|
|
726
|
+
);
|
|
727
|
+
err.code = 'FIAT_DEPOSIT_TIMEOUT';
|
|
728
|
+
err.sessionId = sessionId;
|
|
729
|
+
err.lastDeposit = lastDeposit;
|
|
730
|
+
throw err;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(interval, remaining)));
|
|
734
|
+
interval = Math.min(Math.ceil(interval * 1.5), maxIntervalMs);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* One-shot local-currency collection: create the checkout session, start the
|
|
740
|
+
* fiat transaction, and wait for the deposit — the fiat analogue of
|
|
741
|
+
* `createDirectPayment`.
|
|
742
|
+
*
|
|
743
|
+
* This stops at the QUOTE rather than running to settlement. Confirming a
|
|
744
|
+
* quote locks the FX rate and (for mobile money) fires the prompt on the
|
|
745
|
+
* customer's phone, so that stays an explicit step by default — and the
|
|
746
|
+
* gateway withholds `paymentInstructions` until it happens. Completion is
|
|
747
|
+
* signalled by the `payment.confirmed` webhook, exactly as for crypto.
|
|
748
|
+
*
|
|
749
|
+
* @param {Object} data
|
|
750
|
+
* Session: { amount, currency, description?, customerEmail?, customerName?,
|
|
751
|
+
* redirectUrl?, callbackUrl?, expiresIn?, metadata?, subaccount?, split_code? }
|
|
752
|
+
* Fiat rail: { fromCurrency, country, methodId?, phone? }
|
|
753
|
+
* Control: { waitForDeposit=true, autoConfirmQuote=false, timeoutMs?, pollIntervalMs? }
|
|
754
|
+
* @returns {Promise<{ sessionId, transactionId, depositId, nextAction, quote,
|
|
755
|
+
* paymentInstructions, deposit, session, transaction }>}
|
|
756
|
+
*/
|
|
757
|
+
async createFiatPayment(data = {}) {
|
|
758
|
+
const {
|
|
759
|
+
amount, currency, description, customerEmail, customerName,
|
|
760
|
+
redirectUrl, callbackUrl, expiresIn, metadata, subaccount,
|
|
761
|
+
split_code, splitCode,
|
|
762
|
+
fromCurrency, country, methodId, phone,
|
|
763
|
+
waitForDeposit = true,
|
|
764
|
+
autoConfirmQuote = false,
|
|
765
|
+
timeoutMs, pollIntervalMs,
|
|
766
|
+
} = data;
|
|
767
|
+
|
|
768
|
+
if (!fromCurrency) throw new Error('fromCurrency is required');
|
|
769
|
+
if (!country) throw new Error('country is required');
|
|
770
|
+
|
|
771
|
+
const session = await this.createCheckoutSession({
|
|
772
|
+
amount, currency, description, customerEmail, customerName,
|
|
773
|
+
redirectUrl, callbackUrl, expiresIn, metadata, subaccount,
|
|
774
|
+
split_code: split_code || splitCode,
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
const transaction = await this.createFiatTransaction({
|
|
778
|
+
checkoutSessionId: session.sessionId,
|
|
779
|
+
fromCurrency, country, methodId, phone,
|
|
780
|
+
customerEmail, customerName, description, metadata,
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
const result = {
|
|
784
|
+
sessionId: session.sessionId,
|
|
785
|
+
transactionId: transaction?.data?.transactionId ?? transaction?.transactionId ?? null,
|
|
786
|
+
depositId: null,
|
|
787
|
+
nextAction: null,
|
|
788
|
+
quote: null,
|
|
789
|
+
paymentInstructions: null,
|
|
790
|
+
deposit: null,
|
|
791
|
+
session: session.raw,
|
|
792
|
+
transaction,
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
if (!waitForDeposit) return result;
|
|
796
|
+
|
|
797
|
+
let deposit = await this.waitForFiatDeposit(session.sessionId, { timeoutMs, pollIntervalMs });
|
|
798
|
+
|
|
799
|
+
if (autoConfirmQuote && deposit.nextAction === 'CONFIRM_QUOTE') {
|
|
800
|
+
// CONFIRM_QUOTE echoes the fresh projection back, including the
|
|
801
|
+
// paymentInstructions that were withheld pre-confirm — so use it
|
|
802
|
+
// directly and skip a round-trip. Only re-read if the response came
|
|
803
|
+
// back without instructions (e.g. a provider that defers them).
|
|
804
|
+
const confirmed = await this.submitFiatDepositAction(deposit.depositId, 'CONFIRM_QUOTE');
|
|
805
|
+
if (confirmed && confirmed.paymentInstructions) {
|
|
806
|
+
deposit = { ...deposit, ...confirmed };
|
|
807
|
+
} else {
|
|
808
|
+
try {
|
|
809
|
+
deposit = await this.getFiatDeposit(deposit.depositId);
|
|
810
|
+
} catch (err) {
|
|
811
|
+
/* keep the pre-confirm deposit rather than losing it */
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
result.deposit = deposit;
|
|
817
|
+
result.depositId = deposit?.depositId ?? null;
|
|
818
|
+
result.nextAction = deposit?.nextAction ?? null;
|
|
819
|
+
result.quote = deposit?.quote ?? null;
|
|
820
|
+
result.paymentInstructions = deposit?.paymentInstructions ?? null;
|
|
821
|
+
return result;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// --- Payouts (Sends) — SECRET key only ---
|
|
825
|
+
//
|
|
826
|
+
// These move money OUT of your USD balance. The gateway requires a SECRET
|
|
827
|
+
// API key here and rejects client/publishable keys, so payouts must only
|
|
828
|
+
// ever run server-side. ownerType/ownerId default to your business
|
|
829
|
+
// (MERCHANT). Payouts complete asynchronously — listen for the webhook
|
|
830
|
+
// (verify it with verifyWebhookSignature) rather than polling.
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Verify (resolve) a payout recipient's name before sending.
|
|
834
|
+
* POST /api/v1/fiat/resolve-recipient
|
|
835
|
+
* @param {Object} data
|
|
836
|
+
* @param {string} data.methodId - Payout method id (bank / MoMo operator).
|
|
837
|
+
* @param {string} [data.accountNumber] - Bank account number.
|
|
838
|
+
* @param {string} [data.phone] - Wallet / mobile-money phone.
|
|
839
|
+
*/
|
|
840
|
+
async resolveRecipient(data = {}) {
|
|
841
|
+
const { methodId, accountNumber, phone } = data;
|
|
842
|
+
if (!methodId) throw new Error('methodId is required');
|
|
843
|
+
if (!accountNumber && !phone) throw new Error('accountNumber or phone is required');
|
|
844
|
+
return this._post('/api/v1/fiat/resolve-recipient', { methodId, accountNumber, phone });
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Initiate a fiat payout (bank / mobile money) debited from your USD balance.
|
|
849
|
+
* Provide EXACTLY ONE amount side: `sourceAmountUsd` (USD-input — the
|
|
850
|
+
* recipient's local floats) OR `destinationAmount` (recipient receives this
|
|
851
|
+
* exact local amount; USD locked at confirm).
|
|
852
|
+
* POST /api/v1/fiat/sends
|
|
853
|
+
* @param {Object} data
|
|
854
|
+
* @param {string} data.destinationCountry - ISO country (e.g. 'NG', 'KE').
|
|
855
|
+
* @param {string} data.destinationCurrency - Local currency (e.g. 'NGN').
|
|
856
|
+
* @param {Object} data.recipient - { methodId, accountNumber?, accountName?, phone?, manualInput? }
|
|
857
|
+
* @param {number} [data.sourceAmountUsd]
|
|
858
|
+
* @param {number} [data.destinationAmount]
|
|
859
|
+
* @param {string} [data.narration]
|
|
860
|
+
* @param {Object} [data.metadata]
|
|
861
|
+
* @param {string} [data.ownerType='MERCHANT']
|
|
862
|
+
* @param {string} [data.ownerId] - defaults to your businessId.
|
|
863
|
+
* @returns {Promise<{sendId:string}>}
|
|
864
|
+
*/
|
|
865
|
+
async createFiatSend(data = {}) {
|
|
866
|
+
const { destinationCountry, destinationCurrency, recipient, sourceAmountUsd, destinationAmount } = data;
|
|
867
|
+
if (!destinationCountry || !destinationCurrency) {
|
|
868
|
+
throw new Error('destinationCountry and destinationCurrency are required');
|
|
869
|
+
}
|
|
870
|
+
if (!recipient || !recipient.methodId) throw new Error('recipient.methodId is required');
|
|
871
|
+
if (sourceAmountUsd == null && destinationAmount == null) {
|
|
872
|
+
throw new Error('Provide exactly one of sourceAmountUsd or destinationAmount');
|
|
873
|
+
}
|
|
874
|
+
const payload = {
|
|
875
|
+
ownerType: data.ownerType || 'MERCHANT',
|
|
876
|
+
ownerId: data.ownerId || this.businessId,
|
|
877
|
+
destinationCountry,
|
|
878
|
+
destinationCurrency,
|
|
879
|
+
recipient,
|
|
880
|
+
...(sourceAmountUsd != null ? { sourceAmountUsd } : {}),
|
|
881
|
+
...(destinationAmount != null ? { destinationAmount } : {}),
|
|
882
|
+
...(data.depositId ? { depositId: data.depositId } : {}),
|
|
883
|
+
...(data.narration ? { narration: data.narration } : {}),
|
|
884
|
+
...(data.metadata ? { metadata: data.metadata } : {}),
|
|
885
|
+
};
|
|
886
|
+
return this._post('/api/v1/fiat/sends', payload);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Poll a fiat payout (send) status. Owner-scoped to your business.
|
|
891
|
+
* Payouts settle asynchronously; prefer the completion webhook, but this is
|
|
892
|
+
* useful for server-side reconciliation (and sandbox, where there may be no
|
|
893
|
+
* public webhook endpoint).
|
|
894
|
+
* GET /api/v1/fiat/sends/:id
|
|
895
|
+
* @param {string} sendId
|
|
896
|
+
* @returns {Promise<{ sendId: string, status: string, sourceAmountUsd: number, destinationCountry: string, destinationCurrency: string, destinationAmount: number|null, settledAt: string|null, createdAt: string, updatedAt: string }>}
|
|
897
|
+
*/
|
|
898
|
+
async getFiatSend(sendId) {
|
|
899
|
+
if (!sendId) throw new Error('sendId is required');
|
|
900
|
+
return this._get(`/api/v1/fiat/sends/${encodeURIComponent(sendId)}`);
|
|
901
|
+
}
|
|
902
|
+
|
|
473
903
|
// --- Sandbox Simulator ---
|
|
474
904
|
|
|
475
905
|
async startSandboxSimulator() {
|
|
@@ -553,11 +983,22 @@ class KuvarPayServer {
|
|
|
553
983
|
}
|
|
554
984
|
|
|
555
985
|
/**
|
|
556
|
-
* Create a renewal checkout session for an existing subscription
|
|
557
|
-
*
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
986
|
+
* Create a renewal checkout session for an existing subscription.
|
|
987
|
+
*
|
|
988
|
+
* POST {Payment API Base}/api/v1/subscriptions/{subscriptionId}/renew
|
|
989
|
+
*
|
|
990
|
+
* The renewal route is keyed by subscription id and returns
|
|
991
|
+
* `{ success, data: { checkoutSession: { id, status, approval_url, expires_at } } }`.
|
|
992
|
+
* Pass the id as `subscriptionId` (or `id`); any remaining fields are
|
|
993
|
+
* forwarded as the request body.
|
|
994
|
+
*
|
|
995
|
+
* @param {Object} body - { subscriptionId | id, ...renewalOptions }
|
|
996
|
+
*/
|
|
997
|
+
async createRenewalCheckoutSession(body = {}) {
|
|
998
|
+
const { subscriptionId, id, ...rest } = body;
|
|
999
|
+
const target = subscriptionId || id;
|
|
1000
|
+
if (!target) throw new Error('subscriptionId is required');
|
|
1001
|
+
return this._post(`/api/v1/subscriptions/${encodeURIComponent(target)}/renew`, rest);
|
|
561
1002
|
}
|
|
562
1003
|
|
|
563
1004
|
/**
|