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 +320 -251
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +371 -130
- package/dist/index.d.ts +371 -130
- package/dist/index.js +318 -247
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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/
|
|
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/
|
|
45
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
107
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
68
108
|
try {
|
|
69
|
-
data =
|
|
109
|
+
data = contentType.includes("application/json") ? await response.json() : await response.text();
|
|
70
110
|
} catch {
|
|
71
|
-
data =
|
|
111
|
+
data = null;
|
|
72
112
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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.
|
|
174
|
+
Authorization: this.getBasicAuthHeader()
|
|
140
175
|
}
|
|
141
176
|
});
|
|
142
|
-
const
|
|
143
|
-
if (!
|
|
177
|
+
const { access_token, expires_in } = response.data;
|
|
178
|
+
if (!access_token) {
|
|
144
179
|
throw new PesafyError({
|
|
145
180
|
code: "AUTH_FAILED",
|
|
146
|
-
message: "
|
|
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 =
|
|
151
|
-
this.tokenExpiresAt = now + (
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
233
|
-
|
|
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
|
-
|
|
393
|
+
// ── Internal helpers ────────────────────────────────────────────────────────
|
|
394
|
+
getToken() {
|
|
313
395
|
return this.tokenManager.getAccessToken();
|
|
314
396
|
}
|
|
315
|
-
|
|
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
|
|
321
|
-
|
|
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
|
-
/**
|
|
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
|
|
337
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
620
|
+
error: "Unknown or malformed webhook payload"
|
|
550
621
|
};
|
|
551
622
|
}
|
|
552
623
|
function extractTransactionId(webhook) {
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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.
|
|
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.
|
|
656
|
+
exports.isSuccessfulCallback = isSuccessfulCallback;
|
|
657
|
+
exports.parseStkPushWebhook = parseStkPushWebhook;
|
|
589
658
|
exports.retryWithBackoff = retryWithBackoff;
|
|
590
659
|
exports.verifyWebhookIP = verifyWebhookIP;
|
|
591
660
|
//# sourceMappingURL=index.cjs.map
|