pesafy 0.3.6 → 0.3.8

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,84 @@ 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
64
- });
65
- clearTimeout(timeoutId);
66
- let data;
67
- const text = await response.text();
68
- try {
69
- data = text ? JSON.parse(text) : {};
70
- } catch {
71
- data = { raw: text };
72
- }
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
- };
54
+ passwordBuffer
55
+ );
56
+ return encrypted.toString("base64");
87
57
  } 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
- });
103
- }
104
58
  throw new PesafyError({
105
- code: "REQUEST_FAILED",
106
- message: "An unknown error occurred",
59
+ code: "ENCRYPTION_FAILED",
60
+ message: "Failed to encrypt security credential. Ensure the certificate PEM is valid and matches the environment (sandbox/production).",
107
61
  cause: error
108
62
  });
109
63
  }
110
64
  }
111
65
 
66
+ // src/utils/http/index.ts
67
+ async function httpRequest(url, options) {
68
+ const headers = {
69
+ "Content-Type": "application/json",
70
+ Accept: "application/json",
71
+ ...options.headers
72
+ };
73
+ const init = {
74
+ method: options.method,
75
+ headers
76
+ };
77
+ if (options.body !== void 0) {
78
+ init.body = JSON.stringify(options.body);
79
+ }
80
+ let response;
81
+ try {
82
+ response = await fetch(url, init);
83
+ } catch (err) {
84
+ throw new PesafyError({
85
+ code: "REQUEST_FAILED",
86
+ message: `Network error calling ${url}: ${String(err)}`,
87
+ cause: err
88
+ });
89
+ }
90
+ let data;
91
+ const contentType = response.headers.get("content-type") ?? "";
92
+ if (contentType.includes("application/json")) {
93
+ data = await response.json();
94
+ } else {
95
+ data = await response.text();
96
+ }
97
+ if (!response.ok) {
98
+ const daraja = data;
99
+ const message = daraja?.errorMessage ?? daraja?.ResponseDescription ?? `HTTP ${response.status}`;
100
+ throw new PesafyError({
101
+ code: "HTTP_ERROR",
102
+ message,
103
+ statusCode: response.status,
104
+ response: data
105
+ });
106
+ }
107
+ const responseHeaders = {};
108
+ response.headers.forEach((value, key) => {
109
+ responseHeaders[key] = value;
110
+ });
111
+ return {
112
+ data,
113
+ status: response.status,
114
+ headers: responseHeaders
115
+ };
116
+ }
117
+
112
118
  // src/core/auth/token-manager.ts
113
119
  var TOKEN_BUFFER_SECONDS = 60;
