pesafy 0.0.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +62 -40
- package/dist/adapters/express.d.ts +5 -81
- package/dist/adapters/express.js +127 -1
- package/dist/adapters/express.js.map +1 -0
- package/dist/adapters/fastify.d.ts +10 -74
- package/dist/adapters/fastify.js +85 -1
- package/dist/adapters/fastify.js.map +1 -0
- package/dist/adapters/hono.d.ts +4 -88
- package/dist/adapters/hono.js +105 -1
- package/dist/adapters/hono.js.map +1 -0
- package/dist/adapters/nextjs.d.ts +28 -176
- package/dist/adapters/nextjs.js +166 -1
- package/dist/adapters/nextjs.js.map +1 -0
- package/dist/cli.mjs +2387 -112
- package/dist/cli.mjs.map +1 -0
- package/dist/errors.mjs +6 -1
- package/dist/errors.mjs.map +1 -0
- package/dist/index.d.ts +4864 -136
- package/dist/index.js +4197 -1
- package/dist/index.js.map +1 -0
- package/dist/phone.mjs +5 -1
- package/dist/phone.mjs.map +1 -0
- package/dist/rolldown-runtime.mjs +18 -0
- package/dist/route-definitions.js +3292 -0
- package/dist/route-definitions.js.map +1 -0
- package/dist/types.d.ts +924 -5
- package/package.json +6 -1
- package/dist/chunk.js +0 -1
- package/dist/encryption.mjs +0 -22
- package/dist/utils.mjs +0 -32
- package/dist/webhook-guard.js +0 -1
|
@@ -0,0 +1,3292 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { constants, publicEncrypt } from "node:crypto";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
//#region src/utils/errors/index.ts
|
|
6
|
+
var PesafyError = class PesafyError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
statusCode;
|
|
9
|
+
response;
|
|
10
|
+
requestId;
|
|
11
|
+
cause;
|
|
12
|
+
retryable;
|
|
13
|
+
constructor(options) {
|
|
14
|
+
super(options.message);
|
|
15
|
+
Object.defineProperty(this, "name", { value: "PesafyError" });
|
|
16
|
+
this.code = options.code;
|
|
17
|
+
this.statusCode = options.statusCode;
|
|
18
|
+
this.response = options.response;
|
|
19
|
+
this.requestId = options.requestId;
|
|
20
|
+
this.cause = options.cause;
|
|
21
|
+
this.retryable = options.retryable ?? (options.code === "NETWORK_ERROR" || options.code === "TIMEOUT" || options.code === "RATE_LIMITED" || options.code === "REQUEST_FAILED");
|
|
22
|
+
if (Error.captureStackTrace) Error.captureStackTrace(this, PesafyError);
|
|
23
|
+
}
|
|
24
|
+
/** Returns true if this is a validation error (user bug — do not retry) */
|
|
25
|
+
get isValidation() {
|
|
26
|
+
return this.code === "VALIDATION_ERROR";
|
|
27
|
+
}
|
|
28
|
+
/** Returns true if this is an auth error */
|
|
29
|
+
get isAuth() {
|
|
30
|
+
return this.code === "AUTH_FAILED" || this.code === "INVALID_CREDENTIALS";
|
|
31
|
+
}
|
|
32
|
+
toJSON() {
|
|
33
|
+
return {
|
|
34
|
+
name: this.name,
|
|
35
|
+
code: this.code,
|
|
36
|
+
message: this.message,
|
|
37
|
+
statusCode: this.statusCode,
|
|
38
|
+
requestId: this.requestId,
|
|
39
|
+
retryable: this.retryable
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
/** Convenience factory */
|
|
44
|
+
function createError(options) {
|
|
45
|
+
return new PesafyError(options);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/utils/http/index.ts
|
|
50
|
+
/** Merge explicit Daraja HTTP options into an httpRequest options object. */
|
|
51
|
+
function withDarajaHttp(options, http) {
|
|
52
|
+
if (!http?.idempotency) return options;
|
|
53
|
+
return {
|
|
54
|
+
...options,
|
|
55
|
+
idempotency: http.idempotency
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const RETRYABLE_STATUSES = new Set([
|
|
59
|
+
429,
|
|
60
|
+
500,
|
|
61
|
+
502,
|
|
62
|
+
503,
|
|
63
|
+
504
|
|
64
|
+
]);
|
|
65
|
+
function sleep(ms) {
|
|
66
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
67
|
+
}
|
|
68
|
+
function withJitter(base) {
|
|
69
|
+
const spread = base * .25;
|
|
70
|
+
return base + (Math.random() * spread * 2 - spread);
|
|
71
|
+
}
|
|
72
|
+
/** Origin + path only (no query) for safe retry logs. */
|
|
73
|
+
function logSafeUrl(url) {
|
|
74
|
+
try {
|
|
75
|
+
const u = new URL(url);
|
|
76
|
+
return `${u.origin}${u.pathname}`;
|
|
77
|
+
} catch {
|
|
78
|
+
return url.split("?")[0] ?? url;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Sends an HTTP request to Daraja and returns parsed JSON.
|
|
83
|
+
* Automatically retries transient failures with exponential back-off.
|
|
84
|
+
*
|
|
85
|
+
* @throws {PesafyError} on non-retryable errors or exhausted retries.
|
|
86
|
+
*/
|
|
87
|
+
async function httpRequest(url, options) {
|
|
88
|
+
const maxRetries = options.retries ?? 4;
|
|
89
|
+
const baseDelay = options.retryDelay ?? 2e3;
|
|
90
|
+
const timeout = options.timeout ?? 3e4;
|
|
91
|
+
const headers = {
|
|
92
|
+
"Content-Type": "application/json",
|
|
93
|
+
Accept: "application/json",
|
|
94
|
+
...options.headers
|
|
95
|
+
};
|
|
96
|
+
const manager = options.idempotency;
|
|
97
|
+
let idempotencyKey = options.idempotencyKey;
|
|
98
|
+
if (options.method === "POST" && manager?.enabled) {
|
|
99
|
+
idempotencyKey = manager.reserve(idempotencyKey);
|
|
100
|
+
const headerName = manager.headerName;
|
|
101
|
+
headers[headerName] = idempotencyKey;
|
|
102
|
+
} else if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
|
|
103
|
+
const init = {
|
|
104
|
+
method: options.method,
|
|
105
|
+
headers,
|
|
106
|
+
...options.body !== void 0 ? { body: JSON.stringify(options.body) } : {}
|
|
107
|
+
};
|
|
108
|
+
let lastError = null;
|
|
109
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
110
|
+
if (attempt > 0) {
|
|
111
|
+
const delay = withJitter(baseDelay * Math.pow(2, attempt - 1));
|
|
112
|
+
console.warn(`[pesafy] Retry ${attempt}/${maxRetries} → ${options.method} ${logSafeUrl(url)} in ${Math.round(delay)} ms`);
|
|
113
|
+
await sleep(delay);
|
|
114
|
+
}
|
|
115
|
+
const controller = new AbortController();
|
|
116
|
+
const tid = setTimeout(() => controller.abort(), timeout);
|
|
117
|
+
let response;
|
|
118
|
+
try {
|
|
119
|
+
response = await fetch(url, {
|
|
120
|
+
...init,
|
|
121
|
+
signal: controller.signal
|
|
122
|
+
});
|
|
123
|
+
} catch (err) {
|
|
124
|
+
clearTimeout(tid);
|
|
125
|
+
if (err instanceof Error && err.name === "AbortError") lastError = new PesafyError({
|
|
126
|
+
code: "TIMEOUT",
|
|
127
|
+
message: `Request to ${url} timed out after ${timeout} ms`,
|
|
128
|
+
cause: err,
|
|
129
|
+
retryable: true
|
|
130
|
+
});
|
|
131
|
+
else lastError = new PesafyError({
|
|
132
|
+
code: "NETWORK_ERROR",
|
|
133
|
+
message: `Network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
134
|
+
cause: err,
|
|
135
|
+
retryable: true
|
|
136
|
+
});
|
|
137
|
+
if (attempt < maxRetries) continue;
|
|
138
|
+
if (idempotencyKey && manager?.enabled) manager.release(idempotencyKey);
|
|
139
|
+
throw lastError;
|
|
140
|
+
} finally {
|
|
141
|
+
clearTimeout(tid);
|
|
142
|
+
}
|
|
143
|
+
let rawText = "";
|
|
144
|
+
let data = null;
|
|
145
|
+
const ct = response.headers.get("content-type") ?? "";
|
|
146
|
+
try {
|
|
147
|
+
rawText = await response.text();
|
|
148
|
+
if (rawText) data = ct.includes("application/json") ? JSON.parse(rawText) : rawText;
|
|
149
|
+
} catch {
|
|
150
|
+
data = rawText || null;
|
|
151
|
+
}
|
|
152
|
+
const responseHeaders = {};
|
|
153
|
+
response.headers.forEach((v, k) => {
|
|
154
|
+
responseHeaders[k] = v;
|
|
155
|
+
});
|
|
156
|
+
if (response.ok) {
|
|
157
|
+
if (idempotencyKey && manager?.enabled) manager.complete(idempotencyKey);
|
|
158
|
+
return {
|
|
159
|
+
data,
|
|
160
|
+
status: response.status,
|
|
161
|
+
headers: responseHeaders
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const isTransient = RETRYABLE_STATUSES.has(response.status);
|
|
165
|
+
const daraja = typeof data === "object" && data !== null ? data : {};
|
|
166
|
+
const message = daraja["errorMessage"] ?? daraja["ResponseDescription"] ?? daraja["resultDesc"] ?? rawText ?? `HTTP ${response.status}`;
|
|
167
|
+
lastError = new PesafyError({
|
|
168
|
+
code: isTransient ? "REQUEST_FAILED" : "API_ERROR",
|
|
169
|
+
message,
|
|
170
|
+
statusCode: response.status,
|
|
171
|
+
response: data,
|
|
172
|
+
retryable: isTransient,
|
|
173
|
+
...typeof daraja["requestId"] === "string" ? { requestId: daraja["requestId"] } : {}
|
|
174
|
+
});
|
|
175
|
+
if (isTransient && attempt < maxRetries) continue;
|
|
176
|
+
if (idempotencyKey && manager?.enabled) manager.release(idempotencyKey);
|
|
177
|
+
throw lastError;
|
|
178
|
+
}
|
|
179
|
+
if (idempotencyKey && manager?.enabled) manager.release(idempotencyKey);
|
|
180
|
+
throw lastError;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
//#endregion
|
|
184
|
+
//#region src/core/auth/types.ts
|
|
185
|
+
/**
|
|
186
|
+
* Daraja Authorization API error codes
|
|
187
|
+
* Documented at: https://developer.safaricom.co.ke/APIs/Authorization
|
|
188
|
+
*
|
|
189
|
+
* These are returned in the `errorCode` field of a 400 error response body
|
|
190
|
+
* when the OAuth token request is malformed.
|
|
191
|
+
*/
|
|
192
|
+
const AUTH_ERROR_CODES = {
|
|
193
|
+
INVALID_AUTH_TYPE: "400.008.01",
|
|
194
|
+
INVALID_GRANT_TYPE: "400.008.02"
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
//#endregion
|
|
198
|
+
//#region src/core/auth/token-manager.ts
|
|
199
|
+
/** Refresh the token this many seconds before it actually expires */
|
|
200
|
+
const TOKEN_BUFFER_SECONDS = 60;
|
|
201
|
+
var TokenManager = class {
|
|
202
|
+
consumerKey;
|
|
203
|
+
consumerSecret;
|
|
204
|
+
baseUrl;
|
|
205
|
+
cachedToken = null;
|
|
206
|
+
tokenExpiresAt = 0;
|
|
207
|
+
constructor(consumerKey, consumerSecret, baseUrl) {
|
|
208
|
+
this.consumerKey = consumerKey;
|
|
209
|
+
this.consumerSecret = consumerSecret;
|
|
210
|
+
this.baseUrl = baseUrl;
|
|
211
|
+
}
|
|
212
|
+
getBasicAuthHeader() {
|
|
213
|
+
const credentials = `${this.consumerKey}:${this.consumerSecret}`;
|
|
214
|
+
return `Basic ${Buffer.from(credentials, "utf-8").toString("base64")}`;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Maps Daraja-specific auth error codes (400.008.01 / 400.008.02) to
|
|
218
|
+
* descriptive PesafyError messages so callers get actionable feedback.
|
|
219
|
+
*
|
|
220
|
+
* Always throws — the `never` return type signals this to TypeScript.
|
|
221
|
+
*/
|
|
222
|
+
mapAuthError(error) {
|
|
223
|
+
if (error instanceof PesafyError) {
|
|
224
|
+
if (error.code === "AUTH_FAILED") throw error;
|
|
225
|
+
const raw = error.response;
|
|
226
|
+
if (raw && typeof raw === "object") {
|
|
227
|
+
const errorCode = raw["errorCode"] ?? raw["error_code"];
|
|
228
|
+
if (errorCode === AUTH_ERROR_CODES.INVALID_AUTH_TYPE) throw new PesafyError({
|
|
229
|
+
code: "AUTH_FAILED",
|
|
230
|
+
message: "Invalid authentication type (400.008.01). Use Basic authentication: Authorization: Basic <Base64(consumerKey:consumerSecret)>.",
|
|
231
|
+
...error.statusCode !== void 0 && { statusCode: error.statusCode },
|
|
232
|
+
response: error.response
|
|
233
|
+
});
|
|
234
|
+
if (errorCode === AUTH_ERROR_CODES.INVALID_GRANT_TYPE) throw new PesafyError({
|
|
235
|
+
code: "AUTH_FAILED",
|
|
236
|
+
message: "Invalid grant type (400.008.02). Set grant_type=client_credentials in the request query parameters.",
|
|
237
|
+
...error.statusCode !== void 0 && { statusCode: error.statusCode },
|
|
238
|
+
response: error.response
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Returns a valid access token, fetching a new one when the cached token
|
|
247
|
+
* is absent or within TOKEN_BUFFER_SECONDS of expiry.
|
|
248
|
+
*
|
|
249
|
+
* Daraja endpoint: GET /oauth/v1/generate?grant_type=client_credentials
|
|
250
|
+
* Auth: Basic Base64(consumerKey:consumerSecret)
|
|
251
|
+
* Token lifetime: 3599 seconds (Daraja docs)
|
|
252
|
+
*/
|
|
253
|
+
async getAccessToken() {
|
|
254
|
+
const now = Date.now() / 1e3;
|
|
255
|
+
if (this.cachedToken && this.tokenExpiresAt > now + TOKEN_BUFFER_SECONDS) return this.cachedToken;
|
|
256
|
+
const url = `${this.baseUrl}/oauth/v1/generate?grant_type=client_credentials`;
|
|
257
|
+
try {
|
|
258
|
+
const response = await httpRequest(url, {
|
|
259
|
+
method: "GET",
|
|
260
|
+
headers: { Authorization: this.getBasicAuthHeader() }
|
|
261
|
+
});
|
|
262
|
+
const { access_token, expires_in } = response.data;
|
|
263
|
+
if (!access_token) throw new PesafyError({
|
|
264
|
+
code: "AUTH_FAILED",
|
|
265
|
+
message: "Daraja did not return an access token. Verify your consumer key and consumer secret.",
|
|
266
|
+
response: response.data
|
|
267
|
+
});
|
|
268
|
+
this.cachedToken = access_token;
|
|
269
|
+
this.tokenExpiresAt = now + (expires_in ?? 3600);
|
|
270
|
+
return this.cachedToken;
|
|
271
|
+
} catch (error) {
|
|
272
|
+
return this.mapAuthError(error);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** Force token refresh on the next call (e.g. after a 401 response) */
|
|
276
|
+
clearCache() {
|
|
277
|
+
this.cachedToken = null;
|
|
278
|
+
this.tokenExpiresAt = 0;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
//#endregion
|
|
283
|
+
//#region src/core/encryption/security-credentials.ts
|
|
284
|
+
function encryptSecurityCredential(initiatorPassword, certificatePem) {
|
|
285
|
+
try {
|
|
286
|
+
const passwordBuffer = Buffer.from(initiatorPassword, "utf-8");
|
|
287
|
+
return publicEncrypt({
|
|
288
|
+
key: certificatePem,
|
|
289
|
+
padding: constants.RSA_PKCS1_PADDING
|
|
290
|
+
}, passwordBuffer).toString("base64");
|
|
291
|
+
} catch (error) {
|
|
292
|
+
throw new PesafyError({
|
|
293
|
+
code: "ENCRYPTION_FAILED",
|
|
294
|
+
message: "Failed to encrypt security credential. Ensure the certificate PEM is valid and matches the environment (sandbox/production).",
|
|
295
|
+
cause: error
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
//#endregion
|
|
301
|
+
//#region src/core/idempotency/generate-key.ts
|
|
302
|
+
/**
|
|
303
|
+
* Generate idempotency keys for Daraja mutating requests.
|
|
304
|
+
*/
|
|
305
|
+
/** UUID v4 idempotency key, optionally prefixed for debugging. */
|
|
306
|
+
function generateIdempotencyKey(prefix) {
|
|
307
|
+
const id = crypto.randomUUID();
|
|
308
|
+
return prefix ? `${prefix}-${id}` : id;
|
|
309
|
+
}
|
|
310
|
+
/** Daraja OriginatorConversationID — unique per async API request. */
|
|
311
|
+
function generateOriginatorConversationId() {
|
|
312
|
+
return generateIdempotencyKey("pesafy");
|
|
313
|
+
}
|
|
314
|
+
/** B2B Express RequestRefID — unique per checkout request. */
|
|
315
|
+
function generateRequestRefId() {
|
|
316
|
+
return crypto.randomUUID();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
//#endregion
|
|
320
|
+
//#region src/core/idempotency/store.ts
|
|
321
|
+
var InMemoryIdempotencyStore = class {
|
|
322
|
+
entries = /* @__PURE__ */ new Map();
|
|
323
|
+
get(key) {
|
|
324
|
+
return this.entries.get(key);
|
|
325
|
+
}
|
|
326
|
+
set(key, entry) {
|
|
327
|
+
this.entries.set(key, entry);
|
|
328
|
+
}
|
|
329
|
+
delete(key) {
|
|
330
|
+
this.entries.delete(key);
|
|
331
|
+
}
|
|
332
|
+
/** Remove entries older than ttlMs. */
|
|
333
|
+
prune(ttlMs) {
|
|
334
|
+
const cutoff = Date.now() - ttlMs;
|
|
335
|
+
for (const [key, entry] of this.entries) if (entry.createdAt < cutoff) this.entries.delete(key);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/core/idempotency/manager.ts
|
|
341
|
+
const DEFAULT_TTL_MS = 1440 * 60 * 1e3;
|
|
342
|
+
var IdempotencyManager = class {
|
|
343
|
+
enabled;
|
|
344
|
+
headerName;
|
|
345
|
+
ttlMs;
|
|
346
|
+
store;
|
|
347
|
+
generateKey;
|
|
348
|
+
constructor(config = {}) {
|
|
349
|
+
this.enabled = config.enabled !== false;
|
|
350
|
+
this.headerName = config.headerName ?? "Idempotency-Key";
|
|
351
|
+
this.ttlMs = config.ttlMs ?? DEFAULT_TTL_MS;
|
|
352
|
+
this.store = config.store ?? new InMemoryIdempotencyStore();
|
|
353
|
+
this.generateKey = config.generateKey ?? generateIdempotencyKey;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Reserve an idempotency key before the HTTP call.
|
|
357
|
+
* @throws PesafyError with IDEMPOTENCY_ERROR if duplicate in-flight/completed within TTL.
|
|
358
|
+
*/
|
|
359
|
+
reserve(key) {
|
|
360
|
+
if (!this.enabled) return key ?? this.generateKey();
|
|
361
|
+
this.pruneExpired();
|
|
362
|
+
const resolved = key ?? this.generateKey();
|
|
363
|
+
const existing = this.store.get(resolved);
|
|
364
|
+
if (existing) {
|
|
365
|
+
if (Date.now() - existing.createdAt < this.ttlMs) throw new PesafyError({
|
|
366
|
+
code: "IDEMPOTENCY_ERROR",
|
|
367
|
+
message: `Duplicate request detected for idempotency key "${resolved}".`
|
|
368
|
+
});
|
|
369
|
+
this.store.delete(resolved);
|
|
370
|
+
}
|
|
371
|
+
this.store.set(resolved, {
|
|
372
|
+
key: resolved,
|
|
373
|
+
createdAt: Date.now()
|
|
374
|
+
});
|
|
375
|
+
return resolved;
|
|
376
|
+
}
|
|
377
|
+
/** Mark key as successfully completed. */
|
|
378
|
+
complete(key) {
|
|
379
|
+
if (!this.enabled) return;
|
|
380
|
+
const entry = this.store.get(key);
|
|
381
|
+
if (entry) this.store.set(key, {
|
|
382
|
+
...entry,
|
|
383
|
+
completedAt: Date.now()
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
/** Release reservation on failure so callers can retry with same key. */
|
|
387
|
+
release(key) {
|
|
388
|
+
if (!this.enabled) return;
|
|
389
|
+
this.store.delete(key);
|
|
390
|
+
}
|
|
391
|
+
pruneExpired() {
|
|
392
|
+
if (this.store instanceof InMemoryIdempotencyStore) this.store.prune(this.ttlMs);
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
//#endregion
|
|
397
|
+
//#region src/types/branded.ts
|
|
398
|
+
function ok(data) {
|
|
399
|
+
return {
|
|
400
|
+
ok: true,
|
|
401
|
+
data
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
function err(error) {
|
|
405
|
+
return {
|
|
406
|
+
ok: false,
|
|
407
|
+
error
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
//#endregion
|
|
412
|
+
//#region src/core/validation/zod-error.ts
|
|
413
|
+
/** Map Zod validation failures to PesafyError. */
|
|
414
|
+
function zodToPesafyError(error, label = "Request") {
|
|
415
|
+
return new PesafyError({
|
|
416
|
+
code: "VALIDATION_ERROR",
|
|
417
|
+
message: `${label} validation failed: ${error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`,
|
|
418
|
+
cause: error
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
/** Parse with Zod; throw PesafyError on failure. */
|
|
422
|
+
function parseWithSchema(schema, data, label) {
|
|
423
|
+
const result = schema.safeParse(data);
|
|
424
|
+
if (!result.success) throw zodToPesafyError(result.error, label);
|
|
425
|
+
return result.data;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
//#endregion
|
|
429
|
+
//#region src/schemas/common.ts
|
|
430
|
+
const EnvironmentSchema = z.enum(["sandbox", "production"]);
|
|
431
|
+
const MsisdnSchema = z.string().min(10).regex(/^254\d{9}$/, "Must be Safaricom format 2547XXXXXXXX");
|
|
432
|
+
const KesAmountSchema = z.number().finite().positive().refine((n) => Math.round(n) >= 1, { message: "amount must round to at least 1 KES" });
|
|
433
|
+
const NonEmptyStringSchema = z.string().trim().min(1);
|
|
434
|
+
const UrlSchema = z.string().url();
|
|
435
|
+
const DarajaErrorResponseSchema = z.object({
|
|
436
|
+
errorMessage: z.string().optional(),
|
|
437
|
+
ResponseDescription: z.string().optional(),
|
|
438
|
+
resultDesc: z.string().optional(),
|
|
439
|
+
requestId: z.string().optional()
|
|
440
|
+
}).passthrough();
|
|
441
|
+
|
|
442
|
+
//#endregion
|
|
443
|
+
//#region src/schemas/async-apis.ts
|
|
444
|
+
const IdentifierTypeSchema = z.enum([
|
|
445
|
+
"1",
|
|
446
|
+
"2",
|
|
447
|
+
"4"
|
|
448
|
+
]);
|
|
449
|
+
const AsyncApiResponseSchema = z.object({
|
|
450
|
+
ConversationID: z.string().optional(),
|
|
451
|
+
OriginatorConversationID: z.string().optional(),
|
|
452
|
+
ResponseCode: z.string(),
|
|
453
|
+
ResponseDescription: z.string()
|
|
454
|
+
}).passthrough();
|
|
455
|
+
const TransactionStatusRequestSchema = z.object({
|
|
456
|
+
transactionId: z.string().optional(),
|
|
457
|
+
originalConversationId: z.string().optional(),
|
|
458
|
+
partyA: NonEmptyStringSchema,
|
|
459
|
+
identifierType: IdentifierTypeSchema,
|
|
460
|
+
resultUrl: UrlSchema,
|
|
461
|
+
queueTimeOutUrl: UrlSchema,
|
|
462
|
+
commandId: z.literal("TransactionStatusQuery").optional(),
|
|
463
|
+
remarks: z.string().optional(),
|
|
464
|
+
occasion: z.string().optional()
|
|
465
|
+
}).superRefine((data, ctx) => {
|
|
466
|
+
if (!data.transactionId?.trim() && !data.originalConversationId?.trim()) ctx.addIssue({
|
|
467
|
+
code: "custom",
|
|
468
|
+
message: "Either transactionId (M-Pesa Receipt Number) or originalConversationId is required",
|
|
469
|
+
path: ["transactionId"]
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
const TransactionStatusResponseSchema = AsyncApiResponseSchema;
|
|
473
|
+
const AccountBalanceRequestSchema = z.object({
|
|
474
|
+
partyA: NonEmptyStringSchema,
|
|
475
|
+
identifierType: IdentifierTypeSchema,
|
|
476
|
+
resultUrl: UrlSchema,
|
|
477
|
+
queueTimeOutUrl: UrlSchema,
|
|
478
|
+
remarks: z.string().optional()
|
|
479
|
+
});
|
|
480
|
+
const AccountBalanceResponseSchema = AsyncApiResponseSchema;
|
|
481
|
+
const ReversalRequestSchema = z.object({
|
|
482
|
+
transactionId: NonEmptyStringSchema,
|
|
483
|
+
receiverParty: NonEmptyStringSchema,
|
|
484
|
+
receiverIdentifierType: z.literal("11").optional(),
|
|
485
|
+
amount: KesAmountSchema,
|
|
486
|
+
resultUrl: UrlSchema,
|
|
487
|
+
queueTimeOutUrl: UrlSchema,
|
|
488
|
+
remarks: z.string().optional(),
|
|
489
|
+
occasion: z.string().optional()
|
|
490
|
+
}).superRefine((data, ctx) => {
|
|
491
|
+
if (data.receiverIdentifierType !== void 0 && data.receiverIdentifierType !== "11") ctx.addIssue({
|
|
492
|
+
code: "custom",
|
|
493
|
+
message: "receiverIdentifierType must be \"11\" for the Reversals API",
|
|
494
|
+
path: ["receiverIdentifierType"]
|
|
495
|
+
});
|
|
496
|
+
const remarks = data.remarks ?? "Transaction Reversal";
|
|
497
|
+
if (remarks.length < 2 || remarks.length > 100) ctx.addIssue({
|
|
498
|
+
code: "custom",
|
|
499
|
+
message: "remarks must be between 2 and 100 characters",
|
|
500
|
+
path: ["remarks"]
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
const ReversalResponseSchema = AsyncApiResponseSchema;
|
|
504
|
+
const TaxRemittanceRequestSchema = z.object({
|
|
505
|
+
amount: KesAmountSchema,
|
|
506
|
+
partyA: NonEmptyStringSchema,
|
|
507
|
+
partyB: z.string().optional(),
|
|
508
|
+
accountReference: NonEmptyStringSchema,
|
|
509
|
+
resultUrl: UrlSchema,
|
|
510
|
+
queueTimeOutUrl: UrlSchema,
|
|
511
|
+
remarks: z.string().optional()
|
|
512
|
+
});
|
|
513
|
+
const TaxRemittanceResponseSchema = AsyncApiResponseSchema;
|
|
514
|
+
const DynamicQRRequestSchema = z.object({
|
|
515
|
+
merchantName: NonEmptyStringSchema,
|
|
516
|
+
refNo: NonEmptyStringSchema,
|
|
517
|
+
amount: KesAmountSchema,
|
|
518
|
+
trxCode: z.enum([
|
|
519
|
+
"BG",
|
|
520
|
+
"WA",
|
|
521
|
+
"PB",
|
|
522
|
+
"SM",
|
|
523
|
+
"SB"
|
|
524
|
+
]),
|
|
525
|
+
cpi: NonEmptyStringSchema,
|
|
526
|
+
size: z.number().int().min(1).max(1e3).optional()
|
|
527
|
+
});
|
|
528
|
+
const DynamicQRResponseSchema = z.object({
|
|
529
|
+
ResponseCode: z.string(),
|
|
530
|
+
RequestID: z.string().optional(),
|
|
531
|
+
ResponseDescription: z.string(),
|
|
532
|
+
QRCode: z.string().optional()
|
|
533
|
+
}).passthrough();
|
|
534
|
+
|
|
535
|
+
//#endregion
|
|
536
|
+
//#region src/mpesa/account-balance/query.ts
|
|
537
|
+
/**
|
|
538
|
+
* Account Balance Query — checks the balance of an M-PESA shortcode.
|
|
539
|
+
*
|
|
540
|
+
* API: POST /mpesa/accountbalance/v1/query
|
|
541
|
+
*
|
|
542
|
+
* This is ASYNCHRONOUS. The sync response only confirms receipt.
|
|
543
|
+
* Balance data arrives via POST to your ResultURL.
|
|
544
|
+
*
|
|
545
|
+
* Required org portal role: "Balance Query ORG API" (Account Balance ORG API initiator)
|
|
546
|
+
*
|
|
547
|
+
* Ref: https://sandbox.safaricom.co.ke/mpesa/accountbalance/v1/query
|
|
548
|
+
*/
|
|
549
|
+
async function queryAccountBalance(baseUrl, accessToken, securityCredential, initiatorName, request, http) {
|
|
550
|
+
const validated = parseWithSchema(AccountBalanceRequestSchema, request, "Account Balance request");
|
|
551
|
+
const payload = {
|
|
552
|
+
Initiator: initiatorName,
|
|
553
|
+
SecurityCredential: securityCredential,
|
|
554
|
+
CommandID: "AccountBalance",
|
|
555
|
+
PartyA: String(validated.partyA.trim()),
|
|
556
|
+
IdentifierType: validated.identifierType,
|
|
557
|
+
ResultURL: validated.resultUrl,
|
|
558
|
+
QueueTimeOutURL: validated.queueTimeOutUrl,
|
|
559
|
+
Remarks: validated.remarks ?? "Account Balance Query"
|
|
560
|
+
};
|
|
561
|
+
const { data } = await httpRequest(`${baseUrl}/mpesa/accountbalance/v1/query`, withDarajaHttp({
|
|
562
|
+
method: "POST",
|
|
563
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
564
|
+
body: payload
|
|
565
|
+
}, http));
|
|
566
|
+
return parseWithSchema(AccountBalanceResponseSchema, data, "Account Balance response");
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
//#endregion
|
|
570
|
+
//#region src/mpesa/account-balance/types.ts
|
|
571
|
+
/**
|
|
572
|
+
* Parses the raw Daraja AccountBalance string into structured account objects.
|
|
573
|
+
*
|
|
574
|
+
* Daraja returns each account as 3 consecutive pipe-separated fields:
|
|
575
|
+
* AccountName | Currency | Amount
|
|
576
|
+
* Multiple accounts are concatenated, with additional balance sub-fields interleaved.
|
|
577
|
+
*
|
|
578
|
+
* The parser extracts triplets where the first element is a non-numeric account name.
|
|
579
|
+
*
|
|
580
|
+
* Example input:
|
|
581
|
+
* "Working Account|KES|700000.00|KES|0.00|KES|0.00|Utility Account|KES|228037.00|"
|
|
582
|
+
*
|
|
583
|
+
* Example output:
|
|
584
|
+
* [
|
|
585
|
+
* { name: "Working Account", currency: "KES", amount: "700000.00" },
|
|
586
|
+
* { name: "Utility Account", currency: "KES", amount: "228037.00" },
|
|
587
|
+
* ]
|
|
588
|
+
*/
|
|
589
|
+
function parseAccountBalance(raw) {
|
|
590
|
+
if (!raw.trim()) return [];
|
|
591
|
+
const parts = raw.split("|");
|
|
592
|
+
const accounts = [];
|
|
593
|
+
for (let i = 0; i + 2 < parts.length; i++) {
|
|
594
|
+
const candidate = parts[i]?.trim();
|
|
595
|
+
const currency = parts[i + 1]?.trim();
|
|
596
|
+
const amount = parts[i + 2]?.trim();
|
|
597
|
+
if (candidate && currency && amount !== void 0 && isNaN(Number(candidate)) && candidate.length > 0) accounts.push({
|
|
598
|
+
name: candidate,
|
|
599
|
+
currency,
|
|
600
|
+
amount
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
return accounts;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Extracts a result parameter value from an AccountBalanceResult by key.
|
|
607
|
+
* Handles both array and single-object forms of ResultParameter (Daraja inconsistency).
|
|
608
|
+
*
|
|
609
|
+
* Documented keys: "AccountBalance", "BOCompletedTime"
|
|
610
|
+
*/
|
|
611
|
+
function getAccountBalanceParam(result, key) {
|
|
612
|
+
const params = result.Result.ResultParameters?.ResultParameter;
|
|
613
|
+
if (!params) return void 0;
|
|
614
|
+
return (Array.isArray(params) ? params : [params]).find((p) => p.Key === key)?.Value;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Returns the raw AccountBalance pipe-delimited string from the result, or null if absent.
|
|
618
|
+
* Use parseAccountBalance() to parse this into structured account objects.
|
|
619
|
+
*/
|
|
620
|
+
function getAccountBalanceRawBalance(result) {
|
|
621
|
+
const value = getAccountBalanceParam(result, "AccountBalance");
|
|
622
|
+
if (value === void 0 || value === null) return null;
|
|
623
|
+
return String(value);
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Returns true if the Account Balance result indicates success (ResultCode 0).
|
|
627
|
+
* Handles both numeric 0 and string "0" (Daraja inconsistency).
|
|
628
|
+
*/
|
|
629
|
+
function isAccountBalanceSuccess(result) {
|
|
630
|
+
const code = result.Result.ResultCode;
|
|
631
|
+
return code === 0 || code === "0";
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
//#endregion
|
|
635
|
+
//#region src/schemas/b2b.ts
|
|
636
|
+
const B2BAsyncResponseSchema = z.object({
|
|
637
|
+
ConversationID: z.string(),
|
|
638
|
+
OriginatorConversationID: z.string(),
|
|
639
|
+
ResponseCode: z.string(),
|
|
640
|
+
ResponseDescription: z.string()
|
|
641
|
+
}).passthrough();
|
|
642
|
+
const B2BPaymentBaseSchema = z.object({
|
|
643
|
+
amount: KesAmountSchema,
|
|
644
|
+
partyA: NonEmptyStringSchema,
|
|
645
|
+
partyB: NonEmptyStringSchema,
|
|
646
|
+
accountReference: NonEmptyStringSchema,
|
|
647
|
+
requester: z.string().optional(),
|
|
648
|
+
remarks: z.string().optional(),
|
|
649
|
+
resultUrl: UrlSchema,
|
|
650
|
+
queueTimeOutUrl: UrlSchema,
|
|
651
|
+
occasion: z.string().optional()
|
|
652
|
+
});
|
|
653
|
+
const B2BBuyGoodsRequestSchema = B2BPaymentBaseSchema.extend({ commandId: z.literal("BusinessBuyGoods") });
|
|
654
|
+
const B2BPayBillRequestSchema = B2BPaymentBaseSchema.extend({ commandId: z.literal("BusinessPayBill") });
|
|
655
|
+
const B2BBuyGoodsResponseSchema = B2BAsyncResponseSchema;
|
|
656
|
+
const B2BPayBillResponseSchema = B2BAsyncResponseSchema;
|
|
657
|
+
const B2BExpressCheckoutRequestSchema = z.object({
|
|
658
|
+
primaryShortCode: NonEmptyStringSchema,
|
|
659
|
+
receiverShortCode: NonEmptyStringSchema,
|
|
660
|
+
amount: KesAmountSchema,
|
|
661
|
+
paymentRef: NonEmptyStringSchema,
|
|
662
|
+
callbackUrl: UrlSchema,
|
|
663
|
+
partnerName: NonEmptyStringSchema,
|
|
664
|
+
requestRefId: NonEmptyStringSchema.optional()
|
|
665
|
+
});
|
|
666
|
+
const B2BExpressCheckoutResponseSchema = z.object({
|
|
667
|
+
code: z.string(),
|
|
668
|
+
status: z.string()
|
|
669
|
+
}).passthrough();
|
|
670
|
+
const B2BResponseSchema = z.object({
|
|
671
|
+
ConversationID: z.string().optional(),
|
|
672
|
+
OriginatorConversationID: z.string().optional(),
|
|
673
|
+
ResponseCode: z.string(),
|
|
674
|
+
ResponseDescription: z.string()
|
|
675
|
+
}).passthrough();
|
|
676
|
+
|
|
677
|
+
//#endregion
|
|
678
|
+
//#region src/mpesa/b2b-express-checkout/initiate.ts
|
|
679
|
+
/**
|
|
680
|
+
* src/mpesa/b2b-express-checkout/initiate.ts
|
|
681
|
+
*
|
|
682
|
+
* B2B Express Checkout USSD Push to Till implementation.
|
|
683
|
+
*/
|
|
684
|
+
async function initiateB2BExpressCheckout(baseUrl, accessToken, request, http) {
|
|
685
|
+
const validated = parseWithSchema(B2BExpressCheckoutRequestSchema, request, "B2B Express Checkout request");
|
|
686
|
+
const amount = Math.round(validated.amount);
|
|
687
|
+
const payload = {
|
|
688
|
+
primaryShortCode: String(validated.primaryShortCode),
|
|
689
|
+
receiverShortCode: String(validated.receiverShortCode),
|
|
690
|
+
amount: String(amount),
|
|
691
|
+
paymentRef: validated.paymentRef,
|
|
692
|
+
callbackUrl: validated.callbackUrl,
|
|
693
|
+
partnerName: validated.partnerName,
|
|
694
|
+
RequestRefID: validated.requestRefId ?? generateRequestRefId()
|
|
695
|
+
};
|
|
696
|
+
const { data } = await httpRequest(`${baseUrl}/v1/ussdpush/get-msisdn`, withDarajaHttp({
|
|
697
|
+
method: "POST",
|
|
698
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
699
|
+
body: payload
|
|
700
|
+
}, http));
|
|
701
|
+
return parseWithSchema(B2BExpressCheckoutResponseSchema, data, "B2B Express Checkout response");
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
//#endregion
|
|
705
|
+
//#region src/mpesa/b2b-express-checkout/types.ts
|
|
706
|
+
/**
|
|
707
|
+
* Known B2B Express Checkout result codes.
|
|
708
|
+
*
|
|
709
|
+
* SUCCESS (0) — Transaction completed successfully
|
|
710
|
+
* CANCELLED(4001) — Merchant cancelled the USSD prompt
|
|
711
|
+
* KYC_FAIL (4102) — Merchant KYC failure; provide valid KYC
|
|
712
|
+
* NO_NUMBER(4104) — Missing nominated number; configure in M-PESA portal
|
|
713
|
+
* NET_ERROR(4201) — USSD network error; retry on stable network
|
|
714
|
+
* USSD_ERR (4203) — USSD exception error; retry on stable network
|
|
715
|
+
*/
|
|
716
|
+
const B2B_RESULT_CODES = {
|
|
717
|
+
SUCCESS: "0",
|
|
718
|
+
CANCELLED: "4001",
|
|
719
|
+
KYC_FAIL: "4102",
|
|
720
|
+
NO_NOMINATED_NUMBER: "4104",
|
|
721
|
+
USSD_NETWORK_ERROR: "4201",
|
|
722
|
+
USSD_EXCEPTION_ERROR: "4203"
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
//#endregion
|
|
726
|
+
//#region src/mpesa/b2b-express-checkout/webhooks.ts
|
|
727
|
+
/**
|
|
728
|
+
* src/mpesa/b2b-express-checkout/webhooks.ts
|
|
729
|
+
*
|
|
730
|
+
* B2B Express Checkout callback (webhook) helpers.
|
|
731
|
+
* Strictly aligned with Safaricom Daraja B2B Express Checkout documentation.
|
|
732
|
+
*
|
|
733
|
+
* All callbacks are discriminated on `resultCode`:
|
|
734
|
+
* "0" → success (B2BExpressCheckoutCallbackSuccess)
|
|
735
|
+
* "4001" → cancelled (B2BExpressCheckoutCallbackCancelled)
|
|
736
|
+
* other → failed (B2BExpressCheckoutCallbackFailed)
|
|
737
|
+
*/
|
|
738
|
+
const KNOWN_RESULT_CODES$1 = new Set(Object.values(B2B_RESULT_CODES));
|
|
739
|
+
/**
|
|
740
|
+
* Runtime type guard — returns true if `body` looks like a valid B2B
|
|
741
|
+
* Express Checkout callback payload.
|
|
742
|
+
*
|
|
743
|
+
* Use this in your callback route before casting the body:
|
|
744
|
+
* @example
|
|
745
|
+
* app.post('/mpesa/b2b/callback', (req, res) => {
|
|
746
|
+
* if (!isB2BCheckoutCallback(req.body)) {
|
|
747
|
+
* return res.status(400).json({ error: 'unrecognised payload' })
|
|
748
|
+
* }
|
|
749
|
+
* // safe to use as B2BExpressCheckoutCallback
|
|
750
|
+
* })
|
|
751
|
+
*/
|
|
752
|
+
function isB2BCheckoutCallback(body) {
|
|
753
|
+
if (!body || typeof body !== "object") return false;
|
|
754
|
+
const b = body;
|
|
755
|
+
return typeof b["resultCode"] === "string" && typeof b["requestId"] === "string" && typeof b["amount"] === "string";
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Returns true when the B2B callback represents a SUCCESSFUL transaction.
|
|
759
|
+
* A successful callback has resultCode "0" and includes transactionId.
|
|
760
|
+
*
|
|
761
|
+
* Per Daraja docs:
|
|
762
|
+
* {
|
|
763
|
+
* "resultCode": "0",
|
|
764
|
+
* "resultDesc": "The service request is processed successfully.",
|
|
765
|
+
* "transactionId": "RDQ01NFT1Q",
|
|
766
|
+
* "status": "SUCCESS",
|
|
767
|
+
* ...
|
|
768
|
+
* }
|
|
769
|
+
*/
|
|
770
|
+
function isB2BCheckoutSuccess(callback) {
|
|
771
|
+
return callback.resultCode === B2B_RESULT_CODES.SUCCESS;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Returns true when the merchant CANCELLED the USSD prompt.
|
|
775
|
+
* resultCode "4001" = "User cancelled transaction".
|
|
776
|
+
*
|
|
777
|
+
* Per Daraja docs:
|
|
778
|
+
* {
|
|
779
|
+
* "resultCode": "4001",
|
|
780
|
+
* "resultDesc": "User cancelled transaction",
|
|
781
|
+
* "paymentReference": "MAndbubry3hi",
|
|
782
|
+
* ...
|
|
783
|
+
* }
|
|
784
|
+
*/
|
|
785
|
+
function isB2BCheckoutCancelled(callback) {
|
|
786
|
+
return callback.resultCode === B2B_RESULT_CODES.CANCELLED;
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Returns the transaction amount as a number from any B2B callback.
|
|
790
|
+
* Daraja sends amount as a string (e.g. "71.0"); this converts it to number.
|
|
791
|
+
*/
|
|
792
|
+
function getB2BAmount(callback) {
|
|
793
|
+
return Number(callback.amount);
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Returns the M-PESA receipt number from a SUCCESSFUL callback.
|
|
797
|
+
* Returns null if the callback is not a success.
|
|
798
|
+
*/
|
|
799
|
+
function getB2BTransactionId(callback) {
|
|
800
|
+
if (!isB2BCheckoutSuccess(callback)) return null;
|
|
801
|
+
return callback.transactionId ?? null;
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Returns the M-PESA conversationID from a SUCCESSFUL callback.
|
|
805
|
+
* Returns null if the callback is not a success.
|
|
806
|
+
*/
|
|
807
|
+
function getB2BConversationId(callback) {
|
|
808
|
+
if (!isB2BCheckoutSuccess(callback)) return null;
|
|
809
|
+
return callback.conversationID ?? null;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
//#endregion
|
|
813
|
+
//#region src/mpesa/b2b-buy-goods/payment.ts
|
|
814
|
+
/**
|
|
815
|
+
* src/mpesa/b2b-buy-goods/payment.ts
|
|
816
|
+
*
|
|
817
|
+
* Initiates a Business Buy Goods payment via Safaricom Daraja.
|
|
818
|
+
* Endpoint: POST /mpesa/b2b/v1/paymentrequest
|
|
819
|
+
*
|
|
820
|
+
* Strictly follows the Safaricom Daraja Business Buy Goods API documentation:
|
|
821
|
+
* - CommandID must be "BusinessBuyGoods"
|
|
822
|
+
* - SenderIdentifierType is always "4" (hardcoded per docs)
|
|
823
|
+
* - RecieverIdentifierType is always "4" (hardcoded per docs)
|
|
824
|
+
* - Amount is sent as a string per the JSON spec
|
|
825
|
+
* - AccountReference is truncated to max 13 characters per docs
|
|
826
|
+
* - Requester and Occassion are optional
|
|
827
|
+
*/
|
|
828
|
+
/** Daraja Business Buy Goods endpoint — same as Pay Bill per docs */
|
|
829
|
+
const B2B_BUY_GOODS_ENDPOINT = "/mpesa/b2b/v1/paymentrequest";
|
|
830
|
+
/**
|
|
831
|
+
* Per documentation: SenderIdentifierType and RecieverIdentifierType
|
|
832
|
+
* must always be "4" (Organisation ShortCode). Not configurable.
|
|
833
|
+
*/
|
|
834
|
+
const IDENTIFIER_TYPE$2 = "4";
|
|
835
|
+
/**
|
|
836
|
+
* Initiates a Business Buy Goods payment request.
|
|
837
|
+
*
|
|
838
|
+
* Moves money from your MMF/Working account to the recipient's merchant account
|
|
839
|
+
* (till number, merchant store number, or Merchant HO).
|
|
840
|
+
* The sync response is acknowledgement only — the result arrives via resultUrl.
|
|
841
|
+
*
|
|
842
|
+
* @param baseUrl - Daraja base URL (sandbox or production)
|
|
843
|
+
* @param accessToken - Valid OAuth Bearer token
|
|
844
|
+
* @param securityCredential - RSA-encrypted initiator password (base64)
|
|
845
|
+
* @param initiatorName - M-Pesa API operator username with B2B role
|
|
846
|
+
* @param request - Business Buy Goods request parameters
|
|
847
|
+
* @returns Synchronous acknowledgement response from Daraja
|
|
848
|
+
* @throws {PesafyError} VALIDATION_ERROR for invalid input before HTTP call
|
|
849
|
+
* @throws {PesafyError} From httpRequest on network / API errors
|
|
850
|
+
*/
|
|
851
|
+
async function initiateB2BBuyGoods(baseUrl, accessToken, securityCredential, initiatorName, request, http) {
|
|
852
|
+
const validated = parseWithSchema(B2BBuyGoodsRequestSchema, request, "B2B Buy Goods request");
|
|
853
|
+
const amount = Math.round(validated.amount);
|
|
854
|
+
const payload = {
|
|
855
|
+
Initiator: initiatorName,
|
|
856
|
+
SecurityCredential: securityCredential,
|
|
857
|
+
CommandID: validated.commandId,
|
|
858
|
+
SenderIdentifierType: IDENTIFIER_TYPE$2,
|
|
859
|
+
RecieverIdentifierType: IDENTIFIER_TYPE$2,
|
|
860
|
+
Amount: String(amount),
|
|
861
|
+
PartyA: String(validated.partyA),
|
|
862
|
+
PartyB: String(validated.partyB),
|
|
863
|
+
AccountReference: validated.accountReference.slice(0, 13),
|
|
864
|
+
Remarks: validated.remarks ?? "Business Buy Goods",
|
|
865
|
+
QueueTimeOutURL: validated.queueTimeOutUrl,
|
|
866
|
+
ResultURL: validated.resultUrl
|
|
867
|
+
};
|
|
868
|
+
if (validated.requester?.trim()) payload["Requester"] = String(validated.requester);
|
|
869
|
+
if (validated.occasion?.trim()) payload["Occassion"] = validated.occasion;
|
|
870
|
+
const { data } = await httpRequest(`${baseUrl}${B2B_BUY_GOODS_ENDPOINT}`, withDarajaHttp({
|
|
871
|
+
method: "POST",
|
|
872
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
873
|
+
body: payload
|
|
874
|
+
}, http));
|
|
875
|
+
return parseWithSchema(B2BBuyGoodsResponseSchema, data, "B2B Buy Goods response");
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
//#endregion
|
|
879
|
+
//#region src/mpesa/b2b-pay-bill/payment.ts
|
|
880
|
+
/**
|
|
881
|
+
* src/mpesa/b2b-pay-bill/payment.ts
|
|
882
|
+
*
|
|
883
|
+
* Initiates a Business Pay Bill payment via Safaricom Daraja.
|
|
884
|
+
* Endpoint: POST /mpesa/b2b/v1/paymentrequest
|
|
885
|
+
*
|
|
886
|
+
* Strictly follows the Safaricom Daraja Business Pay Bill API documentation:
|
|
887
|
+
* - CommandID must be "BusinessPayBill"
|
|
888
|
+
* - SenderIdentifierType is always "4" (hardcoded per docs)
|
|
889
|
+
* - RecieverIdentifierType is always "4" (hardcoded per docs)
|
|
890
|
+
* - Amount is sent as a string per the JSON spec
|
|
891
|
+
* - AccountReference is truncated to max 13 characters per docs
|
|
892
|
+
* - Requester and Occassion are optional
|
|
893
|
+
*/
|
|
894
|
+
/** Daraja Business Pay Bill endpoint */
|
|
895
|
+
const B2B_PAY_BILL_ENDPOINT = "/mpesa/b2b/v1/paymentrequest";
|
|
896
|
+
/**
|
|
897
|
+
* Per documentation: SenderIdentifierType and RecieverIdentifierType
|
|
898
|
+
* must always be "4" (Organisation ShortCode). Not configurable.
|
|
899
|
+
*/
|
|
900
|
+
const IDENTIFIER_TYPE$1 = "4";
|
|
901
|
+
/**
|
|
902
|
+
* Initiates a Business Pay Bill payment request.
|
|
903
|
+
*
|
|
904
|
+
* Moves money from your MMF/Working account to the recipient's utility account.
|
|
905
|
+
* The sync response is acknowledgement only — the result arrives via resultUrl.
|
|
906
|
+
*
|
|
907
|
+
* @param baseUrl - Daraja base URL (sandbox or production)
|
|
908
|
+
* @param accessToken - Valid OAuth Bearer token
|
|
909
|
+
* @param securityCredential - RSA-encrypted initiator password (base64)
|
|
910
|
+
* @param initiatorName - M-Pesa API operator username with B2B role
|
|
911
|
+
* @param request - Business Pay Bill request parameters
|
|
912
|
+
* @returns Synchronous acknowledgement response from Daraja
|
|
913
|
+
* @throws {PesafyError} VALIDATION_ERROR for invalid input before HTTP call
|
|
914
|
+
* @throws {PesafyError} From httpRequest on network / API errors
|
|
915
|
+
*/
|
|
916
|
+
async function initiateB2BPayBill(baseUrl, accessToken, securityCredential, initiatorName, request, http) {
|
|
917
|
+
const validated = parseWithSchema(B2BPayBillRequestSchema, request, "B2B Pay Bill request");
|
|
918
|
+
const amount = Math.round(validated.amount);
|
|
919
|
+
const payload = {
|
|
920
|
+
Initiator: initiatorName,
|
|
921
|
+
SecurityCredential: securityCredential,
|
|
922
|
+
CommandID: validated.commandId,
|
|
923
|
+
SenderIdentifierType: IDENTIFIER_TYPE$1,
|
|
924
|
+
RecieverIdentifierType: IDENTIFIER_TYPE$1,
|
|
925
|
+
Amount: String(amount),
|
|
926
|
+
PartyA: String(validated.partyA),
|
|
927
|
+
PartyB: String(validated.partyB),
|
|
928
|
+
AccountReference: validated.accountReference.slice(0, 13),
|
|
929
|
+
Remarks: validated.remarks ?? "Business Pay Bill",
|
|
930
|
+
QueueTimeOutURL: validated.queueTimeOutUrl,
|
|
931
|
+
ResultURL: validated.resultUrl
|
|
932
|
+
};
|
|
933
|
+
if (validated.requester?.trim()) payload["Requester"] = String(validated.requester);
|
|
934
|
+
if (validated.occasion?.trim()) payload["Occassion"] = validated.occasion;
|
|
935
|
+
const { data } = await httpRequest(`${baseUrl}${B2B_PAY_BILL_ENDPOINT}`, withDarajaHttp({
|
|
936
|
+
method: "POST",
|
|
937
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
938
|
+
body: payload
|
|
939
|
+
}, http));
|
|
940
|
+
return parseWithSchema(B2BPayBillResponseSchema, data, "B2B Pay Bill response");
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
//#endregion
|
|
944
|
+
//#region src/schemas/b2c.ts
|
|
945
|
+
const B2CAsyncResponseSchema = z.object({
|
|
946
|
+
ConversationID: z.string(),
|
|
947
|
+
OriginatorConversationID: z.string(),
|
|
948
|
+
ResponseCode: z.string(),
|
|
949
|
+
ResponseDescription: z.string()
|
|
950
|
+
}).passthrough();
|
|
951
|
+
/** B2C Account Top Up (BusinessPayToBulk) */
|
|
952
|
+
const B2CRequestSchema = z.object({
|
|
953
|
+
commandId: z.literal("BusinessPayToBulk"),
|
|
954
|
+
amount: KesAmountSchema,
|
|
955
|
+
partyA: NonEmptyStringSchema,
|
|
956
|
+
partyB: NonEmptyStringSchema,
|
|
957
|
+
accountReference: NonEmptyStringSchema,
|
|
958
|
+
requester: z.string().optional(),
|
|
959
|
+
remarks: z.string().optional(),
|
|
960
|
+
resultUrl: UrlSchema,
|
|
961
|
+
queueTimeOutUrl: UrlSchema
|
|
962
|
+
});
|
|
963
|
+
const B2CResponseSchema = B2CAsyncResponseSchema;
|
|
964
|
+
const B2CDisbursementRequestSchema = z.object({
|
|
965
|
+
commandId: z.enum([
|
|
966
|
+
"BusinessPayment",
|
|
967
|
+
"SalaryPayment",
|
|
968
|
+
"PromotionPayment"
|
|
969
|
+
]),
|
|
970
|
+
amount: z.number().finite().positive(),
|
|
971
|
+
partyA: NonEmptyStringSchema,
|
|
972
|
+
partyB: NonEmptyStringSchema,
|
|
973
|
+
remarks: NonEmptyStringSchema,
|
|
974
|
+
queueTimeOutUrl: UrlSchema,
|
|
975
|
+
resultUrl: UrlSchema,
|
|
976
|
+
originatorConversationId: NonEmptyStringSchema.optional(),
|
|
977
|
+
occasion: z.string().optional()
|
|
978
|
+
});
|
|
979
|
+
const B2CDisbursementResponseSchema = B2CAsyncResponseSchema;
|
|
980
|
+
|
|
981
|
+
//#endregion
|
|
982
|
+
//#region src/mpesa/b2c/payment.ts
|
|
983
|
+
/**
|
|
984
|
+
* src/mpesa/b2c/payment.ts
|
|
985
|
+
*
|
|
986
|
+
* Initiates a B2C Account Top Up via Safaricom Daraja.
|
|
987
|
+
* Endpoint: POST /mpesa/b2b/v1/paymentrequest
|
|
988
|
+
*
|
|
989
|
+
* Strictly follows the Safaricom Daraja B2C Account Top Up API documentation:
|
|
990
|
+
* - CommandID must be "BusinessPayToBulk"
|
|
991
|
+
* - SenderIdentifierType is always "4" (hardcoded per docs)
|
|
992
|
+
* - RecieverIdentifierType is always "4" (hardcoded per docs)
|
|
993
|
+
* - Amount is sent as a string per the JSON spec
|
|
994
|
+
* - Requester is optional
|
|
995
|
+
*/
|
|
996
|
+
/** The only endpoint documented for this API */
|
|
997
|
+
const B2C_ENDPOINT = "/mpesa/b2b/v1/paymentrequest";
|
|
998
|
+
/**
|
|
999
|
+
* Per documentation: SenderIdentifierType and RecieverIdentifierType
|
|
1000
|
+
* must always be "4" (Organisation ShortCode). Not configurable.
|
|
1001
|
+
*/
|
|
1002
|
+
const IDENTIFIER_TYPE = "4";
|
|
1003
|
+
/**
|
|
1004
|
+
* Initiates a B2C Account Top Up payment request.
|
|
1005
|
+
*
|
|
1006
|
+
* @param baseUrl - Daraja base URL (sandbox or production)
|
|
1007
|
+
* @param accessToken - Valid OAuth Bearer token
|
|
1008
|
+
* @param securityCredential - RSA-encrypted initiator password (base64)
|
|
1009
|
+
* @param initiatorName - M-Pesa API operator username with B2B role
|
|
1010
|
+
* @param request - B2C top-up request parameters
|
|
1011
|
+
* @returns Synchronous acknowledgement response from Daraja
|
|
1012
|
+
* @throws {PesafyError} VALIDATION_ERROR for invalid input before HTTP call
|
|
1013
|
+
* @throws {PesafyError} From httpRequest on network / API errors
|
|
1014
|
+
*/
|
|
1015
|
+
async function initiateB2CPayment(baseUrl, accessToken, securityCredential, initiatorName, request, http) {
|
|
1016
|
+
const validated = parseWithSchema(B2CRequestSchema, request, "B2C request");
|
|
1017
|
+
const amount = Math.round(validated.amount);
|
|
1018
|
+
const payload = {
|
|
1019
|
+
Initiator: initiatorName,
|
|
1020
|
+
SecurityCredential: securityCredential,
|
|
1021
|
+
CommandID: validated.commandId,
|
|
1022
|
+
SenderIdentifierType: IDENTIFIER_TYPE,
|
|
1023
|
+
RecieverIdentifierType: IDENTIFIER_TYPE,
|
|
1024
|
+
Amount: String(amount),
|
|
1025
|
+
PartyA: String(validated.partyA),
|
|
1026
|
+
PartyB: String(validated.partyB),
|
|
1027
|
+
AccountReference: validated.accountReference,
|
|
1028
|
+
Remarks: validated.remarks ?? "B2C Account Top Up",
|
|
1029
|
+
QueueTimeOutURL: validated.queueTimeOutUrl,
|
|
1030
|
+
ResultURL: validated.resultUrl
|
|
1031
|
+
};
|
|
1032
|
+
if (validated.requester?.trim()) payload["Requester"] = String(validated.requester);
|
|
1033
|
+
const { data } = await httpRequest(`${baseUrl}${B2C_ENDPOINT}`, withDarajaHttp({
|
|
1034
|
+
method: "POST",
|
|
1035
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1036
|
+
body: payload
|
|
1037
|
+
}, http));
|
|
1038
|
+
return parseWithSchema(B2CResponseSchema, data, "B2C response");
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
//#endregion
|
|
1042
|
+
//#region src/mpesa/b2c/webhooks.ts
|
|
1043
|
+
/**
|
|
1044
|
+
* Runtime type guard — checks if a body looks like a B2C result callback.
|
|
1045
|
+
* Validates the minimum documented structure.
|
|
1046
|
+
*/
|
|
1047
|
+
function isB2CResult(body) {
|
|
1048
|
+
if (!body || typeof body !== "object") return false;
|
|
1049
|
+
const b = body;
|
|
1050
|
+
if (!b["Result"] || typeof b["Result"] !== "object") return false;
|
|
1051
|
+
const result = b["Result"];
|
|
1052
|
+
return (typeof result["ResultCode"] === "number" || typeof result["ResultCode"] === "string") && typeof result["ConversationID"] === "string" && typeof result["OriginatorConversationID"] === "string";
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Returns true if the B2C result represents a successful transaction.
|
|
1056
|
+
* Handles both string "0" (documented in success sample) and number 0.
|
|
1057
|
+
*/
|
|
1058
|
+
function isB2CSuccess(result) {
|
|
1059
|
+
const code = result.Result.ResultCode;
|
|
1060
|
+
return code === 0 || code === "0";
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Extracts the M-PESA transaction ID from a B2C result.
|
|
1064
|
+
* Present on both success and failure (generic ID on failure).
|
|
1065
|
+
*/
|
|
1066
|
+
function getB2CTransactionId(result) {
|
|
1067
|
+
return result.Result.TransactionID ?? null;
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Extracts the OriginatorConversationID from a B2C result.
|
|
1071
|
+
* Use this to correlate with the original API call response.
|
|
1072
|
+
*/
|
|
1073
|
+
function getB2COriginatorConversationId(result) {
|
|
1074
|
+
return result.Result.OriginatorConversationID;
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Extracts the transaction amount from B2C result parameters.
|
|
1078
|
+
* Documented field: "Amount"
|
|
1079
|
+
* Returns null if not present (e.g. on failure).
|
|
1080
|
+
*/
|
|
1081
|
+
function getB2CAmount(result) {
|
|
1082
|
+
const value = getB2CResultParam(result, "Amount");
|
|
1083
|
+
if (value === void 0) return null;
|
|
1084
|
+
const num = Number(value);
|
|
1085
|
+
return Number.isFinite(num) ? num : null;
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Extracts a named value from B2C result parameters.
|
|
1089
|
+
* Handles both single-object and array forms of ResultParameter
|
|
1090
|
+
* (Daraja returns either depending on how many parameters are present).
|
|
1091
|
+
* Returns undefined if key is absent or no ResultParameters exist.
|
|
1092
|
+
*/
|
|
1093
|
+
function getB2CResultParam(result, key) {
|
|
1094
|
+
const params = result.Result.ResultParameters?.ResultParameter;
|
|
1095
|
+
if (!params) return void 0;
|
|
1096
|
+
return (Array.isArray(params) ? params : [params]).find((p) => p.Key === key)?.Value;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
//#endregion
|
|
1100
|
+
//#region src/mpesa/b2c-disbursement/payment.ts
|
|
1101
|
+
/**
|
|
1102
|
+
* src/mpesa/b2c-disbursement/payment.ts
|
|
1103
|
+
*
|
|
1104
|
+
* Initiates a B2C Disbursement payment (Salary / Cashback / Promotion).
|
|
1105
|
+
* Endpoint: POST /mpesa/b2c/v3/paymentrequest
|
|
1106
|
+
*/
|
|
1107
|
+
const B2C_DISBURSEMENT_ENDPOINT = "/mpesa/b2c/v3/paymentrequest";
|
|
1108
|
+
const VALID_COMMAND_IDS = new Set([
|
|
1109
|
+
"BusinessPayment",
|
|
1110
|
+
"SalaryPayment",
|
|
1111
|
+
"PromotionPayment"
|
|
1112
|
+
]);
|
|
1113
|
+
/**
|
|
1114
|
+
* Initiates a B2C disbursement payment request.
|
|
1115
|
+
*
|
|
1116
|
+
* @param baseUrl - Daraja base URL (sandbox or production)
|
|
1117
|
+
* @param accessToken - Valid OAuth Bearer token
|
|
1118
|
+
* @param securityCredential - RSA-encrypted initiator password (base64)
|
|
1119
|
+
* @param initiatorName - M-Pesa API operator username
|
|
1120
|
+
* @param request - B2C disbursement request parameters
|
|
1121
|
+
*/
|
|
1122
|
+
async function initiateB2CDisbursement(baseUrl, accessToken, securityCredential, initiatorName, request, http) {
|
|
1123
|
+
const originatorConversationId = request.originatorConversationId?.trim() || generateOriginatorConversationId();
|
|
1124
|
+
const validated = parseWithSchema(B2CDisbursementRequestSchema, {
|
|
1125
|
+
...request,
|
|
1126
|
+
originatorConversationId
|
|
1127
|
+
}, "B2C Disbursement request");
|
|
1128
|
+
if (!validated.commandId || !VALID_COMMAND_IDS.has(validated.commandId)) throw createError({
|
|
1129
|
+
code: "VALIDATION_ERROR",
|
|
1130
|
+
message: `commandId must be one of: BusinessPayment, SalaryPayment, PromotionPayment. Got "${validated.commandId}".`
|
|
1131
|
+
});
|
|
1132
|
+
const amount = Math.round(validated.amount);
|
|
1133
|
+
if (!Number.isFinite(amount) || amount < 10) throw createError({
|
|
1134
|
+
code: "VALIDATION_ERROR",
|
|
1135
|
+
message: `amount must be ≥ 10 KES (got ${validated.amount} which rounds to ${amount}).`
|
|
1136
|
+
});
|
|
1137
|
+
if (!validated.partyA?.trim()) throw createError({
|
|
1138
|
+
code: "VALIDATION_ERROR",
|
|
1139
|
+
message: "partyA is required — the sending organisation shortcode."
|
|
1140
|
+
});
|
|
1141
|
+
if (!validated.partyB?.trim()) throw createError({
|
|
1142
|
+
code: "VALIDATION_ERROR",
|
|
1143
|
+
message: "partyB is required — the receiving customer MSISDN (2547XXXXXXXX)."
|
|
1144
|
+
});
|
|
1145
|
+
if (!validated.remarks?.trim()) throw createError({
|
|
1146
|
+
code: "VALIDATION_ERROR",
|
|
1147
|
+
message: "remarks is required (2–100 characters)."
|
|
1148
|
+
});
|
|
1149
|
+
if (!validated.resultUrl?.trim()) throw createError({
|
|
1150
|
+
code: "VALIDATION_ERROR",
|
|
1151
|
+
message: "resultUrl is required — Safaricom POSTs the async result here."
|
|
1152
|
+
});
|
|
1153
|
+
if (!validated.queueTimeOutUrl?.trim()) throw createError({
|
|
1154
|
+
code: "VALIDATION_ERROR",
|
|
1155
|
+
message: "queueTimeOutUrl is required — Safaricom calls this on request timeout."
|
|
1156
|
+
});
|
|
1157
|
+
const payload = {
|
|
1158
|
+
OriginatorConversationID: originatorConversationId,
|
|
1159
|
+
InitiatorName: initiatorName,
|
|
1160
|
+
SecurityCredential: securityCredential,
|
|
1161
|
+
CommandID: validated.commandId,
|
|
1162
|
+
Amount: amount,
|
|
1163
|
+
PartyA: String(validated.partyA),
|
|
1164
|
+
PartyB: String(validated.partyB),
|
|
1165
|
+
Remarks: validated.remarks,
|
|
1166
|
+
QueueTimeOutURL: validated.queueTimeOutUrl,
|
|
1167
|
+
ResultURL: validated.resultUrl
|
|
1168
|
+
};
|
|
1169
|
+
if (validated.occasion?.trim()) payload["Occassion"] = validated.occasion;
|
|
1170
|
+
const { data } = await httpRequest(`${baseUrl}${B2C_DISBURSEMENT_ENDPOINT}`, withDarajaHttp({
|
|
1171
|
+
method: "POST",
|
|
1172
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1173
|
+
body: payload
|
|
1174
|
+
}, http));
|
|
1175
|
+
return parseWithSchema(B2CDisbursementResponseSchema, data, "B2C Disbursement response");
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
//#endregion
|
|
1179
|
+
//#region src/mpesa/b2c-disbursement/webhooks.ts
|
|
1180
|
+
function isB2CDisbursementResult(body) {
|
|
1181
|
+
if (!body || typeof body !== "object") return false;
|
|
1182
|
+
const b = body;
|
|
1183
|
+
if (!b["Result"] || typeof b["Result"] !== "object") return false;
|
|
1184
|
+
const result = b["Result"];
|
|
1185
|
+
return (typeof result["ResultCode"] === "number" || typeof result["ResultCode"] === "string") && typeof result["ConversationID"] === "string" && typeof result["OriginatorConversationID"] === "string";
|
|
1186
|
+
}
|
|
1187
|
+
function isB2CDisbursementSuccess(result) {
|
|
1188
|
+
const code = result.Result.ResultCode;
|
|
1189
|
+
return code === 0 || code === "0";
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
//#endregion
|
|
1193
|
+
//#region src/schemas/bill-manager.ts
|
|
1194
|
+
const SendRemindersSchema = z.enum(["0", "1"]);
|
|
1195
|
+
const BillManagerOptInRequestSchema = z.object({
|
|
1196
|
+
shortcode: NonEmptyStringSchema,
|
|
1197
|
+
email: NonEmptyStringSchema,
|
|
1198
|
+
officialContact: NonEmptyStringSchema,
|
|
1199
|
+
sendReminders: SendRemindersSchema,
|
|
1200
|
+
logo: z.string().optional(),
|
|
1201
|
+
callbackUrl: UrlSchema
|
|
1202
|
+
});
|
|
1203
|
+
const BillManagerOptInResponseSchema = z.object({
|
|
1204
|
+
app_key: z.string().optional(),
|
|
1205
|
+
resmsg: z.string(),
|
|
1206
|
+
rescode: z.string()
|
|
1207
|
+
}).passthrough();
|
|
1208
|
+
const BillManagerUpdateOptInRequestSchema = BillManagerOptInRequestSchema;
|
|
1209
|
+
const BillManagerUpdateOptInResponseSchema = z.object({
|
|
1210
|
+
resmsg: z.string(),
|
|
1211
|
+
rescode: z.string()
|
|
1212
|
+
}).passthrough();
|
|
1213
|
+
const BillManagerInvoiceItemSchema = z.object({
|
|
1214
|
+
itemName: NonEmptyStringSchema,
|
|
1215
|
+
amount: KesAmountSchema
|
|
1216
|
+
});
|
|
1217
|
+
const BillManagerSingleInvoiceRequestSchema = z.object({
|
|
1218
|
+
externalReference: NonEmptyStringSchema,
|
|
1219
|
+
billedFullName: NonEmptyStringSchema,
|
|
1220
|
+
billedPhoneNumber: NonEmptyStringSchema,
|
|
1221
|
+
billedPeriod: NonEmptyStringSchema,
|
|
1222
|
+
invoiceName: NonEmptyStringSchema,
|
|
1223
|
+
dueDate: NonEmptyStringSchema,
|
|
1224
|
+
accountReference: NonEmptyStringSchema,
|
|
1225
|
+
amount: KesAmountSchema,
|
|
1226
|
+
invoiceItems: z.array(BillManagerInvoiceItemSchema).optional()
|
|
1227
|
+
});
|
|
1228
|
+
const BillManagerSingleInvoiceResponseSchema = z.object({
|
|
1229
|
+
Status_Message: z.string().optional(),
|
|
1230
|
+
resmsg: z.string(),
|
|
1231
|
+
rescode: z.string()
|
|
1232
|
+
}).passthrough();
|
|
1233
|
+
const BillManagerBulkInvoiceRequestSchema = z.object({ invoices: z.array(BillManagerSingleInvoiceRequestSchema).min(1).max(1e3) });
|
|
1234
|
+
const BillManagerBulkInvoiceResponseSchema = z.object({
|
|
1235
|
+
Status_Message: z.string().optional(),
|
|
1236
|
+
resmsg: z.string(),
|
|
1237
|
+
rescode: z.string()
|
|
1238
|
+
}).passthrough();
|
|
1239
|
+
const BillManagerCancelInvoiceRequestSchema = z.object({ externalReference: NonEmptyStringSchema });
|
|
1240
|
+
const BillManagerCancelInvoiceResponseSchema = z.object({
|
|
1241
|
+
Status_Message: z.string().optional(),
|
|
1242
|
+
resmsg: z.string(),
|
|
1243
|
+
rescode: z.string(),
|
|
1244
|
+
errors: z.array(z.unknown()).optional()
|
|
1245
|
+
}).passthrough();
|
|
1246
|
+
const BillManagerCancelBulkInvoiceRequestSchema = z.object({ externalReferences: z.array(NonEmptyStringSchema).min(1) });
|
|
1247
|
+
const BillManagerCancelBulkInvoiceResponseSchema = z.object({
|
|
1248
|
+
Status_Message: z.string().optional(),
|
|
1249
|
+
resmsg: z.string(),
|
|
1250
|
+
rescode: z.string(),
|
|
1251
|
+
errors: z.array(z.unknown()).optional()
|
|
1252
|
+
}).passthrough();
|
|
1253
|
+
const BillManagerReconciliationRequestSchema = z.object({
|
|
1254
|
+
paymentDate: NonEmptyStringSchema,
|
|
1255
|
+
paidAmount: NonEmptyStringSchema,
|
|
1256
|
+
accountReference: NonEmptyStringSchema,
|
|
1257
|
+
transactionId: NonEmptyStringSchema,
|
|
1258
|
+
phoneNumber: NonEmptyStringSchema,
|
|
1259
|
+
fullName: NonEmptyStringSchema,
|
|
1260
|
+
invoiceName: NonEmptyStringSchema,
|
|
1261
|
+
externalReference: NonEmptyStringSchema
|
|
1262
|
+
});
|
|
1263
|
+
const BillManagerReconciliationResponseSchema = z.object({
|
|
1264
|
+
resmsg: z.string(),
|
|
1265
|
+
rescode: z.string()
|
|
1266
|
+
}).passthrough();
|
|
1267
|
+
|
|
1268
|
+
//#endregion
|
|
1269
|
+
//#region src/mpesa/bill-manager/invoice.ts
|
|
1270
|
+
/**
|
|
1271
|
+
* src/mpesa/bill-manager/invoice.ts
|
|
1272
|
+
*
|
|
1273
|
+
* Bill Manager — opt-in, invoice creation/cancellation, and payment reconciliation.
|
|
1274
|
+
*
|
|
1275
|
+
* Strictly aligned with Safaricom Daraja Bill Manager API documentation.
|
|
1276
|
+
*
|
|
1277
|
+
* APIs:
|
|
1278
|
+
* POST /v1/billmanager-invoice/optin — Opt-in shortcode
|
|
1279
|
+
* POST /v1/billmanager-invoice/change-optin-details — Update opt-in details
|
|
1280
|
+
* POST /v1/billmanager-invoice/single-invoicing — Send a single invoice
|
|
1281
|
+
* POST /v1/billmanager-invoice/bulk-invoicing — Send bulk invoices (up to 1000)
|
|
1282
|
+
* POST /v1/billmanager-invoice/cancel-single-invoice — Cancel a single invoice
|
|
1283
|
+
* POST /v1/billmanager-invoice/cancel-bulk-invoices — Cancel multiple invoices
|
|
1284
|
+
* POST /v1/billmanager-invoice/reconciliation — Acknowledge a payment
|
|
1285
|
+
*/
|
|
1286
|
+
async function billManagerOptIn(baseUrl, accessToken, request, http) {
|
|
1287
|
+
const validated = parseWithSchema(BillManagerOptInRequestSchema, request, "Bill Manager opt-in request");
|
|
1288
|
+
const payload = {
|
|
1289
|
+
shortcode: validated.shortcode,
|
|
1290
|
+
email: validated.email,
|
|
1291
|
+
officialContact: validated.officialContact,
|
|
1292
|
+
sendReminders: validated.sendReminders,
|
|
1293
|
+
logo: validated.logo ?? "",
|
|
1294
|
+
callbackurl: validated.callbackUrl
|
|
1295
|
+
};
|
|
1296
|
+
const { data } = await httpRequest(`${baseUrl}/v1/billmanager-invoice/optin`, withDarajaHttp({
|
|
1297
|
+
method: "POST",
|
|
1298
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1299
|
+
body: payload
|
|
1300
|
+
}, http));
|
|
1301
|
+
return parseWithSchema(BillManagerOptInResponseSchema, data, "Bill Manager opt-in response");
|
|
1302
|
+
}
|
|
1303
|
+
async function updateOptIn(baseUrl, accessToken, request, http) {
|
|
1304
|
+
const validated = parseWithSchema(BillManagerUpdateOptInRequestSchema, request, "Bill Manager update opt-in request");
|
|
1305
|
+
const payload = {
|
|
1306
|
+
shortcode: validated.shortcode,
|
|
1307
|
+
email: validated.email,
|
|
1308
|
+
officialContact: validated.officialContact,
|
|
1309
|
+
sendReminders: validated.sendReminders,
|
|
1310
|
+
logo: validated.logo ?? "",
|
|
1311
|
+
callbackurl: validated.callbackUrl
|
|
1312
|
+
};
|
|
1313
|
+
const { data } = await httpRequest(`${baseUrl}/v1/billmanager-invoice/change-optin-details`, withDarajaHttp({
|
|
1314
|
+
method: "POST",
|
|
1315
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1316
|
+
body: payload
|
|
1317
|
+
}, http));
|
|
1318
|
+
return parseWithSchema(BillManagerUpdateOptInResponseSchema, data, "Bill Manager update opt-in response");
|
|
1319
|
+
}
|
|
1320
|
+
async function sendSingleInvoice(baseUrl, accessToken, request, http) {
|
|
1321
|
+
const validated = parseWithSchema(BillManagerSingleInvoiceRequestSchema, request, "Bill Manager single invoice request");
|
|
1322
|
+
const amount = Math.round(validated.amount);
|
|
1323
|
+
const payload = {
|
|
1324
|
+
externalReference: validated.externalReference,
|
|
1325
|
+
billedFullName: validated.billedFullName,
|
|
1326
|
+
billedPhoneNumber: validated.billedPhoneNumber,
|
|
1327
|
+
billedPeriod: validated.billedPeriod,
|
|
1328
|
+
invoiceName: validated.invoiceName,
|
|
1329
|
+
dueDate: validated.dueDate,
|
|
1330
|
+
accountReference: validated.accountReference,
|
|
1331
|
+
amount: String(amount),
|
|
1332
|
+
invoiceItems: validated.invoiceItems?.map((i) => ({
|
|
1333
|
+
itemName: i.itemName,
|
|
1334
|
+
amount: String(Math.round(i.amount))
|
|
1335
|
+
})) ?? []
|
|
1336
|
+
};
|
|
1337
|
+
const { data } = await httpRequest(`${baseUrl}/v1/billmanager-invoice/single-invoicing`, withDarajaHttp({
|
|
1338
|
+
method: "POST",
|
|
1339
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1340
|
+
body: payload
|
|
1341
|
+
}, http));
|
|
1342
|
+
return parseWithSchema(BillManagerSingleInvoiceResponseSchema, data, "Bill Manager single invoice response");
|
|
1343
|
+
}
|
|
1344
|
+
async function sendBulkInvoices(baseUrl, accessToken, request, http) {
|
|
1345
|
+
const payload = parseWithSchema(BillManagerBulkInvoiceRequestSchema, request, "Bill Manager bulk invoice request").invoices.map((inv) => ({
|
|
1346
|
+
externalReference: inv.externalReference,
|
|
1347
|
+
billedFullName: inv.billedFullName,
|
|
1348
|
+
billedPhoneNumber: inv.billedPhoneNumber,
|
|
1349
|
+
billedPeriod: inv.billedPeriod,
|
|
1350
|
+
invoiceName: inv.invoiceName,
|
|
1351
|
+
dueDate: inv.dueDate,
|
|
1352
|
+
accountReference: inv.accountReference,
|
|
1353
|
+
amount: String(Math.round(inv.amount)),
|
|
1354
|
+
invoiceItems: inv.invoiceItems?.map((item) => ({
|
|
1355
|
+
itemName: item.itemName,
|
|
1356
|
+
amount: String(Math.round(item.amount))
|
|
1357
|
+
})) ?? []
|
|
1358
|
+
}));
|
|
1359
|
+
const { data } = await httpRequest(`${baseUrl}/v1/billmanager-invoice/bulk-invoicing`, withDarajaHttp({
|
|
1360
|
+
method: "POST",
|
|
1361
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1362
|
+
body: payload
|
|
1363
|
+
}, http));
|
|
1364
|
+
return parseWithSchema(BillManagerBulkInvoiceResponseSchema, data, "Bill Manager bulk invoice response");
|
|
1365
|
+
}
|
|
1366
|
+
async function cancelInvoice(baseUrl, accessToken, request, http) {
|
|
1367
|
+
const validated = parseWithSchema(BillManagerCancelInvoiceRequestSchema, request, "Bill Manager cancel invoice request");
|
|
1368
|
+
const { data } = await httpRequest(`${baseUrl}/v1/billmanager-invoice/cancel-single-invoice`, withDarajaHttp({
|
|
1369
|
+
method: "POST",
|
|
1370
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1371
|
+
body: { externalReference: validated.externalReference }
|
|
1372
|
+
}, http));
|
|
1373
|
+
return parseWithSchema(BillManagerCancelInvoiceResponseSchema, data, "Bill Manager cancel invoice response");
|
|
1374
|
+
}
|
|
1375
|
+
async function cancelBulkInvoices(baseUrl, accessToken, request, http) {
|
|
1376
|
+
const payload = parseWithSchema(BillManagerCancelBulkInvoiceRequestSchema, request, "Bill Manager cancel bulk invoices request").externalReferences.map((ref) => ({ externalReference: ref }));
|
|
1377
|
+
const { data } = await httpRequest(`${baseUrl}/v1/billmanager-invoice/cancel-bulk-invoices`, withDarajaHttp({
|
|
1378
|
+
method: "POST",
|
|
1379
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1380
|
+
body: payload
|
|
1381
|
+
}, http));
|
|
1382
|
+
return parseWithSchema(BillManagerCancelBulkInvoiceResponseSchema, data, "Bill Manager cancel bulk invoices response");
|
|
1383
|
+
}
|
|
1384
|
+
async function reconcilePayment(baseUrl, accessToken, request, http) {
|
|
1385
|
+
const validated = parseWithSchema(BillManagerReconciliationRequestSchema, request, "Bill Manager reconciliation request");
|
|
1386
|
+
const payload = {
|
|
1387
|
+
paymentDate: validated.paymentDate,
|
|
1388
|
+
paidAmount: validated.paidAmount,
|
|
1389
|
+
accountReference: validated.accountReference,
|
|
1390
|
+
transactionId: validated.transactionId,
|
|
1391
|
+
phoneNumber: validated.phoneNumber,
|
|
1392
|
+
fullName: validated.fullName,
|
|
1393
|
+
invoiceName: validated.invoiceName,
|
|
1394
|
+
externalReference: validated.externalReference
|
|
1395
|
+
};
|
|
1396
|
+
const { data } = await httpRequest(`${baseUrl}/v1/billmanager-invoice/reconciliation`, withDarajaHttp({
|
|
1397
|
+
method: "POST",
|
|
1398
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1399
|
+
body: payload
|
|
1400
|
+
}, http));
|
|
1401
|
+
return parseWithSchema(BillManagerReconciliationResponseSchema, data, "Bill Manager reconciliation response");
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
//#endregion
|
|
1405
|
+
//#region src/schemas/c2b.ts
|
|
1406
|
+
const C2BRegisterUrlRequestSchema = z.object({
|
|
1407
|
+
shortCode: NonEmptyStringSchema,
|
|
1408
|
+
responseType: z.enum(["Completed", "Cancelled"]),
|
|
1409
|
+
confirmationUrl: UrlSchema,
|
|
1410
|
+
validationUrl: UrlSchema,
|
|
1411
|
+
apiVersion: z.enum(["v1", "v2"]).optional()
|
|
1412
|
+
});
|
|
1413
|
+
const C2BBaseResponseSchema = z.object({
|
|
1414
|
+
OriginatorCoversationID: z.string(),
|
|
1415
|
+
ResponseCode: z.string(),
|
|
1416
|
+
ResponseDescription: z.string()
|
|
1417
|
+
}).passthrough();
|
|
1418
|
+
const C2BRegisterUrlResponseSchema = C2BBaseResponseSchema;
|
|
1419
|
+
const C2BSimulateResponseSchema = C2BBaseResponseSchema;
|
|
1420
|
+
const C2BSimulateRequestSchema = z.object({
|
|
1421
|
+
shortCode: z.union([NonEmptyStringSchema, z.number()]),
|
|
1422
|
+
commandId: z.enum(["CustomerPayBillOnline", "CustomerBuyGoodsOnline"]),
|
|
1423
|
+
amount: KesAmountSchema,
|
|
1424
|
+
msisdn: z.union([NonEmptyStringSchema, z.number()]),
|
|
1425
|
+
billRefNumber: z.union([NonEmptyStringSchema, z.null()]).optional(),
|
|
1426
|
+
apiVersion: z.enum(["v1", "v2"]).optional()
|
|
1427
|
+
}).superRefine((data, ctx) => {
|
|
1428
|
+
if (data.commandId === "CustomerPayBillOnline" && !data.billRefNumber?.trim()) ctx.addIssue({
|
|
1429
|
+
code: "custom",
|
|
1430
|
+
message: "billRefNumber is required for CustomerPayBillOnline",
|
|
1431
|
+
path: ["billRefNumber"]
|
|
1432
|
+
});
|
|
1433
|
+
});
|
|
1434
|
+
const C2BValidationWebhookSchema = z.object({
|
|
1435
|
+
TransactionType: z.string(),
|
|
1436
|
+
TransID: z.string(),
|
|
1437
|
+
TransTime: z.string(),
|
|
1438
|
+
TransAmount: z.union([z.string(), z.number()]),
|
|
1439
|
+
BusinessShortCode: z.string(),
|
|
1440
|
+
BillRefNumber: z.string().optional(),
|
|
1441
|
+
MSISDN: z.string()
|
|
1442
|
+
}).passthrough();
|
|
1443
|
+
|
|
1444
|
+
//#endregion
|
|
1445
|
+
//#region src/mpesa/c2b/register-url.ts
|
|
1446
|
+
/**
|
|
1447
|
+
* src/mpesa/c2b/register-url.ts
|
|
1448
|
+
*
|
|
1449
|
+
* C2B Register URL implementation.
|
|
1450
|
+
* Strictly aligned with Safaricom Daraja C2B Register URL API documentation.
|
|
1451
|
+
*
|
|
1452
|
+
* Endpoint (v1 — documented primary):
|
|
1453
|
+
* Sandbox: POST https://sandbox.safaricom.co.ke/mpesa/c2b/v1/registerurl
|
|
1454
|
+
* Production: POST https://api.safaricom.co.ke/mpesa/c2b/v1/registerurl
|
|
1455
|
+
*
|
|
1456
|
+
* Also supports v2 via the apiVersion option.
|
|
1457
|
+
*/
|
|
1458
|
+
/**
|
|
1459
|
+
* Forbidden URL keywords per Daraja documentation:
|
|
1460
|
+
* "Avoid keywords such as M-PESA, M-Pesa, Safaricom, exe, exec, cme, or variants in your URLs."
|
|
1461
|
+
*
|
|
1462
|
+
* We lowercase-compare, so "MPESA", "Mpesa", "mPeSa" are all caught.
|
|
1463
|
+
*
|
|
1464
|
+
* Additional blocked keywords (documented variants): cmd, sql, query
|
|
1465
|
+
*/
|
|
1466
|
+
const FORBIDDEN_URL_KEYWORDS = [
|
|
1467
|
+
"mpesa",
|
|
1468
|
+
"safaricom",
|
|
1469
|
+
"exec",
|
|
1470
|
+
"exe",
|
|
1471
|
+
"cme",
|
|
1472
|
+
"cmd",
|
|
1473
|
+
"sql",
|
|
1474
|
+
"query"
|
|
1475
|
+
];
|
|
1476
|
+
/**
|
|
1477
|
+
* Validates a callback URL against Daraja's documented URL requirements.
|
|
1478
|
+
* Throws PesafyError if the URL violates any documented rule.
|
|
1479
|
+
*/
|
|
1480
|
+
function validateCallbackUrl(url, fieldName) {
|
|
1481
|
+
if (!url || !url.trim()) throw createError({
|
|
1482
|
+
code: "VALIDATION_ERROR",
|
|
1483
|
+
message: `${fieldName} is required`
|
|
1484
|
+
});
|
|
1485
|
+
const lower = url.toLowerCase();
|
|
1486
|
+
for (const keyword of FORBIDDEN_URL_KEYWORDS) if (lower.includes(keyword)) throw createError({
|
|
1487
|
+
code: "VALIDATION_ERROR",
|
|
1488
|
+
message: `${fieldName} must not contain the keyword "${keyword}". Daraja rejects URLs containing: mpesa, safaricom, exe, exec, cme (and variants: cmd, sql, query).`
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Registers C2B Confirmation and Validation URLs with Safaricom.
|
|
1493
|
+
*
|
|
1494
|
+
* Per Daraja documentation:
|
|
1495
|
+
* - Sandbox: may be called multiple times (URLs can be overwritten).
|
|
1496
|
+
* - Production: one-time call. To change URLs, delete existing on the portal
|
|
1497
|
+
* or email apisupport@safaricom.co.ke, then re-register.
|
|
1498
|
+
* - ResponseType must be sentence-case: "Completed" or "Cancelled".
|
|
1499
|
+
* - Both URLs must be publicly accessible and internet-reachable.
|
|
1500
|
+
* - Production requires HTTPS; Sandbox allows HTTP.
|
|
1501
|
+
* - Do not use public URL testers (ngrok, mockbin, requestbin) — they are blocked.
|
|
1502
|
+
* - The Validation URL is only called when external validation is enabled.
|
|
1503
|
+
* To activate, email apisupport@safaricom.co.ke.
|
|
1504
|
+
* - If M-PESA cannot reach your Validation URL within ~8 seconds, it defaults
|
|
1505
|
+
* to the ResponseType action set during registration.
|
|
1506
|
+
*
|
|
1507
|
+
* @param baseUrl - Daraja environment base URL
|
|
1508
|
+
* @param accessToken - Valid OAuth bearer token from Authorization API
|
|
1509
|
+
* @param request - Registration parameters
|
|
1510
|
+
* @returns - Daraja registration response (ResponseCode "0" = success)
|
|
1511
|
+
*/
|
|
1512
|
+
async function registerC2BUrls(baseUrl, accessToken, request, http) {
|
|
1513
|
+
const validated = parseWithSchema(C2BRegisterUrlRequestSchema, request, "C2B Register URL request");
|
|
1514
|
+
validateCallbackUrl(validated.confirmationUrl, "confirmationUrl");
|
|
1515
|
+
validateCallbackUrl(validated.validationUrl, "validationUrl");
|
|
1516
|
+
const version = validated.apiVersion ?? "v2";
|
|
1517
|
+
const payload = {
|
|
1518
|
+
ShortCode: String(validated.shortCode),
|
|
1519
|
+
ResponseType: validated.responseType,
|
|
1520
|
+
ConfirmationURL: validated.confirmationUrl,
|
|
1521
|
+
ValidationURL: validated.validationUrl
|
|
1522
|
+
};
|
|
1523
|
+
const { data } = await httpRequest(`${baseUrl}/mpesa/c2b/${version}/registerurl`, withDarajaHttp({
|
|
1524
|
+
method: "POST",
|
|
1525
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1526
|
+
body: payload
|
|
1527
|
+
}, http));
|
|
1528
|
+
return parseWithSchema(C2BRegisterUrlResponseSchema, data, "C2B Register URL response");
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
//#endregion
|
|
1532
|
+
//#region src/mpesa/c2b/simulate.ts
|
|
1533
|
+
/**
|
|
1534
|
+
* src/mpesa/c2b/simulate.ts
|
|
1535
|
+
*
|
|
1536
|
+
* C2B Simulate implementation (Sandbox ONLY).
|
|
1537
|
+
* Strictly aligned with Safaricom Daraja C2B API documentation.
|
|
1538
|
+
*
|
|
1539
|
+
* Per docs: "NB: Simulation is not supported on production."
|
|
1540
|
+
*
|
|
1541
|
+
* Endpoint (v2, sandbox only):
|
|
1542
|
+
* POST https://sandbox.safaricom.co.ke/mpesa/c2b/v2/simulate
|
|
1543
|
+
*/
|
|
1544
|
+
/**
|
|
1545
|
+
* Simulates a C2B customer payment. SANDBOX ONLY.
|
|
1546
|
+
*
|
|
1547
|
+
* Daraja payload shape:
|
|
1548
|
+
* {
|
|
1549
|
+
* "ShortCode": 600984, ← numeric
|
|
1550
|
+
* "CommandID": "CustomerPayBillOnline",
|
|
1551
|
+
* "Amount": 1, ← numeric, whole number ≥ 1
|
|
1552
|
+
* "Msisdn": 254708374149, ← numeric
|
|
1553
|
+
* "BillRefNumber": "AccountRef" ← Paybill only; OMIT for BuyGoods
|
|
1554
|
+
* }
|
|
1555
|
+
*
|
|
1556
|
+
* CRITICAL — BillRefNumber handling (per docs):
|
|
1557
|
+
* "Account reference for Customer paybills and null for customer buy goods"
|
|
1558
|
+
* We omit the key entirely for BuyGoods (not null, not "") because Daraja
|
|
1559
|
+
* validates field presence and rejects even null/empty values for Buy Goods.
|
|
1560
|
+
*
|
|
1561
|
+
* @param baseUrl - Must be the sandbox base URL
|
|
1562
|
+
* @param accessToken - Valid OAuth bearer token from Authorization API
|
|
1563
|
+
* @param request - Simulation parameters
|
|
1564
|
+
* @returns - Daraja simulate response (ResponseCode "0" = accepted)
|
|
1565
|
+
*/
|
|
1566
|
+
async function simulateC2B(baseUrl, accessToken, request, http) {
|
|
1567
|
+
const validated = parseWithSchema(C2BSimulateRequestSchema, request, "C2B Simulate request");
|
|
1568
|
+
if (!baseUrl.includes("sandbox")) throw createError({
|
|
1569
|
+
code: "VALIDATION_ERROR",
|
|
1570
|
+
message: "C2B simulate is only available in the Sandbox environment (per Daraja docs). In production, customers initiate payments directly via M-PESA App, USSD, or SIM Toolkit."
|
|
1571
|
+
});
|
|
1572
|
+
const amount = Math.round(validated.amount);
|
|
1573
|
+
const isBuyGoods = validated.commandId === "CustomerBuyGoodsOnline";
|
|
1574
|
+
const version = validated.apiVersion ?? "v2";
|
|
1575
|
+
const payload = {
|
|
1576
|
+
ShortCode: Number(validated.shortCode),
|
|
1577
|
+
CommandID: validated.commandId,
|
|
1578
|
+
Amount: amount,
|
|
1579
|
+
Msisdn: Number(validated.msisdn)
|
|
1580
|
+
};
|
|
1581
|
+
if (!isBuyGoods) payload["BillRefNumber"] = validated.billRefNumber.trim();
|
|
1582
|
+
const { data } = await httpRequest(`${baseUrl}/mpesa/c2b/${version}/simulate`, withDarajaHttp({
|
|
1583
|
+
method: "POST",
|
|
1584
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1585
|
+
body: payload
|
|
1586
|
+
}, http));
|
|
1587
|
+
return parseWithSchema(C2BSimulateResponseSchema, data, "C2B Simulate response");
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
//#endregion
|
|
1591
|
+
//#region src/mpesa/c2b/webhooks.ts
|
|
1592
|
+
/**
|
|
1593
|
+
* Builds an "accept" validation response.
|
|
1594
|
+
*
|
|
1595
|
+
* Per Daraja docs (to accept the payment):
|
|
1596
|
+
* { "ResultCode": "0", "ResultDesc": "Accepted" }
|
|
1597
|
+
*
|
|
1598
|
+
* @param thirdPartyTransID - Optional: echo back the ThirdPartyTransID received
|
|
1599
|
+
* in the validation request. M-PESA includes it in the confirmation callback.
|
|
1600
|
+
*/
|
|
1601
|
+
function acceptC2BValidation(thirdPartyTransID) {
|
|
1602
|
+
return {
|
|
1603
|
+
ResultCode: "0",
|
|
1604
|
+
ResultDesc: "Accepted",
|
|
1605
|
+
...thirdPartyTransID ? { ThirdPartyTransID: thirdPartyTransID } : {}
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
//#endregion
|
|
1610
|
+
//#region src/mpesa/dynamic-qr/generate.ts
|
|
1611
|
+
/**
|
|
1612
|
+
* src/mpesa/dynamic-qr/generate.ts
|
|
1613
|
+
*
|
|
1614
|
+
* Core logic for the Safaricom Daraja Dynamic QR Code API.
|
|
1615
|
+
*
|
|
1616
|
+
* API: POST /mpesa/qrcode/v1/generate
|
|
1617
|
+
*
|
|
1618
|
+
* Error codes from Daraja docs:
|
|
1619
|
+
* 404.001.04 — Invalid Authentication Header
|
|
1620
|
+
* 400.002.05 — Invalid Request Payload
|
|
1621
|
+
* 400.003.01 — Invalid Access Token
|
|
1622
|
+
*/
|
|
1623
|
+
/**
|
|
1624
|
+
* Maps Daraja-specific error codes to structured PesafyErrors with
|
|
1625
|
+
* actionable developer guidance.
|
|
1626
|
+
*
|
|
1627
|
+
* @internal
|
|
1628
|
+
*/
|
|
1629
|
+
function mapDarajaError(errorCode, errorMessage) {
|
|
1630
|
+
switch (errorCode) {
|
|
1631
|
+
case "404.001.04": return new PesafyError({
|
|
1632
|
+
code: "AUTH_FAILED",
|
|
1633
|
+
message: `Daraja rejected the request due to an invalid authentication header. Ensure the Dynamic QR endpoint is called with POST and that the Authorization: Bearer <token> header is present. Daraja: "${errorMessage}"`,
|
|
1634
|
+
statusCode: 404
|
|
1635
|
+
});
|
|
1636
|
+
case "400.003.01": return new PesafyError({
|
|
1637
|
+
code: "AUTH_FAILED",
|
|
1638
|
+
message: `The M-PESA access token is invalid or has expired. Call clearTokenCache() on the Mpesa instance to force a token refresh and retry the request. Daraja: "${errorMessage}"`,
|
|
1639
|
+
statusCode: 401
|
|
1640
|
+
});
|
|
1641
|
+
case "400.002.05": return new PesafyError({
|
|
1642
|
+
code: "VALIDATION_ERROR",
|
|
1643
|
+
message: `Daraja rejected the request payload as malformed. Verify that all required fields (MerchantName, RefNo, Amount, TrxCode, CPI, Size) are present and have correct types. Daraja: "${errorMessage}"`,
|
|
1644
|
+
statusCode: 400
|
|
1645
|
+
});
|
|
1646
|
+
default: return new PesafyError({
|
|
1647
|
+
code: "REQUEST_FAILED",
|
|
1648
|
+
message: `Dynamic QR request failed (${errorCode}): ${errorMessage}`,
|
|
1649
|
+
statusCode: 400
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Returns `true` when the raw response body looks like a Daraja error object.
|
|
1655
|
+
* @internal
|
|
1656
|
+
*/
|
|
1657
|
+
function isDarajaError(body) {
|
|
1658
|
+
return typeof body === "object" && body !== null && "errorCode" in body && typeof body.errorCode === "string";
|
|
1659
|
+
}
|
|
1660
|
+
/**
|
|
1661
|
+
* Returns `true` when the response has the required QR success fields.
|
|
1662
|
+
* @internal
|
|
1663
|
+
*/
|
|
1664
|
+
function isDarajaSuccess(body) {
|
|
1665
|
+
return typeof body === "object" && body !== null && "ResponseCode" in body && "QRCode" in body && typeof body.QRCode === "string" && body.QRCode.length > 0;
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* Generates a Dynamic M-PESA QR Code via the Safaricom Daraja API.
|
|
1669
|
+
*
|
|
1670
|
+
* The QR code can be rendered directly in a browser as a base64 PNG:
|
|
1671
|
+
* ```html
|
|
1672
|
+
* <img src="data:image/png;base64,{response.QRCode}" />
|
|
1673
|
+
* ```
|
|
1674
|
+
* Or written to disk:
|
|
1675
|
+
* ```ts
|
|
1676
|
+
* import { writeFileSync } from 'node:fs'
|
|
1677
|
+
* writeFileSync('qr.png', Buffer.from(response.QRCode, 'base64'))
|
|
1678
|
+
* ```
|
|
1679
|
+
*
|
|
1680
|
+
* @param baseUrl - Daraja base URL (`https://sandbox.safaricom.co.ke` or
|
|
1681
|
+
* `https://api.safaricom.co.ke`)
|
|
1682
|
+
* @param accessToken - Valid Daraja OAuth2 Bearer token
|
|
1683
|
+
* @param request - QR generation parameters (see {@link DynamicQRRequest})
|
|
1684
|
+
* @returns - Daraja response including the base64-encoded QR image
|
|
1685
|
+
*
|
|
1686
|
+
* @throws {PesafyError} `VALIDATION_ERROR` — payload failed pre-flight checks
|
|
1687
|
+
* @throws {PesafyError} `AUTH_FAILED` — bad/expired token or wrong headers
|
|
1688
|
+
* @throws {PesafyError} `REQUEST_FAILED` — unexpected Daraja error
|
|
1689
|
+
*
|
|
1690
|
+
* @example
|
|
1691
|
+
* ```ts
|
|
1692
|
+
* const response = await generateDynamicQR(
|
|
1693
|
+
* 'https://sandbox.safaricom.co.ke',
|
|
1694
|
+
* accessToken,
|
|
1695
|
+
* {
|
|
1696
|
+
* merchantName: 'Test Supermarket',
|
|
1697
|
+
* refNo: 'INV-001',
|
|
1698
|
+
* amount: 500,
|
|
1699
|
+
* trxCode: 'BG',
|
|
1700
|
+
* cpi: '373132',
|
|
1701
|
+
* size: 300,
|
|
1702
|
+
* },
|
|
1703
|
+
* )
|
|
1704
|
+
* console.log(response.QRCode) // base64 PNG
|
|
1705
|
+
* ```
|
|
1706
|
+
*/
|
|
1707
|
+
async function generateDynamicQR(baseUrl, accessToken, request, http) {
|
|
1708
|
+
const validated = parseWithSchema(DynamicQRRequestSchema, request, "Dynamic QR request");
|
|
1709
|
+
if (!accessToken || typeof accessToken !== "string" || accessToken.trim().length === 0) throw new PesafyError({
|
|
1710
|
+
code: "AUTH_FAILED",
|
|
1711
|
+
message: "accessToken is required. Obtain one via the Daraja Authorization API (GET /oauth/v1/generate?grant_type=client_credentials)."
|
|
1712
|
+
});
|
|
1713
|
+
const size = validated.size ?? 300;
|
|
1714
|
+
const amount = Math.round(validated.amount);
|
|
1715
|
+
const payload = {
|
|
1716
|
+
MerchantName: validated.merchantName.trim(),
|
|
1717
|
+
RefNo: validated.refNo.trim(),
|
|
1718
|
+
Amount: amount,
|
|
1719
|
+
TrxCode: validated.trxCode,
|
|
1720
|
+
CPI: validated.cpi.trim(),
|
|
1721
|
+
Size: String(size)
|
|
1722
|
+
};
|
|
1723
|
+
const { data } = await httpRequest(`${baseUrl}/mpesa/qrcode/v1/generate`, withDarajaHttp({
|
|
1724
|
+
method: "POST",
|
|
1725
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1726
|
+
body: payload
|
|
1727
|
+
}, http));
|
|
1728
|
+
if (isDarajaError(data)) throw mapDarajaError(data.errorCode, data.errorMessage);
|
|
1729
|
+
if (!isDarajaSuccess(data)) throw new PesafyError({
|
|
1730
|
+
code: "REQUEST_FAILED",
|
|
1731
|
+
message: `Daraja returned an unexpected response structure for the Dynamic QR request. The response was missing required fields (ResponseCode, QRCode). Raw response: ${JSON.stringify(data).slice(0, 300)}`
|
|
1732
|
+
});
|
|
1733
|
+
return parseWithSchema(DynamicQRResponseSchema, data, "Dynamic QR response");
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
//#endregion
|
|
1737
|
+
//#region src/mpesa/reversal/types.ts
|
|
1738
|
+
/**
|
|
1739
|
+
* src/mpesa/reversal/types.ts
|
|
1740
|
+
*
|
|
1741
|
+
* Transaction Reversal types, constants, and helpers.
|
|
1742
|
+
*
|
|
1743
|
+
* API: POST /mpesa/reversal/v1/request
|
|
1744
|
+
*
|
|
1745
|
+
* Reverses a completed M-PESA C2B transaction. The API is ASYNCHRONOUS —
|
|
1746
|
+
* the synchronous response is only an acknowledgement. The actual reversal
|
|
1747
|
+
* result is POSTed to your ResultURL after processing.
|
|
1748
|
+
*
|
|
1749
|
+
* Required org portal role: "Org Reversals Initiator"
|
|
1750
|
+
*
|
|
1751
|
+
* Per Daraja docs:
|
|
1752
|
+
* RecieverIdentifierType MUST always be "11" for reversals.
|
|
1753
|
+
* CommandID MUST always be "TransactionReversal".
|
|
1754
|
+
*
|
|
1755
|
+
* Ref: Reversals — Safaricom Daraja Developer Portal
|
|
1756
|
+
*/
|
|
1757
|
+
/**
|
|
1758
|
+
* CommandID for the Reversals API.
|
|
1759
|
+
* Only "TransactionReversal" is allowed per Daraja docs.
|
|
1760
|
+
*/
|
|
1761
|
+
const REVERSAL_COMMAND_ID = "TransactionReversal";
|
|
1762
|
+
/**
|
|
1763
|
+
* Result codes returned in the async callback POSTed to your ResultURL.
|
|
1764
|
+
*
|
|
1765
|
+
* Per Daraja Reversals documentation:
|
|
1766
|
+
*
|
|
1767
|
+
* | ResultCode | Meaning |
|
|
1768
|
+
* |------------|----------------------------------------------------|
|
|
1769
|
+
* | 0 | Success — transaction reversed |
|
|
1770
|
+
* | 1 | Insufficient balance |
|
|
1771
|
+
* | 11 | DebitParty in invalid state (shortcode not active) |
|
|
1772
|
+
* | 21 | Initiator not allowed to initiate reversals |
|
|
1773
|
+
* | 2001 | Initiator information invalid (bad credentials) |
|
|
1774
|
+
* | 2006 | Declined due to account rule (shortcode inactive) |
|
|
1775
|
+
* | 2028 | Not permitted (shortcode lacks reversal permission)|
|
|
1776
|
+
* | 8006 | Security credential locked |
|
|
1777
|
+
* | R000001 | Transaction already reversed |
|
|
1778
|
+
* | R000002 | OriginalTransactionID is invalid / does not exist |
|
|
1779
|
+
*/
|
|
1780
|
+
const REVERSAL_RESULT_CODES = {
|
|
1781
|
+
SUCCESS: 0,
|
|
1782
|
+
INSUFFICIENT_BALANCE: 1,
|
|
1783
|
+
DEBIT_PARTY_INVALID_STATE: 11,
|
|
1784
|
+
INITIATOR_NOT_ALLOWED: 21,
|
|
1785
|
+
INITIATOR_INFORMATION_INVALID: 2001,
|
|
1786
|
+
DECLINED_ACCOUNT_RULE: 2006,
|
|
1787
|
+
NOT_PERMITTED: 2028,
|
|
1788
|
+
SECURITY_CREDENTIAL_LOCKED: 8006,
|
|
1789
|
+
ALREADY_REVERSED: "R000001",
|
|
1790
|
+
INVALID_TRANSACTION_ID: "R000002"
|
|
1791
|
+
};
|
|
1792
|
+
/**
|
|
1793
|
+
* Returns true when the payload matches the shape of a ReversalResult.
|
|
1794
|
+
* Works for both success and failure callbacks.
|
|
1795
|
+
*/
|
|
1796
|
+
function isReversalResult(body) {
|
|
1797
|
+
if (!body || typeof body !== "object") return false;
|
|
1798
|
+
const b = body;
|
|
1799
|
+
if (!b["Result"] || typeof b["Result"] !== "object") return false;
|
|
1800
|
+
const r = b["Result"];
|
|
1801
|
+
return typeof r["ResultCode"] !== "undefined" && typeof r["ResultDesc"] === "string" && typeof r["ConversationID"] === "string";
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* Returns true when the reversal result indicates a successful reversal.
|
|
1805
|
+
* A successful reversal has ResultCode === 0 (number).
|
|
1806
|
+
*/
|
|
1807
|
+
function isReversalSuccess(result) {
|
|
1808
|
+
return result.Result.ResultCode === REVERSAL_RESULT_CODES.SUCCESS;
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* Extracts the M-PESA receipt number for the reversal transaction.
|
|
1812
|
+
* Returns null if the result does not contain a TransactionID.
|
|
1813
|
+
*/
|
|
1814
|
+
function getReversalTransactionId(result) {
|
|
1815
|
+
return result.Result.TransactionID ?? null;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
//#endregion
|
|
1819
|
+
//#region src/mpesa/reversal/request.ts
|
|
1820
|
+
/**
|
|
1821
|
+
* src/mpesa/reversal/request.ts
|
|
1822
|
+
*
|
|
1823
|
+
* Transaction Reversal — reverses a completed M-PESA C2B transaction.
|
|
1824
|
+
*
|
|
1825
|
+
* API: POST /mpesa/reversal/v1/request
|
|
1826
|
+
*
|
|
1827
|
+
* ASYNCHRONOUS: The synchronous response is acknowledgement only.
|
|
1828
|
+
* The actual reversal result is POSTed to your ResultURL after processing.
|
|
1829
|
+
*
|
|
1830
|
+
* Per Daraja docs:
|
|
1831
|
+
* - CommandID is always "TransactionReversal"
|
|
1832
|
+
* - RecieverIdentifierType is always "11" (Organisation ShortCode for reversals)
|
|
1833
|
+
* - Amount is sent as a string per the Daraja sample payload
|
|
1834
|
+
* - Remarks must be 2–100 characters
|
|
1835
|
+
* - Cannot be used for B2C reversals (those are done on the M-PESA portal)
|
|
1836
|
+
*
|
|
1837
|
+
* Required org portal role: "Org Reversals Initiator"
|
|
1838
|
+
*/
|
|
1839
|
+
async function requestReversal(baseUrl, accessToken, securityCredential, initiatorName, request, http) {
|
|
1840
|
+
const validated = parseWithSchema(ReversalRequestSchema, request, "Reversal request");
|
|
1841
|
+
const amount = Math.round(validated.amount);
|
|
1842
|
+
const remarks = validated.remarks ?? "Transaction Reversal";
|
|
1843
|
+
const payload = {
|
|
1844
|
+
Initiator: initiatorName,
|
|
1845
|
+
SecurityCredential: securityCredential,
|
|
1846
|
+
CommandID: REVERSAL_COMMAND_ID,
|
|
1847
|
+
TransactionID: validated.transactionId,
|
|
1848
|
+
Amount: String(amount),
|
|
1849
|
+
ReceiverParty: String(validated.receiverParty),
|
|
1850
|
+
RecieverIdentifierType: "11",
|
|
1851
|
+
ResultURL: validated.resultUrl,
|
|
1852
|
+
QueueTimeOutURL: validated.queueTimeOutUrl,
|
|
1853
|
+
Remarks: remarks
|
|
1854
|
+
};
|
|
1855
|
+
if (validated.occasion !== void 0 && validated.occasion !== null) payload["Occasion"] = validated.occasion;
|
|
1856
|
+
const { data } = await httpRequest(`${baseUrl}/mpesa/reversal/v1/request`, withDarajaHttp({
|
|
1857
|
+
method: "POST",
|
|
1858
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1859
|
+
body: payload
|
|
1860
|
+
}, http));
|
|
1861
|
+
return parseWithSchema(ReversalResponseSchema, data, "Reversal response");
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
//#endregion
|
|
1865
|
+
//#region src/schemas/stk-push.ts
|
|
1866
|
+
const TransactionTypeSchema = z.enum(["CustomerPayBillOnline", "CustomerBuyGoodsOnline"]);
|
|
1867
|
+
const StkPushRequestSchema = z.object({
|
|
1868
|
+
amount: z.number().finite({ message: "amount must be a finite number (not NaN or Infinity)" }),
|
|
1869
|
+
phoneNumber: NonEmptyStringSchema,
|
|
1870
|
+
shortCode: NonEmptyStringSchema,
|
|
1871
|
+
passKey: NonEmptyStringSchema,
|
|
1872
|
+
callbackUrl: UrlSchema,
|
|
1873
|
+
accountReference: NonEmptyStringSchema,
|
|
1874
|
+
transactionDesc: NonEmptyStringSchema,
|
|
1875
|
+
transactionType: TransactionTypeSchema.optional(),
|
|
1876
|
+
partyB: z.string().optional()
|
|
1877
|
+
});
|
|
1878
|
+
const StkPushResponseSchema = z.object({
|
|
1879
|
+
MerchantRequestID: z.string(),
|
|
1880
|
+
CheckoutRequestID: z.string(),
|
|
1881
|
+
ResponseCode: z.string(),
|
|
1882
|
+
ResponseDescription: z.string(),
|
|
1883
|
+
CustomerMessage: z.string().optional()
|
|
1884
|
+
}).passthrough();
|
|
1885
|
+
const StkQueryRequestSchema = z.object({
|
|
1886
|
+
checkoutRequestId: NonEmptyStringSchema,
|
|
1887
|
+
shortCode: NonEmptyStringSchema,
|
|
1888
|
+
passKey: NonEmptyStringSchema
|
|
1889
|
+
});
|
|
1890
|
+
const StkQueryResponseSchema = z.object({
|
|
1891
|
+
ResponseCode: z.string(),
|
|
1892
|
+
ResponseDescription: z.string(),
|
|
1893
|
+
MerchantRequestID: z.string().optional(),
|
|
1894
|
+
CheckoutRequestID: z.string().optional(),
|
|
1895
|
+
ResultCode: z.union([z.string(), z.number()]).optional(),
|
|
1896
|
+
ResultDesc: z.string().optional()
|
|
1897
|
+
}).passthrough();
|
|
1898
|
+
const StkPushWebhookSchema = z.object({ Body: z.object({ stkCallback: z.object({
|
|
1899
|
+
MerchantRequestID: z.string(),
|
|
1900
|
+
CheckoutRequestID: z.string(),
|
|
1901
|
+
ResultCode: z.number(),
|
|
1902
|
+
ResultDesc: z.string(),
|
|
1903
|
+
CallbackMetadata: z.object({ Item: z.array(z.object({
|
|
1904
|
+
Name: z.string(),
|
|
1905
|
+
Value: z.union([z.string(), z.number()])
|
|
1906
|
+
})) }).optional()
|
|
1907
|
+
}).passthrough() }) });
|
|
1908
|
+
|
|
1909
|
+
//#endregion
|
|
1910
|
+
//#region src/mpesa/stk-push/types.ts
|
|
1911
|
+
/**
|
|
1912
|
+
* M-PESA transaction limits as documented by Safaricom Daraja.
|
|
1913
|
+
*
|
|
1914
|
+
* | Limit | Value |
|
|
1915
|
+
* |------------------|-----------|
|
|
1916
|
+
* | Min per tx | KES 1 |
|
|
1917
|
+
* | Max per tx | KES 250 000|
|
|
1918
|
+
* | Max daily | KES 500 000|
|
|
1919
|
+
* | Max balance | KES 500 000|
|
|
1920
|
+
*/
|
|
1921
|
+
const STK_PUSH_LIMITS = {
|
|
1922
|
+
MIN_AMOUNT: 1,
|
|
1923
|
+
MAX_AMOUNT: 25e4
|
|
1924
|
+
};
|
|
1925
|
+
|
|
1926
|
+
//#endregion
|
|
1927
|
+
//#region src/utils/phone/index.ts
|
|
1928
|
+
/** Normalises any common Kenyan phone format to 254XXXXXXXXX (12 digits) */
|
|
1929
|
+
function formatSafaricomPhone(phone) {
|
|
1930
|
+
const digits = phone.replace(/\D/g, "");
|
|
1931
|
+
let n;
|
|
1932
|
+
if (digits.startsWith("254") && digits.length === 12) n = digits;
|
|
1933
|
+
else if (digits.startsWith("0") && digits.length === 10) n = `254${digits.slice(1)}`;
|
|
1934
|
+
else if (digits.length === 9) n = `254${digits}`;
|
|
1935
|
+
else throw new PesafyError({
|
|
1936
|
+
code: "INVALID_PHONE",
|
|
1937
|
+
message: `Cannot parse "${phone}". Use 07XXXXXXXX, 2547XXXXXXXX, 2541XXXXXXXX (Airtel), or +2547XXXXXXXX.`
|
|
1938
|
+
});
|
|
1939
|
+
/* v8 ignore next 6 -- unreachable: all branches above assign exactly 12 digits */
|
|
1940
|
+
if (n.length !== 12) throw new PesafyError({
|
|
1941
|
+
code: "INVALID_PHONE",
|
|
1942
|
+
message: `"${phone}" normalised to "${n}" — expected 12 digits.`
|
|
1943
|
+
});
|
|
1944
|
+
return n;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
//#endregion
|
|
1948
|
+
//#region src/mpesa/stk-push/utils.ts
|
|
1949
|
+
/**
|
|
1950
|
+
* Generates the STK Push password.
|
|
1951
|
+
* Formula: Base64( Shortcode + Passkey + Timestamp )
|
|
1952
|
+
*
|
|
1953
|
+
* Uses btoa() — works in Node.js ≥18, Bun, browsers, and edge runtimes.
|
|
1954
|
+
*/
|
|
1955
|
+
function getStkPushPassword(shortCode, passKey, timestamp) {
|
|
1956
|
+
return btoa(`${shortCode}${passKey}${timestamp}`);
|
|
1957
|
+
}
|
|
1958
|
+
/**
|
|
1959
|
+
* Returns a Daraja-compatible timestamp: YYYYMMDDHHmmss
|
|
1960
|
+
*
|
|
1961
|
+
* Call this ONCE per request and reuse the result.
|
|
1962
|
+
*/
|
|
1963
|
+
function getTimestamp() {
|
|
1964
|
+
const now = /* @__PURE__ */ new Date();
|
|
1965
|
+
const pad = (n) => n.toString().padStart(2, "0");
|
|
1966
|
+
return [
|
|
1967
|
+
now.getFullYear(),
|
|
1968
|
+
pad(now.getMonth() + 1),
|
|
1969
|
+
pad(now.getDate()),
|
|
1970
|
+
pad(now.getHours()),
|
|
1971
|
+
pad(now.getMinutes()),
|
|
1972
|
+
pad(now.getSeconds())
|
|
1973
|
+
].join("");
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
//#endregion
|
|
1977
|
+
//#region src/mpesa/stk-push/stk-push.ts
|
|
1978
|
+
/**
|
|
1979
|
+
* src/mpesa/stk-push/stk-push.ts
|
|
1980
|
+
*
|
|
1981
|
+
* Initiates an STK Push (M-PESA Express) payment.
|
|
1982
|
+
*
|
|
1983
|
+
* Daraja endpoint: POST /mpesa/stkpush/v1/processrequest
|
|
1984
|
+
*
|
|
1985
|
+
* Transaction limits (Daraja docs):
|
|
1986
|
+
* Min per transaction: KES 1
|
|
1987
|
+
* Max per transaction: KES 250,000
|
|
1988
|
+
*/
|
|
1989
|
+
async function processStkPush(baseUrl, accessToken, request, http) {
|
|
1990
|
+
const validated = parseWithSchema(StkPushRequestSchema, request, "STK Push request");
|
|
1991
|
+
if (!Number.isFinite(validated.amount)) throw new PesafyError({
|
|
1992
|
+
code: "VALIDATION_ERROR",
|
|
1993
|
+
message: `amount must be a finite number (got ${validated.amount}).`
|
|
1994
|
+
});
|
|
1995
|
+
const amount = Math.round(validated.amount);
|
|
1996
|
+
if (amount < STK_PUSH_LIMITS.MIN_AMOUNT) throw new PesafyError({
|
|
1997
|
+
code: "VALIDATION_ERROR",
|
|
1998
|
+
message: `Amount must be at least KES ${STK_PUSH_LIMITS.MIN_AMOUNT} (got ${validated.amount} which rounds to ${amount}).`
|
|
1999
|
+
});
|
|
2000
|
+
if (amount > STK_PUSH_LIMITS.MAX_AMOUNT) throw new PesafyError({
|
|
2001
|
+
code: "VALIDATION_ERROR",
|
|
2002
|
+
message: `Amount must not exceed KES ${STK_PUSH_LIMITS.MAX_AMOUNT.toLocaleString()} per transaction as per Safaricom Daraja limits (got ${validated.amount} which rounds to ${amount}).`
|
|
2003
|
+
});
|
|
2004
|
+
const timestamp = getTimestamp();
|
|
2005
|
+
const partyB = validated.partyB ?? validated.shortCode;
|
|
2006
|
+
const body = {
|
|
2007
|
+
BusinessShortCode: validated.shortCode,
|
|
2008
|
+
Password: getStkPushPassword(validated.shortCode, validated.passKey, timestamp),
|
|
2009
|
+
Timestamp: timestamp,
|
|
2010
|
+
TransactionType: validated.transactionType ?? "CustomerPayBillOnline",
|
|
2011
|
+
Amount: amount,
|
|
2012
|
+
PartyA: formatSafaricomPhone(validated.phoneNumber),
|
|
2013
|
+
PartyB: partyB,
|
|
2014
|
+
PhoneNumber: formatSafaricomPhone(validated.phoneNumber),
|
|
2015
|
+
CallBackURL: validated.callbackUrl,
|
|
2016
|
+
AccountReference: validated.accountReference.slice(0, 12),
|
|
2017
|
+
TransactionDesc: validated.transactionDesc.slice(0, 13)
|
|
2018
|
+
};
|
|
2019
|
+
const { data } = await httpRequest(`${baseUrl}/mpesa/stkpush/v1/processrequest`, withDarajaHttp({
|
|
2020
|
+
method: "POST",
|
|
2021
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
2022
|
+
body,
|
|
2023
|
+
retries: 5,
|
|
2024
|
+
retryDelay: 3e3
|
|
2025
|
+
}, http));
|
|
2026
|
+
return parseWithSchema(StkPushResponseSchema, data, "STK Push response");
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
//#endregion
|
|
2030
|
+
//#region src/mpesa/stk-push/stk-query.ts
|
|
2031
|
+
async function queryStkPush(baseUrl, accessToken, request, http) {
|
|
2032
|
+
const validated = parseWithSchema(StkQueryRequestSchema, request, "STK Query request");
|
|
2033
|
+
const timestamp = getTimestamp();
|
|
2034
|
+
const body = {
|
|
2035
|
+
BusinessShortCode: validated.shortCode,
|
|
2036
|
+
Password: getStkPushPassword(validated.shortCode, validated.passKey, timestamp),
|
|
2037
|
+
Timestamp: timestamp,
|
|
2038
|
+
CheckoutRequestID: validated.checkoutRequestId
|
|
2039
|
+
};
|
|
2040
|
+
const { data } = await httpRequest(`${baseUrl}/mpesa/stkpushquery/v1/query`, withDarajaHttp({
|
|
2041
|
+
method: "POST",
|
|
2042
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
2043
|
+
body
|
|
2044
|
+
}, http));
|
|
2045
|
+
return parseWithSchema(StkQueryResponseSchema, data, "STK Query response");
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
//#endregion
|
|
2049
|
+
//#region src/mpesa/tax-remittance/remit-tax.ts
|
|
2050
|
+
/** KRA's M-PESA shortcode — the only allowed PartyB for tax remittance */
|
|
2051
|
+
const KRA_SHORTCODE = "572572";
|
|
2052
|
+
/** The only CommandID accepted by the Tax Remittance API */
|
|
2053
|
+
const TAX_COMMAND_ID = "PayTaxToKRA";
|
|
2054
|
+
/**
|
|
2055
|
+
* Remits tax to Kenya Revenue Authority (KRA) via M-PESA.
|
|
2056
|
+
*
|
|
2057
|
+
* Endpoint: POST /mpesa/b2b/v1/remittax
|
|
2058
|
+
*
|
|
2059
|
+
* @param baseUrl - Daraja base URL (sandbox or production)
|
|
2060
|
+
* @param accessToken - Valid OAuth bearer token
|
|
2061
|
+
* @param securityCredential - RSA-encrypted initiator password (base64)
|
|
2062
|
+
* @param initiatorName - M-PESA org portal API operator username
|
|
2063
|
+
* @param request - Tax remittance parameters
|
|
2064
|
+
* @returns - Daraja synchronous acknowledgement response
|
|
2065
|
+
*
|
|
2066
|
+
* @example
|
|
2067
|
+
* const response = await remitTax(
|
|
2068
|
+
* 'https://sandbox.safaricom.co.ke',
|
|
2069
|
+
* accessToken,
|
|
2070
|
+
* securityCredential,
|
|
2071
|
+
* 'TaxPayer',
|
|
2072
|
+
* {
|
|
2073
|
+
* amount: 239,
|
|
2074
|
+
* partyA: '888880',
|
|
2075
|
+
* accountReference: '353353',
|
|
2076
|
+
* resultUrl: 'https://example.org/b2b/remittax/result/',
|
|
2077
|
+
* queueTimeOutUrl: 'https://example.org/b2b/remittax/queue/',
|
|
2078
|
+
* },
|
|
2079
|
+
* )
|
|
2080
|
+
*/
|
|
2081
|
+
async function remitTax(baseUrl, accessToken, securityCredential, initiatorName, request, http) {
|
|
2082
|
+
const validated = parseWithSchema(TaxRemittanceRequestSchema, request, "Tax Remittance request");
|
|
2083
|
+
const amount = Math.round(validated.amount);
|
|
2084
|
+
const payload = {
|
|
2085
|
+
Initiator: initiatorName,
|
|
2086
|
+
SecurityCredential: securityCredential,
|
|
2087
|
+
CommandID: TAX_COMMAND_ID,
|
|
2088
|
+
SenderIdentifierType: "4",
|
|
2089
|
+
RecieverIdentifierType: "4",
|
|
2090
|
+
Amount: String(amount),
|
|
2091
|
+
PartyA: String(validated.partyA),
|
|
2092
|
+
PartyB: validated.partyB ?? "572572",
|
|
2093
|
+
AccountReference: validated.accountReference,
|
|
2094
|
+
Remarks: validated.remarks ?? "Tax Remittance",
|
|
2095
|
+
QueueTimeOutURL: validated.queueTimeOutUrl,
|
|
2096
|
+
ResultURL: validated.resultUrl
|
|
2097
|
+
};
|
|
2098
|
+
const { data } = await httpRequest(`${baseUrl}/mpesa/b2b/v1/remittax`, withDarajaHttp({
|
|
2099
|
+
method: "POST",
|
|
2100
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
2101
|
+
body: payload
|
|
2102
|
+
}, http));
|
|
2103
|
+
return parseWithSchema(TaxRemittanceResponseSchema, data, "Tax Remittance response");
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
//#endregion
|
|
2107
|
+
//#region src/mpesa/tax-remittance/webhooks.ts
|
|
2108
|
+
function isObject(value) {
|
|
2109
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2110
|
+
}
|
|
2111
|
+
function normaliseResultCode(code) {
|
|
2112
|
+
return typeof code === "string" ? Number(code) : code;
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
2115
|
+
* Returns `true` when the value is a valid Tax Remittance async result payload
|
|
2116
|
+
* (the shape POSTed by Safaricom to your ResultURL).
|
|
2117
|
+
*
|
|
2118
|
+
* Checks the minimum required fields per the Daraja documentation:
|
|
2119
|
+
* - Result.ResultCode
|
|
2120
|
+
* - Result.ConversationID
|
|
2121
|
+
* - Result.OriginatorConversationID
|
|
2122
|
+
*/
|
|
2123
|
+
function isTaxRemittanceResult(value) {
|
|
2124
|
+
if (!isObject(value)) return false;
|
|
2125
|
+
const root = value;
|
|
2126
|
+
if (!isObject(root["Result"])) return false;
|
|
2127
|
+
const result = root["Result"];
|
|
2128
|
+
return result["ResultCode"] !== void 0 && result["ResultCode"] !== null && typeof result["ConversationID"] === "string" && typeof result["OriginatorConversationID"] === "string";
|
|
2129
|
+
}
|
|
2130
|
+
/**
|
|
2131
|
+
* Returns `true` when the Tax Remittance result indicates a successful
|
|
2132
|
+
* transaction — i.e. ResultCode is 0 or "0".
|
|
2133
|
+
*/
|
|
2134
|
+
function isTaxRemittanceSuccess(result) {
|
|
2135
|
+
return normaliseResultCode(result.Result.ResultCode) === 0;
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
//#endregion
|
|
2139
|
+
//#region src/mpesa/transaction-status/query.ts
|
|
2140
|
+
/**
|
|
2141
|
+
* Transaction Status Query implementation
|
|
2142
|
+
*
|
|
2143
|
+
* API: POST /mpesa/transactionstatus/v1/query
|
|
2144
|
+
*
|
|
2145
|
+
* This is ASYNCHRONOUS. The synchronous response only acknowledges receipt.
|
|
2146
|
+
* Final results arrive via POST to your ResultURL.
|
|
2147
|
+
*
|
|
2148
|
+
* Required M-PESA org portal role: "Transaction Status query ORG API"
|
|
2149
|
+
*
|
|
2150
|
+
* Reconciliation options (at least one required):
|
|
2151
|
+
* - transactionId — M-Pesa Receipt Number (e.g. "NEF61H8J60")
|
|
2152
|
+
* - originalConversationId — OriginatorConversationID from the original call
|
|
2153
|
+
*/
|
|
2154
|
+
async function queryTransactionStatus(baseUrl, token, securityCredential, initiator, request, http) {
|
|
2155
|
+
const validated = parseWithSchema(TransactionStatusRequestSchema, request, "Transaction Status request");
|
|
2156
|
+
const payload = {
|
|
2157
|
+
Initiator: initiator,
|
|
2158
|
+
SecurityCredential: securityCredential,
|
|
2159
|
+
CommandID: validated.commandId ?? "TransactionStatusQuery",
|
|
2160
|
+
TransactionID: validated.transactionId ?? "",
|
|
2161
|
+
OriginalConversationID: validated.originalConversationId ?? "",
|
|
2162
|
+
PartyA: validated.partyA,
|
|
2163
|
+
IdentifierType: validated.identifierType,
|
|
2164
|
+
ResultURL: validated.resultUrl,
|
|
2165
|
+
QueueTimeOutURL: validated.queueTimeOutUrl,
|
|
2166
|
+
Remarks: validated.remarks ?? "Transaction Status Query",
|
|
2167
|
+
Occasion: validated.occasion ?? ""
|
|
2168
|
+
};
|
|
2169
|
+
const { data } = await httpRequest(`${baseUrl}/mpesa/transactionstatus/v1/query`, withDarajaHttp({
|
|
2170
|
+
method: "POST",
|
|
2171
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
2172
|
+
body: payload
|
|
2173
|
+
}, http));
|
|
2174
|
+
return parseWithSchema(TransactionStatusResponseSchema, data, "Transaction Status response");
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
//#endregion
|
|
2178
|
+
//#region src/mpesa/transaction-status/types.ts
|
|
2179
|
+
/**
|
|
2180
|
+
* Documented result codes for the Transaction Status async callback.
|
|
2181
|
+
* ResultCode 0 = success; anything else is a failure.
|
|
2182
|
+
*/
|
|
2183
|
+
const TRANSACTION_STATUS_RESULT_CODES = {
|
|
2184
|
+
SUCCESS: 0,
|
|
2185
|
+
INVALID_INITIATOR: 2001
|
|
2186
|
+
};
|
|
2187
|
+
|
|
2188
|
+
//#endregion
|
|
2189
|
+
//#region src/mpesa/transaction-status/webhooks.ts
|
|
2190
|
+
/**
|
|
2191
|
+
* Type guards and payload extractors for Transaction Status result callbacks.
|
|
2192
|
+
*
|
|
2193
|
+
* Documented result parameters (POSTed to your ResultURL):
|
|
2194
|
+
* - DebitPartyName
|
|
2195
|
+
* - TransactionStatus
|
|
2196
|
+
* - Amount
|
|
2197
|
+
* - ReceiptNo
|
|
2198
|
+
* - DebitAccountBalance
|
|
2199
|
+
* - TransactionDate
|
|
2200
|
+
* - CreditPartyName
|
|
2201
|
+
*
|
|
2202
|
+
* Docs note: ResultCode is 0 (number) on success. Daraja may return either
|
|
2203
|
+
* a number or a string depending on the transaction type — both forms handled.
|
|
2204
|
+
*
|
|
2205
|
+
* @see https://developer.safaricom.co.ke/APIs/TransactionStatus
|
|
2206
|
+
*/
|
|
2207
|
+
const KNOWN_RESULT_CODES = new Set(Object.values(TRANSACTION_STATUS_RESULT_CODES));
|
|
2208
|
+
/**
|
|
2209
|
+
* Runtime type guard — checks if a body looks like a Transaction Status
|
|
2210
|
+
* result callback. Validates the minimum documented structure.
|
|
2211
|
+
*/
|
|
2212
|
+
function isTransactionStatusResult(body) {
|
|
2213
|
+
if (!body || typeof body !== "object") return false;
|
|
2214
|
+
const b = body;
|
|
2215
|
+
if (!b["Result"] || typeof b["Result"] !== "object") return false;
|
|
2216
|
+
const result = b["Result"];
|
|
2217
|
+
return (typeof result["ResultCode"] === "number" || typeof result["ResultCode"] === "string") && typeof result["ConversationID"] === "string" && typeof result["OriginatorConversationID"] === "string";
|
|
2218
|
+
}
|
|
2219
|
+
/**
|
|
2220
|
+
* Returns true if the Transaction Status result represents a successful query.
|
|
2221
|
+
* Handles both string "0" and number 0 (Daraja inconsistency).
|
|
2222
|
+
*/
|
|
2223
|
+
function isTransactionStatusSuccess(result) {
|
|
2224
|
+
const code = result.Result.ResultCode;
|
|
2225
|
+
return code === 0 || code === "0";
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
//#endregion
|
|
2229
|
+
//#region src/mpesa/types.ts
|
|
2230
|
+
const DARAJA_BASE_URLS = {
|
|
2231
|
+
sandbox: "https://sandbox.safaricom.co.ke",
|
|
2232
|
+
production: "https://api.safaricom.co.ke"
|
|
2233
|
+
};
|
|
2234
|
+
|
|
2235
|
+
//#endregion
|
|
2236
|
+
//#region src/mpesa/webhooks/hmac-verifier.ts
|
|
2237
|
+
/** Default algorithm until Safaricom documents the official scheme. */
|
|
2238
|
+
const DEFAULT_WEBHOOK_HMAC_ALGORITHM = "sha256";
|
|
2239
|
+
const ALGO_MAP = {
|
|
2240
|
+
sha256: "SHA-256",
|
|
2241
|
+
sha512: "SHA-512"
|
|
2242
|
+
};
|
|
2243
|
+
function hexToBytes(hex) {
|
|
2244
|
+
const trimmed = hex.trim();
|
|
2245
|
+
if (!/^[0-9a-fA-F]+$/.test(trimmed) || trimmed.length % 2 !== 0) return null;
|
|
2246
|
+
const bytes = new Uint8Array(trimmed.length / 2);
|
|
2247
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = Number.parseInt(trimmed.slice(i * 2, i * 2 + 2), 16);
|
|
2248
|
+
return bytes;
|
|
2249
|
+
}
|
|
2250
|
+
function bytesToHex(bytes) {
|
|
2251
|
+
return [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2252
|
+
}
|
|
2253
|
+
/** Constant-time comparison for equal-length byte arrays. */
|
|
2254
|
+
function timingSafeEqualBytes(a, b) {
|
|
2255
|
+
if (a.length !== b.length) return false;
|
|
2256
|
+
let diff = 0;
|
|
2257
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
2258
|
+
return diff === 0;
|
|
2259
|
+
}
|
|
2260
|
+
/**
|
|
2261
|
+
* Verify webhook HMAC signature (preview — opt-in until Safaricom publishes spec).
|
|
2262
|
+
* Uses Web Crypto (`globalThis.crypto.subtle`) for Node, Bun, Deno, and Workers.
|
|
2263
|
+
*/
|
|
2264
|
+
async function verifyWebhookHMAC(payload, signature, secret, algorithm = DEFAULT_WEBHOOK_HMAC_ALGORITHM) {
|
|
2265
|
+
if (!secret || !signature) return false;
|
|
2266
|
+
const subtle = globalThis.crypto?.subtle;
|
|
2267
|
+
if (!subtle) return false;
|
|
2268
|
+
const body = typeof payload === "string" ? new TextEncoder().encode(payload) : payload instanceof Uint8Array ? payload : new Uint8Array(payload);
|
|
2269
|
+
const sigBytes = hexToBytes(signature);
|
|
2270
|
+
if (!sigBytes) return false;
|
|
2271
|
+
try {
|
|
2272
|
+
const key = await subtle.importKey("raw", new TextEncoder().encode(secret), {
|
|
2273
|
+
name: "HMAC",
|
|
2274
|
+
hash: ALGO_MAP[algorithm]
|
|
2275
|
+
}, false, ["sign"]);
|
|
2276
|
+
const expected = hexToBytes(bytesToHex(await subtle.sign("HMAC", key, body)));
|
|
2277
|
+
if (!expected) return false;
|
|
2278
|
+
return timingSafeEqualBytes(sigBytes, expected);
|
|
2279
|
+
} catch {
|
|
2280
|
+
return false;
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
//#endregion
|
|
2285
|
+
//#region src/mpesa/webhooks/signature-verifier.ts
|
|
2286
|
+
/** Official Safaricom API Gateway IP addresses */
|
|
2287
|
+
const SAFARICOM_IPS = [
|
|
2288
|
+
"196.201.214.200",
|
|
2289
|
+
"196.201.214.206",
|
|
2290
|
+
"196.201.213.114",
|
|
2291
|
+
"196.201.214.207",
|
|
2292
|
+
"196.201.214.208",
|
|
2293
|
+
"196.201.213.44",
|
|
2294
|
+
"196.201.212.127",
|
|
2295
|
+
"196.201.212.138",
|
|
2296
|
+
"196.201.212.129",
|
|
2297
|
+
"196.201.212.136",
|
|
2298
|
+
"196.201.212.74",
|
|
2299
|
+
"196.201.212.69"
|
|
2300
|
+
];
|
|
2301
|
+
/**
|
|
2302
|
+
* Returns true if requestIP is in the allowed list.
|
|
2303
|
+
* Defaults to the official Safaricom IP whitelist.
|
|
2304
|
+
*/
|
|
2305
|
+
function verifyWebhookIP(requestIP, allowedIPs = SAFARICOM_IPS) {
|
|
2306
|
+
return allowedIPs.includes(requestIP);
|
|
2307
|
+
}
|
|
2308
|
+
/**
|
|
2309
|
+
* Combined webhook verification: IP allowlist (default) + optional HMAC.
|
|
2310
|
+
*/
|
|
2311
|
+
async function verifyWebhook(options) {
|
|
2312
|
+
const errors = [];
|
|
2313
|
+
const allowed = options.allowedIPs ?? SAFARICOM_IPS;
|
|
2314
|
+
const ipValid = options.skipIPCheck === true || verifyWebhookIP(options.requestIP, allowed);
|
|
2315
|
+
if (!ipValid) errors.push("Request IP is not in the Safaricom allowlist.");
|
|
2316
|
+
let hmacValid = true;
|
|
2317
|
+
if (options.secret && options.signature && options.rawBody !== void 0) {
|
|
2318
|
+
hmacValid = await verifyWebhookHMAC(options.rawBody, options.signature, options.secret, options.hmacAlgorithm);
|
|
2319
|
+
if (!hmacValid) errors.push("Webhook HMAC signature verification failed.");
|
|
2320
|
+
} else if (options.requireHMAC) {
|
|
2321
|
+
hmacValid = false;
|
|
2322
|
+
errors.push("HMAC verification required but secret, signature, or rawBody missing.");
|
|
2323
|
+
}
|
|
2324
|
+
return {
|
|
2325
|
+
valid: ipValid && hmacValid,
|
|
2326
|
+
ipValid,
|
|
2327
|
+
hmacValid,
|
|
2328
|
+
errors
|
|
2329
|
+
};
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
//#endregion
|
|
2333
|
+
//#region src/mpesa/webhooks/webhook-handler.ts
|
|
2334
|
+
/** Extracts the M-Pesa receipt number from a successful STK Push callback */
|
|
2335
|
+
function extractTransactionId(webhook) {
|
|
2336
|
+
const item = (webhook.Body?.stkCallback?.CallbackMetadata?.Item)?.find((i) => i.Name === "MpesaReceiptNumber");
|
|
2337
|
+
return item ? String(item.Value) : null;
|
|
2338
|
+
}
|
|
2339
|
+
/** Extracts the transaction amount from a successful STK Push callback */
|
|
2340
|
+
function extractAmount(webhook) {
|
|
2341
|
+
const item = (webhook.Body?.stkCallback?.CallbackMetadata?.Item)?.find((i) => i.Name === "Amount");
|
|
2342
|
+
return item ? Number(item.Value) : null;
|
|
2343
|
+
}
|
|
2344
|
+
/** Extracts the phone number from a successful STK Push callback */
|
|
2345
|
+
function extractPhoneNumber(webhook) {
|
|
2346
|
+
const item = (webhook.Body?.stkCallback?.CallbackMetadata?.Item)?.find((i) => i.Name === "PhoneNumber");
|
|
2347
|
+
return item ? String(item.Value) : null;
|
|
2348
|
+
}
|
|
2349
|
+
/** Returns true if the STK Push callback represents a successful transaction */
|
|
2350
|
+
function isSuccessfulCallback(webhook) {
|
|
2351
|
+
return webhook.Body?.stkCallback?.ResultCode === 0;
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
//#endregion
|
|
2355
|
+
//#region src/mpesa/index.ts
|
|
2356
|
+
/**
|
|
2357
|
+
* src/mpesa/index.ts
|
|
2358
|
+
*
|
|
2359
|
+
* Primary M-PESA module entry point.
|
|
2360
|
+
*
|
|
2361
|
+
* Exports:
|
|
2362
|
+
* 1. Mpesa class — the main SDK client
|
|
2363
|
+
* 2. All submodule APIs, types, constants, and helpers
|
|
2364
|
+
*
|
|
2365
|
+
* Submodules re-exported here:
|
|
2366
|
+
* - account-balance
|
|
2367
|
+
* - b2b-buy-goods
|
|
2368
|
+
* - b2b-express-checkout
|
|
2369
|
+
* - b2b-pay-bill
|
|
2370
|
+
* - b2c (Account Top Up)
|
|
2371
|
+
* - b2c-disbursement
|
|
2372
|
+
* - bill-manager
|
|
2373
|
+
* - c2b
|
|
2374
|
+
* - dynamic-qr
|
|
2375
|
+
* - reversal
|
|
2376
|
+
* - stk-push
|
|
2377
|
+
* - tax-remittance
|
|
2378
|
+
* - transaction-status
|
|
2379
|
+
* - webhooks
|
|
2380
|
+
*/
|
|
2381
|
+
var Mpesa = class {
|
|
2382
|
+
config;
|
|
2383
|
+
tokenManager;
|
|
2384
|
+
baseUrl;
|
|
2385
|
+
idempotencyManager;
|
|
2386
|
+
constructor(config) {
|
|
2387
|
+
if (!config.consumerKey || !config.consumerSecret) throw new PesafyError({
|
|
2388
|
+
code: "INVALID_CREDENTIALS",
|
|
2389
|
+
message: "consumerKey and consumerSecret are required."
|
|
2390
|
+
});
|
|
2391
|
+
this.config = config;
|
|
2392
|
+
this.baseUrl = DARAJA_BASE_URLS[config.environment];
|
|
2393
|
+
this.tokenManager = new TokenManager(config.consumerKey, config.consumerSecret, this.baseUrl);
|
|
2394
|
+
this.idempotencyManager = new IdempotencyManager(config.idempotency);
|
|
2395
|
+
}
|
|
2396
|
+
/** Idempotency options passed to all outbound Daraja HTTP calls. */
|
|
2397
|
+
darajaHttp() {
|
|
2398
|
+
return { idempotency: this.idempotencyManager };
|
|
2399
|
+
}
|
|
2400
|
+
getToken() {
|
|
2401
|
+
return this.tokenManager.getAccessToken();
|
|
2402
|
+
}
|
|
2403
|
+
async buildSecurityCredential() {
|
|
2404
|
+
if (this.config.securityCredential) return this.config.securityCredential;
|
|
2405
|
+
if (!this.config.initiatorPassword) throw new PesafyError({
|
|
2406
|
+
code: "INVALID_CREDENTIALS",
|
|
2407
|
+
message: "Provide securityCredential (pre-encrypted) OR (initiatorPassword + certificatePath/certificatePem)."
|
|
2408
|
+
});
|
|
2409
|
+
let cert;
|
|
2410
|
+
if (this.config.certificatePem) cert = this.config.certificatePem;
|
|
2411
|
+
else if (this.config.certificatePath) cert = await readFile(this.config.certificatePath, "utf-8");
|
|
2412
|
+
else throw new PesafyError({
|
|
2413
|
+
code: "INVALID_CREDENTIALS",
|
|
2414
|
+
message: "certificatePath or certificatePem is required to encrypt the initiator password."
|
|
2415
|
+
});
|
|
2416
|
+
return encryptSecurityCredential(this.config.initiatorPassword, cert);
|
|
2417
|
+
}
|
|
2418
|
+
requireInitiator(forApi) {
|
|
2419
|
+
const name = this.config.initiatorName ?? "";
|
|
2420
|
+
if (!name) throw new PesafyError({
|
|
2421
|
+
code: "VALIDATION_ERROR",
|
|
2422
|
+
message: `initiatorName is required for ${forApi}.`
|
|
2423
|
+
});
|
|
2424
|
+
return name;
|
|
2425
|
+
}
|
|
2426
|
+
async stkPushSafe(request) {
|
|
2427
|
+
try {
|
|
2428
|
+
return ok(await this.stkPush(request));
|
|
2429
|
+
} catch (e) {
|
|
2430
|
+
return err(e);
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
async accountBalanceSafe(request) {
|
|
2434
|
+
try {
|
|
2435
|
+
return ok(await this.accountBalance(request));
|
|
2436
|
+
} catch (e) {
|
|
2437
|
+
return err(e);
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
async stkPush(request) {
|
|
2441
|
+
const shortCode = this.config.lipaNaMpesaShortCode ?? "";
|
|
2442
|
+
const passKey = this.config.lipaNaMpesaPassKey ?? "";
|
|
2443
|
+
if (!shortCode || !passKey) throw new PesafyError({
|
|
2444
|
+
code: "VALIDATION_ERROR",
|
|
2445
|
+
message: "lipaNaMpesaShortCode and lipaNaMpesaPassKey are required for STK Push."
|
|
2446
|
+
});
|
|
2447
|
+
const token = await this.getToken();
|
|
2448
|
+
return processStkPush(this.baseUrl, token, {
|
|
2449
|
+
...request,
|
|
2450
|
+
shortCode,
|
|
2451
|
+
passKey
|
|
2452
|
+
}, this.darajaHttp());
|
|
2453
|
+
}
|
|
2454
|
+
async stkQuery(request) {
|
|
2455
|
+
const shortCode = this.config.lipaNaMpesaShortCode ?? "";
|
|
2456
|
+
const passKey = this.config.lipaNaMpesaPassKey ?? "";
|
|
2457
|
+
if (!shortCode || !passKey) throw new PesafyError({
|
|
2458
|
+
code: "VALIDATION_ERROR",
|
|
2459
|
+
message: "lipaNaMpesaShortCode and lipaNaMpesaPassKey are required for STK Query."
|
|
2460
|
+
});
|
|
2461
|
+
const token = await this.getToken();
|
|
2462
|
+
return queryStkPush(this.baseUrl, token, {
|
|
2463
|
+
...request,
|
|
2464
|
+
shortCode,
|
|
2465
|
+
passKey
|
|
2466
|
+
}, this.darajaHttp());
|
|
2467
|
+
}
|
|
2468
|
+
async transactionStatus(request) {
|
|
2469
|
+
const initiator = this.requireInitiator("Transaction Status");
|
|
2470
|
+
const [token, cred] = await Promise.all([this.getToken(), this.buildSecurityCredential()]);
|
|
2471
|
+
return queryTransactionStatus(this.baseUrl, token, cred, initiator, request, this.darajaHttp());
|
|
2472
|
+
}
|
|
2473
|
+
async accountBalance(request) {
|
|
2474
|
+
const initiator = this.requireInitiator("Account Balance");
|
|
2475
|
+
const [token, cred] = await Promise.all([this.getToken(), this.buildSecurityCredential()]);
|
|
2476
|
+
return queryAccountBalance(this.baseUrl, token, cred, initiator, request, this.darajaHttp());
|
|
2477
|
+
}
|
|
2478
|
+
async reverseTransaction(request) {
|
|
2479
|
+
const initiator = this.requireInitiator("Reversal");
|
|
2480
|
+
const [token, cred] = await Promise.all([this.getToken(), this.buildSecurityCredential()]);
|
|
2481
|
+
return requestReversal(this.baseUrl, token, cred, initiator, request, this.darajaHttp());
|
|
2482
|
+
}
|
|
2483
|
+
async generateDynamicQR(request) {
|
|
2484
|
+
const token = await this.getToken();
|
|
2485
|
+
return generateDynamicQR(this.baseUrl, token, request, this.darajaHttp());
|
|
2486
|
+
}
|
|
2487
|
+
async registerC2BUrls(request) {
|
|
2488
|
+
const token = await this.getToken();
|
|
2489
|
+
return registerC2BUrls(this.baseUrl, token, request, this.darajaHttp());
|
|
2490
|
+
}
|
|
2491
|
+
async simulateC2B(request) {
|
|
2492
|
+
const token = await this.getToken();
|
|
2493
|
+
return simulateC2B(this.baseUrl, token, request, this.darajaHttp());
|
|
2494
|
+
}
|
|
2495
|
+
async remitTax(request) {
|
|
2496
|
+
const initiator = this.requireInitiator("Tax Remittance");
|
|
2497
|
+
const [token, cred] = await Promise.all([this.getToken(), this.buildSecurityCredential()]);
|
|
2498
|
+
return remitTax(this.baseUrl, token, cred, initiator, request, this.darajaHttp());
|
|
2499
|
+
}
|
|
2500
|
+
async b2bExpressCheckout(request) {
|
|
2501
|
+
const token = await this.getToken();
|
|
2502
|
+
return initiateB2BExpressCheckout(this.baseUrl, token, request, this.darajaHttp());
|
|
2503
|
+
}
|
|
2504
|
+
async b2bBuyGoods(request) {
|
|
2505
|
+
const initiator = this.requireInitiator("B2B Buy Goods");
|
|
2506
|
+
const [token, cred] = await Promise.all([this.getToken(), this.buildSecurityCredential()]);
|
|
2507
|
+
return initiateB2BBuyGoods(this.baseUrl, token, cred, initiator, request, this.darajaHttp());
|
|
2508
|
+
}
|
|
2509
|
+
async b2bPayBill(request) {
|
|
2510
|
+
const initiator = this.requireInitiator("B2B Pay Bill");
|
|
2511
|
+
const [token, cred] = await Promise.all([this.getToken(), this.buildSecurityCredential()]);
|
|
2512
|
+
return initiateB2BPayBill(this.baseUrl, token, cred, initiator, request, this.darajaHttp());
|
|
2513
|
+
}
|
|
2514
|
+
async b2cPayment(request) {
|
|
2515
|
+
const initiator = this.requireInitiator("B2C Payment");
|
|
2516
|
+
const [token, cred] = await Promise.all([this.getToken(), this.buildSecurityCredential()]);
|
|
2517
|
+
return initiateB2CPayment(this.baseUrl, token, cred, initiator, request, this.darajaHttp());
|
|
2518
|
+
}
|
|
2519
|
+
async b2cDisbursement(request) {
|
|
2520
|
+
const initiator = this.requireInitiator("B2C Disbursement");
|
|
2521
|
+
const req = {
|
|
2522
|
+
...request,
|
|
2523
|
+
originatorConversationId: request.originatorConversationId ?? generateOriginatorConversationId()
|
|
2524
|
+
};
|
|
2525
|
+
const [token, cred] = await Promise.all([this.getToken(), this.buildSecurityCredential()]);
|
|
2526
|
+
return initiateB2CDisbursement(this.baseUrl, token, cred, initiator, req, this.darajaHttp());
|
|
2527
|
+
}
|
|
2528
|
+
async billManagerOptIn(request) {
|
|
2529
|
+
const token = await this.getToken();
|
|
2530
|
+
return billManagerOptIn(this.baseUrl, token, request, this.darajaHttp());
|
|
2531
|
+
}
|
|
2532
|
+
async updateOptIn(request) {
|
|
2533
|
+
const token = await this.getToken();
|
|
2534
|
+
return updateOptIn(this.baseUrl, token, request, this.darajaHttp());
|
|
2535
|
+
}
|
|
2536
|
+
async sendInvoice(request) {
|
|
2537
|
+
const token = await this.getToken();
|
|
2538
|
+
return sendSingleInvoice(this.baseUrl, token, request, this.darajaHttp());
|
|
2539
|
+
}
|
|
2540
|
+
async sendBulkInvoices(request) {
|
|
2541
|
+
const token = await this.getToken();
|
|
2542
|
+
return sendBulkInvoices(this.baseUrl, token, request, this.darajaHttp());
|
|
2543
|
+
}
|
|
2544
|
+
async cancelInvoice(request) {
|
|
2545
|
+
const token = await this.getToken();
|
|
2546
|
+
return cancelInvoice(this.baseUrl, token, request, this.darajaHttp());
|
|
2547
|
+
}
|
|
2548
|
+
async cancelBulkInvoices(request) {
|
|
2549
|
+
const token = await this.getToken();
|
|
2550
|
+
return cancelBulkInvoices(this.baseUrl, token, request, this.darajaHttp());
|
|
2551
|
+
}
|
|
2552
|
+
async reconcilePayment(request) {
|
|
2553
|
+
const token = await this.getToken();
|
|
2554
|
+
return reconcilePayment(this.baseUrl, token, request, this.darajaHttp());
|
|
2555
|
+
}
|
|
2556
|
+
clearTokenCache() {
|
|
2557
|
+
this.tokenManager.clearCache();
|
|
2558
|
+
}
|
|
2559
|
+
get environment() {
|
|
2560
|
+
return this.config.environment;
|
|
2561
|
+
}
|
|
2562
|
+
};
|
|
2563
|
+
|
|
2564
|
+
//#endregion
|
|
2565
|
+
//#region src/adapters/webhook-guard.ts
|
|
2566
|
+
const DEFAULT_SIGNATURE_HEADER = "x-safaricom-signature";
|
|
2567
|
+
/**
|
|
2568
|
+
* Verify webhook IP (+ optional HMAC). Logs warnings on failure; does not throw.
|
|
2569
|
+
* Returns whether verification passed (IP and HMAC when configured).
|
|
2570
|
+
*/
|
|
2571
|
+
async function guardWebhookRequest(requestIP, rawBody, getHeader, config) {
|
|
2572
|
+
const headerName = config.signatureHeader ?? DEFAULT_SIGNATURE_HEADER;
|
|
2573
|
+
const signature = getHeader(headerName) ?? getHeader(headerName.toLowerCase()) ?? getHeader(headerName.toUpperCase());
|
|
2574
|
+
const result = await verifyWebhook({
|
|
2575
|
+
requestIP,
|
|
2576
|
+
...config.skipIPCheck !== void 0 ? { skipIPCheck: config.skipIPCheck } : {},
|
|
2577
|
+
...config.webhookSecret !== void 0 ? { secret: config.webhookSecret } : {},
|
|
2578
|
+
...signature !== void 0 ? { signature } : {},
|
|
2579
|
+
...rawBody !== void 0 ? { rawBody } : {},
|
|
2580
|
+
...config.requireHMAC !== void 0 ? { requireHMAC: config.requireHMAC } : {}
|
|
2581
|
+
});
|
|
2582
|
+
if (!result.valid) console.warn("[pesafy] Webhook verification failed:", result.errors.join("; "));
|
|
2583
|
+
return result.valid;
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
//#endregion
|
|
2587
|
+
//#region src/adapters/shared/helpers.ts
|
|
2588
|
+
function resolveUrl(explicit, override, fallback, label) {
|
|
2589
|
+
const resolved = explicit || override || fallback || "";
|
|
2590
|
+
if (!resolved) throw new PesafyError({
|
|
2591
|
+
code: "VALIDATION_ERROR",
|
|
2592
|
+
message: `${label} is required. Set it in config or include it in the request body.`
|
|
2593
|
+
});
|
|
2594
|
+
return resolved;
|
|
2595
|
+
}
|
|
2596
|
+
function fireHook(fn, label) {
|
|
2597
|
+
if (!fn) return;
|
|
2598
|
+
fn().catch((err) => console.error(`[pesafy] ${label} hook error:`, err));
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
//#endregion
|
|
2602
|
+
//#region src/adapters/shared/handlers.ts
|
|
2603
|
+
async function runWebhookGuard(ctx, config) {
|
|
2604
|
+
if (!await guardWebhookRequest(ctx.requestIP, ctx.rawBody, ctx.getHeader, config)) throw new PesafyError({
|
|
2605
|
+
code: "AUTH_FAILED",
|
|
2606
|
+
message: "Webhook verification failed: invalid source IP or HMAC signature",
|
|
2607
|
+
statusCode: 403
|
|
2608
|
+
});
|
|
2609
|
+
}
|
|
2610
|
+
function createRouteHandlers(mpesa, config) {
|
|
2611
|
+
return {
|
|
2612
|
+
"stk.push": async (ctx) => {
|
|
2613
|
+
const { amount, phoneNumber, accountReference, transactionDesc, transactionType, partyB } = ctx.body;
|
|
2614
|
+
if (!amount || amount <= 0) throw new PesafyError({
|
|
2615
|
+
code: "VALIDATION_ERROR",
|
|
2616
|
+
message: "amount must be > 0"
|
|
2617
|
+
});
|
|
2618
|
+
if (!phoneNumber) throw new PesafyError({
|
|
2619
|
+
code: "VALIDATION_ERROR",
|
|
2620
|
+
message: "phoneNumber is required"
|
|
2621
|
+
});
|
|
2622
|
+
return {
|
|
2623
|
+
type: "api",
|
|
2624
|
+
body: await mpesa.stkPush({
|
|
2625
|
+
amount,
|
|
2626
|
+
phoneNumber,
|
|
2627
|
+
callbackUrl: config.callbackUrl,
|
|
2628
|
+
accountReference: accountReference ?? `REF-${Date.now().toString(36).toUpperCase()}`,
|
|
2629
|
+
transactionDesc: transactionDesc ?? "Payment",
|
|
2630
|
+
...transactionType !== void 0 ? { transactionType } : {},
|
|
2631
|
+
...partyB !== void 0 ? { partyB } : {}
|
|
2632
|
+
})
|
|
2633
|
+
};
|
|
2634
|
+
},
|
|
2635
|
+
"stk.query": async (ctx) => {
|
|
2636
|
+
const { checkoutRequestId } = ctx.body;
|
|
2637
|
+
if (!checkoutRequestId) throw new PesafyError({
|
|
2638
|
+
code: "VALIDATION_ERROR",
|
|
2639
|
+
message: "checkoutRequestId is required"
|
|
2640
|
+
});
|
|
2641
|
+
return {
|
|
2642
|
+
type: "api",
|
|
2643
|
+
body: await mpesa.stkQuery({ checkoutRequestId })
|
|
2644
|
+
};
|
|
2645
|
+
},
|
|
2646
|
+
"stk.callback": async (ctx) => {
|
|
2647
|
+
await runWebhookGuard(ctx, config);
|
|
2648
|
+
const webhook = ctx.body;
|
|
2649
|
+
const cb = webhook?.Body?.stkCallback;
|
|
2650
|
+
if (!cb) return {
|
|
2651
|
+
type: "daraja",
|
|
2652
|
+
body: {
|
|
2653
|
+
ResultCode: 0,
|
|
2654
|
+
ResultDesc: "Accepted"
|
|
2655
|
+
}
|
|
2656
|
+
};
|
|
2657
|
+
if (isSuccessfulCallback(webhook)) {
|
|
2658
|
+
const payload = {
|
|
2659
|
+
receiptNumber: extractTransactionId(webhook),
|
|
2660
|
+
amount: extractAmount(webhook),
|
|
2661
|
+
phone: extractPhoneNumber(webhook),
|
|
2662
|
+
checkoutRequestId: cb.CheckoutRequestID,
|
|
2663
|
+
merchantRequestId: cb.MerchantRequestID
|
|
2664
|
+
};
|
|
2665
|
+
console.info("[pesafy] STK success:", payload);
|
|
2666
|
+
fireHook(config.onStkSuccess ? () => Promise.resolve(config.onStkSuccess(payload)) : void 0, "onStkSuccess");
|
|
2667
|
+
} else {
|
|
2668
|
+
const payload = {
|
|
2669
|
+
resultCode: cb.ResultCode,
|
|
2670
|
+
resultDesc: cb.ResultDesc,
|
|
2671
|
+
checkoutRequestId: cb.CheckoutRequestID,
|
|
2672
|
+
merchantRequestId: cb.MerchantRequestID
|
|
2673
|
+
};
|
|
2674
|
+
console.warn("[pesafy] STK failure:", payload);
|
|
2675
|
+
fireHook(config.onStkFailure ? () => Promise.resolve(config.onStkFailure(payload)) : void 0, "onStkFailure");
|
|
2676
|
+
}
|
|
2677
|
+
return {
|
|
2678
|
+
type: "daraja",
|
|
2679
|
+
body: {
|
|
2680
|
+
ResultCode: 0,
|
|
2681
|
+
ResultDesc: "Accepted"
|
|
2682
|
+
}
|
|
2683
|
+
};
|
|
2684
|
+
},
|
|
2685
|
+
"c2b.register": async (ctx) => {
|
|
2686
|
+
const { shortCode = config.c2b?.shortCode, confirmationUrl = config.c2b?.confirmationUrl, validationUrl = config.c2b?.validationUrl, responseType = config.c2b?.responseType ?? "Completed", apiVersion = config.c2b?.apiVersion ?? "v2" } = ctx.body;
|
|
2687
|
+
if (!shortCode) throw new PesafyError({
|
|
2688
|
+
code: "VALIDATION_ERROR",
|
|
2689
|
+
message: "shortCode is required"
|
|
2690
|
+
});
|
|
2691
|
+
if (!confirmationUrl) throw new PesafyError({
|
|
2692
|
+
code: "VALIDATION_ERROR",
|
|
2693
|
+
message: "confirmationUrl is required"
|
|
2694
|
+
});
|
|
2695
|
+
if (!validationUrl) throw new PesafyError({
|
|
2696
|
+
code: "VALIDATION_ERROR",
|
|
2697
|
+
message: "validationUrl is required"
|
|
2698
|
+
});
|
|
2699
|
+
return {
|
|
2700
|
+
type: "api",
|
|
2701
|
+
body: await mpesa.registerC2BUrls({
|
|
2702
|
+
shortCode,
|
|
2703
|
+
responseType,
|
|
2704
|
+
confirmationUrl,
|
|
2705
|
+
validationUrl,
|
|
2706
|
+
apiVersion
|
|
2707
|
+
})
|
|
2708
|
+
};
|
|
2709
|
+
},
|
|
2710
|
+
"c2b.simulate": async (ctx) => {
|
|
2711
|
+
const { commandId, amount, msisdn, billRefNumber, shortCode, apiVersion } = ctx.body;
|
|
2712
|
+
if (!commandId) throw new PesafyError({
|
|
2713
|
+
code: "VALIDATION_ERROR",
|
|
2714
|
+
message: "commandId is required"
|
|
2715
|
+
});
|
|
2716
|
+
if (!amount || amount <= 0) throw new PesafyError({
|
|
2717
|
+
code: "VALIDATION_ERROR",
|
|
2718
|
+
message: "amount must be > 0"
|
|
2719
|
+
});
|
|
2720
|
+
if (!msisdn) throw new PesafyError({
|
|
2721
|
+
code: "VALIDATION_ERROR",
|
|
2722
|
+
message: "msisdn is required"
|
|
2723
|
+
});
|
|
2724
|
+
return {
|
|
2725
|
+
type: "api",
|
|
2726
|
+
body: await mpesa.simulateC2B({
|
|
2727
|
+
shortCode: shortCode ?? config.c2b?.shortCode ?? "",
|
|
2728
|
+
commandId,
|
|
2729
|
+
amount,
|
|
2730
|
+
msisdn,
|
|
2731
|
+
apiVersion: apiVersion ?? config.c2b?.apiVersion ?? "v2",
|
|
2732
|
+
...billRefNumber !== void 0 ? { billRefNumber } : {}
|
|
2733
|
+
})
|
|
2734
|
+
};
|
|
2735
|
+
},
|
|
2736
|
+
"c2b.validation": async (ctx) => {
|
|
2737
|
+
await runWebhookGuard(ctx, config);
|
|
2738
|
+
const payload = ctx.body;
|
|
2739
|
+
return {
|
|
2740
|
+
type: "daraja",
|
|
2741
|
+
body: config.onC2BValidation ? await config.onC2BValidation(payload) : acceptC2BValidation()
|
|
2742
|
+
};
|
|
2743
|
+
},
|
|
2744
|
+
"c2b.confirmation": async (ctx) => {
|
|
2745
|
+
await runWebhookGuard(ctx, config);
|
|
2746
|
+
const payload = ctx.body;
|
|
2747
|
+
console.info("[pesafy] C2B confirmation:", {
|
|
2748
|
+
transactionId: payload.TransID,
|
|
2749
|
+
amount: payload.TransAmount,
|
|
2750
|
+
billRef: payload.BillRefNumber
|
|
2751
|
+
});
|
|
2752
|
+
fireHook(config.onC2BConfirmation ? () => Promise.resolve(config.onC2BConfirmation(payload)) : void 0, "onC2BConfirmation");
|
|
2753
|
+
return {
|
|
2754
|
+
type: "daraja",
|
|
2755
|
+
body: {
|
|
2756
|
+
ResultCode: 0,
|
|
2757
|
+
ResultDesc: "Success"
|
|
2758
|
+
}
|
|
2759
|
+
};
|
|
2760
|
+
},
|
|
2761
|
+
"balance.query": async (ctx) => {
|
|
2762
|
+
const body = ctx.body;
|
|
2763
|
+
return {
|
|
2764
|
+
type: "api",
|
|
2765
|
+
body: await mpesa.accountBalance({
|
|
2766
|
+
partyA: body.partyA ?? config.balance?.shortCode ?? "",
|
|
2767
|
+
identifierType: body.identifierType ?? "4",
|
|
2768
|
+
resultUrl: resolveUrl(body.resultUrl, config.balance?.resultUrl, config.resultUrl, "resultUrl"),
|
|
2769
|
+
queueTimeOutUrl: resolveUrl(body.queueTimeoutUrl, config.balance?.queueTimeoutUrl, config.queueTimeoutUrl, "queueTimeoutUrl"),
|
|
2770
|
+
...body.remarks !== void 0 ? { remarks: body.remarks } : {}
|
|
2771
|
+
})
|
|
2772
|
+
};
|
|
2773
|
+
},
|
|
2774
|
+
"balance.result": async (ctx) => {
|
|
2775
|
+
await runWebhookGuard(ctx, config);
|
|
2776
|
+
const body = ctx.body;
|
|
2777
|
+
if (!isAccountBalanceSuccess(body)) console.warn("[pesafy] Account balance failed:", body);
|
|
2778
|
+
else {
|
|
2779
|
+
const raw = getAccountBalanceRawBalance(body);
|
|
2780
|
+
console.info("[pesafy] Account balance:", raw ? parseAccountBalance(raw) : body);
|
|
2781
|
+
}
|
|
2782
|
+
fireHook(config.onAccountBalanceResult ? () => Promise.resolve(config.onAccountBalanceResult(body)) : void 0, "onAccountBalanceResult");
|
|
2783
|
+
return {
|
|
2784
|
+
type: "daraja",
|
|
2785
|
+
body: {
|
|
2786
|
+
ResultCode: 0,
|
|
2787
|
+
ResultDesc: "Accepted"
|
|
2788
|
+
}
|
|
2789
|
+
};
|
|
2790
|
+
},
|
|
2791
|
+
"qr.generate": async (ctx) => {
|
|
2792
|
+
return {
|
|
2793
|
+
type: "api",
|
|
2794
|
+
body: await mpesa.generateDynamicQR(ctx.body)
|
|
2795
|
+
};
|
|
2796
|
+
},
|
|
2797
|
+
"reversal.request": async (ctx) => {
|
|
2798
|
+
const body = ctx.body;
|
|
2799
|
+
if (!body.transactionId) throw new PesafyError({
|
|
2800
|
+
code: "VALIDATION_ERROR",
|
|
2801
|
+
message: "transactionId is required"
|
|
2802
|
+
});
|
|
2803
|
+
if (!body.receiverParty) throw new PesafyError({
|
|
2804
|
+
code: "VALIDATION_ERROR",
|
|
2805
|
+
message: "receiverParty is required"
|
|
2806
|
+
});
|
|
2807
|
+
if (!body.amount || body.amount <= 0) throw new PesafyError({
|
|
2808
|
+
code: "VALIDATION_ERROR",
|
|
2809
|
+
message: "amount must be > 0"
|
|
2810
|
+
});
|
|
2811
|
+
return {
|
|
2812
|
+
type: "api",
|
|
2813
|
+
body: await mpesa.reverseTransaction({
|
|
2814
|
+
transactionId: body.transactionId,
|
|
2815
|
+
receiverParty: body.receiverParty,
|
|
2816
|
+
amount: body.amount,
|
|
2817
|
+
resultUrl: resolveUrl(body.resultUrl, config.reversal?.resultUrl, config.resultUrl, "resultUrl"),
|
|
2818
|
+
queueTimeOutUrl: resolveUrl(body.queueTimeoutUrl, config.reversal?.queueTimeoutUrl, config.queueTimeoutUrl, "queueTimeoutUrl"),
|
|
2819
|
+
...body.remarks !== void 0 ? { remarks: body.remarks } : {},
|
|
2820
|
+
...body.occasion !== void 0 ? { occasion: body.occasion } : {}
|
|
2821
|
+
})
|
|
2822
|
+
};
|
|
2823
|
+
},
|
|
2824
|
+
"reversal.result": async (ctx) => {
|
|
2825
|
+
await runWebhookGuard(ctx, config);
|
|
2826
|
+
const body = ctx.body;
|
|
2827
|
+
if (isReversalResult(body)) {
|
|
2828
|
+
if (isReversalSuccess(body)) console.info("[pesafy] Reversal success:", { txId: getReversalTransactionId(body) });
|
|
2829
|
+
else console.warn("[pesafy] Reversal failed:", body.Result.ResultDesc);
|
|
2830
|
+
fireHook(config.onReversalResult ? () => Promise.resolve(config.onReversalResult(body)) : void 0, "onReversalResult");
|
|
2831
|
+
}
|
|
2832
|
+
return {
|
|
2833
|
+
type: "daraja",
|
|
2834
|
+
body: {
|
|
2835
|
+
ResultCode: 0,
|
|
2836
|
+
ResultDesc: "Accepted"
|
|
2837
|
+
}
|
|
2838
|
+
};
|
|
2839
|
+
},
|
|
2840
|
+
"txStatus.query": async (ctx) => {
|
|
2841
|
+
const body = ctx.body;
|
|
2842
|
+
return {
|
|
2843
|
+
type: "api",
|
|
2844
|
+
body: await mpesa.transactionStatus({
|
|
2845
|
+
...body.transactionId !== void 0 ? { transactionId: body.transactionId } : {},
|
|
2846
|
+
...body.originalConversationId !== void 0 ? { originalConversationId: body.originalConversationId } : {},
|
|
2847
|
+
partyA: body.partyA,
|
|
2848
|
+
identifierType: body.identifierType,
|
|
2849
|
+
resultUrl: resolveUrl(body.resultUrl, config.txStatus?.resultUrl, config.resultUrl, "resultUrl"),
|
|
2850
|
+
queueTimeOutUrl: resolveUrl(body.queueTimeoutUrl, config.txStatus?.queueTimeoutUrl, config.queueTimeoutUrl, "queueTimeoutUrl"),
|
|
2851
|
+
...body.remarks !== void 0 ? { remarks: body.remarks } : {},
|
|
2852
|
+
...body.occasion !== void 0 ? { occasion: body.occasion } : {}
|
|
2853
|
+
})
|
|
2854
|
+
};
|
|
2855
|
+
},
|
|
2856
|
+
"txStatus.result": async (ctx) => {
|
|
2857
|
+
await runWebhookGuard(ctx, config);
|
|
2858
|
+
const body = ctx.body;
|
|
2859
|
+
if (isTransactionStatusResult(body)) {
|
|
2860
|
+
if (isTransactionStatusSuccess(body)) console.info("[pesafy] Transaction status success:", body.Result.TransactionID);
|
|
2861
|
+
else console.warn("[pesafy] Transaction status failed:", body.Result.ResultDesc);
|
|
2862
|
+
fireHook(config.onTxStatusResult ? () => Promise.resolve(config.onTxStatusResult(body)) : void 0, "onTxStatusResult");
|
|
2863
|
+
}
|
|
2864
|
+
return {
|
|
2865
|
+
type: "daraja",
|
|
2866
|
+
body: {
|
|
2867
|
+
ResultCode: 0,
|
|
2868
|
+
ResultDesc: "Accepted"
|
|
2869
|
+
}
|
|
2870
|
+
};
|
|
2871
|
+
},
|
|
2872
|
+
"tax.remit": async (ctx) => {
|
|
2873
|
+
const body = ctx.body;
|
|
2874
|
+
if (!body.amount || body.amount <= 0) throw new PesafyError({
|
|
2875
|
+
code: "VALIDATION_ERROR",
|
|
2876
|
+
message: "amount must be > 0"
|
|
2877
|
+
});
|
|
2878
|
+
if (!body.accountReference) throw new PesafyError({
|
|
2879
|
+
code: "VALIDATION_ERROR",
|
|
2880
|
+
message: "accountReference (KRA PRN) is required"
|
|
2881
|
+
});
|
|
2882
|
+
return {
|
|
2883
|
+
type: "api",
|
|
2884
|
+
body: await mpesa.remitTax({
|
|
2885
|
+
amount: body.amount,
|
|
2886
|
+
partyA: body.partyA ?? config.tax?.partyA ?? "",
|
|
2887
|
+
accountReference: body.accountReference,
|
|
2888
|
+
resultUrl: resolveUrl(body.resultUrl, config.tax?.resultUrl, config.resultUrl, "resultUrl"),
|
|
2889
|
+
queueTimeOutUrl: resolveUrl(body.queueTimeoutUrl, config.tax?.queueTimeoutUrl, config.queueTimeoutUrl, "queueTimeoutUrl"),
|
|
2890
|
+
...body.partyB !== void 0 ? { partyB: body.partyB } : {},
|
|
2891
|
+
...body.remarks !== void 0 ? { remarks: body.remarks } : {}
|
|
2892
|
+
})
|
|
2893
|
+
};
|
|
2894
|
+
},
|
|
2895
|
+
"tax.result": async (ctx) => {
|
|
2896
|
+
await runWebhookGuard(ctx, config);
|
|
2897
|
+
const body = ctx.body;
|
|
2898
|
+
if (isTaxRemittanceResult(body)) {
|
|
2899
|
+
if (isTaxRemittanceSuccess(body)) console.info("[pesafy] Tax remittance success:", body.Result.TransactionID);
|
|
2900
|
+
else console.warn("[pesafy] Tax remittance failed:", body.Result.ResultDesc);
|
|
2901
|
+
fireHook(config.onTaxResult ? () => Promise.resolve(config.onTaxResult(body)) : void 0, "onTaxResult");
|
|
2902
|
+
}
|
|
2903
|
+
return {
|
|
2904
|
+
type: "daraja",
|
|
2905
|
+
body: {
|
|
2906
|
+
ResultCode: 0,
|
|
2907
|
+
ResultDesc: "Accepted"
|
|
2908
|
+
}
|
|
2909
|
+
};
|
|
2910
|
+
},
|
|
2911
|
+
"b2b.checkout": async (ctx) => {
|
|
2912
|
+
const body = ctx.body;
|
|
2913
|
+
if (!body.primaryShortCode) throw new PesafyError({
|
|
2914
|
+
code: "VALIDATION_ERROR",
|
|
2915
|
+
message: "primaryShortCode is required"
|
|
2916
|
+
});
|
|
2917
|
+
if (!body.amount || body.amount <= 0) throw new PesafyError({
|
|
2918
|
+
code: "VALIDATION_ERROR",
|
|
2919
|
+
message: "amount must be > 0"
|
|
2920
|
+
});
|
|
2921
|
+
if (!body.paymentRef) throw new PesafyError({
|
|
2922
|
+
code: "VALIDATION_ERROR",
|
|
2923
|
+
message: "paymentRef is required"
|
|
2924
|
+
});
|
|
2925
|
+
if (!body.partnerName) throw new PesafyError({
|
|
2926
|
+
code: "VALIDATION_ERROR",
|
|
2927
|
+
message: "partnerName is required"
|
|
2928
|
+
});
|
|
2929
|
+
const receiverShortCode = body.receiverShortCode ?? config.b2b?.receiverShortCode ?? "";
|
|
2930
|
+
if (!receiverShortCode) throw new PesafyError({
|
|
2931
|
+
code: "VALIDATION_ERROR",
|
|
2932
|
+
message: "receiverShortCode is required"
|
|
2933
|
+
});
|
|
2934
|
+
const callbackUrl = body.callbackUrl ?? config.b2b?.callbackUrl ?? "";
|
|
2935
|
+
if (!callbackUrl) throw new PesafyError({
|
|
2936
|
+
code: "VALIDATION_ERROR",
|
|
2937
|
+
message: "callbackUrl is required"
|
|
2938
|
+
});
|
|
2939
|
+
return {
|
|
2940
|
+
type: "api",
|
|
2941
|
+
body: await mpesa.b2bExpressCheckout({
|
|
2942
|
+
primaryShortCode: body.primaryShortCode,
|
|
2943
|
+
receiverShortCode,
|
|
2944
|
+
amount: body.amount,
|
|
2945
|
+
paymentRef: body.paymentRef,
|
|
2946
|
+
callbackUrl,
|
|
2947
|
+
partnerName: body.partnerName,
|
|
2948
|
+
...body.requestRefId !== void 0 ? { requestRefId: body.requestRefId } : {}
|
|
2949
|
+
})
|
|
2950
|
+
};
|
|
2951
|
+
},
|
|
2952
|
+
"b2b.callback": async (ctx) => {
|
|
2953
|
+
await runWebhookGuard(ctx, config);
|
|
2954
|
+
const body = ctx.body;
|
|
2955
|
+
if (!isB2BCheckoutCallback(body)) {
|
|
2956
|
+
console.warn("[pesafy] Unknown B2B callback payload");
|
|
2957
|
+
return {
|
|
2958
|
+
type: "daraja",
|
|
2959
|
+
body: {
|
|
2960
|
+
ResultCode: 0,
|
|
2961
|
+
ResultDesc: "Accepted"
|
|
2962
|
+
}
|
|
2963
|
+
};
|
|
2964
|
+
}
|
|
2965
|
+
const callback = body;
|
|
2966
|
+
if (isB2BCheckoutSuccess(callback)) console.info("[pesafy] B2B checkout success:", {
|
|
2967
|
+
txId: getB2BTransactionId(callback),
|
|
2968
|
+
conversationId: getB2BConversationId(callback),
|
|
2969
|
+
amount: getB2BAmount(callback)
|
|
2970
|
+
});
|
|
2971
|
+
else if (isB2BCheckoutCancelled(callback)) console.warn("[pesafy] B2B checkout cancelled by merchant");
|
|
2972
|
+
else console.warn("[pesafy] B2B checkout failed:", callback.resultDesc);
|
|
2973
|
+
fireHook(config.onB2BCheckoutCallback ? () => Promise.resolve(config.onB2BCheckoutCallback(callback)) : void 0, "onB2BCheckoutCallback");
|
|
2974
|
+
return {
|
|
2975
|
+
type: "daraja",
|
|
2976
|
+
body: {
|
|
2977
|
+
ResultCode: 0,
|
|
2978
|
+
ResultDesc: "Accepted"
|
|
2979
|
+
}
|
|
2980
|
+
};
|
|
2981
|
+
},
|
|
2982
|
+
"b2c.payment": async (ctx) => {
|
|
2983
|
+
const body = ctx.body;
|
|
2984
|
+
if (body.commandId !== "BusinessPayToBulk") throw new PesafyError({
|
|
2985
|
+
code: "VALIDATION_ERROR",
|
|
2986
|
+
message: "commandId must be \"BusinessPayToBulk\""
|
|
2987
|
+
});
|
|
2988
|
+
if (!body.amount || body.amount <= 0) throw new PesafyError({
|
|
2989
|
+
code: "VALIDATION_ERROR",
|
|
2990
|
+
message: "amount must be > 0"
|
|
2991
|
+
});
|
|
2992
|
+
if (!body.partyB) throw new PesafyError({
|
|
2993
|
+
code: "VALIDATION_ERROR",
|
|
2994
|
+
message: "partyB is required"
|
|
2995
|
+
});
|
|
2996
|
+
if (!body.accountReference) throw new PesafyError({
|
|
2997
|
+
code: "VALIDATION_ERROR",
|
|
2998
|
+
message: "accountReference is required"
|
|
2999
|
+
});
|
|
3000
|
+
return {
|
|
3001
|
+
type: "api",
|
|
3002
|
+
body: await mpesa.b2cPayment({
|
|
3003
|
+
commandId: "BusinessPayToBulk",
|
|
3004
|
+
amount: body.amount,
|
|
3005
|
+
partyA: body.partyA ?? config.b2c?.partyA ?? "",
|
|
3006
|
+
partyB: body.partyB,
|
|
3007
|
+
accountReference: body.accountReference,
|
|
3008
|
+
resultUrl: resolveUrl(body.resultUrl, config.b2c?.resultUrl, config.resultUrl, "resultUrl"),
|
|
3009
|
+
queueTimeOutUrl: resolveUrl(body.queueTimeoutUrl, config.b2c?.queueTimeoutUrl, config.queueTimeoutUrl, "queueTimeoutUrl"),
|
|
3010
|
+
...body.requester !== void 0 ? { requester: body.requester } : {},
|
|
3011
|
+
...body.remarks !== void 0 ? { remarks: body.remarks } : {}
|
|
3012
|
+
})
|
|
3013
|
+
};
|
|
3014
|
+
},
|
|
3015
|
+
"b2c.result": async (ctx) => {
|
|
3016
|
+
await runWebhookGuard(ctx, config);
|
|
3017
|
+
const body = ctx.body;
|
|
3018
|
+
if (isB2CResult(body)) {
|
|
3019
|
+
if (isB2CSuccess(body)) console.info("[pesafy] B2C success:", {
|
|
3020
|
+
txId: getB2CTransactionId(body),
|
|
3021
|
+
amount: getB2CAmount(body),
|
|
3022
|
+
origConvId: getB2COriginatorConversationId(body)
|
|
3023
|
+
});
|
|
3024
|
+
else console.warn("[pesafy] B2C failed:", body.Result.ResultDesc);
|
|
3025
|
+
fireHook(config.onB2CResult ? () => Promise.resolve(config.onB2CResult(body)) : void 0, "onB2CResult");
|
|
3026
|
+
}
|
|
3027
|
+
return {
|
|
3028
|
+
type: "daraja",
|
|
3029
|
+
body: {
|
|
3030
|
+
ResultCode: 0,
|
|
3031
|
+
ResultDesc: "Accepted"
|
|
3032
|
+
}
|
|
3033
|
+
};
|
|
3034
|
+
},
|
|
3035
|
+
"b2c.disburse": async (ctx) => {
|
|
3036
|
+
const body = ctx.body;
|
|
3037
|
+
return {
|
|
3038
|
+
type: "api",
|
|
3039
|
+
body: await mpesa.b2cDisbursement({
|
|
3040
|
+
originatorConversationId: body.originatorConversationId,
|
|
3041
|
+
commandId: body.commandId,
|
|
3042
|
+
amount: body.amount,
|
|
3043
|
+
partyA: body.partyA,
|
|
3044
|
+
partyB: body.partyB,
|
|
3045
|
+
remarks: body.remarks,
|
|
3046
|
+
resultUrl: resolveUrl(body.resultUrl, config.b2c?.resultUrl, config.resultUrl, "resultUrl"),
|
|
3047
|
+
queueTimeOutUrl: resolveUrl(body.queueTimeoutUrl, config.b2c?.queueTimeoutUrl, config.queueTimeoutUrl, "queueTimeoutUrl"),
|
|
3048
|
+
...body.occasion !== void 0 ? { occasion: body.occasion } : {}
|
|
3049
|
+
})
|
|
3050
|
+
};
|
|
3051
|
+
},
|
|
3052
|
+
"b2c.disburse.result": async (ctx) => {
|
|
3053
|
+
await runWebhookGuard(ctx, config);
|
|
3054
|
+
const body = ctx.body;
|
|
3055
|
+
if (isB2CDisbursementResult(body)) {
|
|
3056
|
+
if (isB2CDisbursementSuccess(body)) console.info("[pesafy] B2C disbursement success:", body.Result.TransactionID);
|
|
3057
|
+
else console.warn("[pesafy] B2C disbursement failed:", body.Result.ResultDesc);
|
|
3058
|
+
fireHook(config.onB2CDisbursementResult ? () => Promise.resolve(config.onB2CDisbursementResult(body)) : void 0, "onB2CDisbursementResult");
|
|
3059
|
+
}
|
|
3060
|
+
return {
|
|
3061
|
+
type: "daraja",
|
|
3062
|
+
body: {
|
|
3063
|
+
ResultCode: 0,
|
|
3064
|
+
ResultDesc: "Accepted"
|
|
3065
|
+
}
|
|
3066
|
+
};
|
|
3067
|
+
},
|
|
3068
|
+
"bills.optin": async (ctx) => {
|
|
3069
|
+
return {
|
|
3070
|
+
type: "api",
|
|
3071
|
+
body: await mpesa.billManagerOptIn(ctx.body)
|
|
3072
|
+
};
|
|
3073
|
+
},
|
|
3074
|
+
"bills.optin.patch": async (ctx) => {
|
|
3075
|
+
return {
|
|
3076
|
+
type: "api",
|
|
3077
|
+
body: await mpesa.updateOptIn(ctx.body)
|
|
3078
|
+
};
|
|
3079
|
+
},
|
|
3080
|
+
"bills.invoice": async (ctx) => {
|
|
3081
|
+
return {
|
|
3082
|
+
type: "api",
|
|
3083
|
+
body: await mpesa.sendInvoice(ctx.body)
|
|
3084
|
+
};
|
|
3085
|
+
},
|
|
3086
|
+
"bills.invoice.delete": async (ctx) => {
|
|
3087
|
+
return {
|
|
3088
|
+
type: "api",
|
|
3089
|
+
body: await mpesa.cancelInvoice(ctx.body)
|
|
3090
|
+
};
|
|
3091
|
+
},
|
|
3092
|
+
"bills.invoice.bulk": async (ctx) => {
|
|
3093
|
+
return {
|
|
3094
|
+
type: "api",
|
|
3095
|
+
body: await mpesa.sendBulkInvoices(ctx.body)
|
|
3096
|
+
};
|
|
3097
|
+
},
|
|
3098
|
+
"bills.invoice.bulk.delete": async (ctx) => {
|
|
3099
|
+
return {
|
|
3100
|
+
type: "api",
|
|
3101
|
+
body: await mpesa.cancelBulkInvoices(ctx.body)
|
|
3102
|
+
};
|
|
3103
|
+
},
|
|
3104
|
+
"bills.reconcile": async (ctx) => {
|
|
3105
|
+
return {
|
|
3106
|
+
type: "api",
|
|
3107
|
+
body: await mpesa.reconcilePayment(ctx.body)
|
|
3108
|
+
};
|
|
3109
|
+
},
|
|
3110
|
+
health: async () => ({
|
|
3111
|
+
type: "api",
|
|
3112
|
+
body: {
|
|
3113
|
+
ok: true,
|
|
3114
|
+
environment: mpesa.environment,
|
|
3115
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
3116
|
+
}
|
|
3117
|
+
})
|
|
3118
|
+
};
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
//#endregion
|
|
3122
|
+
//#region src/adapters/shared/route-definitions.ts
|
|
3123
|
+
const ROUTE_DEFINITIONS = [
|
|
3124
|
+
{
|
|
3125
|
+
id: "stk.push",
|
|
3126
|
+
method: "POST",
|
|
3127
|
+
path: "/mpesa/stk/push"
|
|
3128
|
+
},
|
|
3129
|
+
{
|
|
3130
|
+
id: "stk.query",
|
|
3131
|
+
method: "POST",
|
|
3132
|
+
path: "/mpesa/stk/query"
|
|
3133
|
+
},
|
|
3134
|
+
{
|
|
3135
|
+
id: "stk.callback",
|
|
3136
|
+
method: "POST",
|
|
3137
|
+
path: "/mpesa/stk/callback",
|
|
3138
|
+
webhook: true
|
|
3139
|
+
},
|
|
3140
|
+
{
|
|
3141
|
+
id: "c2b.register",
|
|
3142
|
+
method: "POST",
|
|
3143
|
+
path: "/mpesa/c2b/register"
|
|
3144
|
+
},
|
|
3145
|
+
{
|
|
3146
|
+
id: "c2b.simulate",
|
|
3147
|
+
method: "POST",
|
|
3148
|
+
path: "/mpesa/c2b/simulate"
|
|
3149
|
+
},
|
|
3150
|
+
{
|
|
3151
|
+
id: "c2b.validation",
|
|
3152
|
+
method: "POST",
|
|
3153
|
+
path: "/mpesa/c2b/validation",
|
|
3154
|
+
webhook: true
|
|
3155
|
+
},
|
|
3156
|
+
{
|
|
3157
|
+
id: "c2b.confirmation",
|
|
3158
|
+
method: "POST",
|
|
3159
|
+
path: "/mpesa/c2b/confirmation",
|
|
3160
|
+
webhook: true
|
|
3161
|
+
},
|
|
3162
|
+
{
|
|
3163
|
+
id: "balance.query",
|
|
3164
|
+
method: "POST",
|
|
3165
|
+
path: "/mpesa/balance/query"
|
|
3166
|
+
},
|
|
3167
|
+
{
|
|
3168
|
+
id: "balance.result",
|
|
3169
|
+
method: "POST",
|
|
3170
|
+
path: "/mpesa/balance/result",
|
|
3171
|
+
webhook: true
|
|
3172
|
+
},
|
|
3173
|
+
{
|
|
3174
|
+
id: "qr.generate",
|
|
3175
|
+
method: "POST",
|
|
3176
|
+
path: "/mpesa/qr/generate"
|
|
3177
|
+
},
|
|
3178
|
+
{
|
|
3179
|
+
id: "reversal.request",
|
|
3180
|
+
method: "POST",
|
|
3181
|
+
path: "/mpesa/reversal/request"
|
|
3182
|
+
},
|
|
3183
|
+
{
|
|
3184
|
+
id: "reversal.result",
|
|
3185
|
+
method: "POST",
|
|
3186
|
+
path: "/mpesa/reversal/result",
|
|
3187
|
+
webhook: true
|
|
3188
|
+
},
|
|
3189
|
+
{
|
|
3190
|
+
id: "txStatus.query",
|
|
3191
|
+
method: "POST",
|
|
3192
|
+
path: "/mpesa/tx-status/query"
|
|
3193
|
+
},
|
|
3194
|
+
{
|
|
3195
|
+
id: "txStatus.result",
|
|
3196
|
+
method: "POST",
|
|
3197
|
+
path: "/mpesa/tx-status/result",
|
|
3198
|
+
webhook: true
|
|
3199
|
+
},
|
|
3200
|
+
{
|
|
3201
|
+
id: "tax.remit",
|
|
3202
|
+
method: "POST",
|
|
3203
|
+
path: "/mpesa/tax/remit"
|
|
3204
|
+
},
|
|
3205
|
+
{
|
|
3206
|
+
id: "tax.result",
|
|
3207
|
+
method: "POST",
|
|
3208
|
+
path: "/mpesa/tax/result",
|
|
3209
|
+
webhook: true
|
|
3210
|
+
},
|
|
3211
|
+
{
|
|
3212
|
+
id: "b2b.checkout",
|
|
3213
|
+
method: "POST",
|
|
3214
|
+
path: "/mpesa/b2b/checkout"
|
|
3215
|
+
},
|
|
3216
|
+
{
|
|
3217
|
+
id: "b2b.callback",
|
|
3218
|
+
method: "POST",
|
|
3219
|
+
path: "/mpesa/b2b/callback",
|
|
3220
|
+
webhook: true
|
|
3221
|
+
},
|
|
3222
|
+
{
|
|
3223
|
+
id: "b2c.payment",
|
|
3224
|
+
method: "POST",
|
|
3225
|
+
path: "/mpesa/b2c/payment"
|
|
3226
|
+
},
|
|
3227
|
+
{
|
|
3228
|
+
id: "b2c.result",
|
|
3229
|
+
method: "POST",
|
|
3230
|
+
path: "/mpesa/b2c/result",
|
|
3231
|
+
webhook: true
|
|
3232
|
+
},
|
|
3233
|
+
{
|
|
3234
|
+
id: "b2c.disburse",
|
|
3235
|
+
method: "POST",
|
|
3236
|
+
path: "/mpesa/b2c/disburse"
|
|
3237
|
+
},
|
|
3238
|
+
{
|
|
3239
|
+
id: "b2c.disburse.result",
|
|
3240
|
+
method: "POST",
|
|
3241
|
+
path: "/mpesa/b2c/disburse/result",
|
|
3242
|
+
webhook: true
|
|
3243
|
+
},
|
|
3244
|
+
{
|
|
3245
|
+
id: "bills.optin",
|
|
3246
|
+
method: "POST",
|
|
3247
|
+
path: "/mpesa/bills/optin"
|
|
3248
|
+
},
|
|
3249
|
+
{
|
|
3250
|
+
id: "bills.optin.patch",
|
|
3251
|
+
method: "PATCH",
|
|
3252
|
+
path: "/mpesa/bills/optin"
|
|
3253
|
+
},
|
|
3254
|
+
{
|
|
3255
|
+
id: "bills.invoice",
|
|
3256
|
+
method: "POST",
|
|
3257
|
+
path: "/mpesa/bills/invoice"
|
|
3258
|
+
},
|
|
3259
|
+
{
|
|
3260
|
+
id: "bills.invoice.delete",
|
|
3261
|
+
method: "DELETE",
|
|
3262
|
+
path: "/mpesa/bills/invoice"
|
|
3263
|
+
},
|
|
3264
|
+
{
|
|
3265
|
+
id: "bills.invoice.bulk",
|
|
3266
|
+
method: "POST",
|
|
3267
|
+
path: "/mpesa/bills/invoice/bulk"
|
|
3268
|
+
},
|
|
3269
|
+
{
|
|
3270
|
+
id: "bills.invoice.bulk.delete",
|
|
3271
|
+
method: "DELETE",
|
|
3272
|
+
path: "/mpesa/bills/invoice/bulk"
|
|
3273
|
+
},
|
|
3274
|
+
{
|
|
3275
|
+
id: "bills.reconcile",
|
|
3276
|
+
method: "POST",
|
|
3277
|
+
path: "/mpesa/bills/reconcile"
|
|
3278
|
+
},
|
|
3279
|
+
{
|
|
3280
|
+
id: "health",
|
|
3281
|
+
method: "GET",
|
|
3282
|
+
path: "/mpesa/health"
|
|
3283
|
+
}
|
|
3284
|
+
];
|
|
3285
|
+
/** Base paths (no routePrefix) for all adapter routes. */
|
|
3286
|
+
function getRoutePaths(prefix = "") {
|
|
3287
|
+
return ROUTE_DEFINITIONS.map((r) => `${prefix}${r.path}`);
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
//#endregion
|
|
3291
|
+
export { PesafyError as a, Mpesa as i, getRoutePaths as n, createRouteHandlers as r, ROUTE_DEFINITIONS as t };
|
|
3292
|
+
//# sourceMappingURL=route-definitions.js.map
|