@kuvarpay/sdk 1.6.0 → 1.9.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 +177 -20
- package/index.d.ts +148 -3
- package/index.js +327 -18
- package/index.mjs +320 -23
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@ Official Node.js SDK for **KuvarPay** — the payments gateway bridging crypto a
|
|
|
6
6
|
- **Collect in crypto** — checkout sessions + crypto deposit addresses (USDT, USDC, BTC, … across BSC, Ethereum, Polygon, Tron, Solana, and more).
|
|
7
7
|
- **Collect in local currency (fiat)** — customers pay by bank transfer or mobile money (NGN, KES, GHS, ZAR, UGX, …); you still settle in your chosen currency. See [Fiat Collection](#-fiat-collection-pay-with-local-currency).
|
|
8
8
|
- **Pay out (fiat Sends)** — send to a recipient's bank account or mobile-money wallet, debited from your USD balance. See [Payouts](#-payouts-sends--secret-key-only).
|
|
9
|
+
- **KuvarSend account linking** — securely authorize a KuvarSend account as a reusable payout destination with state + PKCE.
|
|
9
10
|
- **Subscriptions** — fixed & metered recurring billing.
|
|
10
11
|
- **Split payments** — distribute revenue across subaccounts (bank / mobile money / crypto).
|
|
11
12
|
- **Hosted-checkout controls** — restrict rails, crypto tokens, and fiat countries (per business or per session).
|
|
@@ -55,11 +56,28 @@ The SDK defaults to the KuvarPay Production API (`https://payment.kuvarpay.com`)
|
|
|
55
56
|
|
|
56
57
|
| Option | Description | Env Fallback |
|
|
57
58
|
| :--- | :--- | :--- |
|
|
58
|
-
| `businessId` | Your KuvarPay Business ID |
|
|
59
|
-
| `secretApiKey` | Your
|
|
59
|
+
| `businessId` | Your KuvarPay Business ID | `KUVARPAY_BUSINESS_ID` |
|
|
60
|
+
| `secretApiKey` | Your Secret Key (`rsp_secret_…`) | `KUVARPAY_SECRET_API_KEY`, `PAYMENT_SECRET_API_KEY` |
|
|
61
|
+
| `sandbox` | Set `true` when the key belongs to a **sandbox-mode** business. See [Sandbox](#-sandbox). | `KUVARPAY_SANDBOX` |
|
|
60
62
|
| `baseUrl` | Overrides the API endpoint | `KUVARPAY_API_BASE_URL` |
|
|
61
63
|
| `timeoutMs` | Request timeout (default 60s) | - |
|
|
62
64
|
|
|
65
|
+
### 🧪 Sandbox
|
|
66
|
+
|
|
67
|
+
Live and sandbox are **separate `methodId` spaces** — the same bank has a different id in
|
|
68
|
+
each, and the sandbox list is much smaller. Set `sandbox: true` on the client (or per call
|
|
69
|
+
on `getBanks`) so method lists come back in the mode your key actually operates in:
|
|
70
|
+
|
|
71
|
+
```javascript
|
|
72
|
+
const kv = new KuvarPayServer({ businessId, secretApiKey, sandbox: true });
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`getBanks` is served by a public endpoint that cannot see your key, so **it defaults to the
|
|
76
|
+
live list** unless you set this. Everything downstream *does* read the mode from your key, so
|
|
77
|
+
a live `methodId` used with a sandbox key fails with `reason: 'METHOD_MODE_MISMATCH'` on
|
|
78
|
+
`resolveRecipient` and `createFiatSend`. That error means the id came from the wrong
|
|
79
|
+
environment — not that the account is invalid.
|
|
80
|
+
|
|
63
81
|
---
|
|
64
82
|
|
|
65
83
|
## 📖 API Reference
|
|
@@ -95,16 +113,33 @@ The SDK defaults to the KuvarPay Production API (`https://payment.kuvarpay.com`)
|
|
|
95
113
|
|
|
96
114
|
| Method | Description |
|
|
97
115
|
| :--- | :--- |
|
|
98
|
-
| `getBanks({ country? \| currency?, type? })` | List a corridor's **primary-rail** payment methods (banks / mobile money). Pass a country (ISO-2) or currency (ISO-4217). |
|
|
116
|
+
| `getBanks({ country? \| currency?, type?, direction?, sandbox? })` | List a corridor's **primary-rail** payment methods (banks / mobile money). Pass a country (ISO-2) or currency (ISO-4217); a 3-letter `country` is read as a currency, so `getFiatCountries().code` can be passed straight through. `direction` defaults to `'inbound'` — pass `'outbound'` for payout methods. |
|
|
117
|
+
| `resolveRecipient({ methodId, accountNumber? \| phone? })` | Name-enquiry before a payout. See [Name enquiry coverage](#name-enquiry-coverage). |
|
|
99
118
|
| `resolveBankAccount(data)` | **Deprecated** alias for `resolveRecipient` — takes `methodId`, not `bank_code`. |
|
|
100
119
|
|
|
120
|
+
#### Name enquiry coverage
|
|
121
|
+
|
|
122
|
+
Name enquiry resolves **NGN bank accounts**. Every other currency — and all mobile money —
|
|
123
|
+
returns `supported: false`. That is a normal response, **not** an error:
|
|
124
|
+
|
|
125
|
+
```javascript
|
|
126
|
+
const r = await kv.resolveRecipient({ methodId, accountNumber });
|
|
127
|
+
|
|
128
|
+
if (r.resolved) // use r.accountName
|
|
129
|
+
else if (!r.supported) // corridor can't be verified — collect the name from the user
|
|
130
|
+
else // supported but not found — ask them to check the number
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Never block a payout on `r.resolved`: outside NGN it is always `false`, and gating on it
|
|
134
|
+
makes every other corridor un-payable. Methods flagged `manualInput` behave the same way.
|
|
135
|
+
|
|
101
136
|
### 🌍 Fiat Collection (Pay with Local Currency)
|
|
102
137
|
|
|
103
138
|
| Method | Description |
|
|
104
139
|
| :--- | :--- |
|
|
105
140
|
| **`createFiatPayment(data)`** | **One-shot**: checkout session + fiat transaction + wait for the deposit. Returns `{ sessionId, transactionId, depositId, nextAction, quote, paymentInstructions }`. |
|
|
106
141
|
| `getFiatCountries()` | Supported corridors. `code` is a **currency**; use **`countryCode`** (ISO-2) for any `country` param, and filter on `depositAvailable`. |
|
|
107
|
-
| `getFiatMethods(
|
|
142
|
+
| `getFiatMethods(...)` | **Deprecated** alias for `getBanks` — same endpoint, same params. |
|
|
108
143
|
| `getFiatCheckoutQuote({ amount, baseCurrency, currency, country })` | Preview the local amount a customer pays. |
|
|
109
144
|
| `createFiatTransaction(data)` | Create a fiat collection on a checkout session. |
|
|
110
145
|
| `waitForFiatDeposit(sessionId, options?)` | Bounded poll until the deposit attaches. Rejects with `FIAT_DEPOSIT_TIMEOUT`. |
|
|
@@ -117,7 +152,7 @@ the customer's payment prompt, and the gateway withholds `paymentInstructions`
|
|
|
117
152
|
until then. Show `quote`, confirm, then show instructions.
|
|
118
153
|
|
|
119
154
|
```javascript
|
|
120
|
-
const { methods } = await kv.
|
|
155
|
+
const { methods } = await kv.getBanks({ country: 'KE', type: 'mobile_money' });
|
|
121
156
|
|
|
122
157
|
const payment = await kv.createFiatPayment({
|
|
123
158
|
amount: 5000, currency: 'NGN', // you settle in NGN
|
|
@@ -172,33 +207,155 @@ Payouts move money **out** of your USD balance to a recipient's bank or mobile-m
|
|
|
172
207
|
|
|
173
208
|
| Method | Description |
|
|
174
209
|
| :--- | :--- |
|
|
175
|
-
| `
|
|
210
|
+
| `getBanks({ country, type?, direction: 'outbound' })` | List payout (outbound) banks / mobile-money operators. |
|
|
176
211
|
| `resolveRecipient({ methodId, accountNumber?, phone? })` | Name-enquiry on a payout recipient before sending. |
|
|
177
212
|
| `createFiatSend(data)` | Initiate a bank / mobile-money payout (USD-debited). |
|
|
178
213
|
| `getFiatSend(sendId)` | Poll a payout's status (`PROCESSING` → `COMPLETED`/`FAILED`/`REFUNDED`). |
|
|
179
214
|
|
|
180
215
|
```javascript
|
|
181
216
|
// Discover payout methods, then verify the recipient name before sending.
|
|
182
|
-
const { methods } = await kv.
|
|
183
|
-
const
|
|
217
|
+
const { methods } = await kv.getBanks({ country: 'NG', type: 'bank', direction: 'outbound' });
|
|
218
|
+
const methodId = methods[0].methodId;
|
|
219
|
+
const r = await kv.resolveRecipient({ methodId, accountNumber: '0123456789' });
|
|
220
|
+
|
|
221
|
+
// `supported: false` just means this corridor can't be verified (anything outside NGN
|
|
222
|
+
// bank) — fall back to the name your user typed rather than blocking the payout.
|
|
223
|
+
if (r.supported && !r.resolved) throw new Error('Account not found — check the number.');
|
|
224
|
+
const accountName = r.accountName ?? userSuppliedName;
|
|
184
225
|
|
|
185
226
|
// Fiat payout: recipient receives exactly 50,000 NGN; USD debited from your balance.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
227
|
+
const { sendId } = await kv.createFiatSend({
|
|
228
|
+
destinationCountry: 'NG',
|
|
229
|
+
destinationCurrency: 'NGN',
|
|
230
|
+
destinationAmount: 50000,
|
|
231
|
+
recipient: { methodId, accountNumber: '0123456789', accountName },
|
|
232
|
+
narration: 'Vendor payout',
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Prefer the completion webhook; getFiatSend is available for reconciliation.
|
|
236
|
+
const send = await kv.getFiatSend(sendId);
|
|
237
|
+
console.log(send.status); // PROCESSING | COMPLETED | FAILED | REFUNDED
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
> `ownerType`/`ownerId` default to your business (`MERCHANT` + your `businessId`), so you don't need to pass them.
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
### 🔗 KuvarSend Account Linking
|
|
245
|
+
|
|
246
|
+
Account linking runs from your backend because it uses your KuvarPay secret key. The SDK generates
|
|
247
|
+
the state and PKCE values, but your backend must persist them in the signed-in user's server-side
|
|
248
|
+
session before redirecting the browser.
|
|
249
|
+
|
|
250
|
+
First register the exact callback URI under **KuvarPay Dashboard → Developers → Account Linking**.
|
|
251
|
+
LIVE and SANDBOX registrations are separate.
|
|
252
|
+
|
|
253
|
+
```javascript
|
|
254
|
+
// POST /withdraw/kuvarsend/link — your authenticated backend route
|
|
255
|
+
const pending = kv.prepareKuvarSendAccountLink({
|
|
256
|
+
externalUserReference: req.user.id, // stable ID from your database; never email/phone
|
|
257
|
+
redirectUri: 'https://app.example.com/kuvarsend/callback',
|
|
258
|
+
idempotencyKey: `kuvarsend-link:${req.user.id}:${linkAttemptId}`,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Persist BEFORE the network call so an ambiguous timeout can safely reuse it.
|
|
262
|
+
req.session.kuvarSendLink = pending;
|
|
263
|
+
|
|
264
|
+
const authorization = await kv.createKuvarSendAccountLink(pending);
|
|
265
|
+
res.redirect(authorization.authorizationUrl);
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
KuvarSend returns the user to the registered callback. Load the state and verifier from the same
|
|
269
|
+
server-side session; the SDK rejects a different callback path, mismatched state, malformed code,
|
|
270
|
+
or denial before exchanging the one-time code.
|
|
271
|
+
|
|
272
|
+
```javascript
|
|
273
|
+
// GET /kuvarsend/callback — your registered backend callback
|
|
274
|
+
const pending = req.session.kuvarSendLink;
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const linked = await kv.completeKuvarSendAccountLink({
|
|
278
|
+
// Anchor to the persisted redirect URI; never trust the inbound Host header.
|
|
279
|
+
callbackUrl: new URL(req.originalUrl, pending.redirectUri).toString(),
|
|
280
|
+
expectedState: pending.state,
|
|
281
|
+
redirectUri: pending.redirectUri,
|
|
282
|
+
codeVerifier: pending.codeVerifier,
|
|
193
283
|
});
|
|
194
284
|
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
285
|
+
// Save this opaque id as the user's KuvarSend payout destination.
|
|
286
|
+
await users.saveKuvarSendLinkedAccount(req.user.id, linked.linked_account_id);
|
|
287
|
+
delete req.session.kuvarSendLink;
|
|
288
|
+
res.redirect('/withdraw?kuvarsend=linked');
|
|
289
|
+
} catch (error) {
|
|
290
|
+
if (error.code === 'ACCESS_DENIED') return res.redirect('/withdraw?kuvarsend=denied');
|
|
291
|
+
throw error;
|
|
198
292
|
}
|
|
199
293
|
```
|
|
200
294
|
|
|
201
|
-
|
|
295
|
+
If the user approves on a different device than the one they started on — a desktop checkout where
|
|
296
|
+
they scan the `authorizationUrl` as a QR code — no code comes back to your callback. Poll instead
|
|
297
|
+
with the `requestId`, holding the verifier in the same server-side session:
|
|
298
|
+
|
|
299
|
+
```javascript
|
|
300
|
+
// The desktop page renders authorization.authorizationUrl as a QR code and opens an SSE/socket.
|
|
301
|
+
const linked = await kv.waitForKuvarSendAccountLink({
|
|
302
|
+
requestId: authorization.requestId,
|
|
303
|
+
redirectUri: pending.redirectUri,
|
|
304
|
+
codeVerifier: pending.codeVerifier,
|
|
305
|
+
timeoutMs: 10 * 60 * 1000, // default; rejects with AUTHORIZATION_LINK_TIMEOUT
|
|
306
|
+
});
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
`waitForKuvarSendAccountLink` polls until approval and then completes the exchange for you. To drive
|
|
310
|
+
the polling yourself, call `completeKuvarSendAccountLink({ requestId, redirectUri, codeVerifier })`
|
|
311
|
+
directly — it resolves once approved and throws a 409 `AUTHORIZATION_NOT_READY` until then.
|
|
312
|
+
|
|
313
|
+
Use `getKuvarSendAccountLink(linkedAccountId)` for reconciliation and
|
|
314
|
+
`revokeKuvarSendAccountLink(linkedAccountId)` when the user disconnects KuvarSend.
|
|
315
|
+
|
|
316
|
+
Once linked, credit that KuvarSend wallet directly from your backend. Create a restricted secret
|
|
317
|
+
key with `account_links:read`, `account_links:write`, `payouts:read`, and `payouts:write`; never put
|
|
318
|
+
that key or these SDK calls in browser/mobile code. The payout is synchronous and atomic: a
|
|
319
|
+
successful response means the merchant's USD wallet was debited and the user's KuvarSend balance
|
|
320
|
+
was credited in the same database transaction. You name the amount the **user receives**, in any
|
|
321
|
+
currency KuvarSend supports; we debit the USD equivalent at the quoted rate. Quote first with
|
|
322
|
+
`quoteKuvarSendPayout` to show the user what they will get before they confirm.
|
|
323
|
+
|
|
324
|
+
```javascript
|
|
325
|
+
const payout = await kv.createKuvarSendPayout({
|
|
326
|
+
linkedAccountId: linked.linked_account_id,
|
|
327
|
+
amount: '50000', // exact major units; use a decimal string
|
|
328
|
+
currency: 'RWF',
|
|
329
|
+
externalReference: 'withdrawal_123',
|
|
330
|
+
idempotencyKey: 'kuvarsend-payout:withdrawal_123',
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
console.log(payout.payout_id, payout.status); // kwp_… COMPLETED
|
|
334
|
+
|
|
335
|
+
// Reconciliation can poll one payout or walk cursor-based pages.
|
|
336
|
+
const saved = await kv.getKuvarSendPayout(payout.payout_id);
|
|
337
|
+
const page = await kv.listKuvarSendPayouts({
|
|
338
|
+
linkedAccountId: linked.linked_account_id,
|
|
339
|
+
limit: 50,
|
|
340
|
+
});
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Retry an ambiguous timeout with the same idempotency key and identical body. The API returns the
|
|
344
|
+
same payout receipt without a second debit or wallet credit. Reusing the key with a changed amount,
|
|
345
|
+
currency, link, or reference is rejected. Live and sandbox links and balances are isolated; create
|
|
346
|
+
and authorize a separate link in each environment.
|
|
347
|
+
|
|
348
|
+
Subscribe your merchant webhook to `kuvarsend.payout.completed` for durable payout completion and
|
|
349
|
+
`account_link.revoked` for authority changes. Both payloads include an opaque `event_id`, event
|
|
350
|
+
timestamp, business/environment, and link or payout identifiers. They never include the KuvarSend
|
|
351
|
+
user ID, contact details, wallet balance, or internal ledger IDs. Verify the raw request body with
|
|
352
|
+
`verifyWebhookSignature` and deduplicate processing by `event_id`.
|
|
353
|
+
|
|
354
|
+
The authorization URL is a verified HTTPS Universal/App Link. If KuvarSend is installed, it opens
|
|
355
|
+
the native consent screen. Otherwise it opens a KuvarSend install page. V1 does not approve links
|
|
356
|
+
in a web browser and does not silently preserve a request through an app-store installation: after
|
|
357
|
+
installing, the user taps **Open KuvarSend** on the page, or restarts withdrawal if the short-lived
|
|
358
|
+
request expired.
|
|
202
359
|
|
|
203
360
|
---
|
|
204
361
|
|
|
@@ -248,4 +405,4 @@ const kv = new KuvarPayServer({ ... });
|
|
|
248
405
|
|
|
249
406
|
## 📄 License
|
|
250
407
|
|
|
251
|
-
MIT © KuvarPay Development Team
|
|
408
|
+
MIT © KuvarPay Development Team
|
package/index.d.ts
CHANGED
|
@@ -2,6 +2,12 @@ export interface KuvarPayServerConfig {
|
|
|
2
2
|
baseUrl?: string;
|
|
3
3
|
businessId?: string;
|
|
4
4
|
secretApiKey?: string;
|
|
5
|
+
/**
|
|
6
|
+
* Set true when the key belongs to a sandbox-mode business. Live and sandbox
|
|
7
|
+
* are separate `methodId` spaces, so `getBanks()` must be fetched in the same
|
|
8
|
+
* mode the key resolves/pays out in. Defaults to `KUVARPAY_SANDBOX=true`.
|
|
9
|
+
*/
|
|
10
|
+
sandbox?: boolean;
|
|
5
11
|
timeoutMs?: number;
|
|
6
12
|
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
7
13
|
}
|
|
@@ -86,6 +92,102 @@ export interface VerifyPaymentResult {
|
|
|
86
92
|
raw: any;
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
export interface PrepareKuvarSendAccountLinkInput {
|
|
96
|
+
/** Stable user id from your own database. KuvarPay stores only a keyed hash. */
|
|
97
|
+
externalUserReference: string;
|
|
98
|
+
/** Exact URI registered under Dashboard → Developers → Account Linking. */
|
|
99
|
+
redirectUri: string;
|
|
100
|
+
/** Persist and reuse this value when retrying an ambiguous API request. */
|
|
101
|
+
idempotencyKey?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface PreparedKuvarSendAccountLink {
|
|
105
|
+
externalUserReference: string;
|
|
106
|
+
redirectUri: string;
|
|
107
|
+
state: string;
|
|
108
|
+
codeVerifier: string;
|
|
109
|
+
codeChallenge: string;
|
|
110
|
+
idempotencyKey: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface KuvarSendAccountLinkAuthorization extends PreparedKuvarSendAccountLink {
|
|
114
|
+
requestId: string;
|
|
115
|
+
authorizationUrl: string;
|
|
116
|
+
expiresAt: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface KuvarSendLinkedAccount {
|
|
120
|
+
linked_account_id: string;
|
|
121
|
+
status: 'ACTIVE' | 'REVOKED' | string;
|
|
122
|
+
scope: 'payouts:receive';
|
|
123
|
+
display_name: string | null;
|
|
124
|
+
kuvar_tag: string;
|
|
125
|
+
linked_at: string;
|
|
126
|
+
revoked_at?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface KuvarSendListPage<T> {
|
|
130
|
+
data: T[];
|
|
131
|
+
next_cursor: string | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface CreateKuvarSendPayoutInput {
|
|
135
|
+
linkedAccountId: string;
|
|
136
|
+
/** Exact major-unit decimal; pass a string to avoid floating-point rounding. */
|
|
137
|
+
amount: string;
|
|
138
|
+
currency: string;
|
|
139
|
+
externalReference?: string;
|
|
140
|
+
/** Persist and reuse for retries of this exact payout request. */
|
|
141
|
+
idempotencyKey: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface KuvarSendPayout {
|
|
145
|
+
payout_id: string;
|
|
146
|
+
status: 'COMPLETED' | string;
|
|
147
|
+
linked_account_id: string;
|
|
148
|
+
amount: string;
|
|
149
|
+
currency: string;
|
|
150
|
+
external_reference?: string;
|
|
151
|
+
created_at: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface KuvarSendPayoutQuote {
|
|
155
|
+
quote_id: string;
|
|
156
|
+
linked_account_id: string;
|
|
157
|
+
amount: string;
|
|
158
|
+
currency: string;
|
|
159
|
+
source_currency: 'USD';
|
|
160
|
+
source_amount: string;
|
|
161
|
+
rate: string;
|
|
162
|
+
expires_at: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface CompleteKuvarSendAccountLinkInput {
|
|
166
|
+
/** Full HTTPS URL received by your registered callback handler. */
|
|
167
|
+
callbackUrl: string;
|
|
168
|
+
/** State read from the same server-side session as the code verifier. */
|
|
169
|
+
expectedState: string;
|
|
170
|
+
redirectUri: string;
|
|
171
|
+
codeVerifier: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Cross-device completion: the user approved on another device (QR flow). */
|
|
175
|
+
export interface CompleteKuvarSendAccountLinkByRequestInput {
|
|
176
|
+
requestId: string;
|
|
177
|
+
redirectUri: string;
|
|
178
|
+
codeVerifier: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface WaitForKuvarSendAccountLinkInput {
|
|
182
|
+
requestId: string;
|
|
183
|
+
redirectUri: string;
|
|
184
|
+
codeVerifier: string;
|
|
185
|
+
/** Poll interval. Default 3000. */
|
|
186
|
+
pollMs?: number;
|
|
187
|
+
/** Give up and reject after this long. Default 10 minutes. */
|
|
188
|
+
timeoutMs?: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
89
191
|
export interface CreateSubaccountRequest {
|
|
90
192
|
business_name: string;
|
|
91
193
|
percentage_charge?: number | string;
|
|
@@ -234,8 +336,13 @@ export class KuvarPayServer {
|
|
|
234
336
|
renewSubscription(subscriptionId: string, body?: Record<string, any>): Promise<any>;
|
|
235
337
|
|
|
236
338
|
// Banks
|
|
237
|
-
getBanks(currency: string, options?: { type?: 'bank' | 'mobile_money'; direction?: 'inbound' | 'outbound' }): Promise<{ methods: Array<{ methodId: string; name: string; currency: string; type: 'bank' | 'mobile_money'; manualInput?: boolean; minAmount?: number | null; maxAmount?: number | null }> }>;
|
|
238
|
-
|
|
339
|
+
getBanks(currency: string, options?: { type?: 'bank' | 'mobile_money'; direction?: 'inbound' | 'outbound'; sandbox?: boolean }): Promise<{ methods: Array<{ methodId: string; name: string; currency: string; type: 'bank' | 'mobile_money'; manualInput?: boolean; minAmount?: number | null; maxAmount?: number | null }> }>;
|
|
340
|
+
/**
|
|
341
|
+
* `sandbox` must match the mode of the business your key belongs to — live and
|
|
342
|
+
* sandbox are separate methodId spaces. Defaults to the client-level `sandbox`
|
|
343
|
+
* option. Using the wrong one yields `reason: 'METHOD_MODE_MISMATCH'` on resolve.
|
|
344
|
+
*/
|
|
345
|
+
getBanks(params: { country?: string; currency?: string; type?: 'bank' | 'mobile_money'; direction?: 'inbound' | 'outbound'; sandbox?: boolean }): Promise<{ methods: Array<{ methodId: string; name: string; currency: string; type: 'bank' | 'mobile_money'; manualInput?: boolean; minAmount?: number | null; maxAmount?: number | null }> }>;
|
|
239
346
|
/**
|
|
240
347
|
* @deprecated Use `resolveRecipient`. Thin alias over
|
|
241
348
|
* `POST /api/v1/fiat/resolve-recipient`.
|
|
@@ -276,6 +383,7 @@ export class KuvarPayServer {
|
|
|
276
383
|
* which is a CURRENCY — pass it as `currency`, or as `country` and the SDK
|
|
277
384
|
* will route 3-letter codes correctly.
|
|
278
385
|
*/
|
|
386
|
+
/** @deprecated Alias for `getBanks`, which it delegates to. */
|
|
279
387
|
getFiatMethods(params: { country?: string; currency?: string; type?: 'bank' | 'mobile_money'; sandbox?: boolean; direction?: 'inbound' | 'outbound' }): Promise<{ methods: Array<{ methodId: string; name: string; currency: string; type: 'bank' | 'mobile_money'; manualInput?: boolean; minAmount?: number | null; maxAmount?: number | null }> }>;
|
|
280
388
|
getFiatCheckoutQuote(params: { amount: number; baseCurrency: string; currency: string; country: string }): Promise<any>;
|
|
281
389
|
createFiatTransaction(data: {
|
|
@@ -386,10 +494,47 @@ export class KuvarPayServer {
|
|
|
386
494
|
updatedAt: string;
|
|
387
495
|
}>;
|
|
388
496
|
|
|
497
|
+
// KuvarSend account authorization — SECRET key / merchant backend only.
|
|
498
|
+
prepareKuvarSendAccountLink(input: PrepareKuvarSendAccountLinkInput): PreparedKuvarSendAccountLink;
|
|
499
|
+
createKuvarSendAccountLink(
|
|
500
|
+
input: PrepareKuvarSendAccountLinkInput | PreparedKuvarSendAccountLink,
|
|
501
|
+
): Promise<KuvarSendAccountLinkAuthorization>;
|
|
502
|
+
parseKuvarSendAccountLinkCallback(
|
|
503
|
+
callbackUrl: string,
|
|
504
|
+
options: { expectedState: string; redirectUri: string },
|
|
505
|
+
): { code: string; state: string };
|
|
506
|
+
completeKuvarSendAccountLink(
|
|
507
|
+
input: CompleteKuvarSendAccountLinkInput | CompleteKuvarSendAccountLinkByRequestInput,
|
|
508
|
+
): Promise<KuvarSendLinkedAccount>;
|
|
509
|
+
/**
|
|
510
|
+
* Cross-device: polls until the user approves on their phone, then completes
|
|
511
|
+
* the exchange. Rejects with AUTHORIZATION_LINK_TIMEOUT when it runs out.
|
|
512
|
+
*/
|
|
513
|
+
waitForKuvarSendAccountLink(input: WaitForKuvarSendAccountLinkInput): Promise<KuvarSendLinkedAccount>;
|
|
514
|
+
getKuvarSendAccountLink(linkedAccountId: string): Promise<KuvarSendLinkedAccount>;
|
|
515
|
+
listKuvarSendAccountLinks(input?: {
|
|
516
|
+
status?: 'ACTIVE' | 'REVOKED';
|
|
517
|
+
limit?: number;
|
|
518
|
+
cursor?: string;
|
|
519
|
+
}): Promise<KuvarSendListPage<KuvarSendLinkedAccount>>;
|
|
520
|
+
revokeKuvarSendAccountLink(linkedAccountId: string): Promise<KuvarSendLinkedAccount>;
|
|
521
|
+
createKuvarSendPayout(input: CreateKuvarSendPayoutInput): Promise<KuvarSendPayout>;
|
|
522
|
+
quoteKuvarSendPayout(input: {
|
|
523
|
+
linkedAccountId: string;
|
|
524
|
+
amount: string;
|
|
525
|
+
currency: string;
|
|
526
|
+
}): Promise<KuvarSendPayoutQuote>;
|
|
527
|
+
getKuvarSendPayout(payoutId: string): Promise<KuvarSendPayout>;
|
|
528
|
+
listKuvarSendPayouts(input?: {
|
|
529
|
+
linkedAccountId?: string;
|
|
530
|
+
limit?: number;
|
|
531
|
+
cursor?: string;
|
|
532
|
+
}): Promise<KuvarSendListPage<KuvarSendPayout>>;
|
|
533
|
+
|
|
389
534
|
// Sandbox
|
|
390
535
|
startSandboxSimulator(): Promise<any>;
|
|
391
536
|
stopSandboxSimulator(): Promise<any>;
|
|
392
537
|
getSandboxSimulatorStatus(): Promise<any>;
|
|
393
538
|
}
|
|
394
539
|
|
|
395
|
-
export default KuvarPayServer;
|
|
540
|
+
export default KuvarPayServer;
|
package/index.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const crypto = require('crypto');
|
|
12
|
+
const { isIP } = require('net');
|
|
12
13
|
|
|
13
14
|
const DEFAULT_BASE = (process.env.KUVARPAY_API_BASE_URL
|
|
14
15
|
|| process.env.PAYMENT_API_BASE_URL
|
|
@@ -55,6 +56,35 @@ function normalizeFiatCountries(data) {
|
|
|
55
56
|
});
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
function kuvarSendLinkError(code, message) {
|
|
60
|
+
const error = new Error(message);
|
|
61
|
+
error.code = code;
|
|
62
|
+
return error;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateKuvarSendRedirectUri(value) {
|
|
66
|
+
if (typeof value !== 'string' || value.length < 12 || value.length > 2048 || value.trim() !== value
|
|
67
|
+
|| /[*{}?#]/.test(value)) {
|
|
68
|
+
throw kuvarSendLinkError('INVALID_REDIRECT_URI', 'redirectUri must be an exact public HTTPS URL');
|
|
69
|
+
}
|
|
70
|
+
let url;
|
|
71
|
+
try { url = new URL(value); } catch { throw kuvarSendLinkError('INVALID_REDIRECT_URI', 'redirectUri is invalid'); }
|
|
72
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
73
|
+
const safeHost = host.endsWith('.') ? host.slice(0, -1) : host;
|
|
74
|
+
if (url.protocol !== 'https:' || !host || safeHost === 'localhost' || safeHost.endsWith('.localhost')
|
|
75
|
+
|| isIP(safeHost) !== 0 || url.username || url.password || url.search || url.hash) {
|
|
76
|
+
throw kuvarSendLinkError('INVALID_REDIRECT_URI', 'redirectUri must be an exact public HTTPS URL');
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function equalSecretText(left, right) {
|
|
82
|
+
if (typeof left !== 'string' || typeof right !== 'string') return false;
|
|
83
|
+
const a = Buffer.from(left, 'utf8');
|
|
84
|
+
const b = Buffer.from(right, 'utf8');
|
|
85
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
86
|
+
}
|
|
87
|
+
|
|
58
88
|
class KuvarPayServer {
|
|
59
89
|
/**
|
|
60
90
|
* @param {Object} config
|
|
@@ -64,10 +94,16 @@ class KuvarPayServer {
|
|
|
64
94
|
* @param {number} [config.timeoutMs] - Request timeout in ms (default 60000)
|
|
65
95
|
* @param {Function} [config.fetchImpl] - Optional fetch implementation for Node < 18
|
|
66
96
|
*/
|
|
67
|
-
constructor({ baseUrl, businessId, secretApiKey, timeoutMs, fetchImpl } = {}) {
|
|
97
|
+
constructor({ baseUrl, businessId, secretApiKey, timeoutMs, fetchImpl, sandbox } = {}) {
|
|
68
98
|
this.baseUrl = (baseUrl || DEFAULT_BASE).replace(/\/+$/, '');
|
|
69
99
|
this.businessId = businessId || process.env.KUVARPAY_BUSINESS_ID;
|
|
70
100
|
this.secretApiKey = secretApiKey || process.env.KUVARPAY_SECRET_API_KEY || process.env.PAYMENT_SECRET_API_KEY;
|
|
101
|
+
// Set this to true when the key belongs to a sandbox-mode business, so
|
|
102
|
+
// getBanks() returns sandbox methodIds. Mixing the two spaces produces
|
|
103
|
+
// METHOD_MODE_MISMATCH on resolve and payout.
|
|
104
|
+
this.sandbox = sandbox != null
|
|
105
|
+
? !!sandbox
|
|
106
|
+
: String(process.env.KUVARPAY_SANDBOX || '').toLowerCase() === 'true';
|
|
71
107
|
this.timeoutMs = timeoutMs || 60000;
|
|
72
108
|
this.fetchImpl = fetchImpl || (typeof fetch !== 'undefined' ? fetch.bind(globalThis) : null);
|
|
73
109
|
|
|
@@ -139,6 +175,10 @@ class KuvarPayServer {
|
|
|
139
175
|
return this._request('PATCH', path, body, extraHeaders);
|
|
140
176
|
}
|
|
141
177
|
|
|
178
|
+
async _delete(path, body) {
|
|
179
|
+
return this._request('DELETE', path, body);
|
|
180
|
+
}
|
|
181
|
+
|
|
142
182
|
/**
|
|
143
183
|
* Fetch checkout session status by sessionId
|
|
144
184
|
* GET {Payment API Base}/api/v1/checkout-sessions/{sessionId}
|
|
@@ -540,11 +580,27 @@ class KuvarPayServer {
|
|
|
540
580
|
// (settlement) methods.
|
|
541
581
|
const { country, currency, type, direction = 'inbound' } = params || {};
|
|
542
582
|
if (!country && !currency) throw new Error('country or currency is required');
|
|
583
|
+
// Live and sandbox are SEPARATE methodId spaces — the same bank has a
|
|
584
|
+
// different id in each. A methodId from the wrong space is rejected
|
|
585
|
+
// downstream with reason METHOD_MODE_MISMATCH, so the list must be fetched
|
|
586
|
+
// in the same mode the key's business is in. Defaults to the client-level
|
|
587
|
+
// `sandbox` flag when the call doesn't override it.
|
|
588
|
+
const sandbox = params.sandbox != null ? params.sandbox : this.sandbox;
|
|
589
|
+
|
|
543
590
|
const qs = new URLSearchParams();
|
|
544
|
-
if (
|
|
545
|
-
if (
|
|
591
|
+
if (currency) qs.set('currency', String(currency).toUpperCase());
|
|
592
|
+
if (country) {
|
|
593
|
+
// A country and a currency go in DIFFERENT query params, and
|
|
594
|
+
// getFiatCountries() returns its identifier as `code` — which is a
|
|
595
|
+
// CURRENCY. Passing that straight through as `country=KES` silently
|
|
596
|
+
// returns { methods: [] }. 2 chars -> country, 3 chars -> currency.
|
|
597
|
+
const code = String(country).toUpperCase();
|
|
598
|
+
if (!currency && code.length === 3) qs.set('currency', code);
|
|
599
|
+
else qs.set('country', code);
|
|
600
|
+
}
|
|
546
601
|
if (type) qs.set('type', type);
|
|
547
602
|
if (direction) qs.set('direction', direction);
|
|
603
|
+
if (sandbox) qs.set('sandbox', 'true');
|
|
548
604
|
return this._get(`/api/v1/fiat/banks?${qs.toString()}`);
|
|
549
605
|
}
|
|
550
606
|
|
|
@@ -606,21 +662,14 @@ class KuvarPayServer {
|
|
|
606
662
|
*
|
|
607
663
|
* @param {Object} params - { country?, currency?, type?, sandbox?, direction? }
|
|
608
664
|
*/
|
|
665
|
+
/**
|
|
666
|
+
* @deprecated Use `getBanks`, which this now delegates to. Both hit
|
|
667
|
+
* `GET /api/v1/fiat/banks`; having two names for one endpoint is how the
|
|
668
|
+
* missing `sandbox` support on `getBanks` went unnoticed. Kept as an alias so
|
|
669
|
+
* existing integrations keep working.
|
|
670
|
+
*/
|
|
609
671
|
async getFiatMethods(params = {}) {
|
|
610
|
-
|
|
611
|
-
if (!country && !currency) throw new Error('country or currency is required');
|
|
612
|
-
|
|
613
|
-
const qs = new URLSearchParams({ direction });
|
|
614
|
-
if (currency) qs.set('currency', String(currency).toUpperCase());
|
|
615
|
-
if (country) {
|
|
616
|
-
const code = String(country).toUpperCase();
|
|
617
|
-
// A 3-letter code is a currency, not a country.
|
|
618
|
-
if (!currency && code.length === 3) qs.set('currency', code);
|
|
619
|
-
else qs.set('country', code);
|
|
620
|
-
}
|
|
621
|
-
if (type) qs.set('type', type);
|
|
622
|
-
if (sandbox) qs.set('sandbox', 'true');
|
|
623
|
-
return this._get(`/api/v1/fiat/banks?${qs.toString()}`);
|
|
672
|
+
return this.getBanks(params);
|
|
624
673
|
}
|
|
625
674
|
|
|
626
675
|
/**
|
|
@@ -925,6 +974,266 @@ class KuvarPayServer {
|
|
|
925
974
|
return this._get(`/api/v1/fiat/sends/${encodeURIComponent(sendId)}`);
|
|
926
975
|
}
|
|
927
976
|
|
|
977
|
+
/**
|
|
978
|
+
* Prepare state, PKCE and idempotency material before creating an account
|
|
979
|
+
* link. Persist the returned object in the merchant's server-side session.
|
|
980
|
+
*/
|
|
981
|
+
prepareKuvarSendAccountLink({ externalUserReference, redirectUri, idempotencyKey } = {}) {
|
|
982
|
+
if (typeof externalUserReference !== 'string' || !externalUserReference || externalUserReference.length > 255) {
|
|
983
|
+
throw kuvarSendLinkError('INVALID_EXTERNAL_REFERENCE', 'externalUserReference is required');
|
|
984
|
+
}
|
|
985
|
+
const exactRedirectUri = validateKuvarSendRedirectUri(redirectUri);
|
|
986
|
+
const state = crypto.randomBytes(32).toString('base64url');
|
|
987
|
+
const codeVerifier = crypto.randomBytes(64).toString('base64url');
|
|
988
|
+
const codeChallenge = crypto.createHash('sha256').update(codeVerifier, 'ascii').digest('base64url');
|
|
989
|
+
const retryKey = idempotencyKey || crypto.randomUUID();
|
|
990
|
+
if (typeof retryKey !== 'string' || !retryKey || retryKey.length > 255) {
|
|
991
|
+
throw kuvarSendLinkError('INVALID_IDEMPOTENCY_KEY', 'idempotencyKey must be 1-255 characters');
|
|
992
|
+
}
|
|
993
|
+
return { externalUserReference, redirectUri: exactRedirectUri, state, codeVerifier, codeChallenge, idempotencyKey: retryKey };
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
async createKuvarSendAccountLink(input = {}) {
|
|
997
|
+
const prepared = input.state && input.codeVerifier
|
|
998
|
+
? Object.assign({}, input)
|
|
999
|
+
: this.prepareKuvarSendAccountLink(input);
|
|
1000
|
+
const redirectUri = validateKuvarSendRedirectUri(prepared.redirectUri);
|
|
1001
|
+
if (typeof prepared.externalUserReference !== 'string' || !prepared.externalUserReference || prepared.externalUserReference.length > 255) {
|
|
1002
|
+
throw kuvarSendLinkError('INVALID_EXTERNAL_REFERENCE', 'externalUserReference is required');
|
|
1003
|
+
}
|
|
1004
|
+
if (typeof prepared.state !== 'string' || prepared.state.length < 22 || prepared.state.length > 512
|
|
1005
|
+
|| /[\u0000-\u001f\u007f]/.test(prepared.state)) {
|
|
1006
|
+
throw kuvarSendLinkError('INVALID_STATE', 'state is invalid');
|
|
1007
|
+
}
|
|
1008
|
+
if (typeof prepared.codeVerifier !== 'string' || !/^[A-Za-z0-9._~-]{43,128}$/.test(prepared.codeVerifier)) {
|
|
1009
|
+
throw kuvarSendLinkError('INVALID_CODE_VERIFIER', 'codeVerifier is invalid');
|
|
1010
|
+
}
|
|
1011
|
+
const codeChallenge = crypto.createHash('sha256').update(prepared.codeVerifier, 'ascii').digest('base64url');
|
|
1012
|
+
if (prepared.codeChallenge && !equalSecretText(prepared.codeChallenge, codeChallenge)) {
|
|
1013
|
+
throw kuvarSendLinkError('PKCE_MISMATCH', 'codeChallenge does not match codeVerifier');
|
|
1014
|
+
}
|
|
1015
|
+
if (typeof prepared.idempotencyKey !== 'string' || !prepared.idempotencyKey || prepared.idempotencyKey.length > 255) {
|
|
1016
|
+
throw kuvarSendLinkError('INVALID_IDEMPOTENCY_KEY', 'idempotencyKey must be 1-255 characters');
|
|
1017
|
+
}
|
|
1018
|
+
const response = await this._post('/api/v1/payouts/account-link-requests', {
|
|
1019
|
+
external_user_reference: prepared.externalUserReference,
|
|
1020
|
+
redirect_uri: redirectUri,
|
|
1021
|
+
state: prepared.state,
|
|
1022
|
+
code_challenge: codeChallenge,
|
|
1023
|
+
code_challenge_method: 'S256',
|
|
1024
|
+
scope: 'payouts:receive',
|
|
1025
|
+
}, { 'Idempotency-Key': prepared.idempotencyKey });
|
|
1026
|
+
const data = response?.data || response;
|
|
1027
|
+
return {
|
|
1028
|
+
requestId: data.id,
|
|
1029
|
+
authorizationUrl: data.authorization_url,
|
|
1030
|
+
expiresAt: data.expires_at,
|
|
1031
|
+
state: prepared.state,
|
|
1032
|
+
codeVerifier: prepared.codeVerifier,
|
|
1033
|
+
redirectUri,
|
|
1034
|
+
idempotencyKey: prepared.idempotencyKey,
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
parseKuvarSendAccountLinkCallback(callbackUrl, { expectedState, redirectUri } = {}) {
|
|
1039
|
+
const exactRedirectUri = validateKuvarSendRedirectUri(redirectUri);
|
|
1040
|
+
let callback;
|
|
1041
|
+
try { callback = new URL(callbackUrl); } catch { throw kuvarSendLinkError('INVALID_CALLBACK', 'Callback URL is invalid'); }
|
|
1042
|
+
const expected = new URL(exactRedirectUri);
|
|
1043
|
+
if (callback.protocol !== expected.protocol || callback.host !== expected.host || callback.pathname !== expected.pathname
|
|
1044
|
+
|| callback.username || callback.password || callback.hash) {
|
|
1045
|
+
throw kuvarSendLinkError('REDIRECT_URI_MISMATCH', 'Callback URL does not match redirectUri');
|
|
1046
|
+
}
|
|
1047
|
+
const states = callback.searchParams.getAll('state');
|
|
1048
|
+
if (states.length !== 1 || !equalSecretText(states[0], expectedState)) {
|
|
1049
|
+
throw kuvarSendLinkError('STATE_MISMATCH', 'Account-link callback state does not match');
|
|
1050
|
+
}
|
|
1051
|
+
const errors = callback.searchParams.getAll('error');
|
|
1052
|
+
if (errors.length) {
|
|
1053
|
+
if (errors.length !== 1 || errors[0] !== 'access_denied'
|
|
1054
|
+
|| callback.searchParams.getAll('code').length
|
|
1055
|
+
|| [...callback.searchParams.keys()].some((key) => !['error', 'state'].includes(key))) {
|
|
1056
|
+
throw kuvarSendLinkError('AUTHORIZATION_ERROR', 'KuvarSend authorization failed');
|
|
1057
|
+
}
|
|
1058
|
+
throw kuvarSendLinkError('ACCESS_DENIED', 'The user denied KuvarSend account linking');
|
|
1059
|
+
}
|
|
1060
|
+
const codes = callback.searchParams.getAll('code');
|
|
1061
|
+
if (codes.length !== 1 || !/^kpal_[A-Za-z0-9_-]{43}$/.test(codes[0])) {
|
|
1062
|
+
throw kuvarSendLinkError('INVALID_AUTHORIZATION_CODE', 'Authorization code is missing or invalid');
|
|
1063
|
+
}
|
|
1064
|
+
if ([...callback.searchParams.keys()].some((key) => !['code', 'state'].includes(key))) {
|
|
1065
|
+
throw kuvarSendLinkError('INVALID_CALLBACK', 'Callback URL contains unsupported parameters');
|
|
1066
|
+
}
|
|
1067
|
+
return { code: codes[0], state: states[0] };
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
async completeKuvarSendAccountLink(input = {}) {
|
|
1071
|
+
if (typeof input.codeVerifier !== 'string' || !/^[A-Za-z0-9._~-]{43,128}$/.test(input.codeVerifier)) {
|
|
1072
|
+
throw kuvarSendLinkError('INVALID_CODE_VERIFIER', 'codeVerifier is invalid');
|
|
1073
|
+
}
|
|
1074
|
+
// Cross-device shape: the desktop session identifies the request directly
|
|
1075
|
+
// and polls until the phone approves. No code ever crosses devices.
|
|
1076
|
+
if (input.requestId != null) {
|
|
1077
|
+
if (!/^alr_[0-9a-f]{32}$/.test(String(input.requestId))) {
|
|
1078
|
+
throw kuvarSendLinkError('INVALID_REQUEST_ID', 'requestId is invalid');
|
|
1079
|
+
}
|
|
1080
|
+
const response = await this._post('/api/v1/payouts/account-links/exchange', {
|
|
1081
|
+
request_id: input.requestId,
|
|
1082
|
+
redirect_uri: input.redirectUri,
|
|
1083
|
+
code_verifier: input.codeVerifier,
|
|
1084
|
+
});
|
|
1085
|
+
return response?.data || response;
|
|
1086
|
+
}
|
|
1087
|
+
const callback = this.parseKuvarSendAccountLinkCallback(input.callbackUrl, {
|
|
1088
|
+
expectedState: input.expectedState,
|
|
1089
|
+
redirectUri: input.redirectUri,
|
|
1090
|
+
});
|
|
1091
|
+
const response = await this._post('/api/v1/payouts/account-links/exchange', {
|
|
1092
|
+
code: callback.code,
|
|
1093
|
+
redirect_uri: input.redirectUri,
|
|
1094
|
+
code_verifier: input.codeVerifier,
|
|
1095
|
+
});
|
|
1096
|
+
return response?.data || response;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Cross-device completion: polls `completeKuvarSendAccountLink({ requestId, … })`
|
|
1101
|
+
* while the request is still pending, so a desktop session finishes on its own
|
|
1102
|
+
* the moment the user approves in KuvarSend.
|
|
1103
|
+
*/
|
|
1104
|
+
async waitForKuvarSendAccountLink({
|
|
1105
|
+
requestId,
|
|
1106
|
+
redirectUri,
|
|
1107
|
+
codeVerifier,
|
|
1108
|
+
pollMs = 3000,
|
|
1109
|
+
timeoutMs = 10 * 60 * 1000,
|
|
1110
|
+
} = {}) {
|
|
1111
|
+
if (!/^alr_[0-9a-f]{32}$/.test(String(requestId || ''))) {
|
|
1112
|
+
throw kuvarSendLinkError('INVALID_REQUEST_ID', 'requestId is invalid');
|
|
1113
|
+
}
|
|
1114
|
+
const deadline = Date.now() + timeoutMs;
|
|
1115
|
+
for (;;) {
|
|
1116
|
+
try {
|
|
1117
|
+
return await this.completeKuvarSendAccountLink({ requestId, redirectUri, codeVerifier });
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
// The gateway answers 409 AUTHORIZATION_NOT_READY while approval is
|
|
1120
|
+
// pending (err.body carries the JSON). Anything else is real.
|
|
1121
|
+
const notReady = error?.body?.code === 'AUTHORIZATION_NOT_READY'
|
|
1122
|
+
|| error?.code === 'AUTHORIZATION_NOT_READY';
|
|
1123
|
+
if (!notReady) throw error;
|
|
1124
|
+
if (Date.now() >= deadline) {
|
|
1125
|
+
throw kuvarSendLinkError('AUTHORIZATION_LINK_TIMEOUT', 'The account-link request was not approved in time.');
|
|
1126
|
+
}
|
|
1127
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(250, pollMs)));
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
async getKuvarSendAccountLink(linkedAccountId) {
|
|
1133
|
+
if (!/^kal_[0-9a-f]{32}$/.test(String(linkedAccountId || ''))) {
|
|
1134
|
+
throw kuvarSendLinkError('INVALID_LINKED_ACCOUNT_ID', 'linkedAccountId is invalid');
|
|
1135
|
+
}
|
|
1136
|
+
const response = await this._get(`/api/v1/payouts/account-links/${encodeURIComponent(linkedAccountId)}`);
|
|
1137
|
+
return response?.data || response;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
async listKuvarSendAccountLinks({ status, limit = 50, cursor } = {}) {
|
|
1141
|
+
if (status != null && !['ACTIVE', 'REVOKED'].includes(status)) {
|
|
1142
|
+
throw kuvarSendLinkError('INVALID_STATUS', 'status must be ACTIVE or REVOKED');
|
|
1143
|
+
}
|
|
1144
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
1145
|
+
throw kuvarSendLinkError('INVALID_LIMIT', 'limit must be an integer from 1 to 100');
|
|
1146
|
+
}
|
|
1147
|
+
if (cursor != null && !/^kal_[0-9a-f]{32}$/.test(String(cursor))) {
|
|
1148
|
+
throw kuvarSendLinkError('INVALID_CURSOR', 'cursor is invalid');
|
|
1149
|
+
}
|
|
1150
|
+
const query = new URLSearchParams();
|
|
1151
|
+
if (status) query.set('status', status);
|
|
1152
|
+
query.set('limit', String(limit));
|
|
1153
|
+
if (cursor) query.set('cursor', cursor);
|
|
1154
|
+
return this._get(`/api/v1/payouts/account-links?${query}`);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
async createKuvarSendPayout({ linkedAccountId, amount, currency, externalReference, idempotencyKey } = {}) {
|
|
1158
|
+
if (!/^kal_[0-9a-f]{32}$/.test(String(linkedAccountId || ''))) {
|
|
1159
|
+
throw kuvarSendLinkError('INVALID_LINKED_ACCOUNT_ID', 'linkedAccountId is invalid');
|
|
1160
|
+
}
|
|
1161
|
+
const exactAmount = String(amount ?? '');
|
|
1162
|
+
if (!/^\d+(?:\.\d+)?$/.test(exactAmount) || exactAmount.length > 80) {
|
|
1163
|
+
throw kuvarSendLinkError('INVALID_AMOUNT', 'amount must be a positive decimal string');
|
|
1164
|
+
}
|
|
1165
|
+
const exactCurrency = String(currency || '').toUpperCase();
|
|
1166
|
+
if (!/^[A-Z]{3,5}$/.test(exactCurrency)) {
|
|
1167
|
+
throw kuvarSendLinkError('INVALID_CURRENCY', 'currency must be a 3-5 letter code');
|
|
1168
|
+
}
|
|
1169
|
+
if (externalReference != null && (typeof externalReference !== 'string' || !externalReference || externalReference.length > 255)) {
|
|
1170
|
+
throw kuvarSendLinkError('INVALID_EXTERNAL_REFERENCE', 'externalReference must be 1-255 characters');
|
|
1171
|
+
}
|
|
1172
|
+
if (typeof idempotencyKey !== 'string' || !idempotencyKey || idempotencyKey.length > 255) {
|
|
1173
|
+
throw kuvarSendLinkError('INVALID_IDEMPOTENCY_KEY', 'idempotencyKey must be 1-255 characters');
|
|
1174
|
+
}
|
|
1175
|
+
const response = await this._post('/api/v1/payouts/kuvarsend', {
|
|
1176
|
+
linked_account_id: linkedAccountId,
|
|
1177
|
+
amount: exactAmount,
|
|
1178
|
+
currency: exactCurrency,
|
|
1179
|
+
...(externalReference ? { external_reference: externalReference } : {}),
|
|
1180
|
+
}, { 'Idempotency-Key': idempotencyKey });
|
|
1181
|
+
return response?.data || response;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
async quoteKuvarSendPayout({ linkedAccountId, amount, currency } = {}) {
|
|
1185
|
+
if (!/^kal_[0-9a-f]{32}$/.test(String(linkedAccountId || ''))) {
|
|
1186
|
+
throw kuvarSendLinkError('INVALID_LINKED_ACCOUNT_ID', 'linkedAccountId is invalid');
|
|
1187
|
+
}
|
|
1188
|
+
const exactAmount = String(amount ?? '');
|
|
1189
|
+
if (!/^\d+(?:\.\d+)?$/.test(exactAmount) || exactAmount.length > 80) {
|
|
1190
|
+
throw kuvarSendLinkError('INVALID_AMOUNT', 'amount must be a positive decimal string');
|
|
1191
|
+
}
|
|
1192
|
+
const exactCurrency = String(currency || '').toUpperCase();
|
|
1193
|
+
if (!/^[A-Z]{3,5}$/.test(exactCurrency)) {
|
|
1194
|
+
throw kuvarSendLinkError('INVALID_CURRENCY', 'currency must be a 3-5 letter code');
|
|
1195
|
+
}
|
|
1196
|
+
const response = await this._post('/api/v1/payouts/kuvarsend/quote', {
|
|
1197
|
+
linked_account_id: linkedAccountId,
|
|
1198
|
+
amount: exactAmount,
|
|
1199
|
+
currency: exactCurrency,
|
|
1200
|
+
});
|
|
1201
|
+
return response?.data || response;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async getKuvarSendPayout(payoutId) {
|
|
1205
|
+
if (!/^kwp_[0-9a-f]{32}$/.test(String(payoutId || ''))) {
|
|
1206
|
+
throw kuvarSendLinkError('INVALID_PAYOUT_ID', 'payoutId is invalid');
|
|
1207
|
+
}
|
|
1208
|
+
const response = await this._get(`/api/v1/payouts/kuvarsend/${encodeURIComponent(payoutId)}`);
|
|
1209
|
+
return response?.data || response;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
async listKuvarSendPayouts({ linkedAccountId, limit = 50, cursor } = {}) {
|
|
1213
|
+
if (linkedAccountId != null && !/^kal_[0-9a-f]{32}$/.test(String(linkedAccountId))) {
|
|
1214
|
+
throw kuvarSendLinkError('INVALID_LINKED_ACCOUNT_ID', 'linkedAccountId is invalid');
|
|
1215
|
+
}
|
|
1216
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
1217
|
+
throw kuvarSendLinkError('INVALID_LIMIT', 'limit must be an integer from 1 to 100');
|
|
1218
|
+
}
|
|
1219
|
+
if (cursor != null && !/^kwp_[0-9a-f]{32}$/.test(String(cursor))) {
|
|
1220
|
+
throw kuvarSendLinkError('INVALID_CURSOR', 'cursor is invalid');
|
|
1221
|
+
}
|
|
1222
|
+
const query = new URLSearchParams();
|
|
1223
|
+
if (linkedAccountId) query.set('linked_account_id', linkedAccountId);
|
|
1224
|
+
query.set('limit', String(limit));
|
|
1225
|
+
if (cursor) query.set('cursor', cursor);
|
|
1226
|
+
return this._get(`/api/v1/payouts/kuvarsend?${query}`);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
async revokeKuvarSendAccountLink(linkedAccountId) {
|
|
1230
|
+
if (!/^kal_[0-9a-f]{32}$/.test(String(linkedAccountId || ''))) {
|
|
1231
|
+
throw kuvarSendLinkError('INVALID_LINKED_ACCOUNT_ID', 'linkedAccountId is invalid');
|
|
1232
|
+
}
|
|
1233
|
+
const response = await this._delete(`/api/v1/payouts/account-links/${encodeURIComponent(linkedAccountId)}`);
|
|
1234
|
+
return response?.data || response;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
928
1237
|
// --- Sandbox Simulator ---
|
|
929
1238
|
|
|
930
1239
|
async startSandboxSimulator() {
|
|
@@ -1035,4 +1344,4 @@ class KuvarPayServer {
|
|
|
1035
1344
|
}
|
|
1036
1345
|
}
|
|
1037
1346
|
|
|
1038
|
-
module.exports = { KuvarPayServer };
|
|
1347
|
+
module.exports = { KuvarPayServer };
|
package/index.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import crypto from 'node:crypto';
|
|
10
|
+
import { isIP } from 'node:net';
|
|
10
11
|
|
|
11
12
|
const DEFAULT_BASE = (process.env.KUVARPAY_API_BASE_URL
|
|
12
13
|
|| process.env.PAYMENT_API_BASE_URL
|
|
@@ -52,6 +53,35 @@ function normalizeFiatCountries(data) {
|
|
|
52
53
|
});
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
function kuvarSendLinkError(code, message) {
|
|
57
|
+
const error = new Error(message);
|
|
58
|
+
error.code = code;
|
|
59
|
+
return error;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function validateKuvarSendRedirectUri(value) {
|
|
63
|
+
if (typeof value !== 'string' || value.length < 12 || value.length > 2048 || value.trim() !== value
|
|
64
|
+
|| /[*{}?#]/.test(value)) {
|
|
65
|
+
throw kuvarSendLinkError('INVALID_REDIRECT_URI', 'redirectUri must be an exact public HTTPS URL');
|
|
66
|
+
}
|
|
67
|
+
let url;
|
|
68
|
+
try { url = new URL(value); } catch { throw kuvarSendLinkError('INVALID_REDIRECT_URI', 'redirectUri is invalid'); }
|
|
69
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
70
|
+
const safeHost = host.endsWith('.') ? host.slice(0, -1) : host;
|
|
71
|
+
if (url.protocol !== 'https:' || !host || safeHost === 'localhost' || safeHost.endsWith('.localhost')
|
|
72
|
+
|| isIP(safeHost) !== 0 || url.username || url.password || url.search || url.hash) {
|
|
73
|
+
throw kuvarSendLinkError('INVALID_REDIRECT_URI', 'redirectUri must be an exact public HTTPS URL');
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function equalSecretText(left, right) {
|
|
79
|
+
if (typeof left !== 'string' || typeof right !== 'string') return false;
|
|
80
|
+
const a = Buffer.from(left, 'utf8');
|
|
81
|
+
const b = Buffer.from(right, 'utf8');
|
|
82
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
83
|
+
}
|
|
84
|
+
|
|
55
85
|
export class KuvarPayServer {
|
|
56
86
|
/**
|
|
57
87
|
* @param {Object} config
|
|
@@ -61,10 +91,16 @@ export class KuvarPayServer {
|
|
|
61
91
|
* @param {number} [config.timeoutMs] - Request timeout in ms (default 60000)
|
|
62
92
|
* @param {Function} [config.fetchImpl] - Optional fetch implementation for Node < 18
|
|
63
93
|
*/
|
|
64
|
-
constructor({ baseUrl, businessId, secretApiKey, timeoutMs, fetchImpl } = {}) {
|
|
94
|
+
constructor({ baseUrl, businessId, secretApiKey, timeoutMs, fetchImpl, sandbox } = {}) {
|
|
65
95
|
this.baseUrl = (baseUrl || DEFAULT_BASE).replace(/\/+$/, '');
|
|
66
96
|
this.businessId = businessId || process.env.KUVARPAY_BUSINESS_ID;
|
|
67
97
|
this.secretApiKey = secretApiKey || process.env.KUVARPAY_SECRET_API_KEY || process.env.PAYMENT_SECRET_API_KEY;
|
|
98
|
+
// Set this to true when the key belongs to a sandbox-mode business, so
|
|
99
|
+
// getBanks() returns sandbox methodIds. Mixing the two spaces produces
|
|
100
|
+
// METHOD_MODE_MISMATCH on resolve and payout.
|
|
101
|
+
this.sandbox = sandbox != null
|
|
102
|
+
? !!sandbox
|
|
103
|
+
: String(process.env.KUVARPAY_SANDBOX || '').toLowerCase() === 'true';
|
|
68
104
|
this.timeoutMs = timeoutMs || 60000;
|
|
69
105
|
this.fetchImpl = fetchImpl || (typeof fetch !== 'undefined' ? fetch.bind(globalThis) : null);
|
|
70
106
|
|
|
@@ -136,6 +172,10 @@ export class KuvarPayServer {
|
|
|
136
172
|
return this._request('PATCH', path, body, extraHeaders);
|
|
137
173
|
}
|
|
138
174
|
|
|
175
|
+
async _delete(path, body) {
|
|
176
|
+
return this._request('DELETE', path, body);
|
|
177
|
+
}
|
|
178
|
+
|
|
139
179
|
/**
|
|
140
180
|
* Signature Verification for Webhooks
|
|
141
181
|
*
|
|
@@ -428,11 +468,27 @@ export class KuvarPayServer {
|
|
|
428
468
|
// (settlement) methods.
|
|
429
469
|
const { country, currency, type, direction = 'inbound' } = params || {};
|
|
430
470
|
if (!country && !currency) throw new Error('country or currency is required');
|
|
471
|
+
// Live and sandbox are SEPARATE methodId spaces — the same bank has a
|
|
472
|
+
// different id in each. A methodId from the wrong space is rejected
|
|
473
|
+
// downstream with reason METHOD_MODE_MISMATCH, so the list must be fetched
|
|
474
|
+
// in the same mode the key's business is in. Defaults to the client-level
|
|
475
|
+
// `sandbox` flag when the call doesn't override it.
|
|
476
|
+
const sandbox = params.sandbox != null ? params.sandbox : this.sandbox;
|
|
477
|
+
|
|
431
478
|
const qs = new URLSearchParams();
|
|
432
|
-
if (
|
|
433
|
-
if (
|
|
479
|
+
if (currency) qs.set('currency', String(currency).toUpperCase());
|
|
480
|
+
if (country) {
|
|
481
|
+
// A country and a currency go in DIFFERENT query params, and
|
|
482
|
+
// getFiatCountries() returns its identifier as `code` — which is a
|
|
483
|
+
// CURRENCY. Passing that straight through as `country=KES` silently
|
|
484
|
+
// returns { methods: [] }. 2 chars → country, 3 chars → currency.
|
|
485
|
+
const code = String(country).toUpperCase();
|
|
486
|
+
if (!currency && code.length === 3) qs.set('currency', code);
|
|
487
|
+
else qs.set('country', code);
|
|
488
|
+
}
|
|
434
489
|
if (type) qs.set('type', type);
|
|
435
490
|
if (direction) qs.set('direction', direction);
|
|
491
|
+
if (sandbox) qs.set('sandbox', 'true');
|
|
436
492
|
return this._get(`/api/v1/fiat/banks?${qs.toString()}`);
|
|
437
493
|
}
|
|
438
494
|
|
|
@@ -480,28 +536,14 @@ export class KuvarPayServer {
|
|
|
480
536
|
return normalizeFiatCountries(data);
|
|
481
537
|
}
|
|
482
538
|
|
|
483
|
-
/** Payment methods (banks / MoMo operators) for a country. */
|
|
484
539
|
/**
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
* silently returned `{ methods: [] }`. 2 chars → country, 3 chars → currency.
|
|
540
|
+
* @deprecated Use {@link getBanks}, which this now delegates to. Both hit
|
|
541
|
+
* `GET /api/v1/fiat/banks`; having two names for one endpoint is how the
|
|
542
|
+
* missing `sandbox` support on `getBanks` went unnoticed. Kept as an alias so
|
|
543
|
+
* existing integrations keep working.
|
|
490
544
|
*/
|
|
491
545
|
async getFiatMethods(params = {}) {
|
|
492
|
-
|
|
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);
|
|
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()}`);
|
|
546
|
+
return this.getBanks(params);
|
|
505
547
|
}
|
|
506
548
|
|
|
507
549
|
/** Preview the local amount a customer must pay to settle a checkout amount. */
|
|
@@ -754,6 +796,261 @@ export class KuvarPayServer {
|
|
|
754
796
|
return this._get(`/api/v1/fiat/sends/${encodeURIComponent(sendId)}`);
|
|
755
797
|
}
|
|
756
798
|
|
|
799
|
+
prepareKuvarSendAccountLink({ externalUserReference, redirectUri, idempotencyKey } = {}) {
|
|
800
|
+
if (typeof externalUserReference !== 'string' || !externalUserReference || externalUserReference.length > 255) {
|
|
801
|
+
throw kuvarSendLinkError('INVALID_EXTERNAL_REFERENCE', 'externalUserReference is required');
|
|
802
|
+
}
|
|
803
|
+
const exactRedirectUri = validateKuvarSendRedirectUri(redirectUri);
|
|
804
|
+
const state = crypto.randomBytes(32).toString('base64url');
|
|
805
|
+
const codeVerifier = crypto.randomBytes(64).toString('base64url');
|
|
806
|
+
const codeChallenge = crypto.createHash('sha256').update(codeVerifier, 'ascii').digest('base64url');
|
|
807
|
+
const retryKey = idempotencyKey || crypto.randomUUID();
|
|
808
|
+
if (typeof retryKey !== 'string' || !retryKey || retryKey.length > 255) {
|
|
809
|
+
throw kuvarSendLinkError('INVALID_IDEMPOTENCY_KEY', 'idempotencyKey must be 1-255 characters');
|
|
810
|
+
}
|
|
811
|
+
return { externalUserReference, redirectUri: exactRedirectUri, state, codeVerifier, codeChallenge, idempotencyKey: retryKey };
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async createKuvarSendAccountLink(input = {}) {
|
|
815
|
+
const prepared = input.state && input.codeVerifier
|
|
816
|
+
? Object.assign({}, input)
|
|
817
|
+
: this.prepareKuvarSendAccountLink(input);
|
|
818
|
+
const redirectUri = validateKuvarSendRedirectUri(prepared.redirectUri);
|
|
819
|
+
if (typeof prepared.externalUserReference !== 'string' || !prepared.externalUserReference || prepared.externalUserReference.length > 255) {
|
|
820
|
+
throw kuvarSendLinkError('INVALID_EXTERNAL_REFERENCE', 'externalUserReference is required');
|
|
821
|
+
}
|
|
822
|
+
if (typeof prepared.state !== 'string' || prepared.state.length < 22 || prepared.state.length > 512
|
|
823
|
+
|| /[\u0000-\u001f\u007f]/.test(prepared.state)) {
|
|
824
|
+
throw kuvarSendLinkError('INVALID_STATE', 'state is invalid');
|
|
825
|
+
}
|
|
826
|
+
if (typeof prepared.codeVerifier !== 'string' || !/^[A-Za-z0-9._~-]{43,128}$/.test(prepared.codeVerifier)) {
|
|
827
|
+
throw kuvarSendLinkError('INVALID_CODE_VERIFIER', 'codeVerifier is invalid');
|
|
828
|
+
}
|
|
829
|
+
const codeChallenge = crypto.createHash('sha256').update(prepared.codeVerifier, 'ascii').digest('base64url');
|
|
830
|
+
if (prepared.codeChallenge && !equalSecretText(prepared.codeChallenge, codeChallenge)) {
|
|
831
|
+
throw kuvarSendLinkError('PKCE_MISMATCH', 'codeChallenge does not match codeVerifier');
|
|
832
|
+
}
|
|
833
|
+
if (typeof prepared.idempotencyKey !== 'string' || !prepared.idempotencyKey || prepared.idempotencyKey.length > 255) {
|
|
834
|
+
throw kuvarSendLinkError('INVALID_IDEMPOTENCY_KEY', 'idempotencyKey must be 1-255 characters');
|
|
835
|
+
}
|
|
836
|
+
const response = await this._post('/api/v1/payouts/account-link-requests', {
|
|
837
|
+
external_user_reference: prepared.externalUserReference,
|
|
838
|
+
redirect_uri: redirectUri,
|
|
839
|
+
state: prepared.state,
|
|
840
|
+
code_challenge: codeChallenge,
|
|
841
|
+
code_challenge_method: 'S256',
|
|
842
|
+
scope: 'payouts:receive',
|
|
843
|
+
}, { 'Idempotency-Key': prepared.idempotencyKey });
|
|
844
|
+
const data = response?.data || response;
|
|
845
|
+
return {
|
|
846
|
+
requestId: data.id,
|
|
847
|
+
authorizationUrl: data.authorization_url,
|
|
848
|
+
expiresAt: data.expires_at,
|
|
849
|
+
state: prepared.state,
|
|
850
|
+
codeVerifier: prepared.codeVerifier,
|
|
851
|
+
redirectUri,
|
|
852
|
+
idempotencyKey: prepared.idempotencyKey,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
parseKuvarSendAccountLinkCallback(callbackUrl, { expectedState, redirectUri } = {}) {
|
|
857
|
+
const exactRedirectUri = validateKuvarSendRedirectUri(redirectUri);
|
|
858
|
+
let callback;
|
|
859
|
+
try { callback = new URL(callbackUrl); } catch { throw kuvarSendLinkError('INVALID_CALLBACK', 'Callback URL is invalid'); }
|
|
860
|
+
const expected = new URL(exactRedirectUri);
|
|
861
|
+
if (callback.protocol !== expected.protocol || callback.host !== expected.host || callback.pathname !== expected.pathname
|
|
862
|
+
|| callback.username || callback.password || callback.hash) {
|
|
863
|
+
throw kuvarSendLinkError('REDIRECT_URI_MISMATCH', 'Callback URL does not match redirectUri');
|
|
864
|
+
}
|
|
865
|
+
const states = callback.searchParams.getAll('state');
|
|
866
|
+
if (states.length !== 1 || !equalSecretText(states[0], expectedState)) {
|
|
867
|
+
throw kuvarSendLinkError('STATE_MISMATCH', 'Account-link callback state does not match');
|
|
868
|
+
}
|
|
869
|
+
const errors = callback.searchParams.getAll('error');
|
|
870
|
+
if (errors.length) {
|
|
871
|
+
if (errors.length !== 1 || errors[0] !== 'access_denied'
|
|
872
|
+
|| callback.searchParams.getAll('code').length
|
|
873
|
+
|| [...callback.searchParams.keys()].some((key) => !['error', 'state'].includes(key))) {
|
|
874
|
+
throw kuvarSendLinkError('AUTHORIZATION_ERROR', 'KuvarSend authorization failed');
|
|
875
|
+
}
|
|
876
|
+
throw kuvarSendLinkError('ACCESS_DENIED', 'The user denied KuvarSend account linking');
|
|
877
|
+
}
|
|
878
|
+
const codes = callback.searchParams.getAll('code');
|
|
879
|
+
if (codes.length !== 1 || !/^kpal_[A-Za-z0-9_-]{43}$/.test(codes[0])) {
|
|
880
|
+
throw kuvarSendLinkError('INVALID_AUTHORIZATION_CODE', 'Authorization code is missing or invalid');
|
|
881
|
+
}
|
|
882
|
+
if ([...callback.searchParams.keys()].some((key) => !['code', 'state'].includes(key))) {
|
|
883
|
+
throw kuvarSendLinkError('INVALID_CALLBACK', 'Callback URL contains unsupported parameters');
|
|
884
|
+
}
|
|
885
|
+
return { code: codes[0], state: states[0] };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async completeKuvarSendAccountLink(input = {}) {
|
|
889
|
+
if (typeof input.codeVerifier !== 'string' || !/^[A-Za-z0-9._~-]{43,128}$/.test(input.codeVerifier)) {
|
|
890
|
+
throw kuvarSendLinkError('INVALID_CODE_VERIFIER', 'codeVerifier is invalid');
|
|
891
|
+
}
|
|
892
|
+
// Cross-device shape: the desktop session identifies the request directly
|
|
893
|
+
// and polls until the phone approves. No code ever crosses devices.
|
|
894
|
+
if (input.requestId != null) {
|
|
895
|
+
if (!/^alr_[0-9a-f]{32}$/.test(String(input.requestId))) {
|
|
896
|
+
throw kuvarSendLinkError('INVALID_REQUEST_ID', 'requestId is invalid');
|
|
897
|
+
}
|
|
898
|
+
const response = await this._post('/api/v1/payouts/account-links/exchange', {
|
|
899
|
+
request_id: input.requestId,
|
|
900
|
+
redirect_uri: input.redirectUri,
|
|
901
|
+
code_verifier: input.codeVerifier,
|
|
902
|
+
});
|
|
903
|
+
return response?.data || response;
|
|
904
|
+
}
|
|
905
|
+
const callback = this.parseKuvarSendAccountLinkCallback(input.callbackUrl, {
|
|
906
|
+
expectedState: input.expectedState,
|
|
907
|
+
redirectUri: input.redirectUri,
|
|
908
|
+
});
|
|
909
|
+
const response = await this._post('/api/v1/payouts/account-links/exchange', {
|
|
910
|
+
code: callback.code,
|
|
911
|
+
redirect_uri: input.redirectUri,
|
|
912
|
+
code_verifier: input.codeVerifier,
|
|
913
|
+
});
|
|
914
|
+
return response?.data || response;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Cross-device completion: polls `completeKuvarSendAccountLink({ requestId, … })`
|
|
919
|
+
* while the request is still pending, so a desktop session finishes on its own
|
|
920
|
+
* the moment the user approves in KuvarSend. Resolves with the linked account.
|
|
921
|
+
*/
|
|
922
|
+
async waitForKuvarSendAccountLink({
|
|
923
|
+
requestId,
|
|
924
|
+
redirectUri,
|
|
925
|
+
codeVerifier,
|
|
926
|
+
pollMs = 3000,
|
|
927
|
+
timeoutMs = 10 * 60 * 1000,
|
|
928
|
+
} = {}) {
|
|
929
|
+
if (!/^alr_[0-9a-f]{32}$/.test(String(requestId || ''))) {
|
|
930
|
+
throw kuvarSendLinkError('INVALID_REQUEST_ID', 'requestId is invalid');
|
|
931
|
+
}
|
|
932
|
+
const deadline = Date.now() + timeoutMs;
|
|
933
|
+
for (;;) {
|
|
934
|
+
try {
|
|
935
|
+
return await this.completeKuvarSendAccountLink({ requestId, redirectUri, codeVerifier });
|
|
936
|
+
} catch (error) {
|
|
937
|
+
// The gateway answers 409 AUTHORIZATION_NOT_READY while approval is
|
|
938
|
+
// pending (err.body carries the JSON). Anything else is real.
|
|
939
|
+
const notReady = error?.body?.code === 'AUTHORIZATION_NOT_READY'
|
|
940
|
+
|| error?.code === 'AUTHORIZATION_NOT_READY';
|
|
941
|
+
if (!notReady) throw error;
|
|
942
|
+
if (Date.now() >= deadline) {
|
|
943
|
+
throw kuvarSendLinkError('AUTHORIZATION_LINK_TIMEOUT', 'The account-link request was not approved in time.');
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(250, pollMs)));
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
async getKuvarSendAccountLink(linkedAccountId) {
|
|
951
|
+
if (!/^kal_[0-9a-f]{32}$/.test(String(linkedAccountId || ''))) {
|
|
952
|
+
throw kuvarSendLinkError('INVALID_LINKED_ACCOUNT_ID', 'linkedAccountId is invalid');
|
|
953
|
+
}
|
|
954
|
+
const response = await this._get(`/api/v1/payouts/account-links/${encodeURIComponent(linkedAccountId)}`);
|
|
955
|
+
return response?.data || response;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
async listKuvarSendAccountLinks({ status, limit = 50, cursor } = {}) {
|
|
959
|
+
if (status != null && !['ACTIVE', 'REVOKED'].includes(status)) {
|
|
960
|
+
throw kuvarSendLinkError('INVALID_STATUS', 'status must be ACTIVE or REVOKED');
|
|
961
|
+
}
|
|
962
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
963
|
+
throw kuvarSendLinkError('INVALID_LIMIT', 'limit must be an integer from 1 to 100');
|
|
964
|
+
}
|
|
965
|
+
if (cursor != null && !/^kal_[0-9a-f]{32}$/.test(String(cursor))) {
|
|
966
|
+
throw kuvarSendLinkError('INVALID_CURSOR', 'cursor is invalid');
|
|
967
|
+
}
|
|
968
|
+
const query = new URLSearchParams();
|
|
969
|
+
if (status) query.set('status', status);
|
|
970
|
+
query.set('limit', String(limit));
|
|
971
|
+
if (cursor) query.set('cursor', cursor);
|
|
972
|
+
return this._get(`/api/v1/payouts/account-links?${query}`);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async createKuvarSendPayout({ linkedAccountId, amount, currency, externalReference, idempotencyKey } = {}) {
|
|
976
|
+
if (!/^kal_[0-9a-f]{32}$/.test(String(linkedAccountId || ''))) {
|
|
977
|
+
throw kuvarSendLinkError('INVALID_LINKED_ACCOUNT_ID', 'linkedAccountId is invalid');
|
|
978
|
+
}
|
|
979
|
+
const exactAmount = String(amount ?? '');
|
|
980
|
+
if (!/^\d+(?:\.\d+)?$/.test(exactAmount) || exactAmount.length > 80) {
|
|
981
|
+
throw kuvarSendLinkError('INVALID_AMOUNT', 'amount must be a positive decimal string');
|
|
982
|
+
}
|
|
983
|
+
const exactCurrency = String(currency || '').toUpperCase();
|
|
984
|
+
if (!/^[A-Z]{3,5}$/.test(exactCurrency)) {
|
|
985
|
+
throw kuvarSendLinkError('INVALID_CURRENCY', 'currency must be a 3-5 letter code');
|
|
986
|
+
}
|
|
987
|
+
if (externalReference != null && (typeof externalReference !== 'string' || !externalReference || externalReference.length > 255)) {
|
|
988
|
+
throw kuvarSendLinkError('INVALID_EXTERNAL_REFERENCE', 'externalReference must be 1-255 characters');
|
|
989
|
+
}
|
|
990
|
+
if (typeof idempotencyKey !== 'string' || !idempotencyKey || idempotencyKey.length > 255) {
|
|
991
|
+
throw kuvarSendLinkError('INVALID_IDEMPOTENCY_KEY', 'idempotencyKey must be 1-255 characters');
|
|
992
|
+
}
|
|
993
|
+
const response = await this._post('/api/v1/payouts/kuvarsend', {
|
|
994
|
+
linked_account_id: linkedAccountId,
|
|
995
|
+
amount: exactAmount,
|
|
996
|
+
currency: exactCurrency,
|
|
997
|
+
...(externalReference ? { external_reference: externalReference } : {}),
|
|
998
|
+
}, { 'Idempotency-Key': idempotencyKey });
|
|
999
|
+
return response?.data || response;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
async quoteKuvarSendPayout({ linkedAccountId, amount, currency } = {}) {
|
|
1003
|
+
if (!/^kal_[0-9a-f]{32}$/.test(String(linkedAccountId || ''))) {
|
|
1004
|
+
throw kuvarSendLinkError('INVALID_LINKED_ACCOUNT_ID', 'linkedAccountId is invalid');
|
|
1005
|
+
}
|
|
1006
|
+
const exactAmount = String(amount ?? '');
|
|
1007
|
+
if (!/^\d+(?:\.\d+)?$/.test(exactAmount) || exactAmount.length > 80) {
|
|
1008
|
+
throw kuvarSendLinkError('INVALID_AMOUNT', 'amount must be a positive decimal string');
|
|
1009
|
+
}
|
|
1010
|
+
const exactCurrency = String(currency || '').toUpperCase();
|
|
1011
|
+
if (!/^[A-Z]{3,5}$/.test(exactCurrency)) {
|
|
1012
|
+
throw kuvarSendLinkError('INVALID_CURRENCY', 'currency must be a 3-5 letter code');
|
|
1013
|
+
}
|
|
1014
|
+
const response = await this._post('/api/v1/payouts/kuvarsend/quote', {
|
|
1015
|
+
linked_account_id: linkedAccountId,
|
|
1016
|
+
amount: exactAmount,
|
|
1017
|
+
currency: exactCurrency,
|
|
1018
|
+
});
|
|
1019
|
+
return response?.data || response;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
async getKuvarSendPayout(payoutId) {
|
|
1023
|
+
if (!/^kwp_[0-9a-f]{32}$/.test(String(payoutId || ''))) {
|
|
1024
|
+
throw kuvarSendLinkError('INVALID_PAYOUT_ID', 'payoutId is invalid');
|
|
1025
|
+
}
|
|
1026
|
+
const response = await this._get(`/api/v1/payouts/kuvarsend/${encodeURIComponent(payoutId)}`);
|
|
1027
|
+
return response?.data || response;
|
|
1028
|
+
}
|
|
1029
|
+
async listKuvarSendPayouts({ linkedAccountId, limit = 50, cursor } = {}) {
|
|
1030
|
+
if (linkedAccountId != null && !/^kal_[0-9a-f]{32}$/.test(String(linkedAccountId))) {
|
|
1031
|
+
throw kuvarSendLinkError('INVALID_LINKED_ACCOUNT_ID', 'linkedAccountId is invalid');
|
|
1032
|
+
}
|
|
1033
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
1034
|
+
throw kuvarSendLinkError('INVALID_LIMIT', 'limit must be an integer from 1 to 100');
|
|
1035
|
+
}
|
|
1036
|
+
if (cursor != null && !/^kwp_[0-9a-f]{32}$/.test(String(cursor))) {
|
|
1037
|
+
throw kuvarSendLinkError('INVALID_CURSOR', 'cursor is invalid');
|
|
1038
|
+
}
|
|
1039
|
+
const query = new URLSearchParams();
|
|
1040
|
+
if (linkedAccountId) query.set('linked_account_id', linkedAccountId);
|
|
1041
|
+
query.set('limit', String(limit));
|
|
1042
|
+
if (cursor) query.set('cursor', cursor);
|
|
1043
|
+
return this._get(`/api/v1/payouts/kuvarsend?${query}`);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
async revokeKuvarSendAccountLink(linkedAccountId) {
|
|
1047
|
+
if (!/^kal_[0-9a-f]{32}$/.test(String(linkedAccountId || ''))) {
|
|
1048
|
+
throw kuvarSendLinkError('INVALID_LINKED_ACCOUNT_ID', 'linkedAccountId is invalid');
|
|
1049
|
+
}
|
|
1050
|
+
const response = await this._delete(`/api/v1/payouts/account-links/${encodeURIComponent(linkedAccountId)}`);
|
|
1051
|
+
return response?.data || response;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
757
1054
|
// --- Sandbox Simulator ---
|
|
758
1055
|
|
|
759
1056
|
async startSandboxSimulator() {
|
|
@@ -830,4 +1127,4 @@ export class KuvarPayServer {
|
|
|
830
1127
|
async renewSubscription(subscriptionId, body = {}) {
|
|
831
1128
|
return this._post(`/api/v1/subscriptions/${encodeURIComponent(subscriptionId)}/renew`, body);
|
|
832
1129
|
}
|
|
833
|
-
}
|
|
1130
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuvarpay/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Official KuvarPay Server SDK for Node.js",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"module": "index.mjs",
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
"kuvarpay",
|
|
24
24
|
"blockchain",
|
|
25
25
|
"fiat-payout",
|
|
26
|
+
"kuvarsend",
|
|
27
|
+
"account-linking",
|
|
26
28
|
"split-payments"
|
|
27
29
|
],
|
|
28
30
|
"publishConfig": {
|