@kuvarpay/sdk 1.7.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/README.md +173 -16
  2. package/index.d.ts +147 -1
  3. package/index.js +295 -1
  4. package/index.mjs +290 -1
  5. 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 Production Secret Key | - |
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). Set `sandbox: true` when your key is a sandbox-mode business — live and sandbox are separate `methodId` spaces. |
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 recipient = await kv.resolveRecipient({ methodId: methods[0].methodId, accountNumber: '0123456789' });
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
- if (recipient.resolved) {
187
- const { sendId } = await kv.createFiatSend({
188
- destinationCountry: 'NG',
189
- destinationCurrency: 'NGN',
190
- destinationAmount: 50000,
191
- recipient: { methodId: methods[0].methodId, accountNumber: '0123456789', accountName: recipient.accountName },
192
- narration: 'Vendor payout',
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
- // Prefer the completion webhook; getFiatSend is available for reconciliation.
196
- const send = await kv.getFiatSend(sendId);
197
- console.log(send.status); // PROCESSING | COMPLETED | FAILED | REFUNDED
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
- > `ownerType`/`ownerId` default to your business (`MERCHANT` + your `businessId`), so you don't need to pass them.
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,115 @@ 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
+ /** Your balance's currency and what it was debited. Create response only. */
152
+ source_currency?: string;
153
+ source_amount?: string;
154
+ /** The same charge valued in USD. Create response only. */
155
+ source_amount_usd?: string;
156
+ created_at: string;
157
+ }
158
+
159
+ export interface KuvarSendPayoutQuote {
160
+ quote_id: string;
161
+ linked_account_id: string;
162
+ amount: string;
163
+ currency: string;
164
+ /** Your balance's currency — the one the payout will debit. */
165
+ source_currency: string;
166
+ /** What your balance will be debited, in source_currency. */
167
+ source_amount: string;
168
+ /** Destination units per USD. */
169
+ rate: string;
170
+ fee_percent: string;
171
+ /** Recipient fee, deducted from amount. */
172
+ fee_amount: string;
173
+ /** amount minus fee_amount — what the user is credited. */
174
+ recipient_amount: string;
175
+ expires_at: string;
176
+ }
177
+
178
+ export interface CompleteKuvarSendAccountLinkInput {
179
+ /** Full HTTPS URL received by your registered callback handler. */
180
+ callbackUrl: string;
181
+ /** State read from the same server-side session as the code verifier. */
182
+ expectedState: string;
183
+ redirectUri: string;
184
+ codeVerifier: string;
185
+ }
186
+
187
+ /** Cross-device completion: the user approved on another device (QR flow). */
188
+ export interface CompleteKuvarSendAccountLinkByRequestInput {
189
+ requestId: string;
190
+ redirectUri: string;
191
+ codeVerifier: string;
192
+ }
193
+
194
+ export interface WaitForKuvarSendAccountLinkInput {
195
+ requestId: string;
196
+ redirectUri: string;
197
+ codeVerifier: string;
198
+ /** Poll interval. Default 3000. */
199
+ pollMs?: number;
200
+ /** Give up and reject after this long. Default 10 minutes. */
201
+ timeoutMs?: number;
202
+ }
203
+
95
204
  export interface CreateSubaccountRequest {
96
205
  business_name: string;
97
206
  percentage_charge?: number | string;
@@ -398,10 +507,47 @@ export class KuvarPayServer {
398
507
  updatedAt: string;
399
508
  }>;
400
509
 
510
+ // KuvarSend account authorization — SECRET key / merchant backend only.
511
+ prepareKuvarSendAccountLink(input: PrepareKuvarSendAccountLinkInput): PreparedKuvarSendAccountLink;
512
+ createKuvarSendAccountLink(
513
+ input: PrepareKuvarSendAccountLinkInput | PreparedKuvarSendAccountLink,
514
+ ): Promise<KuvarSendAccountLinkAuthorization>;
515
+ parseKuvarSendAccountLinkCallback(
516
+ callbackUrl: string,
517
+ options: { expectedState: string; redirectUri: string },
518
+ ): { code: string; state: string };
519
+ completeKuvarSendAccountLink(
520
+ input: CompleteKuvarSendAccountLinkInput | CompleteKuvarSendAccountLinkByRequestInput,
521
+ ): Promise<KuvarSendLinkedAccount>;
522
+ /**
523
+ * Cross-device: polls until the user approves on their phone, then completes
524
+ * the exchange. Rejects with AUTHORIZATION_LINK_TIMEOUT when it runs out.
525
+ */
526
+ waitForKuvarSendAccountLink(input: WaitForKuvarSendAccountLinkInput): Promise<KuvarSendLinkedAccount>;
527
+ getKuvarSendAccountLink(linkedAccountId: string): Promise<KuvarSendLinkedAccount>;
528
+ listKuvarSendAccountLinks(input?: {
529
+ status?: 'ACTIVE' | 'REVOKED';
530
+ limit?: number;
531
+ cursor?: string;
532
+ }): Promise<KuvarSendListPage<KuvarSendLinkedAccount>>;
533
+ revokeKuvarSendAccountLink(linkedAccountId: string): Promise<KuvarSendLinkedAccount>;
534
+ createKuvarSendPayout(input: CreateKuvarSendPayoutInput): Promise<KuvarSendPayout>;
535
+ quoteKuvarSendPayout(input: {
536
+ linkedAccountId: string;
537
+ amount: string;
538
+ currency: string;
539
+ }): Promise<KuvarSendPayoutQuote>;
540
+ getKuvarSendPayout(payoutId: string): Promise<KuvarSendPayout>;
541
+ listKuvarSendPayouts(input?: {
542
+ linkedAccountId?: string;
543
+ limit?: number;
544
+ cursor?: string;
545
+ }): Promise<KuvarSendListPage<KuvarSendPayout>>;
546
+
401
547
  // Sandbox
402
548
  startSandboxSimulator(): Promise<any>;
403
549
  stopSandboxSimulator(): Promise<any>;
404
550
  getSandboxSimulatorStatus(): Promise<any>;
405
551
  }
406
552
 
407
- export default KuvarPayServer;
553
+ 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.7.0",
3
+ "version": "1.9.1",
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": {