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.
@@ -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