@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/LICENSE +201 -0
- package/dist/index.d.mts +369 -0
- package/dist/index.d.ts +369 -0
- package/dist/index.js +896 -0
- package/dist/index.mjs +858 -0
- package/package.json +34 -0
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
|
+
});
|