@singularity-payments/core 0.1.0-alpha.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/dist/index.mjs ADDED
@@ -0,0 +1,858 @@
1
+ // src/utils/errors.ts
2
+ var MpesaError = class _MpesaError extends Error {
3
+ constructor(message, code, statusCode, details) {
4
+ super(message);
5
+ this.code = code;
6
+ this.statusCode = statusCode;
7
+ this.details = details;
8
+ this.name = "MpesaError";
9
+ Object.setPrototypeOf(this, _MpesaError.prototype);
10
+ }
11
+ };
12
+ var MpesaAuthError = class _MpesaAuthError extends MpesaError {
13
+ constructor(message, details) {
14
+ super(message, "AUTH_ERROR", 401, details);
15
+ this.name = "MpesaAuthError";
16
+ Object.setPrototypeOf(this, _MpesaAuthError.prototype);
17
+ }
18
+ };
19
+ var MpesaValidationError = class _MpesaValidationError extends MpesaError {
20
+ constructor(message, details) {
21
+ super(message, "VALIDATION_ERROR", 400, details);
22
+ this.name = "MpesaValidationError";
23
+ Object.setPrototypeOf(this, _MpesaValidationError.prototype);
24
+ }
25
+ };
26
+ var MpesaNetworkError = class _MpesaNetworkError extends MpesaError {
27
+ constructor(message, isRetryable, details) {
28
+ super(message, "NETWORK_ERROR", 503, details);
29
+ this.isRetryable = isRetryable;
30
+ this.name = "MpesaNetworkError";
31
+ Object.setPrototypeOf(this, _MpesaNetworkError.prototype);
32
+ }
33
+ };
34
+ var MpesaTimeoutError = class _MpesaTimeoutError extends MpesaError {
35
+ constructor(message, details) {
36
+ super(message, "TIMEOUT_ERROR", 408, details);
37
+ this.name = "MpesaTimeoutError";
38
+ Object.setPrototypeOf(this, _MpesaTimeoutError.prototype);
39
+ }
40
+ };
41
+ var MpesaRateLimitError = class _MpesaRateLimitError extends MpesaError {
42
+ constructor(message, retryAfter, details) {
43
+ super(message, "RATE_LIMIT_ERROR", 429, details);
44
+ this.retryAfter = retryAfter;
45
+ this.name = "MpesaRateLimitError";
46
+ Object.setPrototypeOf(this, _MpesaRateLimitError.prototype);
47
+ }
48
+ };
49
+ var MpesaApiError = class _MpesaApiError extends MpesaError {
50
+ constructor(message, code, statusCode, responseBody) {
51
+ super(message, code, statusCode, responseBody);
52
+ this.responseBody = responseBody;
53
+ this.name = "MpesaApiError";
54
+ Object.setPrototypeOf(this, _MpesaApiError.prototype);
55
+ }
56
+ };
57
+ function parseMpesaApiError(statusCode, responseBody) {
58
+ const errorMessage = responseBody?.errorMessage || responseBody?.ResponseDescription || responseBody?.message || "Unknown API error";
59
+ const errorCode = responseBody?.errorCode || responseBody?.ResponseCode || "UNKNOWN_ERROR";
60
+ if (statusCode === 401 || statusCode === 403) {
61
+ return new MpesaAuthError(errorMessage, responseBody);
62
+ }
63
+ if (statusCode === 400) {
64
+ return new MpesaValidationError(errorMessage, responseBody);
65
+ }
66
+ if (statusCode === 429) {
67
+ const retryAfter = responseBody?.retryAfter;
68
+ return new MpesaRateLimitError(errorMessage, retryAfter, responseBody);
69
+ }
70
+ if (statusCode >= 500) {
71
+ return new MpesaNetworkError(errorMessage, true, responseBody);
72
+ }
73
+ return new MpesaApiError(errorMessage, errorCode, statusCode, responseBody);
74
+ }
75
+
76
+ // src/utils/retry.ts
77
+ var DEFAULT_RETRY_OPTIONS = {
78
+ maxRetries: 3,
79
+ initialDelayMs: 1e3,
80
+ maxDelayMs: 1e4,
81
+ backoffMultiplier: 2,
82
+ retryableStatusCodes: [408, 429, 500, 502, 503, 504],
83
+ onRetry: () => {
84
+ }
85
+ };
86
+ function isRetryableError(error, retryableStatusCodes) {
87
+ if (error instanceof MpesaNetworkError) {
88
+ return error.isRetryable;
89
+ }
90
+ if (error instanceof MpesaRateLimitError) {
91
+ return true;
92
+ }
93
+ if (error instanceof MpesaError && error.statusCode) {
94
+ return retryableStatusCodes.includes(error.statusCode);
95
+ }
96
+ if (error.name === "FetchError" || error.code === "ECONNREFUSED" || error.code === "ETIMEDOUT") {
97
+ return true;
98
+ }
99
+ return false;
100
+ }
101
+ function calculateDelay(attempt, options, error) {
102
+ if (error instanceof MpesaRateLimitError && error.retryAfter) {
103
+ return error.retryAfter * 1e3;
104
+ }
105
+ const delay = Math.min(
106
+ options.initialDelayMs * Math.pow(options.backoffMultiplier, attempt),
107
+ options.maxDelayMs
108
+ );
109
+ const jitter = delay * 0.2 * (Math.random() - 0.5) * 2;
110
+ return Math.floor(delay + jitter);
111
+ }
112
+ function sleep(ms) {
113
+ return new Promise((resolve) => setTimeout(resolve, ms));
114
+ }
115
+ async function retryWithBackoff(fn, options = {}) {
116
+ const opts = { ...DEFAULT_RETRY_OPTIONS, ...options };
117
+ let lastError;
118
+ for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
119
+ try {
120
+ return await fn();
121
+ } catch (error) {
122
+ lastError = error;
123
+ if (!isRetryableError(error, opts.retryableStatusCodes) || attempt === opts.maxRetries) {
124
+ throw error;
125
+ }
126
+ const delay = calculateDelay(attempt, opts, error);
127
+ opts.onRetry(error, attempt + 1);
128
+ await sleep(delay);
129
+ }
130
+ }
131
+ throw lastError;
132
+ }
133
+
134
+ // src/utils/auth.ts
135
+ var ENDPOINTS = {
136
+ sandbox: "https://sandbox.safaricom.co.ke",
137
+ production: "https://api.safaricom.co.ke"
138
+ };
139
+ var MpesaAuth = class {
140
+ // 30 seconds
141
+ constructor(config) {
142
+ this.token = null;
143
+ this.tokenExpiry = 0;
144
+ this.REQUEST_TIMEOUT = 3e4;
145
+ this.config = config;
146
+ }
147
+ async getAccessToken() {
148
+ if (this.token && Date.now() < this.tokenExpiry) {
149
+ return this.token;
150
+ }
151
+ return retryWithBackoff(
152
+ async () => {
153
+ const baseUrl = ENDPOINTS[this.config.environment];
154
+ const auth = Buffer.from(
155
+ `${this.config.consumerKey}:${this.config.consumerSecret}`
156
+ ).toString("base64");
157
+ const controller = new AbortController();
158
+ const timeoutId = setTimeout(
159
+ () => controller.abort(),
160
+ this.REQUEST_TIMEOUT
161
+ );
162
+ try {
163
+ const response = await fetch(
164
+ `${baseUrl}/oauth/v1/generate?grant_type=client_credentials`,
165
+ {
166
+ headers: {
167
+ Authorization: `Basic ${auth}`
168
+ },
169
+ signal: controller.signal
170
+ }
171
+ );
172
+ clearTimeout(timeoutId);
173
+ if (!response.ok) {
174
+ const errorBody = await response.json().catch(() => ({}));
175
+ throw parseMpesaApiError(response.status, errorBody);
176
+ }
177
+ const data = await response.json();
178
+ if (!data.access_token) {
179
+ throw new MpesaAuthError("No access token in response", data);
180
+ }
181
+ this.token = data.access_token;
182
+ this.tokenExpiry = Date.now() + 50 * 60 * 1e3;
183
+ return this.token;
184
+ } catch (error) {
185
+ clearTimeout(timeoutId);
186
+ if (error.name === "AbortError") {
187
+ throw new MpesaTimeoutError(
188
+ "Request timed out while getting access token"
189
+ );
190
+ }
191
+ if (error instanceof MpesaAuthError) {
192
+ throw error;
193
+ }
194
+ throw new MpesaNetworkError(
195
+ `Failed to get access token: ${error.message}`,
196
+ true,
197
+ error
198
+ );
199
+ }
200
+ },
201
+ {
202
+ maxRetries: 3,
203
+ initialDelayMs: 1e3,
204
+ onRetry: (error, attempt) => {
205
+ console.warn(
206
+ `Retrying authentication (attempt ${attempt}):`,
207
+ error.message
208
+ );
209
+ }
210
+ }
211
+ );
212
+ }
213
+ getBaseUrl() {
214
+ return ENDPOINTS[this.config.environment];
215
+ }
216
+ getPassword() {
217
+ const timestamp = this.getTimestamp();
218
+ const password = Buffer.from(
219
+ `${this.config.shortcode}${this.config.passkey}${timestamp}`
220
+ ).toString("base64");
221
+ return password;
222
+ }
223
+ getTimestamp() {
224
+ const now = /* @__PURE__ */ new Date();
225
+ const year = now.getFullYear();
226
+ const month = String(now.getMonth() + 1).padStart(2, "0");
227
+ const day = String(now.getDate()).padStart(2, "0");
228
+ const hours = String(now.getHours()).padStart(2, "0");
229
+ const minutes = String(now.getMinutes()).padStart(2, "0");
230
+ const seconds = String(now.getSeconds()).padStart(2, "0");
231
+ return `${year}${month}${day}${hours}${minutes}${seconds}`;
232
+ }
233
+ };
234
+
235
+ // src/utils/callback.ts
236
+ var MpesaCallbackHandler = class {
237
+ constructor(options = {}) {
238
+ // Safaricom's known IP ranges (periodically updated) - Link(https://developer.safaricom.co.ke/dashboard/apis?api=GettingStarted)
239
+ this.SAFARICOM_IPS = [
240
+ "196.201.214.200",
241
+ "196.201.214.206",
242
+ "196.201.213.114",
243
+ "196.201.214.207",
244
+ "196.201.214.208",
245
+ "196.201.213.44",
246
+ "196.201.212.127",
247
+ "196.201.212.138",
248
+ "196.201.212.129",
249
+ "196.201.212.136",
250
+ "196.201.212.74",
251
+ "196.201.212.69"
252
+ ];
253
+ this.options = {
254
+ validateIp: true,
255
+ allowedIps: this.SAFARICOM_IPS,
256
+ ...options
257
+ };
258
+ }
259
+ /**
260
+ * Validate that the callback is from a trusted IP
261
+ */
262
+ validateCallbackIp(ipAddress) {
263
+ if (!this.options.validateIp) {
264
+ return true;
265
+ }
266
+ const allowedIps = this.options.allowedIps || this.SAFARICOM_IPS;
267
+ return allowedIps.includes(ipAddress);
268
+ }
269
+ /**
270
+ * Parse STK Push callback data from M-Pesa
271
+ */
272
+ parseCallback(callback) {
273
+ const stkCallback = callback.Body.stkCallback;
274
+ const parsed = {
275
+ merchantRequestId: stkCallback.MerchantRequestID,
276
+ CheckoutRequestID: stkCallback.CheckoutRequestID,
277
+ resultCode: stkCallback.ResultCode,
278
+ resultDescription: stkCallback.ResultDesc,
279
+ isSuccess: stkCallback.ResultCode === 0,
280
+ errorMessage: stkCallback.ResultCode !== 0 ? this.getErrorMessage(stkCallback.ResultCode) : void 0
281
+ };
282
+ if (stkCallback.ResultCode === 0 && stkCallback.CallbackMetadata) {
283
+ const metadata = this.extractMetadata(stkCallback.CallbackMetadata);
284
+ Object.assign(parsed, metadata);
285
+ }
286
+ return parsed;
287
+ }
288
+ /**
289
+ * Parse C2B callback data
290
+ */
291
+ parseC2BCallback(callback) {
292
+ return {
293
+ transactionType: callback.TransactionType,
294
+ transactionId: callback.TransID,
295
+ transactionTime: callback.TransTime,
296
+ amount: parseFloat(callback.TransAmount),
297
+ businessShortCode: callback.BusinessShortCode,
298
+ billRefNumber: callback.BillRefNumber,
299
+ invoiceNumber: callback.InvoiceNumber,
300
+ msisdn: callback.MSISDN,
301
+ firstName: callback.FirstName,
302
+ middleName: callback.MiddleName,
303
+ lastName: callback.LastName
304
+ };
305
+ }
306
+ /**
307
+ * Extract metadata from STK callback
308
+ */
309
+ extractMetadata(metadata) {
310
+ const result = {};
311
+ metadata.Item.forEach((item) => {
312
+ switch (item.Name) {
313
+ case "Amount":
314
+ result.amount = Number(item.Value);
315
+ break;
316
+ case "MpesaReceiptNumber":
317
+ result.mpesaReceiptNumber = String(item.Value);
318
+ break;
319
+ case "TransactionDate":
320
+ result.transactionDate = this.formatTransactionDate(
321
+ String(item.Value)
322
+ );
323
+ break;
324
+ case "PhoneNumber":
325
+ result.phoneNumber = String(item.Value);
326
+ break;
327
+ }
328
+ });
329
+ return result;
330
+ }
331
+ /**
332
+ * Format transaction date from M-Pesa format (YYYYMMDDHHmmss) to ISO
333
+ */
334
+ formatTransactionDate(dateStr) {
335
+ const year = dateStr.substring(0, 4);
336
+ const month = dateStr.substring(4, 6);
337
+ const day = dateStr.substring(6, 8);
338
+ const hours = dateStr.substring(8, 10);
339
+ const minutes = dateStr.substring(10, 12);
340
+ const seconds = dateStr.substring(12, 14);
341
+ return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
342
+ }
343
+ /**
344
+ * Check if callback indicates success
345
+ */
346
+ isSuccess(data) {
347
+ return data.resultCode === 0;
348
+ }
349
+ /**
350
+ * Check if callback indicates failure
351
+ */
352
+ isFailure(data) {
353
+ return data.resultCode !== 0;
354
+ }
355
+ /**
356
+ * Get a readable error message based on the code
357
+ */
358
+ getErrorMessage(resultCode) {
359
+ const errorMessages = {
360
+ 0: "Success",
361
+ 1: "Insufficient funds in M-Pesa account",
362
+ 17: "User cancelled the transaction",
363
+ 26: "System internal error",
364
+ 1001: "Unable to lock subscriber, a transaction is already in process",
365
+ 1019: "Transaction expired. No response from user",
366
+ 1032: "Request cancelled by user",
367
+ 1037: "Timeout in sending PIN request",
368
+ 2001: "Wrong PIN entered",
369
+ 9999: "Request cancelled by user"
370
+ };
371
+ return errorMessages[resultCode] || `Transaction failed with code: ${resultCode}`;
372
+ }
373
+ /**
374
+ * Handle STK Push callback and invoke appropriate handlers
375
+ */
376
+ async handleCallback(callback, ipAddress) {
377
+ if (ipAddress && !this.validateCallbackIp(ipAddress)) {
378
+ this.log("warn", `Invalid callback IP: ${ipAddress}`);
379
+ throw new Error(`Invalid callback IP: ${ipAddress}`);
380
+ }
381
+ const parsed = this.parseCallback(callback);
382
+ this.log("info", "Processing STK callback", {
383
+ CheckoutRequestID: parsed.CheckoutRequestID,
384
+ resultCode: parsed.resultCode
385
+ });
386
+ if (this.options.isDuplicate) {
387
+ const isDupe = await this.options.isDuplicate(parsed.CheckoutRequestID);
388
+ if (isDupe) {
389
+ this.log("warn", "Duplicate callback detected", {
390
+ CheckoutRequestID: parsed.CheckoutRequestID
391
+ });
392
+ return;
393
+ }
394
+ }
395
+ if (this.options.onCallback) {
396
+ await this.options.onCallback(parsed);
397
+ }
398
+ if (this.isSuccess(parsed) && this.options.onSuccess) {
399
+ await this.options.onSuccess(parsed);
400
+ } else if (this.isFailure(parsed) && this.options.onFailure) {
401
+ await this.options.onFailure(parsed);
402
+ }
403
+ }
404
+ /**
405
+ * Handle C2B validation request
406
+ * Returns true if validation passes, false otherwise
407
+ */
408
+ async handleC2BValidation(callback) {
409
+ this.log("info", "Processing C2B validation", {
410
+ transactionId: callback.TransID
411
+ });
412
+ if (this.options.onC2BValidation) {
413
+ return await this.options.onC2BValidation(
414
+ this.parseC2BCallback(callback)
415
+ );
416
+ }
417
+ return true;
418
+ }
419
+ /**
420
+ * Handle C2B confirmation
421
+ */
422
+ async handleC2BConfirmation(callback) {
423
+ this.log("info", "Processing C2B confirmation", {
424
+ transactionId: callback.TransID
425
+ });
426
+ if (this.options.onC2BConfirmation) {
427
+ await this.options.onC2BConfirmation(this.parseC2BCallback(callback));
428
+ }
429
+ }
430
+ /**
431
+ * Create a standard callback response for M-Pesa
432
+ */
433
+ createCallbackResponse(success = true, message) {
434
+ return {
435
+ ResultCode: success ? 0 : 1,
436
+ ResultDesc: message || (success ? "Accepted" : "Rejected")
437
+ };
438
+ }
439
+ /**
440
+ * Internal logging helper
441
+ */
442
+ log(level, message, data) {
443
+ if (this.options.logger) {
444
+ this.options.logger[level](message, data);
445
+ }
446
+ }
447
+ };
448
+
449
+ // src/utils/ratelimiter.ts
450
+ var RateLimiter = class {
451
+ constructor(options) {
452
+ this.store = /* @__PURE__ */ new Map();
453
+ this.cleanupInterval = null;
454
+ this.options = {
455
+ keyPrefix: "mpesa",
456
+ ...options
457
+ };
458
+ this.startCleanup();
459
+ }
460
+ /**
461
+ * Check if request is allowed
462
+ */
463
+ async checkLimit(key) {
464
+ const fullKey = `${this.options.keyPrefix}:${key}`;
465
+ const now = Date.now();
466
+ const entry = this.store.get(fullKey);
467
+ if (!entry || now >= entry.resetAt) {
468
+ this.store.set(fullKey, {
469
+ count: 1,
470
+ resetAt: now + this.options.windowMs
471
+ });
472
+ return;
473
+ }
474
+ if (entry.count >= this.options.maxRequests) {
475
+ const retryAfter = Math.ceil((entry.resetAt - now) / 1e3);
476
+ throw new MpesaRateLimitError(
477
+ `Rate limit exceeded. Try again in ${retryAfter} seconds.`,
478
+ retryAfter,
479
+ {
480
+ limit: this.options.maxRequests,
481
+ windowMs: this.options.windowMs,
482
+ resetAt: entry.resetAt
483
+ }
484
+ );
485
+ }
486
+ entry.count++;
487
+ }
488
+ /**
489
+ * Get current usage for a key
490
+ */
491
+ getUsage(key) {
492
+ const fullKey = `${this.options.keyPrefix}:${key}`;
493
+ const entry = this.store.get(fullKey);
494
+ const now = Date.now();
495
+ if (!entry || now >= entry.resetAt) {
496
+ return {
497
+ count: 0,
498
+ remaining: this.options.maxRequests,
499
+ resetAt: now + this.options.windowMs
500
+ };
501
+ }
502
+ return {
503
+ count: entry.count,
504
+ remaining: Math.max(0, this.options.maxRequests - entry.count),
505
+ resetAt: entry.resetAt
506
+ };
507
+ }
508
+ /**
509
+ * Reset rate limit for a key
510
+ */
511
+ reset(key) {
512
+ const fullKey = `${this.options.keyPrefix}:${key}`;
513
+ this.store.delete(fullKey);
514
+ }
515
+ /**
516
+ * Clear all rate limits
517
+ */
518
+ resetAll() {
519
+ this.store.clear();
520
+ }
521
+ /**
522
+ * Start cleanup interval
523
+ */
524
+ startCleanup() {
525
+ this.cleanupInterval = setInterval(() => {
526
+ const now = Date.now();
527
+ for (const [key, entry] of this.store.entries()) {
528
+ if (now >= entry.resetAt) {
529
+ this.store.delete(key);
530
+ }
531
+ }
532
+ }, 6e4);
533
+ if (this.cleanupInterval.unref) {
534
+ this.cleanupInterval.unref();
535
+ }
536
+ }
537
+ /**
538
+ * Stop cleanup interval
539
+ */
540
+ destroy() {
541
+ if (this.cleanupInterval) {
542
+ clearInterval(this.cleanupInterval);
543
+ this.cleanupInterval = null;
544
+ }
545
+ this.store.clear();
546
+ }
547
+ };
548
+ var RedisRateLimiter = class {
549
+ constructor(redis, options) {
550
+ this.redis = redis;
551
+ this.options = {
552
+ keyPrefix: "mpesa",
553
+ ...options
554
+ };
555
+ }
556
+ async checkLimit(key) {
557
+ const fullKey = `${this.options.keyPrefix}:${key}`;
558
+ const count = await this.redis.incr(fullKey);
559
+ if (count === 1) {
560
+ await this.redis.expire(fullKey, Math.ceil(this.options.windowMs / 1e3));
561
+ }
562
+ if (count > this.options.maxRequests) {
563
+ const ttlKey = `${fullKey}:ttl`;
564
+ const ttl = await this.redis.get(ttlKey);
565
+ const retryAfter = ttl ? parseInt(ttl) : Math.ceil(this.options.windowMs / 1e3);
566
+ throw new MpesaRateLimitError(
567
+ `Rate limit exceeded. Try again in ${retryAfter} seconds.`,
568
+ retryAfter,
569
+ {
570
+ limit: this.options.maxRequests,
571
+ windowMs: this.options.windowMs
572
+ }
573
+ );
574
+ }
575
+ }
576
+ async reset(key) {
577
+ const fullKey = `${this.options.keyPrefix}:${key}`;
578
+ await this.redis.set(fullKey, "0", "EX", 0);
579
+ }
580
+ };
581
+
582
+ // src/client/mpesa-client.ts
583
+ var MpesaClient = class {
584
+ constructor(config, options = {}) {
585
+ this.plugins = [];
586
+ this.rateLimiter = null;
587
+ this.config = config;
588
+ this.auth = new MpesaAuth(config);
589
+ this.callbackHandler = new MpesaCallbackHandler(options.callbackOptions);
590
+ this.retryOptions = options.retryOptions || {};
591
+ this.REQUEST_TIMEOUT = options.requestTimeout || 3e4;
592
+ if (options.rateLimitOptions?.enabled !== false) {
593
+ const rateLimitOpts = {
594
+ maxRequests: options.rateLimitOptions?.maxRequests || 100,
595
+ windowMs: options.rateLimitOptions?.windowMs || 6e4
596
+ };
597
+ if (options.rateLimitOptions?.redis) {
598
+ this.rateLimiter = new RedisRateLimiter(
599
+ options.rateLimitOptions.redis,
600
+ rateLimitOpts
601
+ );
602
+ } else {
603
+ this.rateLimiter = new RateLimiter(rateLimitOpts);
604
+ }
605
+ }
606
+ }
607
+ /**
608
+ * Make HTTP request with error handling
609
+ */
610
+ async makeRequest(endpoint, payload, rateLimitKey) {
611
+ return retryWithBackoff(async () => {
612
+ if (this.rateLimiter && rateLimitKey) {
613
+ await this.rateLimiter.checkLimit(rateLimitKey);
614
+ }
615
+ const token = await this.auth.getAccessToken();
616
+ const baseUrl = this.auth.getBaseUrl();
617
+ const controller = new AbortController();
618
+ const timeoutId = setTimeout(
619
+ () => controller.abort(),
620
+ this.REQUEST_TIMEOUT
621
+ );
622
+ try {
623
+ const response = await fetch(`${baseUrl}${endpoint}`, {
624
+ method: "POST",
625
+ headers: {
626
+ Authorization: `Bearer ${token}`,
627
+ "Content-Type": "application/json"
628
+ },
629
+ body: JSON.stringify(payload),
630
+ signal: controller.signal
631
+ });
632
+ clearTimeout(timeoutId);
633
+ if (!response.ok) {
634
+ const errorBody = await response.json().catch(() => ({}));
635
+ throw parseMpesaApiError(response.status, errorBody);
636
+ }
637
+ return await response.json();
638
+ } catch (error) {
639
+ clearTimeout(timeoutId);
640
+ if (error.name === "AbortError") {
641
+ throw new MpesaTimeoutError(`Request timed out for ${endpoint}`);
642
+ }
643
+ if (error.statusCode) {
644
+ throw error;
645
+ }
646
+ throw new MpesaNetworkError(
647
+ `Network error on ${endpoint}: ${error.message}`,
648
+ true,
649
+ error
650
+ );
651
+ }
652
+ }, this.retryOptions);
653
+ }
654
+ /**
655
+ * Validate phone number format
656
+ */
657
+ validateAndFormatPhone(phone) {
658
+ let formatted = phone.replace(/[\s\-\+]/g, "");
659
+ if (formatted.startsWith("0")) {
660
+ formatted = "254" + formatted.substring(1);
661
+ } else if (!formatted.startsWith("254")) {
662
+ formatted = "254" + formatted;
663
+ }
664
+ if (!/^254[17]\d{8}$/.test(formatted)) {
665
+ throw new MpesaValidationError(
666
+ `Invalid phone number format: ${phone}. Must be a valid Kenyan number.`
667
+ );
668
+ }
669
+ return formatted;
670
+ }
671
+ /**
672
+ * Add a plugin to extend functionality
673
+ */
674
+ use(plugin) {
675
+ this.plugins.push(plugin);
676
+ plugin.init(this);
677
+ return this;
678
+ }
679
+ /**
680
+ * Initiate STK Push (Lipa Na M-Pesa Online)
681
+ */
682
+ async stkPush(request) {
683
+ if (request.amount < 1) {
684
+ throw new MpesaValidationError("Amount must be at least 1 KES");
685
+ }
686
+ if (!request.accountReference || request.accountReference.length > 13) {
687
+ throw new MpesaValidationError(
688
+ "Account reference is required and must be 13 characters or less"
689
+ );
690
+ }
691
+ if (!request.transactionDesc) {
692
+ throw new MpesaValidationError("Transaction description is required");
693
+ }
694
+ const phone = this.validateAndFormatPhone(request.phoneNumber);
695
+ const payload = {
696
+ BusinessShortCode: this.config.shortcode,
697
+ Password: this.auth.getPassword(),
698
+ Timestamp: this.auth.getTimestamp(),
699
+ TransactionType: "CustomerPayBillOnline",
700
+ Amount: Math.floor(request.amount),
701
+ PartyA: phone,
702
+ PartyB: this.config.shortcode,
703
+ PhoneNumber: phone,
704
+ CallBackURL: request.callbackUrl || this.config.callbackUrl,
705
+ AccountReference: request.accountReference,
706
+ TransactionDesc: request.transactionDesc
707
+ };
708
+ return this.makeRequest(
709
+ "/mpesa/stkpush/v1/processrequest",
710
+ payload,
711
+ `stk:${phone}`
712
+ );
713
+ }
714
+ /**
715
+ * Query STK Push transaction status
716
+ */
717
+ async stkQuery(request) {
718
+ if (!request.CheckoutRequestID) {
719
+ throw new MpesaValidationError("CheckoutRequestID is required");
720
+ }
721
+ const payload = {
722
+ BusinessShortCode: this.config.shortcode,
723
+ Password: this.auth.getPassword(),
724
+ Timestamp: this.auth.getTimestamp(),
725
+ CheckoutRequestID: request.CheckoutRequestID
726
+ };
727
+ return this.makeRequest(
728
+ "/mpesa/stkpushquery/v1/query",
729
+ payload,
730
+ `query:${request.CheckoutRequestID}`
731
+ );
732
+ }
733
+ /**
734
+ * Register C2B URLs for validation and confirmation
735
+ */
736
+ async registerC2BUrl(request) {
737
+ if (!request.confirmationURL || !request.validationURL) {
738
+ throw new MpesaValidationError(
739
+ "Both confirmationURL and validationURL are required"
740
+ );
741
+ }
742
+ const payload = {
743
+ ShortCode: request.shortCode,
744
+ ResponseType: request.responseType,
745
+ ConfirmationURL: request.confirmationURL,
746
+ ValidationURL: request.validationURL
747
+ };
748
+ return this.makeRequest(
749
+ "/mpesa/c2b/v1/registerurl",
750
+ payload,
751
+ "c2b:register"
752
+ );
753
+ }
754
+ /**
755
+ * Get the callback handler instance
756
+ */
757
+ getCallbackHandler() {
758
+ return this.callbackHandler;
759
+ }
760
+ /**
761
+ * Handle an incoming STK Push callback
762
+ * Returns M-Pesa compliant response
763
+ */
764
+ async handleSTKCallback(callback, ipAddress) {
765
+ try {
766
+ await this.callbackHandler.handleCallback(callback, ipAddress);
767
+ return this.callbackHandler.createCallbackResponse(true);
768
+ } catch (error) {
769
+ console.error("STK Callback handling error:", error);
770
+ return this.callbackHandler.createCallbackResponse(
771
+ false,
772
+ "Internal error"
773
+ );
774
+ }
775
+ }
776
+ /**
777
+ * Handle C2B validation request
778
+ */
779
+ async handleC2BValidation(callback) {
780
+ try {
781
+ const isValid = await this.callbackHandler.handleC2BValidation(callback);
782
+ return this.callbackHandler.createCallbackResponse(
783
+ isValid,
784
+ isValid ? "Accepted" : "Rejected"
785
+ );
786
+ } catch (error) {
787
+ console.error("C2B Validation error:", error);
788
+ return this.callbackHandler.createCallbackResponse(
789
+ false,
790
+ "Validation failed"
791
+ );
792
+ }
793
+ }
794
+ /**
795
+ * Handle C2B confirmation
796
+ */
797
+ async handleC2BConfirmation(callback) {
798
+ try {
799
+ await this.callbackHandler.handleC2BConfirmation(callback);
800
+ return this.callbackHandler.createCallbackResponse(true);
801
+ } catch (error) {
802
+ console.error("C2B Confirmation error:", error);
803
+ return this.callbackHandler.createCallbackResponse(
804
+ false,
805
+ "Processing failed"
806
+ );
807
+ }
808
+ }
809
+ /**
810
+ * Parse STK callback without handling (for testing)
811
+ */
812
+ parseSTKCallback(callback) {
813
+ return this.callbackHandler.parseCallback(callback);
814
+ }
815
+ /**
816
+ * Parse C2B callback without handling (for testing)
817
+ */
818
+ parseC2BCallback(callback) {
819
+ return this.callbackHandler.parseC2BCallback(callback);
820
+ }
821
+ /**
822
+ * Get configuration (for plugins)
823
+ */
824
+ getConfig() {
825
+ return this.config;
826
+ }
827
+ /**
828
+ * Get rate limiter usage for a key
829
+ */
830
+ getRateLimitUsage(key) {
831
+ if (this.rateLimiter instanceof RateLimiter) {
832
+ return this.rateLimiter.getUsage(key);
833
+ }
834
+ return null;
835
+ }
836
+ /**
837
+ * Cleanup resources
838
+ */
839
+ destroy() {
840
+ if (this.rateLimiter instanceof RateLimiter) {
841
+ this.rateLimiter.destroy();
842
+ }
843
+ }
844
+ };
845
+ export {
846
+ MpesaApiError,
847
+ MpesaAuthError,
848
+ MpesaCallbackHandler,
849
+ MpesaClient,
850
+ MpesaError,
851
+ MpesaNetworkError,
852
+ MpesaRateLimitError,
853
+ MpesaTimeoutError,
854
+ MpesaValidationError,
855
+ RateLimiter,
856
+ RedisRateLimiter,
857
+ retryWithBackoff
858
+ };