pesafy 0.3.6 → 0.3.9

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.cjs CHANGED
@@ -8,7 +8,7 @@ var __defProp = Object.defineProperty;
8
8
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
9
9
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
10
10
 
11
- // src/utils/errors/error-factory.ts
11
+ // src/utils/errors/index.ts
12
12
  var PesafyError = class _PesafyError extends Error {
13
13
  constructor(options) {
14
14
  super(options.message);
@@ -41,77 +41,108 @@ function createError(options) {
41
41
  return new PesafyError(options);
42
42
  }
43
43
 
44
- // src/utils/http/client.ts
45
- var DEFAULT_TIMEOUT = 3e4;
46
- async function httpRequest(url, options = {}) {
47
- const {
48
- method = "GET",
49
- headers = {},
50
- body,
51
- timeout = DEFAULT_TIMEOUT
52
- } = options;
53
- const controller = new AbortController();
54
- const timeoutId = setTimeout(() => controller.abort(), timeout);
44
+ // src/core/encryption/security-credentials.ts
45
+ function encryptSecurityCredential(initiatorPassword, certificatePem) {
55
46
  try {
56
- const response = await fetch(url, {
57
- method,
58
- headers: {
59
- "Content-Type": "application/json",
60
- ...headers
47
+ const passwordBuffer = Buffer.from(initiatorPassword, "utf-8");
48
+ const encrypted = crypto.publicEncrypt(
49
+ {
50
+ key: certificatePem,
51
+ // RSA_PKCS1_PADDING = 1 (NOT RSA_PKCS1_OAEP_PADDING = 4)
52
+ padding: crypto.constants.RSA_PKCS1_PADDING
61
53
  },
62
- body: body ? JSON.stringify(body) : void 0,
63
- signal: controller.signal
54
+ passwordBuffer
55
+ );
56
+ return encrypted.toString("base64");
57
+ } catch (error) {
58
+ throw new PesafyError({
59
+ code: "ENCRYPTION_FAILED",
60
+ message: "Failed to encrypt security credential. Ensure the certificate PEM is valid and matches the environment (sandbox/production).",
61
+ cause: error
64
62
  });
65
- clearTimeout(timeoutId);
63
+ }
64
+ }
65
+
66
+ // src/utils/http/index.ts
67
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
68
+ function sleep(ms) {
69
+ return new Promise((resolve) => setTimeout(resolve, ms));
70
+ }
71
+ function jitter(baseMs) {
72
+ const spread = baseMs * 0.25;
73
+ return baseMs + (Math.random() * spread * 2 - spread);
74
+ }
75
+ async function httpRequest(url, options) {
76
+ const maxRetries = options.retries ?? 4;
77
+ const baseDelay = options.retryDelay ?? 2e3;
78
+ const headers = {
79
+ "Content-Type": "application/json",
80
+ Accept: "application/json",
81
+ ...options.headers
82
+ };
83
+ const init = {
84
+ method: options.method,
85
+ headers,
86
+ ...options.body !== void 0 ? { body: JSON.stringify(options.body) } : {}
87
+ };
88
+ let lastError = null;
89
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
90
+ if (attempt > 0) {
91
+ const delay = jitter(baseDelay * Math.pow(2, attempt - 1));
92
+ await sleep(delay);
93
+ }
94
+ let response;
95
+ try {
96
+ response = await fetch(url, init);
97
+ } catch (err) {
98
+ lastError = new PesafyError({
99
+ code: "NETWORK_ERROR",
100
+ message: `Network error calling ${url}: ${String(err)}`,
101
+ cause: err
102
+ });
103
+ if (attempt < maxRetries) continue;
104
+ throw lastError;
105
+ }
66
106
  let data;
67
- const text = await response.text();
107
+ const contentType = response.headers.get("content-type") ?? "";
68
108
  try {
69
- data = text ? JSON.parse(text) : {};
109
+ data = contentType.includes("application/json") ? await response.json() : await response.text();
70
110
  } catch {
71
- data = { raw: text };
111
+ data = null;
72
112
  }
73
- if (!response.ok) {
74
- const bodyStr = text.length > 0 ? ` \u2014 ${text}` : "";
75
- throw new PesafyError({
76
- code: "API_ERROR",
77
- message: `Request failed with status ${response.status}${bodyStr}`,
78
- statusCode: response.status,
79
- response: data
80
- });
81
- }
82
- return {
83
- data,
84
- status: response.status,
85
- headers: response.headers
86
- };
87
- } catch (error) {
88
- clearTimeout(timeoutId);
89
- if (error instanceof PesafyError) throw error;
90
- if (error instanceof Error) {
91
- if (error.name === "AbortError") {
92
- throw new PesafyError({
93
- code: "TIMEOUT",
94
- message: `Request timed out after ${timeout}ms`,
95
- cause: error
96
- });
97
- }
98
- throw new PesafyError({
99
- code: "NETWORK_ERROR",
100
- message: error.message,
101
- cause: error
102
- });
113
+ const responseHeaders = {};
114
+ response.headers.forEach((value, key) => {
115
+ responseHeaders[key] = value;
116
+ });
117
+ if (response.ok) {
118
+ return {
119
+ data,
120
+ status: response.status,
121
+ headers: responseHeaders
122
+ };
103
123
  }
104
- throw new PesafyError({
105
- code: "REQUEST_FAILED",
106
- message: "An unknown error occurred",
107
- cause: error
124
+ const isTransient = RETRYABLE_STATUSES.has(response.status);
125
+ const daraja = data ?? {};
126
+ const message = daraja.errorMessage ?? daraja.ResponseDescription ?? `HTTP ${response.status}`;
127
+ lastError = new PesafyError({
128
+ code: isTransient ? "REQUEST_FAILED" : "HTTP_ERROR",
129
+ message,
130
+ statusCode: response.status,
131
+ response: data,
132
+ requestId: daraja.requestId
108
133
  });
134
+ if (isTransient && attempt < maxRetries) {
135
+ continue;
136
+ }
137
+ throw lastError;
109
138
  }
139
+ throw lastError;
110
140
  }
111
141
 
112
142
  // src/core/auth/token-manager.ts
113
143
  var TOKEN_BUFFER_SECONDS = 60;
114
144
  var TokenManager = class {
145
+ // Unix seconds
115
146
  constructor(consumerKey, consumerSecret, baseUrl) {
116
147
  __publicField(this, "consumerKey");
117
148
  __publicField(this, "consumerSecret");
@@ -122,11 +153,15 @@ var TokenManager = class {
122
153
  this.consumerSecret = consumerSecret;
123
154
  this.baseUrl = baseUrl;
124
155
  }
125
- getAuthHeader() {
156
+ getBasicAuthHeader() {
126
157
  const credentials = `${this.consumerKey}:${this.consumerSecret}`;
127
158
  const encoded = Buffer.from(credentials, "utf-8").toString("base64");
128
159
  return `Basic ${encoded}`;
129
160
  }
161
+ /**
162
+ * Returns a valid access token, fetching a new one when the cached token
163
+ * is absent or within TOKEN_BUFFER_SECONDS of expiry.
164
+ */
130
165
  async getAccessToken() {
131
166
  const now = Date.now() / 1e3;
132
167
  if (this.cachedToken && this.tokenExpiresAt > now + TOKEN_BUFFER_SECONDS) {
@@ -136,81 +171,61 @@ var TokenManager = class {
136
171
  const response = await httpRequest(url, {
137
172
  method: "GET",
138
173
  headers: {
139
- Authorization: this.getAuthHeader()
174
+ Authorization: this.getBasicAuthHeader()
140
175
  }
141
176
  });
142
- const data = response.data;
143
- if (!data.access_token) {
177
+ const { access_token, expires_in } = response.data;
178
+ if (!access_token) {
144
179
  throw new PesafyError({
145
180
  code: "AUTH_FAILED",
146
- message: "Failed to obtain access token",
147
- response: data
181
+ message: "Daraja did not return an access token. Check your consumer key and secret.",
182
+ response: response.data
148
183
  });
149
184
  }
150
- this.cachedToken = data.access_token;
151
- this.tokenExpiresAt = now + (data.expires_in ?? 3600);
185
+ this.cachedToken = access_token;
186
+ this.tokenExpiresAt = now + (expires_in ?? 3600);
152
187
  return this.cachedToken;
153
188
  }
189
+ /** Force token refresh on the next call (e.g. after a 401 response) */
154
190
  clearCache() {
155
191
  this.cachedToken = null;
156
192
  this.tokenExpiresAt = 0;
157
193
  }
158
194
  };
159
- function encryptSecurityCredential(initiatorPassword, certificatePem) {
160
- try {
161
- const passwordBuffer = Buffer.from(initiatorPassword, "utf-8");
162
- const encrypted = crypto.publicEncrypt(
163
- {
164
- key: certificatePem,
165
- padding: 1
166
- // RSA_PKCS1_PADDING
167
- },
168
- passwordBuffer
169
- );
170
- return encrypted.toString("base64");
171
- } catch (error) {
172
- throw new PesafyError({
173
- code: "ENCRYPTION_FAILED",
174
- message: "Failed to encrypt security credential",
175
- cause: error
176
- });
177
- }
178
- }
179
195
 
180
- // src/utils/phone.ts
181
- function toE164Kenya(phone) {
182
- const digits = phone.replace(/\D/g, "");
183
- if (digits.startsWith("254")) return digits;
184
- if (digits.startsWith("0")) return `254${digits.slice(1)}`;
185
- if (digits.length === 9) return `254${digits}`;
186
- return `254${digits}`;
187
- }
196
+ // src/utils/phone/index.ts
188
197
  function formatSafaricomPhone(phone) {
189
- const formatted = toE164Kenya(phone);
190
- if (!/^254[71]\d{8}$/.test(formatted)) {
191
- throw new Error(
192
- `Invalid Kenyan phone number: "${phone}". Expected format: 07XXXXXXXX, 2547XXXXXXXX, or +2547XXXXXXXX.`
193
- );
198
+ const digits = phone.replace(/\D/g, "");
199
+ let normalised;
200
+ if (digits.startsWith("254") && digits.length === 12) {
201
+ normalised = digits;
202
+ } else if (digits.startsWith("0") && digits.length === 10) {
203
+ normalised = `254${digits.slice(1)}`;
204
+ } else if (digits.length === 9) {
205
+ normalised = `254${digits}`;
206
+ } else if (digits.startsWith("254") && digits.length !== 12) {
207
+ throw new PesafyError({
208
+ code: "INVALID_PHONE",
209
+ message: `Invalid phone number "${phone}". Expected 254XXXXXXXXX (12 digits).`
210
+ });
211
+ } else {
212
+ throw new PesafyError({
213
+ code: "INVALID_PHONE",
214
+ message: `Cannot parse phone number "${phone}". Use 07XXXXXXXX, 2547XXXXXXXX, or +2547XXXXXXXX.`
215
+ });
194
216
  }
195
- return formatted;
196
- }
197
- function formatKenyanMsisdn(phone) {
198
- const formatted = toE164Kenya(phone);
199
- if (!/^254\d{9}$/.test(formatted)) {
200
- throw new Error(
201
- `Invalid MSISDN: "${phone}". Expected a 12-digit Kenyan number starting with 254.`
202
- );
217
+ if (normalised.length !== 12) {
218
+ throw new PesafyError({
219
+ code: "INVALID_PHONE",
220
+ message: `Phone number "${phone}" normalised to "${normalised}" which is not 12 digits.`
221
+ });
203
222
  }
204
- return formatted;
205
- }
206
- function msisdnToNumber(phone) {
207
- return parseInt(formatKenyanMsisdn(phone), 10);
223
+ return normalised;
208
224
  }
209
225
 
210
226
  // src/mpesa/stk-push/utils.ts
211
227
  function getStkPushPassword(shortCode, passKey, timestamp) {
212
- const raw = `${shortCode}${passKey}${timestamp}`;
213
- return btoa(raw);
228
+ return btoa(`${shortCode}${passKey}${timestamp}`);
214
229
  }
215
230
  function getTimestamp() {
216
231
  const now = /* @__PURE__ */ new Date();
@@ -229,9 +244,10 @@ function getTimestamp() {
229
244
  async function processStkPush(baseUrl, accessToken, request) {
230
245
  const amount = Math.round(request.amount);
231
246
  if (amount < 1) {
232
- throw new Error(
233
- `Amount must be at least KES 1 (got ${request.amount} which rounds to ${amount}).`
234
- );
247
+ throw new PesafyError({
248
+ code: "VALIDATION_ERROR",
249
+ message: `Amount must be at least KES 1 (got ${request.amount} which rounds to ${amount}).`
250
+ });
235
251
  }
236
252
  const timestamp = getTimestamp();
237
253
  const partyB = request.partyB ?? request.shortCode;
@@ -245,6 +261,7 @@ async function processStkPush(baseUrl, accessToken, request) {
245
261
  PartyB: partyB,
246
262
  PhoneNumber: formatSafaricomPhone(request.phoneNumber),
247
263
  CallBackURL: request.callbackUrl,
264
+ // Daraja docs: AccountReference max 12 chars, TransactionDesc max 13 chars
248
265
  AccountReference: request.accountReference.slice(0, 12),
249
266
  TransactionDesc: request.transactionDesc.slice(0, 13)
250
267
  };
@@ -253,7 +270,10 @@ async function processStkPush(baseUrl, accessToken, request) {
253
270
  {
254
271
  method: "POST",
255
272
  headers: { Authorization: `Bearer ${accessToken}` },
256
- body
273
+ body,
274
+ // Daraja sandbox needs more retries and longer gaps due to instability
275
+ retries: 5,
276
+ retryDelay: 3e3
257
277
  }
258
278
  );
259
279
  return data;
@@ -289,6 +309,61 @@ function getCallbackValue(callback, name) {
289
309
  return inner.CallbackMetadata.Item.find((i) => i.Name === name)?.Value;
290
310
  }
291
311
 
312
+ // src/mpesa/transaction-status/query.ts
313
+ async function queryTransactionStatus(baseUrl, token, securityCredential, initiator, request) {
314
+ if (!request.transactionId) {
315
+ throw createError({
316
+ code: "VALIDATION_ERROR",
317
+ message: "transactionId is required"
318
+ });
319
+ }
320
+ if (!request.partyA) {
321
+ throw createError({
322
+ code: "VALIDATION_ERROR",
323
+ message: "partyA is required (your business shortcode, till number, or MSISDN)"
324
+ });
325
+ }
326
+ if (!request.identifierType) {
327
+ throw createError({
328
+ code: "VALIDATION_ERROR",
329
+ message: 'identifierType is required: "1" (MSISDN) | "2" (Till) | "4" (ShortCode)'
330
+ });
331
+ }
332
+ if (!request.resultUrl) {
333
+ throw createError({
334
+ code: "VALIDATION_ERROR",
335
+ message: "resultUrl is required \u2014 Safaricom POSTs the transaction result here"
336
+ });
337
+ }
338
+ if (!request.queueTimeOutUrl) {
339
+ throw createError({
340
+ code: "VALIDATION_ERROR",
341
+ message: "queueTimeOutUrl is required \u2014 Safaricom calls this on timeout"
342
+ });
343
+ }
344
+ const payload = {
345
+ Initiator: initiator,
346
+ SecurityCredential: securityCredential,
347
+ CommandID: request.commandId ?? "TransactionStatusQuery",
348
+ TransactionID: request.transactionId,
349
+ PartyA: request.partyA,
350
+ IdentifierType: request.identifierType,
351
+ ResultURL: request.resultUrl,
352
+ QueueTimeOutURL: request.queueTimeOutUrl,
353
+ Remarks: request.remarks ?? "Transaction Status Query",
354
+ Occasion: request.occasion ?? ""
355
+ };
356
+ const { data } = await httpRequest(
357
+ `${baseUrl}/mpesa/transactionstatus/v1/query`,
358
+ {
359
+ method: "POST",
360
+ headers: { Authorization: `Bearer ${token}` },
361
+ body: payload
362
+ }
363
+ );
364
+ return data;
365
+ }
366
+
292
367
  // src/mpesa/types.ts
293
368
  var DARAJA_BASE_URLS = {
294
369
  sandbox: "https://sandbox.safaricom.co.ke",
@@ -301,6 +376,12 @@ var Mpesa = class {
301
376
  __publicField(this, "config");
302
377
  __publicField(this, "tokenManager");
303
378
  __publicField(this, "baseUrl");
379
+ if (!config.consumerKey || !config.consumerSecret) {
380
+ throw new PesafyError({
381
+ code: "INVALID_CREDENTIALS",
382
+ message: "consumerKey and consumerSecret are required"
383
+ });
384
+ }
304
385
  this.config = config;
305
386
  this.baseUrl = DARAJA_BASE_URLS[config.environment];
306
387
  this.tokenManager = new TokenManager(
@@ -309,17 +390,60 @@ var Mpesa = class {
309
390
  this.baseUrl
310
391
  );
311
392
  }
312
- async getToken() {
393
+ // ── Internal helpers ────────────────────────────────────────────────────────
394
+ getToken() {
313
395
  return this.tokenManager.getAccessToken();
314
396
  }
315
- /** STK Push (M-Pesa Express) - Initiate payment on customer phone */
397
+ async buildSecurityCredential() {
398
+ if (this.config.securityCredential) return this.config.securityCredential;
399
+ if (!this.config.initiatorPassword) {
400
+ throw new PesafyError({
401
+ code: "INVALID_CREDENTIALS",
402
+ message: "Provide securityCredential (pre-encrypted) OR (initiatorPassword + certificatePath/certificatePem)"
403
+ });
404
+ }
405
+ let cert;
406
+ if (this.config.certificatePem) {
407
+ cert = this.config.certificatePem;
408
+ } else if (this.config.certificatePath) {
409
+ if (typeof Bun !== "undefined") {
410
+ cert = await Bun.file(this.config.certificatePath).text();
411
+ } else {
412
+ const { readFile } = await import('fs/promises');
413
+ cert = await readFile(this.config.certificatePath, "utf-8");
414
+ }
415
+ } else {
416
+ throw new PesafyError({
417
+ code: "INVALID_CREDENTIALS",
418
+ message: "certificatePath or certificatePem required to encrypt the initiator password"
419
+ });
420
+ }
421
+ return encryptSecurityCredential(this.config.initiatorPassword, cert);
422
+ }
423
+ // ── STK Push ──────────────────────────────────────────────────────────────
424
+ /**
425
+ * M-Pesa Express — sends a payment prompt to the customer's phone.
426
+ *
427
+ * Requires: lipaNaMpesaShortCode + lipaNaMpesaPassKey in config.
428
+ *
429
+ * @example
430
+ * const res = await mpesa.stkPush({
431
+ * amount: 100,
432
+ * phoneNumber: "0712345678",
433
+ * callbackUrl: "https://yourdomain.com/mpesa/callback",
434
+ * accountReference: "INV-001",
435
+ * transactionDesc: "Payment",
436
+ * });
437
+ * console.log(res.CheckoutRequestID); // use to poll status
438
+ */
316
439
  async stkPush(request) {
317
440
  const shortCode = this.config.lipaNaMpesaShortCode ?? "";
318
441
  const passKey = this.config.lipaNaMpesaPassKey ?? "";
319
442
  if (!shortCode || !passKey) {
320
- throw new Error(
321
- "lipaNaMpesaShortCode and lipaNaMpesaPassKey required for STK Push"
322
- );
443
+ throw new PesafyError({
444
+ code: "VALIDATION_ERROR",
445
+ message: "lipaNaMpesaShortCode and lipaNaMpesaPassKey are required for STK Push"
446
+ });
323
447
  }
324
448
  const token = await this.getToken();
325
449
  return processStkPush(this.baseUrl, token, {
@@ -328,14 +452,23 @@ var Mpesa = class {
328
452
  passKey
329
453
  });
330
454
  }
331
- /** STK Query - Check STK Push transaction status */
455
+ /**
456
+ * STK Query — checks the status of a previous STK Push.
457
+ *
458
+ * @example
459
+ * const status = await mpesa.stkQuery({
460
+ * checkoutRequestId: "ws_CO_1007202409152617172396192",
461
+ * });
462
+ * if (status.ResultCode === 0) // payment confirmed
463
+ */
332
464
  async stkQuery(request) {
333
465
  const shortCode = this.config.lipaNaMpesaShortCode ?? "";
334
466
  const passKey = this.config.lipaNaMpesaPassKey ?? "";
335
467
  if (!shortCode || !passKey) {
336
- throw new Error(
337
- "lipaNaMpesaShortCode and lipaNaMpesaPassKey required for STK Query"
338
- );
468
+ throw new PesafyError({
469
+ code: "VALIDATION_ERROR",
470
+ message: "lipaNaMpesaShortCode and lipaNaMpesaPassKey are required for STK Query"
471
+ });
339
472
  }
340
473
  const token = await this.getToken();
341
474
  return queryStkPush(this.baseUrl, token, {
@@ -344,117 +477,55 @@ var Mpesa = class {
344
477
  passKey
345
478
  });
346
479
  }
347
- };
348
-
349
- // src/express/index.ts
350
- function createMpesaExpressClient(config) {
351
- if (!config.consumerKey || !config.consumerSecret) {
352
- throw new PesafyError({
353
- code: "INVALID_CREDENTIALS",
354
- message: "consumerKey and consumerSecret are required"
355
- });
356
- }
357
- if (!config.lipaNaMpesaShortCode || !config.lipaNaMpesaPassKey) {
358
- throw new PesafyError({
359
- code: "VALIDATION_ERROR",
360
- message: "lipaNaMpesaShortCode and lipaNaMpesaPassKey are required for STK Push"
361
- });
480
+ /**
481
+ * Transaction Status — queries the result of a completed M-Pesa transaction.
482
+ *
483
+ * Requires: initiatorName + (initiatorPassword + certificate) OR securityCredential.
484
+ *
485
+ * This is ASYNCHRONOUS. The synchronous response only confirms receipt.
486
+ * Final details are POSTed to your resultUrl.
487
+ *
488
+ * @example
489
+ * await mpesa.transactionStatus({
490
+ * transactionId: "OEI2AK4XXXX",
491
+ * partyA: "174379",
492
+ * identifierType: "4",
493
+ * resultUrl: "https://yourdomain.com/mpesa/result",
494
+ * queueTimeOutUrl: "https://yourdomain.com/mpesa/timeout",
495
+ * remarks: "Check payment status",
496
+ * });
497
+ */
498
+ async transactionStatus(request) {
499
+ const initiator = this.config.initiatorName ?? "";
500
+ if (!initiator) {
501
+ throw new PesafyError({
502
+ code: "VALIDATION_ERROR",
503
+ message: "initiatorName is required for Transaction Status"
504
+ });
505
+ }
506
+ const [token, securityCred] = await Promise.all([
507
+ this.getToken(),
508
+ this.buildSecurityCredential()
509
+ ]);
510
+ return queryTransactionStatus(
511
+ this.baseUrl,
512
+ token,
513
+ securityCred,
514
+ initiator,
515
+ request
516
+ );
362
517
  }
363
- if (!config.callbackUrl) {
364
- throw new PesafyError({
365
- code: "VALIDATION_ERROR",
366
- message: "callbackUrl is required for STK Push callbacks"
367
- });
518
+ /** Force the cached OAuth token to be refreshed on the next API call */
519
+ clearTokenCache() {
520
+ this.tokenManager.clearCache();
368
521
  }
369
- const mpesa = new Mpesa(config);
370
- return { mpesa };
371
- }
372
- function normalizePhone(phone) {
373
- const digits = phone.replace(/\D/g, "");
374
- if (digits.startsWith("254")) return digits;
375
- if (digits.startsWith("0")) return `254${digits.slice(1)}`;
376
- return digits;
377
- }
378
- function sendError(res, error) {
379
- if (error instanceof PesafyError) {
380
- const status = error.statusCode ?? 400;
381
- res.status(status).json({
382
- error: error.code,
383
- message: error.message,
384
- statusCode: status
385
- });
386
- return;
387
- }
388
- res.status(500).json({
389
- error: "REQUEST_FAILED",
390
- message: "Unexpected error while processing M-Pesa request"
391
- });
392
- }
393
- function createMpesaExpressRouter(router, config) {
394
- const { mpesa } = createMpesaExpressClient(config);
395
- router.post(
396
- "/mpesa/express/stk-push",
397
- async (req, res, next) => {
398
- try {
399
- const body = req.body;
400
- if (!body || typeof body.amount !== "number" || body.amount <= 0) {
401
- throw new PesafyError({
402
- code: "VALIDATION_ERROR",
403
- message: "amount must be a positive number"
404
- });
405
- }
406
- if (!body.phoneNumber) {
407
- throw new PesafyError({
408
- code: "VALIDATION_ERROR",
409
- message: "phoneNumber is required"
410
- });
411
- }
412
- const phoneNumber = normalizePhone(body.phoneNumber);
413
- const result = await mpesa.stkPush({
414
- amount: body.amount,
415
- phoneNumber,
416
- callbackUrl: config.callbackUrl,
417
- accountReference: body.accountReference ?? `PESAFY-${Date.now().toString(36).toUpperCase()}`,
418
- transactionDesc: body.transactionDesc ?? "Payment"
419
- });
420
- res.status(200).json(result);
421
- } catch (error) {
422
- if (res.headersSent) return next(error);
423
- sendError(res, error);
424
- }
425
- }
426
- );
427
- router.post(
428
- "/mpesa/express/stk-query",
429
- async (req, res, next) => {
430
- try {
431
- const body = req.body;
432
- if (!body || !body.checkoutRequestId) {
433
- throw new PesafyError({
434
- code: "VALIDATION_ERROR",
435
- message: "checkoutRequestId is required"
436
- });
437
- }
438
- const result = await mpesa.stkQuery({
439
- checkoutRequestId: body.checkoutRequestId
440
- });
441
- res.status(200).json(result);
442
- } catch (error) {
443
- if (res.headersSent) return next(error);
444
- sendError(res, error);
445
- }
446
- }
447
- );
448
- return router;
449
- }
522
+ };
450
523
 
451
524
  // src/mpesa/webhooks/retry.ts
452
525
  var DEFAULT_OPTIONS = {
453
526
  maxRetries: Infinity,
454
527
  initialDelay: 1e3,
455
- // 1 second
456
528
  maxDelay: 36e5,
457
- // 1 hour
458
529
  backoffMultiplier: 2,
459
530
  maxRetryDuration: 30 * 24 * 60 * 60 * 1e3
460
531
  // 30 days
@@ -515,7 +586,7 @@ function verifyWebhookIP(requestIP, allowedIPs = SAFARICOM_IPS) {
515
586
  function parseStkPushWebhook(body) {
516
587
  try {
517
588
  const parsed = body;
518
- if (parsed.Body?.stkCallback) return parsed;
589
+ if (parsed?.Body?.stkCallback) return parsed;
519
590
  return null;
520
591
  } catch {
521
592
  return null;
@@ -530,7 +601,7 @@ function handleWebhook(body, options = {}) {
530
601
  success: false,
531
602
  eventType: null,
532
603
  data: null,
533
- error: "IP address not whitelisted"
604
+ error: `IP address ${options.requestIP} is not in the Safaricom whitelist`
534
605
  };
535
606
  }
536
607
  }
@@ -546,46 +617,44 @@ function handleWebhook(body, options = {}) {
546
617
  success: false,
547
618
  eventType: null,
548
619
  data: null,
549
- error: "Unknown webhook format"
620
+ error: "Unknown or malformed webhook payload"
550
621
  };
551
622
  }
552
623
  function extractTransactionId(webhook) {
553
- if ("Body" in webhook && webhook.Body?.stkCallback) {
554
- const items = webhook.Body.stkCallback.CallbackMetadata?.Item;
555
- const mpesaReceipt = items?.find(
556
- (item) => item.Name === "MpesaReceiptNumber"
557
- );
558
- return mpesaReceipt ? String(mpesaReceipt.Value) : null;
559
- }
560
- return null;
624
+ const items = webhook.Body?.stkCallback?.CallbackMetadata?.Item;
625
+ const item = items?.find((i) => i.Name === "MpesaReceiptNumber");
626
+ return item ? String(item.Value) : null;
561
627
  }
562
628
  function extractAmount(webhook) {
563
- if ("Body" in webhook && webhook.Body?.stkCallback) {
564
- const items = webhook.Body.stkCallback.CallbackMetadata?.Item;
565
- const amount = items?.find((item) => item.Name === "Amount");
566
- return amount ? Number(amount.Value) : null;
567
- }
568
- return null;
629
+ const items = webhook.Body?.stkCallback?.CallbackMetadata?.Item;
630
+ const item = items?.find((i) => i.Name === "Amount");
631
+ return item ? Number(item.Value) : null;
632
+ }
633
+ function extractPhoneNumber(webhook) {
634
+ const items = webhook.Body?.stkCallback?.CallbackMetadata?.Item;
635
+ const item = items?.find((i) => i.Name === "PhoneNumber");
636
+ return item ? String(item.Value) : null;
637
+ }
638
+ function isSuccessfulCallback(webhook) {
639
+ return webhook.Body?.stkCallback?.ResultCode === 0;
569
640
  }
570
641
 
571
642
  exports.DARAJA_BASE_URLS = DARAJA_BASE_URLS;
572
643
  exports.Mpesa = Mpesa;
573
644
  exports.PesafyError = PesafyError;
574
- exports.TokenManager = TokenManager;
645
+ exports.SAFARICOM_IPS = SAFARICOM_IPS;
575
646
  exports.createError = createError;
576
- exports.createMpesaExpressClient = createMpesaExpressClient;
577
- exports.createMpesaExpressRouter = createMpesaExpressRouter;
578
647
  exports.encryptSecurityCredential = encryptSecurityCredential;
579
648
  exports.extractAmount = extractAmount;
649
+ exports.extractPhoneNumber = extractPhoneNumber;
580
650
  exports.extractTransactionId = extractTransactionId;
581
- exports.formatKenyanMsisdn = formatKenyanMsisdn;
582
651
  exports.formatPhoneNumber = formatSafaricomPhone;
583
- exports.formatSafaricomPhone = formatSafaricomPhone;
584
652
  exports.getCallbackValue = getCallbackValue;
585
653
  exports.getTimestamp = getTimestamp;
586
654
  exports.handleWebhook = handleWebhook;
587
655
  exports.isStkCallbackSuccess = isStkCallbackSuccess;
588
- exports.msisdnToNumber = msisdnToNumber;
656
+ exports.isSuccessfulCallback = isSuccessfulCallback;
657
+ exports.parseStkPushWebhook = parseStkPushWebhook;
589
658
  exports.retryWithBackoff = retryWithBackoff;
590
659
  exports.verifyWebhookIP = verifyWebhookIP;
591
660
  //# sourceMappingURL=index.cjs.map