@kuvarpay/sdk 1.7.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 +173 -16
- package/index.d.ts +134 -1
- package/index.js +295 -1
- package/index.mjs +290 -1
- 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,9 +113,26 @@ 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?, direction?, sandbox? })` | 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 |
|
|
@@ -180,25 +215,147 @@ Payouts move money **out** of your USD balance to a recipient's bank or mobile-m
|
|
|
180
215
|
```javascript
|
|
181
216
|
// Discover payout methods, then verify the recipient name before sending.
|
|
182
217
|
const { methods } = await kv.getBanks({ country: 'NG', type: 'bank', direction: 'outbound' });
|
|
183
|
-
const
|
|
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
|
@@ -92,6 +92,102 @@ export interface VerifyPaymentResult {
|
|
|
92
92
|
raw: any;
|
|
93
93
|
}
|
|
94
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
|
+
|
|
95
191
|
export interface CreateSubaccountRequest {
|
|
96
192
|
business_name: string;
|
|
97
193
|
percentage_charge?: number | string;
|
|
@@ -398,10 +494,47 @@ export class KuvarPayServer {
|
|
|
398
494
|
updatedAt: string;
|
|
399
495
|
}>;
|
|
400
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
|
+
|
|
401
534
|
// Sandbox
|
|
402
535
|
startSandboxSimulator(): Promise<any>;
|
|
403
536
|
stopSandboxSimulator(): Promise<any>;
|
|
404
537
|
getSandboxSimulatorStatus(): Promise<any>;
|
|
405
538
|
}
|
|
406
539
|
|
|
407
|
-
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
|
|
@@ -145,6 +175,10 @@ class KuvarPayServer {
|
|
|
145
175
|
return this._request('PATCH', path, body, extraHeaders);
|
|
146
176
|
}
|
|
147
177
|
|
|
178
|
+
async _delete(path, body) {
|
|
179
|
+
return this._request('DELETE', path, body);
|
|
180
|
+
}
|
|
181
|
+
|
|
148
182
|
/**
|
|
149
183
|
* Fetch checkout session status by sessionId
|
|
150
184
|
* GET {Payment API Base}/api/v1/checkout-sessions/{sessionId}
|
|
@@ -940,6 +974,266 @@ class KuvarPayServer {
|
|
|
940
974
|
return this._get(`/api/v1/fiat/sends/${encodeURIComponent(sendId)}`);
|
|
941
975
|
}
|
|
942
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
|
+
|
|
943
1237
|
// --- Sandbox Simulator ---
|
|
944
1238
|
|
|
945
1239
|
async startSandboxSimulator() {
|
|
@@ -1050,4 +1344,4 @@ class KuvarPayServer {
|
|
|
1050
1344
|
}
|
|
1051
1345
|
}
|
|
1052
1346
|
|
|
1053
|
-
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
|
|
@@ -142,6 +172,10 @@ export class KuvarPayServer {
|
|
|
142
172
|
return this._request('PATCH', path, body, extraHeaders);
|
|
143
173
|
}
|
|
144
174
|
|
|
175
|
+
async _delete(path, body) {
|
|
176
|
+
return this._request('DELETE', path, body);
|
|
177
|
+
}
|
|
178
|
+
|
|
145
179
|
/**
|
|
146
180
|
* Signature Verification for Webhooks
|
|
147
181
|
*
|
|
@@ -762,6 +796,261 @@ export class KuvarPayServer {
|
|
|
762
796
|
return this._get(`/api/v1/fiat/sends/${encodeURIComponent(sendId)}`);
|
|
763
797
|
}
|
|
764
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
|
+
|
|
765
1054
|
// --- Sandbox Simulator ---
|
|
766
1055
|
|
|
767
1056
|
async startSandboxSimulator() {
|
|
@@ -838,4 +1127,4 @@ export class KuvarPayServer {
|
|
|
838
1127
|
async renewSubscription(subscriptionId, body = {}) {
|
|
839
1128
|
return this._post(`/api/v1/subscriptions/${encodeURIComponent(subscriptionId)}/renew`, body);
|
|
840
1129
|
}
|
|
841
|
-
}
|
|
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": {
|