@kuvarpay/sdk 1.4.2 → 1.6.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.
Files changed (5) hide show
  1. package/README.md +133 -13
  2. package/index.d.ts +226 -6
  3. package/index.js +510 -44
  4. package/index.mjs +462 -35
  5. package/package.json +5 -2
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
- const msg = (json && (json.error || json.message)) || `HTTP ${res.status}`;
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
- * @param {string|Buffer} payload - Raw request body
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 hmac = crypto.createHmac('sha256', secret);
98
- const digest = hmac.update(payload).digest('hex');
99
- return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature));
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
- const transactionId = data?.transactionId || data?.data?.transactionId || null;
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: data?.id || transactionId,
125
- status: data?.status,
126
- metadata: data?.metadata || data?.data?.metadata || null,
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,352 @@ export class KuvarPayServer {
306
406
  }
307
407
 
308
408
  /**
309
- * List supported banks
310
- * GET /api/v1/banks or GET /api/v1/banks/:currency
311
- * @param {string|Object} [params] - Currency string or params object { currency, type, country }
312
- * @param {Object} [options] - Additional query params if first arg is currency string
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
- // Handle (currency, options) signature
316
- if (typeof params === 'string') {
317
- const query = new URLSearchParams(options).toString();
318
- const path = query ? `/api/v1/banks/${encodeURIComponent(params)}?${query}` : `/api/v1/banks/${encodeURIComponent(params)}`;
319
- return this._get(path);
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
+ /**
440
+ * @deprecated Use {@link resolveRecipient}. Kept as a thin alias so existing
441
+ * integrations keep working.
442
+ *
443
+ * The legacy `POST /api/v1/banks/resolve` endpoint (which took a raw
444
+ * `bank_code`) has been retired — calling it now 404s. Name enquiry runs
445
+ * through `POST /api/v1/fiat/resolve-recipient`, which is keyed off the opaque
446
+ * `methodId` from `getBanks()`.
447
+ *
448
+ * Accepts either the new field names or the legacy snake_case ones:
449
+ * { methodId | method_id, accountNumber | account_number, phone }
450
+ *
451
+ * Returns the resolveRecipient shape ({ supported, resolved, accountName, … })
452
+ * plus `account_name` / `account_number` aliases for older call sites.
453
+ */
454
+ async resolveBankAccount(data = {}) {
455
+ const methodId = data.methodId || data.method_id;
456
+ const accountNumber = data.accountNumber || data.account_number;
457
+ const phone = data.phone;
458
+
459
+ if (!methodId) {
460
+ throw new Error(
461
+ (data.bank_code || data.bankCode)
462
+ ? 'resolveBankAccount no longer accepts bank_code — the legacy /api/v1/banks endpoints were retired. ' +
463
+ 'Pass methodId from getBanks({ country, direction: "outbound" }) instead, or call resolveRecipient() directly.'
464
+ : 'methodId is required (get one from getBanks({ country, direction: "outbound" }))'
465
+ );
320
466
  }
321
467
 
322
- const { currency, ...rest } = params || {};
323
- if (currency) {
324
- const query = new URLSearchParams(rest).toString();
325
- const path = query ? `/api/v1/banks/${encodeURIComponent(currency)}?${query}` : `/api/v1/banks/${encodeURIComponent(currency)}`;
326
- return this._get(path);
468
+ const result = await this.resolveRecipient({ methodId, accountNumber, phone });
469
+ return Object.assign({}, result, {
470
+ account_name: result && result.accountName != null ? result.accountName : null,
471
+ account_number: accountNumber != null ? accountNumber : null,
472
+ });
473
+ }
474
+
475
+ // --- Fiat collection (Pay with Local Currency) ---
476
+
477
+ /** Supported fiat collection corridors (public). GET /api/v1/send/countries */
478
+ async getFiatCountries() {
479
+ const data = await this._get('/api/v1/send/countries');
480
+ return normalizeFiatCountries(data);
481
+ }
482
+
483
+ /** Payment methods (banks / MoMo operators) for a country. */
484
+ /**
485
+ * Payment methods for a corridor. Accepts an ISO-3166 alpha-2 country ("KE")
486
+ * or an ISO-4217 currency ("KES") — the gateway resolves either, but they go
487
+ * in DIFFERENT query params. `getFiatCountries()` returns its identifier as
488
+ * `code`, which is a CURRENCY, so passing it through as `country=KES`
489
+ * silently returned `{ methods: [] }`. 2 chars → country, 3 chars → currency.
490
+ */
491
+ async getFiatMethods(params = {}) {
492
+ const { country, currency, type, sandbox, direction = 'inbound' } = params;
493
+ if (!country && !currency) throw new Error('country or currency is required');
494
+
495
+ const qs = new URLSearchParams({ direction });
496
+ if (currency) qs.set('currency', String(currency).toUpperCase());
497
+ if (country) {
498
+ const code = String(country).toUpperCase();
499
+ if (!currency && code.length === 3) qs.set('currency', code);
500
+ else qs.set('country', code);
327
501
  }
502
+ if (type) qs.set('type', type);
503
+ if (sandbox) qs.set('sandbox', 'true');
504
+ return this._get(`/api/v1/fiat/banks?${qs.toString()}`);
505
+ }
328
506
 
329
- const query = new URLSearchParams(rest).toString();
330
- const path = query ? `/api/v1/banks?${query}` : '/api/v1/banks';
331
- return this._get(path);
507
+ /** Preview the local amount a customer must pay to settle a checkout amount. */
508
+ async getFiatCheckoutQuote(params = {}) {
509
+ const { amount, baseCurrency, currency, country } = params;
510
+ if (!amount || !baseCurrency || !currency || !country) {
511
+ throw new Error('amount, baseCurrency, currency and country are required');
512
+ }
513
+ const qs = new URLSearchParams({
514
+ amount: String(amount), baseCurrency, currency, country,
515
+ });
516
+ return this._get(`/api/v1/fiat/checkout-quote?${qs.toString()}`);
332
517
  }
333
518
 
334
- async resolveBankAccount(data) {
335
- return this._post('/api/v1/banks/resolve', data);
519
+ /**
520
+ * Create a FIAT collection transaction on a checkout session (async — the
521
+ * deposit projection arrives on getSessionStatus().raw.data.fiatDeposit).
522
+ */
523
+ async createFiatTransaction(data = {}) {
524
+ const { checkoutSessionId, fromCurrency, country } = data;
525
+ if (!checkoutSessionId || !fromCurrency || !country) {
526
+ throw new Error('checkoutSessionId, fromCurrency and country are required');
527
+ }
528
+ return this._post('/api/v1/transactions/create', {
529
+ ...data,
530
+ fromNetwork: 'FIAT',
531
+ });
532
+ }
533
+
534
+ /** Poll a fiat deposit (public; self-healing reconcile server-side). */
535
+ async getFiatDeposit(depositId) {
536
+ if (!depositId) throw new Error('depositId is required');
537
+ return this._get(`/api/v1/fiat/deposits/${encodeURIComponent(depositId)}`);
538
+ }
539
+
540
+ /** Drive the next required step on a fiat deposit (CONFIRM_QUOTE / SUBMIT_OTP). */
541
+ async submitFiatDepositAction(depositId, action, otp) {
542
+ if (!depositId) throw new Error('depositId is required');
543
+ if (!action) throw new Error('action is required');
544
+ return this._post(`/api/v1/fiat/deposits/${encodeURIComponent(depositId)}/action`, {
545
+ action, ...(otp ? { otp } : {}),
546
+ });
547
+ }
548
+
549
+ /** SANDBOX-ONLY: force-complete a stuck fiat deposit. */
550
+ async simulateFiatDepositComplete(depositId) {
551
+ if (!depositId) throw new Error('depositId is required');
552
+ return this._post(`/api/v1/fiat/deposits/${encodeURIComponent(depositId)}/simulate-complete`, {});
553
+ }
554
+
555
+ /**
556
+ * Wait for the fiat deposit projection to appear on a checkout session.
557
+ * Bounded deadline + backoff, so a stalled worker times out cleanly instead
558
+ * of spinning. Pass `until` to wait for a different state (e.g. CREDITED).
559
+ * Throws an Error with code `FIAT_DEPOSIT_TIMEOUT` on expiry.
560
+ */
561
+ async waitForFiatDeposit(sessionId, options = {}) {
562
+ if (!sessionId) throw new Error('sessionId is required');
563
+ const {
564
+ timeoutMs = 30000,
565
+ pollIntervalMs = 1000,
566
+ maxIntervalMs = 5000,
567
+ until,
568
+ } = options;
569
+ const isReady = typeof until === 'function'
570
+ ? until
571
+ : (d) => Boolean(d && d.depositId);
572
+
573
+ const deadline = Date.now() + timeoutMs;
574
+ let interval = pollIntervalMs;
575
+ let lastDeposit = null;
576
+
577
+ for (;;) {
578
+ let deposit = null;
579
+ try {
580
+ const session = await this.getSessionStatus(sessionId);
581
+ deposit = session.fiatDeposit || null;
582
+ } catch (err) {
583
+ if (Date.now() >= deadline) throw err;
584
+ }
585
+
586
+ if (deposit) lastDeposit = deposit;
587
+ if (deposit && isReady(deposit)) return deposit;
588
+
589
+ const remaining = deadline - Date.now();
590
+ if (remaining <= 0) {
591
+ const err = new Error(
592
+ `Timed out after ${timeoutMs}ms waiting for the fiat deposit on session ${sessionId}`
593
+ );
594
+ err.code = 'FIAT_DEPOSIT_TIMEOUT';
595
+ err.sessionId = sessionId;
596
+ err.lastDeposit = lastDeposit;
597
+ throw err;
598
+ }
599
+
600
+ await new Promise((resolve) => setTimeout(resolve, Math.min(interval, remaining)));
601
+ interval = Math.min(Math.ceil(interval * 1.5), maxIntervalMs);
602
+ }
603
+ }
604
+
605
+ /**
606
+ * One-shot local-currency collection: session + fiat transaction + wait for
607
+ * the deposit. Stops at the quote — confirming locks the FX rate and fires
608
+ * the customer's prompt, and instructions are withheld until it happens.
609
+ * Completion arrives on the `payment.confirmed` webhook, as with crypto.
610
+ */
611
+ async createFiatPayment(data = {}) {
612
+ const {
613
+ amount, currency, description, customerEmail, customerName,
614
+ redirectUrl, callbackUrl, expiresIn, metadata, subaccount,
615
+ split_code, splitCode,
616
+ fromCurrency, country, methodId, phone,
617
+ waitForDeposit = true,
618
+ autoConfirmQuote = false,
619
+ timeoutMs, pollIntervalMs,
620
+ } = data;
621
+
622
+ if (!fromCurrency) throw new Error('fromCurrency is required');
623
+ if (!country) throw new Error('country is required');
624
+
625
+ const session = await this.createCheckoutSession({
626
+ amount, currency, description, customerEmail, customerName,
627
+ redirectUrl, callbackUrl, expiresIn, metadata, subaccount,
628
+ split_code: split_code || splitCode,
629
+ });
630
+
631
+ const transaction = await this.createFiatTransaction({
632
+ checkoutSessionId: session.sessionId,
633
+ fromCurrency, country, methodId, phone,
634
+ customerEmail, customerName, description, metadata,
635
+ });
636
+
637
+ const result = {
638
+ sessionId: session.sessionId,
639
+ transactionId: transaction?.data?.transactionId ?? transaction?.transactionId ?? null,
640
+ depositId: null,
641
+ nextAction: null,
642
+ quote: null,
643
+ paymentInstructions: null,
644
+ deposit: null,
645
+ session: session.raw,
646
+ transaction,
647
+ };
648
+
649
+ if (!waitForDeposit) return result;
650
+
651
+ let deposit = await this.waitForFiatDeposit(session.sessionId, { timeoutMs, pollIntervalMs });
652
+
653
+ if (autoConfirmQuote && deposit.nextAction === 'CONFIRM_QUOTE') {
654
+ // CONFIRM_QUOTE echoes the fresh projection back, including the
655
+ // paymentInstructions that were withheld pre-confirm — so use it
656
+ // directly and skip a round-trip. Only re-read if the response came
657
+ // back without instructions (e.g. a provider that defers them).
658
+ const confirmed = await this.submitFiatDepositAction(deposit.depositId, 'CONFIRM_QUOTE');
659
+ if (confirmed && confirmed.paymentInstructions) {
660
+ deposit = { ...deposit, ...confirmed };
661
+ } else {
662
+ try {
663
+ deposit = await this.getFiatDeposit(deposit.depositId);
664
+ } catch (err) {
665
+ /* keep the pre-confirm deposit rather than losing it */
666
+ }
667
+ }
668
+ }
669
+
670
+ result.deposit = deposit;
671
+ result.depositId = deposit?.depositId ?? null;
672
+ result.nextAction = deposit?.nextAction ?? null;
673
+ result.quote = deposit?.quote ?? null;
674
+ result.paymentInstructions = deposit?.paymentInstructions ?? null;
675
+ return result;
676
+ }
677
+
678
+ // --- Payouts (Sends) — SECRET key only ---
679
+ //
680
+ // These move money OUT of your USD balance. The gateway requires a SECRET
681
+ // API key here and rejects client/publishable keys, so payouts must only
682
+ // ever run server-side. ownerType/ownerId default to your business
683
+ // (MERCHANT). Payouts complete asynchronously — listen for the webhook
684
+ // (verify it with verifyWebhookSignature) rather than polling.
685
+
686
+ /**
687
+ * Verify (resolve) a payout recipient's name before sending.
688
+ * POST /api/v1/fiat/resolve-recipient
689
+ * @param {Object} data
690
+ * @param {string} data.methodId - Payout method id (bank / MoMo operator).
691
+ * @param {string} [data.accountNumber] - Bank account number.
692
+ * @param {string} [data.phone] - Wallet / mobile-money phone.
693
+ */
694
+ async resolveRecipient(data = {}) {
695
+ const { methodId, accountNumber, phone } = data;
696
+ if (!methodId) throw new Error('methodId is required');
697
+ if (!accountNumber && !phone) throw new Error('accountNumber or phone is required');
698
+ return this._post('/api/v1/fiat/resolve-recipient', { methodId, accountNumber, phone });
699
+ }
700
+
701
+ /**
702
+ * Initiate a fiat payout (bank / mobile money) debited from your USD balance.
703
+ * Provide EXACTLY ONE amount side: `sourceAmountUsd` (USD-input — the
704
+ * recipient's local floats) OR `destinationAmount` (recipient receives this
705
+ * exact local amount; USD locked at confirm).
706
+ * POST /api/v1/fiat/sends
707
+ * @param {Object} data
708
+ * @param {string} data.destinationCountry - ISO country (e.g. 'NG', 'KE').
709
+ * @param {string} data.destinationCurrency - Local currency (e.g. 'NGN').
710
+ * @param {Object} data.recipient - { methodId, accountNumber?, accountName?, phone?, manualInput? }
711
+ * @param {number} [data.sourceAmountUsd]
712
+ * @param {number} [data.destinationAmount]
713
+ * @param {string} [data.narration]
714
+ * @param {Object} [data.metadata]
715
+ * @param {string} [data.ownerType='MERCHANT']
716
+ * @param {string} [data.ownerId] - defaults to your businessId.
717
+ * @returns {Promise<{sendId:string}>}
718
+ */
719
+ async createFiatSend(data = {}) {
720
+ const { destinationCountry, destinationCurrency, recipient, sourceAmountUsd, destinationAmount } = data;
721
+ if (!destinationCountry || !destinationCurrency) {
722
+ throw new Error('destinationCountry and destinationCurrency are required');
723
+ }
724
+ if (!recipient || !recipient.methodId) throw new Error('recipient.methodId is required');
725
+ if (sourceAmountUsd == null && destinationAmount == null) {
726
+ throw new Error('Provide exactly one of sourceAmountUsd or destinationAmount');
727
+ }
728
+ const payload = {
729
+ ownerType: data.ownerType || 'MERCHANT',
730
+ ownerId: data.ownerId || this.businessId,
731
+ destinationCountry,
732
+ destinationCurrency,
733
+ recipient,
734
+ ...(sourceAmountUsd != null ? { sourceAmountUsd } : {}),
735
+ ...(destinationAmount != null ? { destinationAmount } : {}),
736
+ ...(data.depositId ? { depositId: data.depositId } : {}),
737
+ ...(data.narration ? { narration: data.narration } : {}),
738
+ ...(data.metadata ? { metadata: data.metadata } : {}),
739
+ };
740
+ return this._post('/api/v1/fiat/sends', payload);
741
+ }
742
+
743
+ /**
744
+ * Poll a fiat payout (send) status. Owner-scoped to your business.
745
+ * Payouts settle asynchronously; prefer the completion webhook, but this is
746
+ * useful for server-side reconciliation (and sandbox, where there may be no
747
+ * public webhook endpoint).
748
+ * GET /api/v1/fiat/sends/:id
749
+ * @param {string} sendId
750
+ * @returns {Promise<{ sendId: string, status: string, sourceAmountUsd: number, destinationCountry: string, destinationCurrency: string, destinationAmount: number|null, settledAt: string|null, createdAt: string, updatedAt: string }>}
751
+ */
752
+ async getFiatSend(sendId) {
753
+ if (!sendId) throw new Error('sendId is required');
754
+ return this._get(`/api/v1/fiat/sends/${encodeURIComponent(sendId)}`);
336
755
  }
337
756
 
338
757
  // --- Sandbox Simulator ---
@@ -396,8 +815,16 @@ export class KuvarPayServer {
396
815
  return this._post('/api/v1/subscriptions/relay/revokeAuth', body);
397
816
  }
398
817
 
399
- async createRenewalCheckoutSession(body) {
400
- return this._post('/api/v1/subscriptions/renewal-checkout-sessions', body);
818
+ /**
819
+ * Create a renewal checkout session for an existing subscription.
820
+ * POST /api/v1/subscriptions/{subscriptionId}/renew
821
+ * @param {Object} body - { subscriptionId | id, ...renewalOptions }
822
+ */
823
+ async createRenewalCheckoutSession(body = {}) {
824
+ const { subscriptionId, id, ...rest } = body;
825
+ const target = subscriptionId || id;
826
+ if (!target) throw new Error('subscriptionId is required');
827
+ return this._post(`/api/v1/subscriptions/${encodeURIComponent(target)}/renew`, rest);
401
828
  }
402
829
 
403
830
  async renewSubscription(subscriptionId, body = {}) {
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@kuvarpay/sdk",
3
- "version": "1.4.2",
3
+ "version": "1.6.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",
@@ -27,4 +30,4 @@
27
30
  },
28
31
  "author": "KuvarPay Team",
29
32
  "license": "MIT"
30
- }
33
+ }