@nile-squad/nylonpay-ts 1.0.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/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/index.cjs +781 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +573 -0
- package/dist/index.d.ts +573 -0
- package/dist/index.js +777 -0
- package/dist/index.js.map +1 -0
- package/package.json +72 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual, randomBytes } from 'crypto';
|
|
2
|
+
import { Ok, Err } from 'slang-ts';
|
|
3
|
+
import { type, platform, arch, release, hostname } from 'os';
|
|
4
|
+
|
|
5
|
+
// src/sdk.ts
|
|
6
|
+
|
|
7
|
+
// src/pubsub.ts
|
|
8
|
+
function createEmitter() {
|
|
9
|
+
const state = {
|
|
10
|
+
listeners: /* @__PURE__ */ new Map()
|
|
11
|
+
};
|
|
12
|
+
function on(event, handler) {
|
|
13
|
+
if (!state.listeners.has(event)) {
|
|
14
|
+
state.listeners.set(event, /* @__PURE__ */ new Set());
|
|
15
|
+
}
|
|
16
|
+
state.listeners.get(event).add(handler);
|
|
17
|
+
return () => off(event, handler);
|
|
18
|
+
}
|
|
19
|
+
function once(event, handler) {
|
|
20
|
+
const wrapper = (data) => {
|
|
21
|
+
off(event, wrapper);
|
|
22
|
+
handler(data);
|
|
23
|
+
};
|
|
24
|
+
on(event, wrapper);
|
|
25
|
+
return emitter;
|
|
26
|
+
}
|
|
27
|
+
function off(event, handler) {
|
|
28
|
+
const handlers = state.listeners.get(event);
|
|
29
|
+
if (handlers) {
|
|
30
|
+
handlers.delete(handler);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function emit(event, data) {
|
|
34
|
+
const handlers = state.listeners.get(event);
|
|
35
|
+
if (!handlers || handlers.size === 0) return;
|
|
36
|
+
for (const handler of handlers) {
|
|
37
|
+
try {
|
|
38
|
+
handler(data);
|
|
39
|
+
} catch {
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function clear(event) {
|
|
44
|
+
if (event) {
|
|
45
|
+
state.listeners.delete(event);
|
|
46
|
+
} else {
|
|
47
|
+
state.listeners.clear();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function listenerCount(event) {
|
|
51
|
+
return state.listeners.get(event)?.size ?? 0;
|
|
52
|
+
}
|
|
53
|
+
const emitter = { on, once, off, emit, clear, listenerCount };
|
|
54
|
+
return emitter;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/payment.ts
|
|
58
|
+
var STATUS_TO_EVENT = {
|
|
59
|
+
successful: "success",
|
|
60
|
+
failed: "failed",
|
|
61
|
+
processing: "processing",
|
|
62
|
+
cancelled: "cancelled"
|
|
63
|
+
};
|
|
64
|
+
function statusToEvent(status) {
|
|
65
|
+
return STATUS_TO_EVENT[status] ?? null;
|
|
66
|
+
}
|
|
67
|
+
var TERMINAL_STATES = /* @__PURE__ */ new Set([
|
|
68
|
+
"successful",
|
|
69
|
+
"failed",
|
|
70
|
+
"cancelled"
|
|
71
|
+
]);
|
|
72
|
+
function createPaymentInstance(initialResponse, deps) {
|
|
73
|
+
const state = {
|
|
74
|
+
reference: initialResponse.reference,
|
|
75
|
+
status: initialResponse.status,
|
|
76
|
+
transaction: null,
|
|
77
|
+
pollingTimer: null,
|
|
78
|
+
resolved: false,
|
|
79
|
+
pollAttempts: 0,
|
|
80
|
+
pollStartTime: Date.now(),
|
|
81
|
+
emitter: createEmitter(),
|
|
82
|
+
fetchStatus: deps.fetchStatus,
|
|
83
|
+
fetchTransaction: deps.fetchTransaction,
|
|
84
|
+
pollIntervalMs: deps.pollIntervalMs ?? 2e3,
|
|
85
|
+
maxPollDuration: deps.maxPollDuration ?? 3e5,
|
|
86
|
+
maxPollAttempts: deps.maxPollAttempts ?? 150
|
|
87
|
+
};
|
|
88
|
+
function resolveWithError(error) {
|
|
89
|
+
state.resolved = true;
|
|
90
|
+
stopPolling();
|
|
91
|
+
emitEvent("error", error);
|
|
92
|
+
}
|
|
93
|
+
function emitEvent(event, error) {
|
|
94
|
+
const data = {
|
|
95
|
+
event,
|
|
96
|
+
transaction: state.transaction ?? void 0,
|
|
97
|
+
error,
|
|
98
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
99
|
+
};
|
|
100
|
+
state.emitter.emit(event, data);
|
|
101
|
+
}
|
|
102
|
+
async function handleTerminalState(status) {
|
|
103
|
+
const txResult = await state.fetchTransaction({
|
|
104
|
+
reference: state.reference
|
|
105
|
+
});
|
|
106
|
+
if (txResult.isOk) {
|
|
107
|
+
state.transaction = txResult.value;
|
|
108
|
+
const event = statusToEvent(status);
|
|
109
|
+
if (event) {
|
|
110
|
+
emitEvent(event);
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
emitEvent("error", `Failed to fetch transaction: ${txResult.error}`);
|
|
114
|
+
}
|
|
115
|
+
state.resolved = true;
|
|
116
|
+
stopPolling();
|
|
117
|
+
}
|
|
118
|
+
async function handleStatusUpdate(response) {
|
|
119
|
+
if (response.reference !== state.reference) {
|
|
120
|
+
resolveWithError(
|
|
121
|
+
`Reference mismatch: expected ${state.reference} but got ${response.reference}`
|
|
122
|
+
);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const newStatus = response.status;
|
|
126
|
+
const oldStatus = state.status;
|
|
127
|
+
state.status = newStatus;
|
|
128
|
+
if (newStatus !== oldStatus) {
|
|
129
|
+
const event = statusToEvent(newStatus);
|
|
130
|
+
if (event) {
|
|
131
|
+
if (TERMINAL_STATES.has(newStatus)) {
|
|
132
|
+
await handleTerminalState(newStatus);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
emitEvent(event);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function handlePollError(error) {
|
|
140
|
+
const isNotFound = error.includes("not found") || error.includes("NOT_FOUND");
|
|
141
|
+
if (isNotFound) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
emitEvent("error", error);
|
|
145
|
+
state.resolved = true;
|
|
146
|
+
stopPolling();
|
|
147
|
+
}
|
|
148
|
+
function scheduleNextPoll() {
|
|
149
|
+
if (state.resolved || state.pollingTimer) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
state.pollingTimer = setTimeout(() => {
|
|
153
|
+
state.pollingTimer = null;
|
|
154
|
+
void pollStatus();
|
|
155
|
+
}, state.pollIntervalMs);
|
|
156
|
+
}
|
|
157
|
+
async function pollStatus() {
|
|
158
|
+
if (state.resolved) {
|
|
159
|
+
stopPolling();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (state.pollAttempts >= state.maxPollAttempts) {
|
|
163
|
+
resolveWithError("Polling timeout: exceeded maximum attempts");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (Date.now() - state.pollStartTime >= state.maxPollDuration) {
|
|
167
|
+
resolveWithError("Polling timeout: exceeded maximum duration");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
state.pollAttempts += 1;
|
|
171
|
+
const result = await state.fetchStatus({ reference: state.reference });
|
|
172
|
+
if (result.isOk) {
|
|
173
|
+
await handleStatusUpdate(result.value);
|
|
174
|
+
} else {
|
|
175
|
+
handlePollError(result.error);
|
|
176
|
+
}
|
|
177
|
+
if (state.resolved) {
|
|
178
|
+
stopPolling();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
scheduleNextPoll();
|
|
182
|
+
}
|
|
183
|
+
function startPolling() {
|
|
184
|
+
scheduleNextPoll();
|
|
185
|
+
}
|
|
186
|
+
function stopPolling() {
|
|
187
|
+
if (state.pollingTimer) {
|
|
188
|
+
clearTimeout(state.pollingTimer);
|
|
189
|
+
state.pollingTimer = null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function on(event, handler) {
|
|
193
|
+
state.emitter.on(event, handler);
|
|
194
|
+
return paymentInstance;
|
|
195
|
+
}
|
|
196
|
+
function off(event, handler) {
|
|
197
|
+
state.emitter.off(event, handler);
|
|
198
|
+
return paymentInstance;
|
|
199
|
+
}
|
|
200
|
+
function once(event, handler) {
|
|
201
|
+
state.emitter.once(event, handler);
|
|
202
|
+
return paymentInstance;
|
|
203
|
+
}
|
|
204
|
+
function wait() {
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
if (state.resolved) {
|
|
207
|
+
if (state.status === "successful" && state.transaction) {
|
|
208
|
+
resolve(state.transaction);
|
|
209
|
+
} else {
|
|
210
|
+
reject(new Error(`Payment ${state.status ?? "error"}`));
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
function onSuccess() {
|
|
215
|
+
cleanup();
|
|
216
|
+
if (state.transaction) {
|
|
217
|
+
resolve(state.transaction);
|
|
218
|
+
} else {
|
|
219
|
+
reject(
|
|
220
|
+
new Error("Payment successful but transaction data unavailable")
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function onFailed() {
|
|
225
|
+
cleanup();
|
|
226
|
+
reject(new Error("Payment failed"));
|
|
227
|
+
}
|
|
228
|
+
function onCancelled() {
|
|
229
|
+
cleanup();
|
|
230
|
+
reject(new Error("Payment cancelled"));
|
|
231
|
+
}
|
|
232
|
+
function onError(data) {
|
|
233
|
+
cleanup();
|
|
234
|
+
const eventData = data;
|
|
235
|
+
reject(new Error(eventData.error ?? "Payment error"));
|
|
236
|
+
}
|
|
237
|
+
function cleanup() {
|
|
238
|
+
state.emitter.off("success", onSuccess);
|
|
239
|
+
state.emitter.off("failed", onFailed);
|
|
240
|
+
state.emitter.off("cancelled", onCancelled);
|
|
241
|
+
state.emitter.off("error", onError);
|
|
242
|
+
}
|
|
243
|
+
state.emitter.on("success", onSuccess);
|
|
244
|
+
state.emitter.on("failed", onFailed);
|
|
245
|
+
state.emitter.on("cancelled", onCancelled);
|
|
246
|
+
state.emitter.on("error", onError);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const paymentInstance = {
|
|
250
|
+
get reference() {
|
|
251
|
+
return state.reference;
|
|
252
|
+
},
|
|
253
|
+
get status() {
|
|
254
|
+
return state.status;
|
|
255
|
+
},
|
|
256
|
+
on,
|
|
257
|
+
once,
|
|
258
|
+
off,
|
|
259
|
+
wait
|
|
260
|
+
};
|
|
261
|
+
startPolling();
|
|
262
|
+
return paymentInstance;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// src/sdk.config.ts
|
|
266
|
+
var DEFAULT_BASE_URL = "https://api.nylonpay.io/api/services";
|
|
267
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
268
|
+
var DEFAULT_MAX_RETRIES = 3;
|
|
269
|
+
var DEFAULT_MAX_POLL_INTERVAL_MS = 2e3;
|
|
270
|
+
var DEFAULT_MAX_POLL_DURATION_MS = 3e5;
|
|
271
|
+
var DEFAULT_MAX_POLL_ATTEMPTS = 150;
|
|
272
|
+
var SDK_SERVICE = "sdk";
|
|
273
|
+
var SDK_ACTIONS = {
|
|
274
|
+
collectPayment: "sdk-collect-payment",
|
|
275
|
+
collectPaymentAndResolve: "sdk-collect-payment-and-resolve",
|
|
276
|
+
makePayout: "sdk-make-payout",
|
|
277
|
+
makePayoutAndResolve: "sdk-make-payout-and-resolve",
|
|
278
|
+
getStatus: "sdk-get-status",
|
|
279
|
+
getTransaction: "sdk-get-transaction",
|
|
280
|
+
verifyPhone: "sdk-verify-phone",
|
|
281
|
+
createInvoice: "sdk-create-invoice"
|
|
282
|
+
};
|
|
283
|
+
var RETRYABLE_STATUS_CODES = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
|
|
284
|
+
function generateFingerprint() {
|
|
285
|
+
const components = [
|
|
286
|
+
`type:${type()}`,
|
|
287
|
+
`platform:${platform()}`,
|
|
288
|
+
`arch:${arch()}`,
|
|
289
|
+
`release:${release()}`,
|
|
290
|
+
`hostname:${hostname()}`,
|
|
291
|
+
`node:${process.versions.node}`,
|
|
292
|
+
`v8:${process.versions.v8}`
|
|
293
|
+
].join("|");
|
|
294
|
+
return createHash("sha256").update(components).digest("hex");
|
|
295
|
+
}
|
|
296
|
+
function generateNonce(length = 16) {
|
|
297
|
+
return randomBytes(length).toString("hex");
|
|
298
|
+
}
|
|
299
|
+
function sortValue(value) {
|
|
300
|
+
if (Array.isArray(value)) {
|
|
301
|
+
return value.map((entry) => sortValue(entry));
|
|
302
|
+
}
|
|
303
|
+
if (value && typeof value === "object") {
|
|
304
|
+
const sortedEntries = Object.entries(value).sort(
|
|
305
|
+
([firstKey], [secondKey]) => firstKey.localeCompare(secondKey)
|
|
306
|
+
);
|
|
307
|
+
return Object.fromEntries(
|
|
308
|
+
sortedEntries.map(([entryKey, entryValue]) => [
|
|
309
|
+
entryKey,
|
|
310
|
+
sortValue(entryValue)
|
|
311
|
+
])
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return value;
|
|
315
|
+
}
|
|
316
|
+
function createCanonicalPayload(payload) {
|
|
317
|
+
return JSON.stringify(sortValue(payload));
|
|
318
|
+
}
|
|
319
|
+
function createSignaturePayload(input) {
|
|
320
|
+
return `${input.fingerprint}.${input.nonce}.${input.timestamp}.${createCanonicalPayload(input.payload)}`;
|
|
321
|
+
}
|
|
322
|
+
function createSignature(input) {
|
|
323
|
+
const payload = createSignaturePayload(input);
|
|
324
|
+
return createHmac("sha256", input.secret).update(payload).digest("hex");
|
|
325
|
+
}
|
|
326
|
+
function createTimestamp() {
|
|
327
|
+
return Date.now().toString();
|
|
328
|
+
}
|
|
329
|
+
function verifyResponseSignature(data, signature, secret) {
|
|
330
|
+
const expectedSignature = createHmac("sha256", secret).update(createCanonicalPayload(data)).digest("hex");
|
|
331
|
+
const providedBuffer = Buffer.from(signature, "hex");
|
|
332
|
+
const expectedBuffer = Buffer.from(expectedSignature, "hex");
|
|
333
|
+
if (providedBuffer.length !== expectedBuffer.length) {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
return timingSafeEqual(providedBuffer, expectedBuffer);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/transport.ts
|
|
340
|
+
var CACHED_FINGERPRINT = generateFingerprint();
|
|
341
|
+
function calculateBackoff(attempt) {
|
|
342
|
+
const base = 2 ** attempt * 1e3;
|
|
343
|
+
const jitter = Math.random() * 500;
|
|
344
|
+
return base + jitter;
|
|
345
|
+
}
|
|
346
|
+
function delay(ms) {
|
|
347
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
348
|
+
}
|
|
349
|
+
function stripResponseSignature(payload) {
|
|
350
|
+
if (!payload || typeof payload !== "object" || !("_responseSignature" in payload)) {
|
|
351
|
+
return { data: payload, responseSignature: null };
|
|
352
|
+
}
|
|
353
|
+
const { _responseSignature, ...rest } = payload;
|
|
354
|
+
return {
|
|
355
|
+
data: rest,
|
|
356
|
+
responseSignature: typeof _responseSignature === "string" ? _responseSignature : null
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
function buildEnvelope({
|
|
360
|
+
action,
|
|
361
|
+
payload
|
|
362
|
+
}) {
|
|
363
|
+
return {
|
|
364
|
+
intent: "execute",
|
|
365
|
+
service: SDK_SERVICE,
|
|
366
|
+
action,
|
|
367
|
+
payload: {
|
|
368
|
+
...payload,
|
|
369
|
+
_fingerprint: CACHED_FINGERPRINT
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function buildAuthHeaders({
|
|
374
|
+
apiKey,
|
|
375
|
+
apiSecret,
|
|
376
|
+
payload
|
|
377
|
+
}) {
|
|
378
|
+
const nonce = generateNonce();
|
|
379
|
+
const timestamp = createTimestamp();
|
|
380
|
+
const signature = createSignature({
|
|
381
|
+
fingerprint: CACHED_FINGERPRINT,
|
|
382
|
+
nonce,
|
|
383
|
+
timestamp,
|
|
384
|
+
payload,
|
|
385
|
+
secret: apiSecret
|
|
386
|
+
});
|
|
387
|
+
return {
|
|
388
|
+
"content-type": "application/json",
|
|
389
|
+
"x-nylon-key": apiKey,
|
|
390
|
+
"x-nylon-nonce": nonce,
|
|
391
|
+
"x-nylon-signature": signature,
|
|
392
|
+
"x-nylon-timestamp": timestamp
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
function withTimeout(timeoutMs) {
|
|
396
|
+
const controller = new AbortController();
|
|
397
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
398
|
+
return { controller, cleanup: () => clearTimeout(timeoutId) };
|
|
399
|
+
}
|
|
400
|
+
function createTransport({
|
|
401
|
+
apiKey,
|
|
402
|
+
apiSecret,
|
|
403
|
+
baseUrl = DEFAULT_BASE_URL,
|
|
404
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
405
|
+
maxRetries = DEFAULT_MAX_RETRIES,
|
|
406
|
+
fetch: fetchImpl
|
|
407
|
+
}) {
|
|
408
|
+
async function send(request) {
|
|
409
|
+
const envelope = buildEnvelope(request);
|
|
410
|
+
const signedPayload = envelope.payload;
|
|
411
|
+
const headers = buildAuthHeaders({
|
|
412
|
+
apiKey,
|
|
413
|
+
apiSecret,
|
|
414
|
+
payload: signedPayload
|
|
415
|
+
});
|
|
416
|
+
const bodyString = JSON.stringify(envelope);
|
|
417
|
+
async function attempt(currentAttempt) {
|
|
418
|
+
const { controller, cleanup } = withTimeout(timeoutMs);
|
|
419
|
+
try {
|
|
420
|
+
const response = await fetchImpl(baseUrl, {
|
|
421
|
+
method: "POST",
|
|
422
|
+
headers,
|
|
423
|
+
body: bodyString,
|
|
424
|
+
signal: controller.signal
|
|
425
|
+
});
|
|
426
|
+
if (!response.ok) {
|
|
427
|
+
const statusCode = response.status;
|
|
428
|
+
const retryable = RETRYABLE_STATUS_CODES.has(statusCode);
|
|
429
|
+
let errorMessage = `HTTP ${statusCode}`;
|
|
430
|
+
try {
|
|
431
|
+
const errorBody = await response.json();
|
|
432
|
+
if (errorBody && typeof errorBody === "object" && "message" in errorBody) {
|
|
433
|
+
errorMessage = String(errorBody.message);
|
|
434
|
+
}
|
|
435
|
+
} catch {
|
|
436
|
+
errorMessage = response.statusText || errorMessage;
|
|
437
|
+
}
|
|
438
|
+
if (retryable && currentAttempt < maxRetries) {
|
|
439
|
+
cleanup();
|
|
440
|
+
await delay(calculateBackoff(currentAttempt));
|
|
441
|
+
return attempt(currentAttempt + 1);
|
|
442
|
+
}
|
|
443
|
+
const sdkError = {
|
|
444
|
+
code: `HTTP_${statusCode}`,
|
|
445
|
+
message: errorMessage,
|
|
446
|
+
statusCode,
|
|
447
|
+
retryable
|
|
448
|
+
};
|
|
449
|
+
cleanup();
|
|
450
|
+
return Err(JSON.stringify(sdkError));
|
|
451
|
+
}
|
|
452
|
+
const responseBody = await response.json();
|
|
453
|
+
if (!responseBody || typeof responseBody !== "object" || !("status" in responseBody)) {
|
|
454
|
+
cleanup();
|
|
455
|
+
return Err(
|
|
456
|
+
JSON.stringify({
|
|
457
|
+
code: "INVALID_RESPONSE",
|
|
458
|
+
message: "Response missing status field",
|
|
459
|
+
retryable: false
|
|
460
|
+
})
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
const { status, message, data } = responseBody;
|
|
464
|
+
if (status === true) {
|
|
465
|
+
const { data: strippedData, responseSignature } = stripResponseSignature(data);
|
|
466
|
+
if (responseSignature) {
|
|
467
|
+
const isValid = verifyResponseSignature(
|
|
468
|
+
strippedData,
|
|
469
|
+
responseSignature,
|
|
470
|
+
apiSecret
|
|
471
|
+
);
|
|
472
|
+
if (!isValid) {
|
|
473
|
+
cleanup();
|
|
474
|
+
return Err(
|
|
475
|
+
JSON.stringify({
|
|
476
|
+
code: "RESPONSE_TAMPERED",
|
|
477
|
+
message: "Response signature verification failed",
|
|
478
|
+
retryable: false
|
|
479
|
+
})
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
cleanup();
|
|
484
|
+
return Ok(strippedData);
|
|
485
|
+
}
|
|
486
|
+
const parsedError = parseError(message);
|
|
487
|
+
cleanup();
|
|
488
|
+
return Err(JSON.stringify(parsedError));
|
|
489
|
+
} catch (error) {
|
|
490
|
+
cleanup();
|
|
491
|
+
const isAbort = error instanceof DOMException && error.name === "AbortError";
|
|
492
|
+
const sdkError = {
|
|
493
|
+
code: isAbort ? "TIMEOUT" : "NETWORK_ERROR",
|
|
494
|
+
message: isAbort ? `Request timed out after ${timeoutMs}ms` : String(error),
|
|
495
|
+
retryable: true
|
|
496
|
+
};
|
|
497
|
+
if (currentAttempt < maxRetries) {
|
|
498
|
+
await delay(calculateBackoff(currentAttempt));
|
|
499
|
+
return attempt(currentAttempt + 1);
|
|
500
|
+
}
|
|
501
|
+
return Err(JSON.stringify(sdkError));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return attempt(0);
|
|
505
|
+
}
|
|
506
|
+
return { send, parseError };
|
|
507
|
+
}
|
|
508
|
+
function parseError(error) {
|
|
509
|
+
try {
|
|
510
|
+
const parsed = JSON.parse(error);
|
|
511
|
+
if (parsed && typeof parsed === "object" && "code" in parsed && "message" in parsed && typeof parsed.code === "string" && typeof parsed.message === "string") {
|
|
512
|
+
return parsed;
|
|
513
|
+
}
|
|
514
|
+
} catch {
|
|
515
|
+
}
|
|
516
|
+
return { code: "UNKNOWN", message: error };
|
|
517
|
+
}
|
|
518
|
+
function verifyWebhookSignature(input) {
|
|
519
|
+
const payloadBytes = typeof input.payload === "string" ? Buffer.from(input.payload, "utf8") : Buffer.from(input.payload);
|
|
520
|
+
const expectedSignature = createHmac("sha256", input.secret).update(payloadBytes).digest("hex");
|
|
521
|
+
const providedBuffer = Buffer.from(input.signature, "hex");
|
|
522
|
+
const expectedBuffer = Buffer.from(expectedSignature, "hex");
|
|
523
|
+
if (providedBuffer.length !== expectedBuffer.length) {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
return timingSafeEqual(providedBuffer, expectedBuffer);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// src/sdk.ts
|
|
530
|
+
function generateReference() {
|
|
531
|
+
return randomBytes(16).toString("hex").slice(0, 15);
|
|
532
|
+
}
|
|
533
|
+
function validateAmount(amount) {
|
|
534
|
+
if (!Number.isInteger(amount) || amount <= 0) {
|
|
535
|
+
throw new Error("amount must be a positive integer");
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
function validateNonEmpty(value, fieldName) {
|
|
539
|
+
if (!value || value.trim() === "") {
|
|
540
|
+
throw new Error(`${fieldName} is required`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
function createSdkInstance(config) {
|
|
544
|
+
const transport = createTransport({
|
|
545
|
+
apiKey: config.apiKey,
|
|
546
|
+
apiSecret: config.apiSecret,
|
|
547
|
+
baseUrl: config.baseUrl,
|
|
548
|
+
timeoutMs: config.timeoutMs,
|
|
549
|
+
maxRetries: config.maxRetries,
|
|
550
|
+
fetch: config.fetch
|
|
551
|
+
});
|
|
552
|
+
const commonDeps = {
|
|
553
|
+
fetchStatus: (input) => transport.send({
|
|
554
|
+
action: SDK_ACTIONS.getStatus,
|
|
555
|
+
payload: input
|
|
556
|
+
}),
|
|
557
|
+
fetchTransaction: (input) => transport.send({
|
|
558
|
+
action: SDK_ACTIONS.getTransaction,
|
|
559
|
+
payload: input
|
|
560
|
+
}),
|
|
561
|
+
pollIntervalMs: config.maxPollIntervalMs,
|
|
562
|
+
maxPollDuration: config.maxPollDurationMs,
|
|
563
|
+
maxPollAttempts: config.maxPollAttempts
|
|
564
|
+
};
|
|
565
|
+
async function collectPayment(input) {
|
|
566
|
+
const reference = input.reference ?? generateReference();
|
|
567
|
+
validateAmount(input.amount);
|
|
568
|
+
validateNonEmpty(input.customer.name, "customer.name");
|
|
569
|
+
validateNonEmpty(input.customer.phoneNumber, "customer.phoneNumber");
|
|
570
|
+
validateNonEmpty(input.description, "description");
|
|
571
|
+
if (input.method === "bank" && !input.bank) {
|
|
572
|
+
throw new Error('bank details are required when method is "bank"');
|
|
573
|
+
}
|
|
574
|
+
const payload = { ...input, reference };
|
|
575
|
+
const result = await transport.send({
|
|
576
|
+
action: SDK_ACTIONS.collectPayment,
|
|
577
|
+
payload
|
|
578
|
+
});
|
|
579
|
+
if (result.isOk) {
|
|
580
|
+
return createPaymentInstance(result.value, commonDeps);
|
|
581
|
+
}
|
|
582
|
+
return createPaymentInstance(
|
|
583
|
+
{ reference, status: "pending" },
|
|
584
|
+
{
|
|
585
|
+
fetchStatus: async () => Err(result.error),
|
|
586
|
+
fetchTransaction: async () => Err(result.error),
|
|
587
|
+
pollIntervalMs: 0,
|
|
588
|
+
maxPollAttempts: 1,
|
|
589
|
+
maxPollDuration: Number.MAX_SAFE_INTEGER
|
|
590
|
+
}
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
async function collectPaymentAndResolve(input) {
|
|
594
|
+
const reference = input.reference ?? generateReference();
|
|
595
|
+
validateAmount(input.amount);
|
|
596
|
+
validateNonEmpty(input.customer.name, "customer.name");
|
|
597
|
+
validateNonEmpty(input.customer.phoneNumber, "customer.phoneNumber");
|
|
598
|
+
validateNonEmpty(input.description, "description");
|
|
599
|
+
if (input.method === "bank" && !input.bank) {
|
|
600
|
+
throw new Error('bank details are required when method is "bank"');
|
|
601
|
+
}
|
|
602
|
+
const payload = { ...input, reference };
|
|
603
|
+
const result = await transport.send({
|
|
604
|
+
action: SDK_ACTIONS.collectPaymentAndResolve,
|
|
605
|
+
payload
|
|
606
|
+
});
|
|
607
|
+
if (result.isOk) {
|
|
608
|
+
return Ok(result.value);
|
|
609
|
+
}
|
|
610
|
+
return Err(result.error);
|
|
611
|
+
}
|
|
612
|
+
async function makePayout(input) {
|
|
613
|
+
const reference = input.reference ?? generateReference();
|
|
614
|
+
validateAmount(input.amount);
|
|
615
|
+
validateNonEmpty(input.customer.name, "customer.name");
|
|
616
|
+
validateNonEmpty(input.customer.phoneNumber, "customer.phoneNumber");
|
|
617
|
+
validateNonEmpty(input.description, "description");
|
|
618
|
+
validateNonEmpty(
|
|
619
|
+
input.destination.accountHolderName,
|
|
620
|
+
"destination.accountHolderName"
|
|
621
|
+
);
|
|
622
|
+
validateNonEmpty(
|
|
623
|
+
input.destination.accountNumber,
|
|
624
|
+
"destination.accountNumber"
|
|
625
|
+
);
|
|
626
|
+
const payload = { ...input, reference };
|
|
627
|
+
const result = await transport.send({
|
|
628
|
+
action: SDK_ACTIONS.makePayout,
|
|
629
|
+
payload
|
|
630
|
+
});
|
|
631
|
+
if (result.isOk) {
|
|
632
|
+
return createPaymentInstance(result.value, commonDeps);
|
|
633
|
+
}
|
|
634
|
+
return createPaymentInstance(
|
|
635
|
+
{ reference, status: "pending" },
|
|
636
|
+
{
|
|
637
|
+
fetchStatus: async () => Err(result.error),
|
|
638
|
+
fetchTransaction: async () => Err(result.error),
|
|
639
|
+
pollIntervalMs: 0,
|
|
640
|
+
maxPollAttempts: 1,
|
|
641
|
+
maxPollDuration: Number.MAX_SAFE_INTEGER
|
|
642
|
+
}
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
async function makePayoutAndResolve(input) {
|
|
646
|
+
const reference = input.reference ?? generateReference();
|
|
647
|
+
validateAmount(input.amount);
|
|
648
|
+
validateNonEmpty(input.customer.name, "customer.name");
|
|
649
|
+
validateNonEmpty(input.customer.phoneNumber, "customer.phoneNumber");
|
|
650
|
+
validateNonEmpty(input.description, "description");
|
|
651
|
+
validateNonEmpty(
|
|
652
|
+
input.destination.accountHolderName,
|
|
653
|
+
"destination.accountHolderName"
|
|
654
|
+
);
|
|
655
|
+
validateNonEmpty(
|
|
656
|
+
input.destination.accountNumber,
|
|
657
|
+
"destination.accountNumber"
|
|
658
|
+
);
|
|
659
|
+
const payload = { ...input, reference };
|
|
660
|
+
const result = await transport.send({
|
|
661
|
+
action: SDK_ACTIONS.makePayoutAndResolve,
|
|
662
|
+
payload
|
|
663
|
+
});
|
|
664
|
+
if (result.isOk) {
|
|
665
|
+
return Ok(result.value);
|
|
666
|
+
}
|
|
667
|
+
return Err(result.error);
|
|
668
|
+
}
|
|
669
|
+
async function getStatus(input) {
|
|
670
|
+
validateNonEmpty(input.reference, "reference");
|
|
671
|
+
const result = await transport.send({
|
|
672
|
+
action: SDK_ACTIONS.getStatus,
|
|
673
|
+
payload: input
|
|
674
|
+
});
|
|
675
|
+
if (result.isOk) {
|
|
676
|
+
return Ok(result.value);
|
|
677
|
+
}
|
|
678
|
+
return Err(result.error);
|
|
679
|
+
}
|
|
680
|
+
async function getTransaction(input) {
|
|
681
|
+
if (!input.id && !input.reference) {
|
|
682
|
+
throw new Error("id or reference is required");
|
|
683
|
+
}
|
|
684
|
+
const result = await transport.send({
|
|
685
|
+
action: SDK_ACTIONS.getTransaction,
|
|
686
|
+
payload: input
|
|
687
|
+
});
|
|
688
|
+
if (result.isOk) {
|
|
689
|
+
return Ok(result.value);
|
|
690
|
+
}
|
|
691
|
+
return Err(result.error);
|
|
692
|
+
}
|
|
693
|
+
async function verifyPhone(input) {
|
|
694
|
+
validateNonEmpty(input.phoneNumber, "phoneNumber");
|
|
695
|
+
const result = await transport.send({
|
|
696
|
+
action: SDK_ACTIONS.verifyPhone,
|
|
697
|
+
payload: input
|
|
698
|
+
});
|
|
699
|
+
if (result.isOk) {
|
|
700
|
+
return Ok(result.value);
|
|
701
|
+
}
|
|
702
|
+
return Err(result.error);
|
|
703
|
+
}
|
|
704
|
+
async function createInvoice(input) {
|
|
705
|
+
const reference = input.reference ?? generateReference();
|
|
706
|
+
validateAmount(input.amount);
|
|
707
|
+
validateNonEmpty(input.description, "description");
|
|
708
|
+
if (input.items) {
|
|
709
|
+
if (input.items.length > 50) {
|
|
710
|
+
throw new Error("items must not exceed 50");
|
|
711
|
+
}
|
|
712
|
+
for (const item of input.items) {
|
|
713
|
+
if (!Number.isInteger(item.quantity) || item.quantity <= 0) {
|
|
714
|
+
throw new Error("item quantity must be a positive integer");
|
|
715
|
+
}
|
|
716
|
+
if (!Number.isInteger(item.unitPrice) || item.unitPrice <= 0) {
|
|
717
|
+
throw new Error("item unitPrice must be a positive integer");
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
const payload = { ...input, reference };
|
|
722
|
+
const result = await transport.send({
|
|
723
|
+
action: SDK_ACTIONS.createInvoice,
|
|
724
|
+
payload
|
|
725
|
+
});
|
|
726
|
+
if (result.isOk) {
|
|
727
|
+
return Ok(result.value);
|
|
728
|
+
}
|
|
729
|
+
return Err(result.error);
|
|
730
|
+
}
|
|
731
|
+
function verifyWebhook(input) {
|
|
732
|
+
return verifyWebhookSignature(input);
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
collectPayment,
|
|
736
|
+
collectPaymentAndResolve,
|
|
737
|
+
makePayout,
|
|
738
|
+
makePayoutAndResolve,
|
|
739
|
+
getStatus,
|
|
740
|
+
getTransaction,
|
|
741
|
+
verifyPhone,
|
|
742
|
+
createInvoice,
|
|
743
|
+
verifyWebhookSignature: verifyWebhook
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// src/create-nylon-pay.ts
|
|
748
|
+
function createNylonPay(config) {
|
|
749
|
+
if (!config.apiKey) {
|
|
750
|
+
throw new Error("apiKey is required");
|
|
751
|
+
}
|
|
752
|
+
if (!config.apiKey.startsWith("npk_")) {
|
|
753
|
+
throw new Error('apiKey must start with "npk_"');
|
|
754
|
+
}
|
|
755
|
+
if (!config.apiSecret) {
|
|
756
|
+
throw new Error("apiSecret is required");
|
|
757
|
+
}
|
|
758
|
+
if (!config.apiSecret.startsWith("nps_")) {
|
|
759
|
+
throw new Error('apiSecret must start with "nps_"');
|
|
760
|
+
}
|
|
761
|
+
const resolvedConfig = {
|
|
762
|
+
apiKey: config.apiKey,
|
|
763
|
+
apiSecret: config.apiSecret,
|
|
764
|
+
baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
|
|
765
|
+
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
766
|
+
maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
|
|
767
|
+
maxPollIntervalMs: config.maxPollIntervalMs ?? DEFAULT_MAX_POLL_INTERVAL_MS,
|
|
768
|
+
maxPollDurationMs: config.maxPollDurationMs ?? DEFAULT_MAX_POLL_DURATION_MS,
|
|
769
|
+
maxPollAttempts: config.maxPollAttempts ?? DEFAULT_MAX_POLL_ATTEMPTS,
|
|
770
|
+
fetch: config.fetch ?? globalThis.fetch.bind(globalThis)
|
|
771
|
+
};
|
|
772
|
+
return createSdkInstance(resolvedConfig);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
export { createNylonPay, parseError, verifyWebhookSignature };
|
|
776
|
+
//# sourceMappingURL=index.js.map
|
|
777
|
+
//# sourceMappingURL=index.js.map
|