114
120
  var TokenManager = class {
121
+ // Unix seconds
115
122
  constructor(consumerKey, consumerSecret, baseUrl) {
116
123
  __publicField(this, "consumerKey");
117
124
  __publicField(this, "consumerSecret");
@@ -122,11 +129,15 @@ var TokenManager = class {
122
129
  this.consumerSecret = consumerSecret;
123
130
  this.baseUrl = baseUrl;
124
131
  }
125
- getAuthHeader() {
132
+ getBasicAuthHeader() {
126
133
  const credentials = `${this.consumerKey}:${this.consumerSecret}`;
127
134
  const encoded = Buffer.from(credentials, "utf-8").toString("base64");
128
135
  return `Basic ${encoded}`;
129
136
  }
137
+ /**
138
+ * Returns a valid access token, fetching a new one when the cached token
139
+ * is absent or within TOKEN_BUFFER_SECONDS of expiry.
140
+ */
130
141
  async getAccessToken() {
131
142
  const now = Date.now() / 1e3;
132
143
  if (this.cachedToken && this.tokenExpiresAt > now + TOKEN_BUFFER_SECONDS) {
@@ -136,81 +147,61 @@ var TokenManager = class {
136
147
  const response = await httpRequest(url, {
137
148
  method: "GET",
138
149
  headers: {
139
- Authorization: this.getAuthHeader()
150
+ Authorization: this.getBasicAuthHeader()
140
151
  }
141
152
  });
142
- const data = response.data;
143
- if (!data.access_token) {
153
+ const { access_token, expires_in } = response.data;
154
+ if (!access_token) {
144
155
  throw new PesafyError({
145
156
  code: "AUTH_FAILED",
146
- message: "Failed to obtain access token",
147
- response: data
157
+ message: "Daraja did not return an access token. Check your consumer key and secret.",
158
+ response: response.data
148
159
  });
149
160
  }
150
- this.cachedToken = data.access_token;
151
- this.tokenExpiresAt = now + (data.expires_in ?? 3600);
161
+ this.cachedToken = access_token;
162
+ this.tokenExpiresAt = now + (expires_in ?? 3600);
152
163
  return this.cachedToken;
153
164
  }
165
+ /** Force token refresh on the next call (e.g. after a 401 response) */
154
166
  clearCache() {
155
167
  this.cachedToken = null;
156
168
  this.tokenExpiresAt = 0;
157
169
  }
158
170
  };
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
171
 
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
- }
172
+ // src/utils/phone/index.ts
188
173
  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
- );
174
+ const digits = phone.replace(/\D/g, "");
175
+ let normalised;
176
+ if (digits.startsWith("254") && digits.length === 12) {
177
+ normalised = digits;
178
+ } else if (digits.startsWith("0") && digits.length === 10) {
179
+ normalised = `254${digits.slice(1)}`;
180
+ } else if (digits.length === 9) {
181
+ normalised = `254${digits}`;
182
+ } else if (digits.startsWith("254") && digits.length !== 12) {
183
+ throw new PesafyError({
184
+ code: "INVALID_PHONE",
185
+ message: `Invalid phone number "${phone}". Expected 254XXXXXXXXX (12 digits).`
186
+ });
187
+ } else {
188
+ throw new PesafyError({
189
+ code: "INVALID_PHONE",
190
+ message: `Cannot parse phone number "${phone}". Use 07XXXXXXXX, 2547XXXXXXXX, or +2547XXXXXXXX.`
191
+ });
194
192
  }
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
- );
193
+ if (normalised.length !== 12) {
194
+ throw new PesafyError({
195
+ code: "INVALID_PHONE",
196
+ message: `Phone number "${phone}" normalised to "${normalised}" which is not 12 digits.`
197
+ });
203
198
  }
204
- return formatted;
205
- }
206
- function msisdnToNumber(phone) {
207
- return parseInt(formatKenyanMsisdn(phone), 10);
199
+ return normalised;
208
200
  }
209
201
 
210
202
  // src/mpesa/stk-push/utils.ts
211
203
  function getStkPushPassword(shortCode, passKey, timestamp) {
212
- const raw = `${shortCode}${passKey}${timestamp}`;
213
- return btoa(raw);
204
+ return btoa(`${shortCode}${passKey}${timestamp}`);
214
205
  }
215
206
  function getTimestamp() {
216
207
  const now = /* @__PURE__ */ new Date();
@@ -229,9 +220,10 @@ function getTimestamp() {
229
220
  async function processStkPush(baseUrl, accessToken, request) {
230
221
  const amount = Math.round(request.amount);
231
222
  if (amount < 1) {
232
- throw new Error(
233
- `Amount must be at least KES 1 (got ${request.amount} which rounds to ${amount}).`
234
- );
223
+ throw new PesafyError({
224
+ code: "VALIDATION_ERROR",
225
+ message: `Amount must be at least KES 1 (got ${request.amount} which rounds to ${amount}).`
226
+ });
235
227
  }
236
228
  const timestamp = getTimestamp();
237
229
  const partyB = request.partyB ?? request.shortCode;
@@ -289,6 +281,61 @@ function getCallbackValue(callback, name) {
289
281
  return inner.CallbackMetadata.Item.find((i) => i.Name === name)?.Value;
290
282
  }
291
283
 
284
+ // src/mpesa/transaction-status/query.ts
285
+ async function queryTransactionStatus(baseUrl, token, securityCredential, initiator, request) {
286
+ if (!request.transactionId) {
287
+ throw createError({
288
+ code: "VALIDATION_ERROR",
289
+ message: "transactionId is required"
290
+ });
291
+ }
292
+ if (!request.partyA) {
293
+ throw createError({
294
+ code: "VALIDATION_ERROR",
295
+ message: "partyA is required (your business shortcode, till number, or MSISDN)"
296
+ });
297
+ }
298
+ if (!request.identifierType) {
299
+ throw createError({
300
+ code: "VALIDATION_ERROR",
301
+ message: 'identifierType is required: "1" (MSISDN) | "2" (Till) | "4" (ShortCode)'
302
+ });
303
+ }
304
+ if (!request.resultUrl) {
305
+ throw createError({
306
+ code: "VALIDATION_ERROR",
307
+ message: "resultUrl is required \u2014 Safaricom POSTs the transaction result here"
308
+ });
309
+ }
310
+ if (!request.queueTimeOutUrl) {
311
+ throw createError({
312
+ code: "VALIDATION_ERROR",
313
+ message: "queueTimeOutUrl is required \u2014 Safaricom calls this on timeout"
314
+ });
315
+ }
316
+ const payload = {
317
+ Initiator: initiator,
318
+ SecurityCredential: securityCredential,
319
+ CommandID: request.commandId ?? "TransactionStatusQuery",
320
+ TransactionID: request.transactionId,
321
+ PartyA: request.partyA,
322
+ IdentifierType: request.identifierType,
323
+ ResultURL: request.resultUrl,
324
+ QueueTimeOutURL: request.queueTimeOutUrl,
325
+ Remarks: request.remarks ?? "Transaction Status Query",
326
+ Occasion: request.occasion ?? ""
327
+ };
328
+ const { data } = await httpRequest(
329
+ `${baseUrl}/mpesa/transactionstatus/v1/query`,
330
+ {
331
+ method: "POST",
332
+ headers: { Authorization: `Bearer ${token}` },
333
+ body: payload
334
+ }
335
+ );
336
+ return data;
337
+ }
338
+
292
339
  // src/mpesa/types.ts
293
340
  var DARAJA_BASE_URLS = {
294
341
  sandbox: "https://sandbox.safaricom.co.ke",
@@ -301,6 +348,12 @@ var Mpesa = class {
301
348
  __publicField(this, "config");
302
349
  __publicField(this, "tokenManager");
303
350
  __publicField(this, "baseUrl");
351
+ if (!config.consumerKey || !config.consumerSecret) {
352
+ throw new PesafyError({
353
+ code: "INVALID_CREDENTIALS",
354
+ message: "consumerKey and consumerSecret are required"
355
+ });
356
+ }
304
357
  this.config = config;
305
358
  this.baseUrl = DARAJA_BASE_URLS[config.environment];
306
359
  this.tokenManager = new TokenManager(
@@ -309,17 +362,60 @@ var Mpesa = class {
309
362
  this.baseUrl
310
363
  );
311
364
  }
312
- async getToken() {
365
+ // ── Internal helpers ────────────────────────────────────────────────────────
366
+ getToken() {
313
367
  return this.tokenManager.getAccessToken();
314
368
  }
315
- /** STK Push (M-Pesa Express) - Initiate payment on customer phone */
369
+ async buildSecurityCredential() {
370
+ if (this.config.securityCredential) return this.config.securityCredential;
371
+ if (!this.config.initiatorPassword) {
372
+ throw new PesafyError({
373
+ code: "INVALID_CREDENTIALS",
374
+ message: "Provide securityCredential (pre-encrypted) OR (initiatorPassword + certificatePath/certificatePem)"
375
+ });
376
+ }
377
+ let cert;
378
+ if (this.config.certificatePem) {
379
+ cert = this.config.certificatePem;
380
+ } else if (this.config.certificatePath) {
381
+ if (typeof Bun !== "undefined") {
382
+ cert = await Bun.file(this.config.certificatePath).text();
383
+ } else {
384
+ const { readFile } = await import('fs/promises');
385
+ cert = await readFile(this.config.certificatePath, "utf-8");
386
+ }
387
+ } else {
388
+ throw new PesafyError({
389
+ code: "INVALID_CREDENTIALS",
390
+ message: "certificatePath or certificatePem required to encrypt the initiator password"
391
+ });
392
+ }
393
+ return encryptSecurityCredential(this.config.initiatorPassword, cert);
394
+ }
395
+ // ── STK Push ──────────────────────────────────────────────────────────────
396
+ /**
397
+ * M-Pesa Express — sends a payment prompt to the customer's phone.
398
+ *
399
+ * Requires: lipaNaMpesaShortCode + lipaNaMpesaPassKey in config.
400
+ *
401
+ * @example
402
+ * const res = await mpesa.stkPush({
403
+ * amount: 100,
404
+ * phoneNumber: "0712345678",
405
+ * callbackUrl: "https://yourdomain.com/mpesa/callback",
406
+ * accountReference: "INV-001",
407
+ * transactionDesc: "Payment",
408
+ * });
409
+ * console.log(res.CheckoutRequestID); // use to poll status
410
+ */
316
411
  async stkPush(request) {
317
412
  const shortCode = this.config.lipaNaMpesaShortCode ?? "";
318
413
  const passKey = this.config.lipaNaMpesaPassKey ?? "";
319
414
  if (!shortCode || !passKey) {
320
- throw new Error(
321
- "lipaNaMpesaShortCode and lipaNaMpesaPassKey required for STK Push"
322
- );
415
+ throw new PesafyError({
416
+ code: "VALIDATION_ERROR",
417
+ message: "lipaNaMpesaShortCode and lipaNaMpesaPassKey are required for STK Push"
418
+ });
323
419
  }
324
420
  const token = await this.getToken();
325
421
  return processStkPush(this.baseUrl, token, {
@@ -328,14 +424,23 @@ var Mpesa = class {
328
424
  passKey
329
425
  });
330
426
  }
331
- /** STK Query - Check STK Push transaction status */
427
+ /**
428
+ * STK Query — checks the status of a previous STK Push.
429
+ *
430
+ * @example
431
+ * const status = await mpesa.stkQuery({
432
+ * checkoutRequestId: "ws_CO_1007202409152617172396192",
433
+ * });
434
+ * if (status.ResultCode === 0) // payment confirmed
435
+ */
332
436
  async stkQuery(request) {
333
437
  const shortCode = this.config.lipaNaMpesaShortCode ?? "";
334
438
  const passKey = this.config.lipaNaMpesaPassKey ?? "";
335
439
  if (!shortCode || !passKey) {
336
- throw new Error(
337
- "lipaNaMpesaShortCode and lipaNaMpesaPassKey required for STK Query"
338
- );
440
+ throw new PesafyError({
441
+ code: "VALIDATION_ERROR",
442
+ message: "lipaNaMpesaShortCode and lipaNaMpesaPassKey are required for STK Query"
443
+ });
339
444
  }
340
445
  const token = await this.getToken();
341
446
  return queryStkPush(this.baseUrl, token, {
@@ -344,117 +449,55 @@ var Mpesa = class {
344
449
  passKey
345
450
  });
346
451
  }
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
- });
452
+ /**
453
+ * Transaction Status — queries the result of a completed M-Pesa transaction.
454
+ *
455
+ * Requires: initiatorName + (initiatorPassword + certificate) OR securityCredential.
456
+ *
457
+ * This is ASYNCHRONOUS. The synchronous response only confirms receipt.
458
+ * Final details are POSTed to your resultUrl.
459
+ *
460
+ * @example
461
+ * await mpesa.transactionStatus({
462
+ * transactionId: "OEI2AK4XXXX",
463
+ * partyA: "174379",
464
+ * identifierType: "4",
465
+ * resultUrl: "https://yourdomain.com/mpesa/result",
466
+ * queueTimeOutUrl: "https://yourdomain.com/mpesa/timeout",
467
+ * remarks: "Check payment status",
468
+ * });
469
+ */
470
+ async transactionStatus(request) {
471
+ const initiator = this.config.initiatorName ?? "";
472
+ if (!initiator) {
473
+ throw new PesafyError({
474
+ code: "VALIDATION_ERROR",
475
+ message: "initiatorName is required for Transaction Status"
476
+ });
477
+ }
478
+ const [token, securityCred] = await Promise.all([
479
+ this.getToken(),
480
+ this.buildSecurityCredential()
481
+ ]);
482
+ return queryTransactionStatus(
483
+ this.baseUrl,
484
+ token,
485
+ securityCred,
486
+ initiator,
487
+ request
488
+ );
362
489
  }
363
- if (!config.callbackUrl) {
364
- throw new PesafyError({
365
- code: "VALIDATION_ERROR",
366
- message: "callbackUrl is required for STK Push callbacks"
367
- });
490
+ /** Force the cached OAuth token to be refreshed on the next API call */
491
+ clearTokenCache() {
492
+ this.tokenManager.clearCache();
368
493
  }
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
- }
494
+ };
450
495
 
451
496
  // src/mpesa/webhooks/retry.ts
452
497
  var DEFAULT_OPTIONS = {
453
498
  maxRetries: Infinity,
454
499
  initialDelay: 1e3,
455
- // 1 second
456
500
  maxDelay: 36e5,
457
- // 1 hour
458
501
  backoffMultiplier: 2,
459
502
  maxRetryDuration: 30 * 24 * 60 * 60 * 1e3
460
503
  // 30 days
@@ -515,7 +558,7 @@ function verifyWebhookIP(requestIP, allowedIPs = SAFARICOM_IPS) {
515
558
  function parseStkPushWebhook(body) {
516
559
  try {
517
560
  const parsed = body;
518
- if (parsed.Body?.stkCallback) return parsed;
561
+ if (parsed?.Body?.stkCallback) return parsed;
519
562
  return null;
520
563
  } catch {
521
564
  return null;
@@ -530,7 +573,7 @@ function handleWebhook(body, options = {}) {
530
573
  success: false,
531
574
  eventType: null,
532
575
  data: null,
533
- error: "IP address not whitelisted"
576
+ error: `IP address ${options.requestIP} is not in the Safaricom whitelist`
534
577
  };
535
578
  }
536
579
  }
@@ -546,46 +589,44 @@ function handleWebhook(body, options = {}) {
546
589
  success: false,
547
590
  eventType: null,
548
591
  data: null,
549
- error: "Unknown webhook format"
592
+ error: "Unknown or malformed webhook payload"
550
593
  };
551
594
  }
552
595
  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;
596
+ const items = webhook.Body?.stkCallback?.CallbackMetadata?.Item;
597
+ const item = items?.find((i) => i.Name === "MpesaReceiptNumber");
598
+ return item ? String(item.Value) : null;
561
599
  }
562
600
  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;
601
+ const items = webhook.Body?.stkCallback?.CallbackMetadata?.Item;
602
+ const item = items?.find((i) => i.Name === "Amount");
603
+ return item ? Number(item.Value) : null;
604
+ }
605
+ function extractPhoneNumber(webhook) {
606
+ const items = webhook.Body?.stkCallback?.CallbackMetadata?.Item;
607
+ const item = items?.find((i) => i.Name === "PhoneNumber");
608
+ return item ? String(item.Value) : null;
609
+ }
610
+ function isSuccessfulCallback(webhook) {
611
+ return webhook.Body?.stkCallback?.ResultCode === 0;
569
612
  }
570
613
 
571
614
  exports.DARAJA_BASE_URLS = DARAJA_BASE_URLS;
572
615
  exports.Mpesa = Mpesa;
573
616
  exports.PesafyError = PesafyError;
574
- exports.TokenManager = TokenManager;
617
+ exports.SAFARICOM_IPS = SAFARICOM_IPS;
575
618
  exports.createError = createError;
576
- exports.createMpesaExpressClient = createMpesaExpressClient;
577
- exports.createMpesaExpressRouter = createMpesaExpressRouter;
578
619
  exports.encryptSecurityCredential = encryptSecurityCredential;
579
620
  exports.extractAmount = extractAmount;
621
+ exports.extractPhoneNumber = extractPhoneNumber;
580
622
  exports.extractTransactionId = extractTransactionId;
581
- exports.formatKenyanMsisdn = formatKenyanMsisdn;
582
623
  exports.formatPhoneNumber = formatSafaricomPhone;
583
- exports.formatSafaricomPhone = formatSafaricomPhone;
584
624
  exports.getCallbackValue = getCallbackValue;
585
625
  exports.getTimestamp = getTimestamp;
586
626
  exports.handleWebhook = handleWebhook;
587
627
  exports.isStkCallbackSuccess = isStkCallbackSuccess;
588
- exports.msisdnToNumber = msisdnToNumber;
628
+ exports.isSuccessfulCallback = isSuccessfulCallback;
629
+ exports.parseStkPushWebhook = parseStkPushWebhook;
589
630
  exports.retryWithBackoff = retryWithBackoff;
590
631
  exports.verifyWebhookIP = verifyWebhookIP;
591
632
  //# sourceMappingURL=index.cjs.map