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