@kuvarpay/sdk 1.4.1 → 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 +224 -8
- package/index.js +486 -45
- package/index.mjs +430 -35
- package/package.json +4 -1
package/index.mjs
CHANGED
|
@@ -13,6 +13,45 @@ const DEFAULT_BASE = (process.env.KUVARPAY_API_BASE_URL
|
|
|
13
13
|
|| 'https://payment.kuvarpay.com'
|
|
14
14
|
).replace(/\/$/, '');
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Decode an ISO-3166 alpha-2 code from a flag emoji. Flags are two regional
|
|
18
|
+
* indicator symbols (U+1F1E6 = 'A'), so this is an exact inverse, not a guess.
|
|
19
|
+
*/
|
|
20
|
+
function iso2FromFlag(flag) {
|
|
21
|
+
if (typeof flag !== 'string') return null;
|
|
22
|
+
const points = Array.from(flag).map((ch) => ch.codePointAt(0));
|
|
23
|
+
if (points.length !== 2) return null;
|
|
24
|
+
const letters = points.map((cp) =>
|
|
25
|
+
cp >= 0x1f1e6 && cp <= 0x1f1ff ? String.fromCharCode(65 + (cp - 0x1f1e6)) : null
|
|
26
|
+
);
|
|
27
|
+
return letters.every(Boolean) ? letters.join('') : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Guarantee a `countryCode` (ISO-2) on every corridor.
|
|
32
|
+
*
|
|
33
|
+
* `/send/countries` returns `code` as a CURRENCY and `country` as a display
|
|
34
|
+
* name, so neither is the ISO-2 that `/fiat/banks?country=` and the FIAT rail
|
|
35
|
+
* on `/transactions/create` require. Newer gateways return `countryCode`
|
|
36
|
+
* directly; for older ones we recover it from the flag emoji.
|
|
37
|
+
*/
|
|
38
|
+
function normalizeFiatCountries(data) {
|
|
39
|
+
const list = data && Array.isArray(data.countries) ? data.countries : null;
|
|
40
|
+
if (!list) return data;
|
|
41
|
+
return Object.assign({}, data, {
|
|
42
|
+
countries: list.map((entry) => {
|
|
43
|
+
if (!entry || typeof entry !== 'object') return entry;
|
|
44
|
+
const declared = typeof entry.countryCode === 'string' ? entry.countryCode.toUpperCase() : null;
|
|
45
|
+
const fromCountry = /^[A-Za-z]{2}$/.test(String(entry.country || ''))
|
|
46
|
+
? String(entry.country).toUpperCase()
|
|
47
|
+
: null;
|
|
48
|
+
return Object.assign({}, entry, {
|
|
49
|
+
countryCode: declared || fromCountry || iso2FromFlag(entry.flag) || null,
|
|
50
|
+
});
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
16
55
|
export class KuvarPayServer {
|
|
17
56
|
/**
|
|
18
57
|
* @param {Object} config
|
|
@@ -65,7 +104,14 @@ export class KuvarPayServer {
|
|
|
65
104
|
let json;
|
|
66
105
|
try { json = text ? JSON.parse(text) : null; } catch (e) { json = null; }
|
|
67
106
|
if (!res.ok) {
|
|
68
|
-
|
|
107
|
+
// The gateway returns errors as either { error: 'msg' }, { message: 'msg' },
|
|
108
|
+
// or { error: { code, message, status } }. Flatten to a readable string so
|
|
109
|
+
// err.message is never '[object Object]'. Full payload stays on err.body.
|
|
110
|
+
const errField = json && json.error;
|
|
111
|
+
const msg =
|
|
112
|
+
(errField && typeof errField === 'object' ? (errField.message || errField.code) : errField) ||
|
|
113
|
+
(json && json.message) ||
|
|
114
|
+
`HTTP ${res.status}`;
|
|
69
115
|
const err = new Error(msg);
|
|
70
116
|
err.status = res.status;
|
|
71
117
|
err.body = json || text;
|
|
@@ -86,17 +132,36 @@ export class KuvarPayServer {
|
|
|
86
132
|
return this._request('POST', path, body, extraHeaders);
|
|
87
133
|
}
|
|
88
134
|
|
|
135
|
+
async _patch(path, body, extraHeaders = {}) {
|
|
136
|
+
return this._request('PATCH', path, body, extraHeaders);
|
|
137
|
+
}
|
|
138
|
+
|
|
89
139
|
/**
|
|
90
140
|
* Signature Verification for Webhooks
|
|
91
|
-
*
|
|
141
|
+
*
|
|
142
|
+
* The gateway sends `X-KuvarPay-Signature: sha256=<hex>`. The prefix is
|
|
143
|
+
* stripped here, and bare-hex signatures still verify, so this works
|
|
144
|
+
* whichever format the header arrives in.
|
|
145
|
+
*
|
|
146
|
+
* Always returns a boolean — never throws. A handler that throws on a bad
|
|
147
|
+
* signature returns 5xx, which the gateway treats as a failed delivery and
|
|
148
|
+
* retries, so malformed input must fail closed rather than blow up.
|
|
149
|
+
*
|
|
150
|
+
* @param {string|Buffer} payload - Raw request body (pre-JSON.parse)
|
|
92
151
|
* @param {string} signature - Value from X-KuvarPay-Signature header
|
|
93
152
|
* @param {string} secret - Your Webhook Secret from the dashboard
|
|
153
|
+
* @returns {boolean}
|
|
94
154
|
*/
|
|
95
155
|
verifyWebhookSignature(payload, signature, secret) {
|
|
96
|
-
if (!signature || !secret) return false;
|
|
97
|
-
const
|
|
98
|
-
const digest =
|
|
99
|
-
|
|
156
|
+
if (!signature || typeof signature !== 'string' || !secret) return false;
|
|
157
|
+
const received = signature.startsWith('sha256=') ? signature.slice(7) : signature;
|
|
158
|
+
const digest = crypto.createHmac('sha256', secret).update(payload).digest('hex');
|
|
159
|
+
// Decoding as hex is case-insensitive and silently truncates on any
|
|
160
|
+
// non-hex character, so the length check below also rejects junk input.
|
|
161
|
+
const expectedBuf = Buffer.from(digest, 'hex');
|
|
162
|
+
const receivedBuf = Buffer.from(received, 'hex');
|
|
163
|
+
if (receivedBuf.length !== expectedBuf.length) return false;
|
|
164
|
+
return crypto.timingSafeEqual(expectedBuf, receivedBuf);
|
|
100
165
|
}
|
|
101
166
|
|
|
102
167
|
async getSessionStatus(sessionId) {
|
|
@@ -105,11 +170,24 @@ export class KuvarPayServer {
|
|
|
105
170
|
}
|
|
106
171
|
const data = await this._get(`/api/v1/checkout-sessions/${encodeURIComponent(sessionId)}`);
|
|
107
172
|
const status = data?.status || data?.checkoutSession?.status || data?.data?.status;
|
|
108
|
-
|
|
173
|
+
// The transaction may be exposed either as a flat `transactionId` or, for
|
|
174
|
+
// fiat/crypto checkouts, nested in a `transactions` array on the session
|
|
175
|
+
// body — fall back through both so this is populated for fiat sessions too.
|
|
176
|
+
const transactionId = data?.transactionId
|
|
177
|
+
|| data?.data?.transactionId
|
|
178
|
+
|| data?.data?.transactions?.[0]?.id
|
|
179
|
+
|| data?.transactions?.[0]?.id
|
|
180
|
+
|| null;
|
|
181
|
+
// Fiat checkouts: the linked deposit projection (depositId, status,
|
|
182
|
+
// nextAction, paymentInstructions, quote) is attached to the session so a
|
|
183
|
+
// server-side/headless integrator can recover it by polling — no browser
|
|
184
|
+
// SSE required. Null for crypto checkouts or before a fiat tx is created.
|
|
185
|
+
const fiatDeposit = data?.data?.fiatDeposit ?? data?.fiatDeposit ?? null;
|
|
109
186
|
return {
|
|
110
187
|
sessionId: data?.id || data?.sessionId || sessionId,
|
|
111
188
|
status,
|
|
112
189
|
transactionId,
|
|
190
|
+
fiatDeposit,
|
|
113
191
|
metadata: data?.metadata || data?.checkoutSession?.metadata || data?.data?.metadata || null,
|
|
114
192
|
raw: data,
|
|
115
193
|
};
|
|
@@ -120,14 +198,33 @@ export class KuvarPayServer {
|
|
|
120
198
|
throw new Error('transactionId is required');
|
|
121
199
|
}
|
|
122
200
|
const data = await this._get(`/api/v1/transactions/${encodeURIComponent(transactionId)}`);
|
|
201
|
+
// The gateway wraps the transaction in a `{ success, data }` envelope, so
|
|
202
|
+
// read fields off `data.data` (falling back to the root for older shapes).
|
|
203
|
+
const body = data?.data || data;
|
|
123
204
|
return {
|
|
124
|
-
transactionId:
|
|
125
|
-
status:
|
|
126
|
-
metadata:
|
|
205
|
+
transactionId: body?.id || transactionId,
|
|
206
|
+
status: body?.status,
|
|
207
|
+
metadata: body?.metadata || data?.metadata || null,
|
|
127
208
|
raw: data,
|
|
128
209
|
};
|
|
129
210
|
}
|
|
130
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Update a transaction's status
|
|
214
|
+
* PATCH /api/v1/transactions/{id}/status
|
|
215
|
+
*/
|
|
216
|
+
async updateTransactionStatus(transactionId, data) {
|
|
217
|
+
return this._patch(`/api/v1/transactions/${encodeURIComponent(transactionId)}/status`, data);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Simulate a payment (Sandbox only)
|
|
222
|
+
* POST /api/v1/transactions/{id}/demo-pay
|
|
223
|
+
*/
|
|
224
|
+
async demoPay(transactionId) {
|
|
225
|
+
return this._post(`/api/v1/transactions/${encodeURIComponent(transactionId)}/demo-pay`, {});
|
|
226
|
+
}
|
|
227
|
+
|
|
131
228
|
async getTransactionStatusByReference(reference) {
|
|
132
229
|
if (!reference) throw new Error('reference is required');
|
|
133
230
|
return this._get(`/api/v1/transactions/status/${encodeURIComponent(reference)}`);
|
|
@@ -212,15 +309,18 @@ export class KuvarPayServer {
|
|
|
212
309
|
fromNetwork: data.fromNetwork,
|
|
213
310
|
description: data.description,
|
|
214
311
|
metadata: data.metadata,
|
|
312
|
+
// FIAT rail context. The gateway hard-rejects a FIAT transaction with no
|
|
313
|
+
// country, so these must be forwarded — dropping them silently produced
|
|
314
|
+
// `400 country is required for FIAT transactions`. For a fiat collection
|
|
315
|
+
// prefer createFiatPayment(), which also waits for the deposit.
|
|
316
|
+
...(data.country ? { country: data.country } : {}),
|
|
317
|
+
...(data.methodId ? { methodId: data.methodId } : {}),
|
|
318
|
+
...(data.phone ? { phone: data.phone } : {}),
|
|
215
319
|
};
|
|
216
320
|
|
|
217
321
|
return this.createTransaction(transactionData);
|
|
218
322
|
}
|
|
219
323
|
|
|
220
|
-
async sendInvoiceEmail(sessionId) {
|
|
221
|
-
return this._post(`/api/v1/invoices/${encodeURIComponent(sessionId)}/send-email`, {});
|
|
222
|
-
}
|
|
223
|
-
|
|
224
324
|
// --- Subaccount Management ---
|
|
225
325
|
|
|
226
326
|
async createSubaccount(subaccountData) {
|
|
@@ -306,33 +406,320 @@ export class KuvarPayServer {
|
|
|
306
406
|
}
|
|
307
407
|
|
|
308
408
|
/**
|
|
309
|
-
* List supported banks
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
409
|
+
* List supported payment methods (banks / mobile-money operators) for a
|
|
410
|
+
* corridor, from the fiat gateway. Returns ONLY the corridor's primary-rail
|
|
411
|
+
* methods (e.g. an NGN corridor routed via Yellow Card returns YC methods
|
|
412
|
+
* only), each as an opaque `methodId`.
|
|
413
|
+
*
|
|
414
|
+
* Pass EITHER `country` (ISO-3166 alpha-2, e.g. 'NG') OR `currency`
|
|
415
|
+
* (ISO-4217, e.g. 'NGN') — a currency is resolved to its corridor country
|
|
416
|
+
* server-side, and when both are given `currency` further filters the list.
|
|
417
|
+
* A bare string is treated as a currency for backward compatibility.
|
|
418
|
+
* GET /api/v1/fiat/banks
|
|
419
|
+
* @param {string|Object} params - Currency string, or { country?, currency?, type?, direction? }
|
|
420
|
+
* @param {Object} [options] - Extra params when the first arg is a currency string
|
|
421
|
+
* @returns {Promise<{ methods: Array<{ methodId: string, name: string, currency: string, type: 'bank'|'mobile_money' }> }>}
|
|
313
422
|
*/
|
|
314
423
|
async getBanks(params = {}, options = {}) {
|
|
315
|
-
//
|
|
316
|
-
if (typeof params === 'string') {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
424
|
+
// Back-compat: getBanks('NGN', { type }) → treat the string as a currency.
|
|
425
|
+
if (typeof params === 'string') params = Object.assign({ currency: params }, options);
|
|
426
|
+
// Default to the collection (deposit) rail — getBanks is used to populate a
|
|
427
|
+
// checkout's "pay with" method picker. Pass direction:'outbound' for payout
|
|
428
|
+
// (settlement) methods.
|
|
429
|
+
const { country, currency, type, direction = 'inbound' } = params || {};
|
|
430
|
+
if (!country && !currency) throw new Error('country or currency is required');
|
|
431
|
+
const qs = new URLSearchParams();
|
|
432
|
+
if (country) qs.set('country', country);
|
|
433
|
+
if (currency) qs.set('currency', currency);
|
|
434
|
+
if (type) qs.set('type', type);
|
|
435
|
+
if (direction) qs.set('direction', direction);
|
|
436
|
+
return this._get(`/api/v1/fiat/banks?${qs.toString()}`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async resolveBankAccount(data) {
|
|
440
|
+
return this._post('/api/v1/banks/resolve', data);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// --- Fiat collection (Pay with Local Currency) ---
|
|
444
|
+
|
|
445
|
+
/** Supported fiat collection corridors (public). GET /api/v1/send/countries */
|
|
446
|
+
async getFiatCountries() {
|
|
447
|
+
const data = await this._get('/api/v1/send/countries');
|
|
448
|
+
return normalizeFiatCountries(data);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Payment methods (banks / MoMo operators) for a country. */
|
|
452
|
+
/**
|
|
453
|
+
* Payment methods for a corridor. Accepts an ISO-3166 alpha-2 country ("KE")
|
|
454
|
+
* or an ISO-4217 currency ("KES") — the gateway resolves either, but they go
|
|
455
|
+
* in DIFFERENT query params. `getFiatCountries()` returns its identifier as
|
|
456
|
+
* `code`, which is a CURRENCY, so passing it through as `country=KES`
|
|
457
|
+
* silently returned `{ methods: [] }`. 2 chars → country, 3 chars → currency.
|
|
458
|
+
*/
|
|
459
|
+
async getFiatMethods(params = {}) {
|
|
460
|
+
const { country, currency, type, sandbox, direction = 'inbound' } = params;
|
|
461
|
+
if (!country && !currency) throw new Error('country or currency is required');
|
|
462
|
+
|
|
463
|
+
const qs = new URLSearchParams({ direction });
|
|
464
|
+
if (currency) qs.set('currency', String(currency).toUpperCase());
|
|
465
|
+
if (country) {
|
|
466
|
+
const code = String(country).toUpperCase();
|
|
467
|
+
if (!currency && code.length === 3) qs.set('currency', code);
|
|
468
|
+
else qs.set('country', code);
|
|
469
|
+
}
|
|
470
|
+
if (type) qs.set('type', type);
|
|
471
|
+
if (sandbox) qs.set('sandbox', 'true');
|
|
472
|
+
return this._get(`/api/v1/fiat/banks?${qs.toString()}`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Preview the local amount a customer must pay to settle a checkout amount. */
|
|
476
|
+
async getFiatCheckoutQuote(params = {}) {
|
|
477
|
+
const { amount, baseCurrency, currency, country } = params;
|
|
478
|
+
if (!amount || !baseCurrency || !currency || !country) {
|
|
479
|
+
throw new Error('amount, baseCurrency, currency and country are required');
|
|
320
480
|
}
|
|
481
|
+
const qs = new URLSearchParams({
|
|
482
|
+
amount: String(amount), baseCurrency, currency, country,
|
|
483
|
+
});
|
|
484
|
+
return this._get(`/api/v1/fiat/checkout-quote?${qs.toString()}`);
|
|
485
|
+
}
|
|
321
486
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
487
|
+
/**
|
|
488
|
+
* Create a FIAT collection transaction on a checkout session (async — the
|
|
489
|
+
* deposit projection arrives on getSessionStatus().raw.data.fiatDeposit).
|
|
490
|
+
*/
|
|
491
|
+
async createFiatTransaction(data = {}) {
|
|
492
|
+
const { checkoutSessionId, fromCurrency, country } = data;
|
|
493
|
+
if (!checkoutSessionId || !fromCurrency || !country) {
|
|
494
|
+
throw new Error('checkoutSessionId, fromCurrency and country are required');
|
|
327
495
|
}
|
|
496
|
+
return this._post('/api/v1/transactions/create', {
|
|
497
|
+
...data,
|
|
498
|
+
fromNetwork: 'FIAT',
|
|
499
|
+
});
|
|
500
|
+
}
|
|
328
501
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
502
|
+
/** Poll a fiat deposit (public; self-healing reconcile server-side). */
|
|
503
|
+
async getFiatDeposit(depositId) {
|
|
504
|
+
if (!depositId) throw new Error('depositId is required');
|
|
505
|
+
return this._get(`/api/v1/fiat/deposits/${encodeURIComponent(depositId)}`);
|
|
332
506
|
}
|
|
333
507
|
|
|
334
|
-
|
|
335
|
-
|
|
508
|
+
/** Drive the next required step on a fiat deposit (CONFIRM_QUOTE / SUBMIT_OTP). */
|
|
509
|
+
async submitFiatDepositAction(depositId, action, otp) {
|
|
510
|
+
if (!depositId) throw new Error('depositId is required');
|
|
511
|
+
if (!action) throw new Error('action is required');
|
|
512
|
+
return this._post(`/api/v1/fiat/deposits/${encodeURIComponent(depositId)}/action`, {
|
|
513
|
+
action, ...(otp ? { otp } : {}),
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** SANDBOX-ONLY: force-complete a stuck fiat deposit. */
|
|
518
|
+
async simulateFiatDepositComplete(depositId) {
|
|
519
|
+
if (!depositId) throw new Error('depositId is required');
|
|
520
|
+
return this._post(`/api/v1/fiat/deposits/${encodeURIComponent(depositId)}/simulate-complete`, {});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Wait for the fiat deposit projection to appear on a checkout session.
|
|
525
|
+
* Bounded deadline + backoff, so a stalled worker times out cleanly instead
|
|
526
|
+
* of spinning. Pass `until` to wait for a different state (e.g. CREDITED).
|
|
527
|
+
* Throws an Error with code `FIAT_DEPOSIT_TIMEOUT` on expiry.
|
|
528
|
+
*/
|
|
529
|
+
async waitForFiatDeposit(sessionId, options = {}) {
|
|
530
|
+
if (!sessionId) throw new Error('sessionId is required');
|
|
531
|
+
const {
|
|
532
|
+
timeoutMs = 30000,
|
|
533
|
+
pollIntervalMs = 1000,
|
|
534
|
+
maxIntervalMs = 5000,
|
|
535
|
+
until,
|
|
536
|
+
} = options;
|
|
537
|
+
const isReady = typeof until === 'function'
|
|
538
|
+
? until
|
|
539
|
+
: (d) => Boolean(d && d.depositId);
|
|
540
|
+
|
|
541
|
+
const deadline = Date.now() + timeoutMs;
|
|
542
|
+
let interval = pollIntervalMs;
|
|
543
|
+
let lastDeposit = null;
|
|
544
|
+
|
|
545
|
+
for (;;) {
|
|
546
|
+
let deposit = null;
|
|
547
|
+
try {
|
|
548
|
+
const session = await this.getSessionStatus(sessionId);
|
|
549
|
+
deposit = session.fiatDeposit || null;
|
|
550
|
+
} catch (err) {
|
|
551
|
+
if (Date.now() >= deadline) throw err;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (deposit) lastDeposit = deposit;
|
|
555
|
+
if (deposit && isReady(deposit)) return deposit;
|
|
556
|
+
|
|
557
|
+
const remaining = deadline - Date.now();
|
|
558
|
+
if (remaining <= 0) {
|
|
559
|
+
const err = new Error(
|
|
560
|
+
`Timed out after ${timeoutMs}ms waiting for the fiat deposit on session ${sessionId}`
|
|
561
|
+
);
|
|
562
|
+
err.code = 'FIAT_DEPOSIT_TIMEOUT';
|
|
563
|
+
err.sessionId = sessionId;
|
|
564
|
+
err.lastDeposit = lastDeposit;
|
|
565
|
+
throw err;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(interval, remaining)));
|
|
569
|
+
interval = Math.min(Math.ceil(interval * 1.5), maxIntervalMs);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* One-shot local-currency collection: session + fiat transaction + wait for
|
|
575
|
+
* the deposit. Stops at the quote — confirming locks the FX rate and fires
|
|
576
|
+
* the customer's prompt, and instructions are withheld until it happens.
|
|
577
|
+
* Completion arrives on the `payment.confirmed` webhook, as with crypto.
|
|
578
|
+
*/
|
|
579
|
+
async createFiatPayment(data = {}) {
|
|
580
|
+
const {
|
|
581
|
+
amount, currency, description, customerEmail, customerName,
|
|
582
|
+
redirectUrl, callbackUrl, expiresIn, metadata, subaccount,
|
|
583
|
+
split_code, splitCode,
|
|
584
|
+
fromCurrency, country, methodId, phone,
|
|
585
|
+
waitForDeposit = true,
|
|
586
|
+
autoConfirmQuote = false,
|
|
587
|
+
timeoutMs, pollIntervalMs,
|
|
588
|
+
} = data;
|
|
589
|
+
|
|
590
|
+
if (!fromCurrency) throw new Error('fromCurrency is required');
|
|
591
|
+
if (!country) throw new Error('country is required');
|
|
592
|
+
|
|
593
|
+
const session = await this.createCheckoutSession({
|
|
594
|
+
amount, currency, description, customerEmail, customerName,
|
|
595
|
+
redirectUrl, callbackUrl, expiresIn, metadata, subaccount,
|
|
596
|
+
split_code: split_code || splitCode,
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
const transaction = await this.createFiatTransaction({
|
|
600
|
+
checkoutSessionId: session.sessionId,
|
|
601
|
+
fromCurrency, country, methodId, phone,
|
|
602
|
+
customerEmail, customerName, description, metadata,
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
const result = {
|
|
606
|
+
sessionId: session.sessionId,
|
|
607
|
+
transactionId: transaction?.data?.transactionId ?? transaction?.transactionId ?? null,
|
|
608
|
+
depositId: null,
|
|
609
|
+
nextAction: null,
|
|
610
|
+
quote: null,
|
|
611
|
+
paymentInstructions: null,
|
|
612
|
+
deposit: null,
|
|
613
|
+
session: session.raw,
|
|
614
|
+
transaction,
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
if (!waitForDeposit) return result;
|
|
618
|
+
|
|
619
|
+
let deposit = await this.waitForFiatDeposit(session.sessionId, { timeoutMs, pollIntervalMs });
|
|
620
|
+
|
|
621
|
+
if (autoConfirmQuote && deposit.nextAction === 'CONFIRM_QUOTE') {
|
|
622
|
+
// CONFIRM_QUOTE echoes the fresh projection back, including the
|
|
623
|
+
// paymentInstructions that were withheld pre-confirm — so use it
|
|
624
|
+
// directly and skip a round-trip. Only re-read if the response came
|
|
625
|
+
// back without instructions (e.g. a provider that defers them).
|
|
626
|
+
const confirmed = await this.submitFiatDepositAction(deposit.depositId, 'CONFIRM_QUOTE');
|
|
627
|
+
if (confirmed && confirmed.paymentInstructions) {
|
|
628
|
+
deposit = { ...deposit, ...confirmed };
|
|
629
|
+
} else {
|
|
630
|
+
try {
|
|
631
|
+
deposit = await this.getFiatDeposit(deposit.depositId);
|
|
632
|
+
} catch (err) {
|
|
633
|
+
/* keep the pre-confirm deposit rather than losing it */
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
result.deposit = deposit;
|
|
639
|
+
result.depositId = deposit?.depositId ?? null;
|
|
640
|
+
result.nextAction = deposit?.nextAction ?? null;
|
|
641
|
+
result.quote = deposit?.quote ?? null;
|
|
642
|
+
result.paymentInstructions = deposit?.paymentInstructions ?? null;
|
|
643
|
+
return result;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// --- Payouts (Sends) — SECRET key only ---
|
|
647
|
+
//
|
|
648
|
+
// These move money OUT of your USD balance. The gateway requires a SECRET
|
|
649
|
+
// API key here and rejects client/publishable keys, so payouts must only
|
|
650
|
+
// ever run server-side. ownerType/ownerId default to your business
|
|
651
|
+
// (MERCHANT). Payouts complete asynchronously — listen for the webhook
|
|
652
|
+
// (verify it with verifyWebhookSignature) rather than polling.
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Verify (resolve) a payout recipient's name before sending.
|
|
656
|
+
* POST /api/v1/fiat/resolve-recipient
|
|
657
|
+
* @param {Object} data
|
|
658
|
+
* @param {string} data.methodId - Payout method id (bank / MoMo operator).
|
|
659
|
+
* @param {string} [data.accountNumber] - Bank account number.
|
|
660
|
+
* @param {string} [data.phone] - Wallet / mobile-money phone.
|
|
661
|
+
*/
|
|
662
|
+
async resolveRecipient(data = {}) {
|
|
663
|
+
const { methodId, accountNumber, phone } = data;
|
|
664
|
+
if (!methodId) throw new Error('methodId is required');
|
|
665
|
+
if (!accountNumber && !phone) throw new Error('accountNumber or phone is required');
|
|
666
|
+
return this._post('/api/v1/fiat/resolve-recipient', { methodId, accountNumber, phone });
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Initiate a fiat payout (bank / mobile money) debited from your USD balance.
|
|
671
|
+
* Provide EXACTLY ONE amount side: `sourceAmountUsd` (USD-input — the
|
|
672
|
+
* recipient's local floats) OR `destinationAmount` (recipient receives this
|
|
673
|
+
* exact local amount; USD locked at confirm).
|
|
674
|
+
* POST /api/v1/fiat/sends
|
|
675
|
+
* @param {Object} data
|
|
676
|
+
* @param {string} data.destinationCountry - ISO country (e.g. 'NG', 'KE').
|
|
677
|
+
* @param {string} data.destinationCurrency - Local currency (e.g. 'NGN').
|
|
678
|
+
* @param {Object} data.recipient - { methodId, accountNumber?, accountName?, phone?, manualInput? }
|
|
679
|
+
* @param {number} [data.sourceAmountUsd]
|
|
680
|
+
* @param {number} [data.destinationAmount]
|
|
681
|
+
* @param {string} [data.narration]
|
|
682
|
+
* @param {Object} [data.metadata]
|
|
683
|
+
* @param {string} [data.ownerType='MERCHANT']
|
|
684
|
+
* @param {string} [data.ownerId] - defaults to your businessId.
|
|
685
|
+
* @returns {Promise<{sendId:string}>}
|
|
686
|
+
*/
|
|
687
|
+
async createFiatSend(data = {}) {
|
|
688
|
+
const { destinationCountry, destinationCurrency, recipient, sourceAmountUsd, destinationAmount } = data;
|
|
689
|
+
if (!destinationCountry || !destinationCurrency) {
|
|
690
|
+
throw new Error('destinationCountry and destinationCurrency are required');
|
|
691
|
+
}
|
|
692
|
+
if (!recipient || !recipient.methodId) throw new Error('recipient.methodId is required');
|
|
693
|
+
if (sourceAmountUsd == null && destinationAmount == null) {
|
|
694
|
+
throw new Error('Provide exactly one of sourceAmountUsd or destinationAmount');
|
|
695
|
+
}
|
|
696
|
+
const payload = {
|
|
697
|
+
ownerType: data.ownerType || 'MERCHANT',
|
|
698
|
+
ownerId: data.ownerId || this.businessId,
|
|
699
|
+
destinationCountry,
|
|
700
|
+
destinationCurrency,
|
|
701
|
+
recipient,
|
|
702
|
+
...(sourceAmountUsd != null ? { sourceAmountUsd } : {}),
|
|
703
|
+
...(destinationAmount != null ? { destinationAmount } : {}),
|
|
704
|
+
...(data.depositId ? { depositId: data.depositId } : {}),
|
|
705
|
+
...(data.narration ? { narration: data.narration } : {}),
|
|
706
|
+
...(data.metadata ? { metadata: data.metadata } : {}),
|
|
707
|
+
};
|
|
708
|
+
return this._post('/api/v1/fiat/sends', payload);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Poll a fiat payout (send) status. Owner-scoped to your business.
|
|
713
|
+
* Payouts settle asynchronously; prefer the completion webhook, but this is
|
|
714
|
+
* useful for server-side reconciliation (and sandbox, where there may be no
|
|
715
|
+
* public webhook endpoint).
|
|
716
|
+
* GET /api/v1/fiat/sends/:id
|
|
717
|
+
* @param {string} sendId
|
|
718
|
+
* @returns {Promise<{ sendId: string, status: string, sourceAmountUsd: number, destinationCountry: string, destinationCurrency: string, destinationAmount: number|null, settledAt: string|null, createdAt: string, updatedAt: string }>}
|
|
719
|
+
*/
|
|
720
|
+
async getFiatSend(sendId) {
|
|
721
|
+
if (!sendId) throw new Error('sendId is required');
|
|
722
|
+
return this._get(`/api/v1/fiat/sends/${encodeURIComponent(sendId)}`);
|
|
336
723
|
}
|
|
337
724
|
|
|
338
725
|
// --- Sandbox Simulator ---
|
|
@@ -396,8 +783,16 @@ export class KuvarPayServer {
|
|
|
396
783
|
return this._post('/api/v1/subscriptions/relay/revokeAuth', body);
|
|
397
784
|
}
|
|
398
785
|
|
|
399
|
-
|
|
400
|
-
|
|
786
|
+
/**
|
|
787
|
+
* Create a renewal checkout session for an existing subscription.
|
|
788
|
+
* POST /api/v1/subscriptions/{subscriptionId}/renew
|
|
789
|
+
* @param {Object} body - { subscriptionId | id, ...renewalOptions }
|
|
790
|
+
*/
|
|
791
|
+
async createRenewalCheckoutSession(body = {}) {
|
|
792
|
+
const { subscriptionId, id, ...rest } = body;
|
|
793
|
+
const target = subscriptionId || id;
|
|
794
|
+
if (!target) throw new Error('subscriptionId is required');
|
|
795
|
+
return this._post(`/api/v1/subscriptions/${encodeURIComponent(target)}/renew`, rest);
|
|
401
796
|
}
|
|
402
797
|
|
|
403
798
|
async renewSubscription(subscriptionId, body = {}) {
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuvarpay/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Official KuvarPay Server SDK for Node.js",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"module": "index.mjs",
|
|
7
7
|
"types": "index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "node --test"
|
|
10
|
+
},
|
|
8
11
|
"files": [
|
|
9
12
|
"index.js",
|
|
10
13
|
"index.mjs",
|