@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.
Files changed (5) hide show
  1. package/README.md +132 -12
  2. package/index.d.ts +224 -8
  3. package/index.js +486 -45
  4. package/index.mjs +430 -35
  5. 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
- const msg = (json && (json.error || json.message)) || `HTTP ${res.status}`;
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
- * @param {string|Buffer} payload - Raw request body
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 hmac = crypto.createHmac('sha256', secret);
106
- const digest = hmac.update(payload).digest('hex');
107
- return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature));
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
- const transactionId = data?.transactionId || data?.data?.transactionId || null;
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: data?.id || transactionId,
139
- status: data?.status,
140
- metadata: data?.metadata || data?.data?.metadata || null,
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,23 +365,23 @@ 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
  /**
301
383
  * Create a new subaccount
302
- * @param {Object} subaccountData - { business_name, percentage_charge, ... }
384
+ * @param {Object} subaccountData - { business_name, percentage_charge, currency, type, crypto_currency, crypto_network, wallet_address, ... }
303
385
  */
304
386
  async createSubaccount(subaccountData) {
305
387
  return this._post('/api/v1/subaccounts', subaccountData);
@@ -436,29 +518,34 @@ class KuvarPayServer {
436
518
  // --- Banks & Account Verification ---
437
519
 
438
520
  /**
439
- * List supported banks
440
- * GET /api/v1/banks or GET /api/v1/banks/:currency
441
- * @param {string|Object} [params] - Currency string or params object { currency, type, country }
442
- * @param {Object} [options] - Additional query params if first arg is currency string
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
- // Handle (currency, options) signature
446
- if (typeof params === 'string') {
447
- const query = new URLSearchParams(options).toString();
448
- const path = query ? `/api/v1/banks/${encodeURIComponent(params)}?${query}` : `/api/v1/banks/${encodeURIComponent(params)}`;
449
- return this._get(path);
450
- }
451
-
452
- const { currency, ...rest } = params || {};
453
- if (currency) {
454
- const query = new URLSearchParams(rest).toString();
455
- const path = query ? `/api/v1/banks/${encodeURIComponent(currency)}?${query}` : `/api/v1/banks/${encodeURIComponent(currency)}`;
456
- return this._get(path);
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
- * POST {Payment API Base}/api/v1/subscriptions/renewal-checkout-sessions
558
- */
559
- async createRenewalCheckoutSession(body) {
560
- return this._post('/api/v1/subscriptions/renewal-checkout-sessions', body);
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
  /**