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 +294 -253
- 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 +292 -249
- 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,84 @@ 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
|
-
|
|
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: "
|
|
106
|
-
message: "
|
|
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
|
-
|
|
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.
|
|
150
|
+
Authorization: this.getBasicAuthHeader()
|
|
140
151
|
}
|
|
141
152
|
});
|
|
142
|
-
const
|
|
143
|
-
if (!
|
|
153
|
+
const { access_token, expires_in } = response.data;
|
|
154
|
+
if (!access_token) {
|
|
144
155
|
throw new PesafyError({
|
|
145
156
|
code: "AUTH_FAILED",
|
|
146
|
-
message: "
|
|
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 =
|
|
151
|
-
this.tokenExpiresAt = now + (
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
233
|
-
|
|
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
|
-
|
|
365
|
+
// ── Internal helpers ────────────────────────────────────────────────────────
|
|
366
|
+
getToken() {
|
|
313
367
|
return this.tokenManager.getAccessToken();
|
|
314
368
|
}
|
|
315
|
-
|
|
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
|
|
321
|
-
|
|
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
|
-
/**
|
|
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
|
|
337
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
592
|
+
error: "Unknown or malformed webhook payload"
|
|
550
593
|
};
|
|
551
594
|
}
|
|
552
595
|
function extractTransactionId(webhook) {
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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.
|
|
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.
|
|
628
|
+
exports.isSuccessfulCallback = isSuccessfulCallback;
|
|
629
|
+
exports.parseStkPushWebhook = parseStkPushWebhook;
|
|
589
630
|
exports.retryWithBackoff = retryWithBackoff;
|
|
590
631
|
exports.verifyWebhookIP = verifyWebhookIP;
|
|
591
632
|
//# sourceMappingURL=index.cjs.map
|