mpesa-mock 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,1407 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var src_exports = {};
32
+ __export(src_exports, {
33
+ InMemoryStore: () => InMemoryStore,
34
+ WebhookDispatcher: () => WebhookDispatcher,
35
+ createServer: () => createServer,
36
+ defaultConfig: () => defaultConfig,
37
+ pickScenario: () => pickScenario
38
+ });
39
+ module.exports = __toCommonJS(src_exports);
40
+
41
+ // src/server.ts
42
+ var import_hono11 = require("hono");
43
+ var import_logger = require("hono/logger");
44
+
45
+ // src/core/transactions.ts
46
+ var import_node_events = require("events");
47
+ var InMemoryStore = class extends import_node_events.EventEmitter {
48
+ byCheckout = /* @__PURE__ */ new Map();
49
+ byConversation = /* @__PURE__ */ new Map();
50
+ put(record) {
51
+ this.byCheckout.set(record.checkoutRequestID, record);
52
+ if (record.conversationID) {
53
+ this.byConversation.set(record.conversationID, record.checkoutRequestID);
54
+ }
55
+ this.emit("change", record);
56
+ }
57
+ get(checkoutRequestID) {
58
+ return this.byCheckout.get(checkoutRequestID);
59
+ }
60
+ getByConversationID(id) {
61
+ const key = this.byConversation.get(id);
62
+ return key ? this.byCheckout.get(key) : void 0;
63
+ }
64
+ list(limit) {
65
+ const all = Array.from(this.byCheckout.values()).sort((a, b) => b.createdAt - a.createdAt);
66
+ return typeof limit === "number" ? all.slice(0, limit) : all;
67
+ }
68
+ update(checkoutRequestID, patch) {
69
+ const existing = this.byCheckout.get(checkoutRequestID);
70
+ if (!existing) return void 0;
71
+ const next = { ...existing, ...patch };
72
+ this.byCheckout.set(checkoutRequestID, next);
73
+ if (next.conversationID) this.byConversation.set(next.conversationID, checkoutRequestID);
74
+ this.emit("change", next);
75
+ return next;
76
+ }
77
+ clear() {
78
+ this.byCheckout.clear();
79
+ this.byConversation.clear();
80
+ this.emit("clear");
81
+ }
82
+ };
83
+ function stateToResultCode(state) {
84
+ switch (state) {
85
+ case "success":
86
+ return 0;
87
+ case "insufficient_funds":
88
+ return 1;
89
+ case "user_cancelled":
90
+ return 1032;
91
+ case "wrong_pin":
92
+ return 2001;
93
+ case "expired":
94
+ return 1037;
95
+ case "system_error":
96
+ return 1025;
97
+ case "pending":
98
+ return 1019;
99
+ case "timeout":
100
+ return 1037;
101
+ }
102
+ }
103
+ function stateToResultDesc(state) {
104
+ switch (state) {
105
+ case "success":
106
+ return "The service request is processed successfully.";
107
+ case "insufficient_funds":
108
+ return "The balance is insufficient for the transaction.";
109
+ case "user_cancelled":
110
+ return "Request cancelled by user.";
111
+ case "wrong_pin":
112
+ return "The initiator information is invalid.";
113
+ case "expired":
114
+ return "DS timeout. User cannot be reached.";
115
+ case "system_error":
116
+ return "An error occurred while sending a push request.";
117
+ case "pending":
118
+ return "The transaction is being processed.";
119
+ case "timeout":
120
+ return "DS timeout. User cannot be reached.";
121
+ }
122
+ }
123
+
124
+ // src/core/webhook-dispatcher.ts
125
+ var import_node_events2 = require("events");
126
+ var import_p_retry = __toESM(require("p-retry"), 1);
127
+ var WebhookDispatcher = class extends import_node_events2.EventEmitter {
128
+ pending = /* @__PURE__ */ new Map();
129
+ store;
130
+ fetchImpl;
131
+ onLog;
132
+ constructor(opts = {}) {
133
+ super();
134
+ this.store = opts.store;
135
+ this.fetchImpl = opts.fetchImpl ?? fetch;
136
+ this.onLog = opts.onLog;
137
+ }
138
+ schedule(job, delayMs) {
139
+ if (delayMs < 0) {
140
+ this.emit("skipped", { id: job.id, reason: "timeout" });
141
+ return;
142
+ }
143
+ if (this.pending.has(job.id)) {
144
+ clearTimeout(this.pending.get(job.id));
145
+ }
146
+ const handle = setTimeout(() => {
147
+ this.pending.delete(job.id);
148
+ void this.fire(job);
149
+ }, delayMs);
150
+ this.pending.set(job.id, handle);
151
+ this.emit("scheduled", { id: job.id, delayMs, url: job.url });
152
+ }
153
+ cancel(id) {
154
+ const h = this.pending.get(id);
155
+ if (!h) return false;
156
+ clearTimeout(h);
157
+ this.pending.delete(id);
158
+ return true;
159
+ }
160
+ pendingIds() {
161
+ return Array.from(this.pending.keys());
162
+ }
163
+ async fire(job) {
164
+ let attempt = 0;
165
+ try {
166
+ await (0, import_p_retry.default)(
167
+ async () => {
168
+ attempt += 1;
169
+ if (typeof job.failNTimesFirst === "number" && attempt <= job.failNTimesFirst) {
170
+ this.onLog?.(`webhook attempt ${attempt} forced-fail for ${job.url}`);
171
+ throw new Error(`forced fail ${attempt}`);
172
+ }
173
+ const res = await this.fetchImpl(job.url, {
174
+ method: "POST",
175
+ headers: { "content-type": "application/json" },
176
+ body: JSON.stringify(job.body)
177
+ });
178
+ if (!res.ok && res.status >= 500) {
179
+ throw new Error(`callback ${res.status}`);
180
+ }
181
+ if (!res.ok && res.status >= 400) {
182
+ throw new import_p_retry.AbortError(`callback ${res.status}`);
183
+ }
184
+ this.emit("delivered", { id: job.id, url: job.url, attempts: attempt });
185
+ if (this.store && job.transactionId) {
186
+ this.store.update(job.transactionId, {
187
+ callbackAttempts: attempt,
188
+ callbackDeliveredAt: Date.now()
189
+ });
190
+ }
191
+ },
192
+ {
193
+ retries: job.maxAttempts - 1,
194
+ minTimeout: job.backoffMs,
195
+ factor: 2,
196
+ onFailedAttempt: (err) => {
197
+ this.onLog?.(`webhook ${job.url} attempt ${err.attemptNumber} failed: ${err.message}`);
198
+ }
199
+ }
200
+ );
201
+ } catch (err) {
202
+ this.emit("failed", {
203
+ id: job.id,
204
+ url: job.url,
205
+ attempts: attempt,
206
+ error: err instanceof Error ? err.message : String(err)
207
+ });
208
+ if (this.store && job.transactionId) {
209
+ this.store.update(job.transactionId, { callbackAttempts: attempt });
210
+ }
211
+ }
212
+ }
213
+ shutdown() {
214
+ for (const handle of this.pending.values()) clearTimeout(handle);
215
+ this.pending.clear();
216
+ }
217
+ };
218
+
219
+ // src/routes/oauth.ts
220
+ var import_hono = require("hono");
221
+
222
+ // src/core/id-generator.ts
223
+ var import_node_crypto = require("crypto");
224
+ function generateAccessToken() {
225
+ return (0, import_node_crypto.randomBytes)(24).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 32).padEnd(32, "0");
226
+ }
227
+ function generateMerchantRequestID() {
228
+ const a = (0, import_node_crypto.randomInt)(1e4, 99999);
229
+ const b = (0, import_node_crypto.randomInt)(1e7, 99999999);
230
+ const c = (0, import_node_crypto.randomInt)(1, 9);
231
+ return `${a}-${b}-${c}`;
232
+ }
233
+ function generateCheckoutRequestID(date = /* @__PURE__ */ new Date()) {
234
+ const pad = (n, len = 2) => String(n).padStart(len, "0");
235
+ const dd = pad(date.getDate());
236
+ const mm = pad(date.getMonth() + 1);
237
+ const yyyy = String(date.getFullYear());
238
+ const hh = pad(date.getHours());
239
+ const mi = pad(date.getMinutes());
240
+ const ss = pad(date.getSeconds());
241
+ const ms = pad(date.getMilliseconds(), 3);
242
+ return `ws_CO_${dd}${mm}${yyyy}${hh}${mi}${ss}${ms}`;
243
+ }
244
+ function generateConversationID() {
245
+ const a = (0, import_node_crypto.randomInt)(1e3, 9999);
246
+ const b = (0, import_node_crypto.randomInt)(1e5, 999999);
247
+ const c = (0, import_node_crypto.randomInt)(10, 99);
248
+ return `AG_${formatDate(/* @__PURE__ */ new Date())}_${a}${b}${c}`;
249
+ }
250
+ function generateOriginatorConversationID() {
251
+ const a = (0, import_node_crypto.randomInt)(1e4, 99999);
252
+ const b = (0, import_node_crypto.randomInt)(1e6, 9999999);
253
+ const c = (0, import_node_crypto.randomInt)(1, 9);
254
+ return `${a}-${b}-${c}`;
255
+ }
256
+ function generateMpesaReceiptNumber() {
257
+ const chars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
258
+ let out = "";
259
+ for (let i = 0; i < 10; i++) {
260
+ out += chars[(0, import_node_crypto.randomInt)(0, chars.length)];
261
+ }
262
+ return out;
263
+ }
264
+ function generateTransactionDate(date = /* @__PURE__ */ new Date()) {
265
+ const pad = (n) => String(n).padStart(2, "0");
266
+ const yyyy = date.getFullYear();
267
+ const mm = pad(date.getMonth() + 1);
268
+ const dd = pad(date.getDate());
269
+ const hh = pad(date.getHours());
270
+ const mi = pad(date.getMinutes());
271
+ const ss = pad(date.getSeconds());
272
+ return Number(`${yyyy}${mm}${dd}${hh}${mi}${ss}`);
273
+ }
274
+ function formatDate(d) {
275
+ const pad = (n) => String(n).padStart(2, "0");
276
+ return `${pad(d.getDate())}${pad(d.getMonth() + 1)}${d.getFullYear()}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
277
+ }
278
+
279
+ // src/config/defaults.ts
280
+ var DEFAULTS = {
281
+ port: 4e3,
282
+ host: "0.0.0.0",
283
+ callbackDelayMs: 8e3,
284
+ callbackRetryAttempts: 3,
285
+ callbackRetryBackoffMs: 1e3,
286
+ oauthTokenExpiresIn: "3599",
287
+ testCredentials: {
288
+ consumerKey: "test_key",
289
+ consumerSecret: "test_secret"
290
+ },
291
+ testShortcode: "174379",
292
+ testTill: "600000",
293
+ partyB: "254708374149"
294
+ };
295
+
296
+ // src/core/auth.ts
297
+ var issuedTokens = /* @__PURE__ */ new Map();
298
+ var TOKEN_TTL_MS = 3599 * 1e3;
299
+ function parseBasicAuth(header) {
300
+ if (!header || !header.toLowerCase().startsWith("basic ")) return null;
301
+ const encoded = header.slice(6).trim();
302
+ let decoded;
303
+ try {
304
+ decoded = Buffer.from(encoded, "base64").toString("utf-8");
305
+ } catch {
306
+ return null;
307
+ }
308
+ const idx = decoded.indexOf(":");
309
+ if (idx < 0) return null;
310
+ const key = decoded.slice(0, idx);
311
+ const secret = decoded.slice(idx + 1);
312
+ if (!key || !secret) return null;
313
+ return { key, secret };
314
+ }
315
+ function issueToken() {
316
+ const token = generateAccessToken();
317
+ issuedTokens.set(token, Date.now() + TOKEN_TTL_MS);
318
+ return { access_token: token, expires_in: DEFAULTS.oauthTokenExpiresIn };
319
+ }
320
+ function isUsingTestCredentials(key, secret) {
321
+ return key === DEFAULTS.testCredentials.consumerKey && secret === DEFAULTS.testCredentials.consumerSecret;
322
+ }
323
+ function parseBearerToken(header) {
324
+ if (!header) return null;
325
+ const m = header.match(/^Bearer\s+(.+)$/i);
326
+ return m && m[1] ? m[1].trim() : null;
327
+ }
328
+ function isValidToken(token) {
329
+ const exp = issuedTokens.get(token);
330
+ if (!exp) {
331
+ return /^[a-zA-Z0-9]{20,}$/.test(token);
332
+ }
333
+ if (Date.now() > exp) {
334
+ issuedTokens.delete(token);
335
+ return false;
336
+ }
337
+ return true;
338
+ }
339
+
340
+ // src/routes/oauth.ts
341
+ function oauthRoute() {
342
+ const app = new import_hono.Hono();
343
+ app.get("/oauth/v1/generate", (c) => {
344
+ const grantType = c.req.query("grant_type");
345
+ if (grantType !== "client_credentials") {
346
+ return c.json(
347
+ { requestId: "no-request-id", errorCode: "400.001.01", errorMessage: "Invalid grant_type" },
348
+ 400
349
+ );
350
+ }
351
+ const parsed = parseBasicAuth(c.req.header("authorization"));
352
+ if (!parsed) {
353
+ return c.json(
354
+ { requestId: "no-request-id", errorCode: "401.002.01", errorMessage: "Invalid Authentication passed" },
355
+ 401
356
+ );
357
+ }
358
+ if (isUsingTestCredentials(parsed.key, parsed.secret)) {
359
+ c.get("log")?.("warn: using default test credentials (test_key:test_secret) \u2014 fine for mock");
360
+ }
361
+ return c.json(issueToken());
362
+ });
363
+ return app;
364
+ }
365
+
366
+ // src/routes/stk-push.ts
367
+ var import_hono2 = require("hono");
368
+
369
+ // src/schemas/index.ts
370
+ var import_zod = require("zod");
371
+ var stkPushSchema = import_zod.z.object({
372
+ BusinessShortCode: import_zod.z.string().min(1),
373
+ Password: import_zod.z.string().min(1),
374
+ Timestamp: import_zod.z.string().min(1),
375
+ TransactionType: import_zod.z.enum(["CustomerPayBillOnline", "CustomerBuyGoodsOnline"]),
376
+ Amount: import_zod.z.number().int().positive(),
377
+ PartyA: import_zod.z.string().min(1),
378
+ PartyB: import_zod.z.string().min(1),
379
+ PhoneNumber: import_zod.z.string().min(1),
380
+ CallBackURL: import_zod.z.string().url(),
381
+ AccountReference: import_zod.z.string().min(1).max(12),
382
+ TransactionDesc: import_zod.z.string().min(1).max(13)
383
+ });
384
+ var stkQuerySchema = import_zod.z.object({
385
+ BusinessShortCode: import_zod.z.string().min(1),
386
+ Password: import_zod.z.string().min(1),
387
+ Timestamp: import_zod.z.string().min(1),
388
+ CheckoutRequestID: import_zod.z.string().min(1)
389
+ });
390
+ var c2bRegisterUrlSchema = import_zod.z.object({
391
+ ShortCode: import_zod.z.string().min(1),
392
+ ResponseType: import_zod.z.enum(["Completed", "Cancelled"]),
393
+ ConfirmationURL: import_zod.z.string().url(),
394
+ ValidationURL: import_zod.z.string().url()
395
+ });
396
+ var c2bSimulateSchema = import_zod.z.object({
397
+ ShortCode: import_zod.z.string().min(1),
398
+ CommandID: import_zod.z.enum(["CustomerPayBillOnline", "CustomerBuyGoodsOnline"]),
399
+ Amount: import_zod.z.number().int().positive(),
400
+ Msisdn: import_zod.z.string().min(1),
401
+ BillRefNumber: import_zod.z.string().min(1)
402
+ });
403
+ var b2cSchema = import_zod.z.object({
404
+ InitiatorName: import_zod.z.string().min(1),
405
+ SecurityCredential: import_zod.z.string().min(1),
406
+ CommandID: import_zod.z.enum(["SalaryPayment", "BusinessPayment", "PromotionPayment"]),
407
+ Amount: import_zod.z.number().int().positive(),
408
+ PartyA: import_zod.z.string().min(1),
409
+ PartyB: import_zod.z.string().min(1),
410
+ Remarks: import_zod.z.string().min(1),
411
+ QueueTimeOutURL: import_zod.z.string().url(),
412
+ ResultURL: import_zod.z.string().url(),
413
+ Occasion: import_zod.z.string().optional().default("")
414
+ });
415
+ var b2bSchema = import_zod.z.object({
416
+ Initiator: import_zod.z.string().min(1),
417
+ SecurityCredential: import_zod.z.string().min(1),
418
+ CommandID: import_zod.z.string().min(1),
419
+ SenderIdentifierType: import_zod.z.string().min(1),
420
+ RecieverIdentifierType: import_zod.z.string().min(1),
421
+ Amount: import_zod.z.number().int().positive(),
422
+ PartyA: import_zod.z.string().min(1),
423
+ PartyB: import_zod.z.string().min(1),
424
+ AccountReference: import_zod.z.string().optional().default(""),
425
+ Remarks: import_zod.z.string().min(1),
426
+ QueueTimeOutURL: import_zod.z.string().url(),
427
+ ResultURL: import_zod.z.string().url()
428
+ });
429
+ var transactionStatusSchema = import_zod.z.object({
430
+ Initiator: import_zod.z.string().min(1),
431
+ SecurityCredential: import_zod.z.string().min(1),
432
+ CommandID: import_zod.z.literal("TransactionStatusQuery"),
433
+ TransactionID: import_zod.z.string().min(1),
434
+ PartyA: import_zod.z.string().min(1),
435
+ IdentifierType: import_zod.z.string().min(1),
436
+ ResultURL: import_zod.z.string().url(),
437
+ QueueTimeOutURL: import_zod.z.string().url(),
438
+ Remarks: import_zod.z.string().min(1),
439
+ Occasion: import_zod.z.string().optional().default("")
440
+ });
441
+ var accountBalanceSchema = import_zod.z.object({
442
+ Initiator: import_zod.z.string().min(1),
443
+ SecurityCredential: import_zod.z.string().min(1),
444
+ CommandID: import_zod.z.literal("AccountBalance"),
445
+ PartyA: import_zod.z.string().min(1),
446
+ IdentifierType: import_zod.z.string().min(1),
447
+ Remarks: import_zod.z.string().min(1),
448
+ QueueTimeOutURL: import_zod.z.string().url(),
449
+ ResultURL: import_zod.z.string().url()
450
+ });
451
+ var reversalSchema = import_zod.z.object({
452
+ Initiator: import_zod.z.string().min(1),
453
+ SecurityCredential: import_zod.z.string().min(1),
454
+ CommandID: import_zod.z.literal("TransactionReversal"),
455
+ TransactionID: import_zod.z.string().min(1),
456
+ Amount: import_zod.z.number().int().positive(),
457
+ ReceiverParty: import_zod.z.string().min(1),
458
+ RecieverIdentifierType: import_zod.z.string().min(1),
459
+ ResultURL: import_zod.z.string().url(),
460
+ QueueTimeOutURL: import_zod.z.string().url(),
461
+ Remarks: import_zod.z.string().min(1),
462
+ Occasion: import_zod.z.string().optional().default("")
463
+ });
464
+
465
+ // src/core/failure-injector.ts
466
+ var SUFFIX_MAP = {
467
+ "00": "success",
468
+ "01": "user_cancelled",
469
+ "02": "insufficient_funds",
470
+ "03": "wrong_pin",
471
+ "04": "timeout",
472
+ "05": "callback_retry",
473
+ "06": "expired",
474
+ "07": "system_error",
475
+ "99": "slow"
476
+ };
477
+ function pickScenario(phoneNumber, config) {
478
+ const direct = config.scenarios[phoneNumber];
479
+ if (direct) return direct;
480
+ const suffix = phoneNumber.slice(-2);
481
+ return SUFFIX_MAP[suffix] ?? "success";
482
+ }
483
+ function callbackDelayFor(scenario, config) {
484
+ if (scenario === "slow") return 3e4;
485
+ if (scenario === "timeout") return -1;
486
+ return config.defaultCallbackDelayMs;
487
+ }
488
+
489
+ // src/routes/stk-push.ts
490
+ function scenarioToState(s) {
491
+ switch (s) {
492
+ case "success":
493
+ case "callback_retry":
494
+ case "slow":
495
+ return "success";
496
+ case "user_cancelled":
497
+ return "user_cancelled";
498
+ case "insufficient_funds":
499
+ return "insufficient_funds";
500
+ case "wrong_pin":
501
+ return "wrong_pin";
502
+ case "expired":
503
+ return "expired";
504
+ case "system_error":
505
+ return "system_error";
506
+ case "timeout":
507
+ return "timeout";
508
+ }
509
+ }
510
+ function stkPushRoute() {
511
+ const app = new import_hono2.Hono();
512
+ app.post("/mpesa/stkpush/v1/processrequest", async (c) => {
513
+ const token = parseBearerToken(c.req.header("authorization"));
514
+ if (!token || !isValidToken(token)) {
515
+ return c.json({ errorCode: "404.001.03", errorMessage: "Invalid Access Token" }, 401);
516
+ }
517
+ const raw = await c.req.json().catch(() => null);
518
+ const parsed = stkPushSchema.safeParse(raw);
519
+ if (!parsed.success) {
520
+ return c.json(
521
+ {
522
+ requestId: "no-request-id",
523
+ errorCode: "400.002.05",
524
+ errorMessage: "Invalid request payload",
525
+ errors: parsed.error.flatten()
526
+ },
527
+ 400
528
+ );
529
+ }
530
+ const body = parsed.data;
531
+ const merchantRequestID = generateMerchantRequestID();
532
+ const checkoutRequestID = generateCheckoutRequestID();
533
+ const scenario = pickScenario(body.PhoneNumber, c.var.config);
534
+ const targetState = scenarioToState(scenario);
535
+ const delay = callbackDelayFor(scenario, c.var.config);
536
+ const record = {
537
+ checkoutRequestID,
538
+ merchantRequestID,
539
+ kind: "stk",
540
+ amount: body.Amount,
541
+ phoneNumber: body.PhoneNumber,
542
+ shortCode: body.BusinessShortCode,
543
+ callbackUrl: body.CallBackURL,
544
+ state: "pending",
545
+ createdAt: Date.now(),
546
+ callbackAttempts: 0
547
+ };
548
+ c.var.store.put(record);
549
+ const failNTimesFirst = scenario === "callback_retry" ? 3 : void 0;
550
+ const callbackBody = buildStkCallback({
551
+ merchantRequestID,
552
+ checkoutRequestID,
553
+ state: targetState,
554
+ amount: body.Amount,
555
+ phoneNumber: body.PhoneNumber
556
+ });
557
+ if (scenario === "timeout") {
558
+ c.var.log?.(`stk-push ${checkoutRequestID}: timeout scenario, no callback will fire`);
559
+ } else {
560
+ c.var.dispatcher.schedule(
561
+ {
562
+ id: checkoutRequestID,
563
+ url: body.CallBackURL,
564
+ body: callbackBody,
565
+ scheduledAt: Date.now() + delay,
566
+ attempts: 0,
567
+ maxAttempts: c.var.config.webhookRetry.attempts + (failNTimesFirst ?? 0),
568
+ backoffMs: c.var.config.webhookRetry.backoffMs,
569
+ transactionId: checkoutRequestID,
570
+ ...failNTimesFirst !== void 0 ? { failNTimesFirst } : {}
571
+ },
572
+ delay
573
+ );
574
+ }
575
+ setTimeout(() => {
576
+ const cur = c.var.store.get(checkoutRequestID);
577
+ if (cur && cur.state === "pending") {
578
+ c.var.store.update(checkoutRequestID, {
579
+ state: targetState,
580
+ resultCode: stateToResultCode(targetState),
581
+ resultDesc: stateToResultDesc(targetState),
582
+ completedAt: Date.now(),
583
+ ...targetState === "success" ? { mpesaReceiptNumber: generateMpesaReceiptNumber() } : {}
584
+ });
585
+ }
586
+ }, Math.max(0, delay));
587
+ return c.json({
588
+ MerchantRequestID: merchantRequestID,
589
+ CheckoutRequestID: checkoutRequestID,
590
+ ResponseCode: "0",
591
+ ResponseDescription: "Success. Request accepted for processing",
592
+ CustomerMessage: "Success. Request accepted for processing"
593
+ });
594
+ });
595
+ return app;
596
+ }
597
+ function buildStkCallback(params) {
598
+ const resultCode = stateToResultCode(params.state);
599
+ const resultDesc = stateToResultDesc(params.state);
600
+ if (params.state === "success") {
601
+ return {
602
+ Body: {
603
+ stkCallback: {
604
+ MerchantRequestID: params.merchantRequestID,
605
+ CheckoutRequestID: params.checkoutRequestID,
606
+ ResultCode: resultCode,
607
+ ResultDesc: resultDesc,
608
+ CallbackMetadata: {
609
+ Item: [
610
+ { Name: "Amount", Value: params.amount },
611
+ { Name: "MpesaReceiptNumber", Value: generateMpesaReceiptNumber() },
612
+ { Name: "TransactionDate", Value: generateTransactionDate() },
613
+ { Name: "PhoneNumber", Value: Number(params.phoneNumber) }
614
+ ]
615
+ }
616
+ }
617
+ }
618
+ };
619
+ }
620
+ return {
621
+ Body: {
622
+ stkCallback: {
623
+ MerchantRequestID: params.merchantRequestID,
624
+ CheckoutRequestID: params.checkoutRequestID,
625
+ ResultCode: resultCode,
626
+ ResultDesc: resultDesc
627
+ }
628
+ }
629
+ };
630
+ }
631
+
632
+ // src/routes/stk-query.ts
633
+ var import_hono3 = require("hono");
634
+ function stkQueryRoute() {
635
+ const app = new import_hono3.Hono();
636
+ app.post("/mpesa/stkpushquery/v1/query", async (c) => {
637
+ const token = parseBearerToken(c.req.header("authorization"));
638
+ if (!token || !isValidToken(token)) {
639
+ return c.json({ errorCode: "404.001.03", errorMessage: "Invalid Access Token" }, 401);
640
+ }
641
+ const raw = await c.req.json().catch(() => null);
642
+ const parsed = stkQuerySchema.safeParse(raw);
643
+ if (!parsed.success) {
644
+ return c.json(
645
+ { errorCode: "400.002.05", errorMessage: "Invalid request payload", errors: parsed.error.flatten() },
646
+ 400
647
+ );
648
+ }
649
+ const record = c.var.store.get(parsed.data.CheckoutRequestID);
650
+ if (!record) {
651
+ return c.json(
652
+ {
653
+ requestId: "no-request-id",
654
+ errorCode: "500.001.1001",
655
+ errorMessage: "The transaction is being processed"
656
+ },
657
+ 500
658
+ );
659
+ }
660
+ const resultCode = record.resultCode ?? stateToResultCode(record.state);
661
+ return c.json({
662
+ ResponseCode: "0",
663
+ ResponseDescription: "The service request has been accepted successfully",
664
+ MerchantRequestID: record.merchantRequestID,
665
+ CheckoutRequestID: record.checkoutRequestID,
666
+ ResultCode: String(resultCode),
667
+ ResultDesc: record.resultDesc ?? stateToResultDesc(record.state)
668
+ });
669
+ });
670
+ return app;
671
+ }
672
+
673
+ // src/routes/c2b.ts
674
+ var import_hono4 = require("hono");
675
+ var registry = /* @__PURE__ */ new Map();
676
+ function c2bRoute() {
677
+ const app = new import_hono4.Hono();
678
+ app.post("/mpesa/c2b/v1/registerurl", async (c) => {
679
+ const token = parseBearerToken(c.req.header("authorization"));
680
+ if (!token || !isValidToken(token)) {
681
+ return c.json({ errorCode: "404.001.03", errorMessage: "Invalid Access Token" }, 401);
682
+ }
683
+ const raw = await c.req.json().catch(() => null);
684
+ const parsed = c2bRegisterUrlSchema.safeParse(raw);
685
+ if (!parsed.success) {
686
+ return c.json(
687
+ { errorCode: "400.002.05", errorMessage: "Invalid request payload", errors: parsed.error.flatten() },
688
+ 400
689
+ );
690
+ }
691
+ registry.set(parsed.data.ShortCode, {
692
+ shortCode: parsed.data.ShortCode,
693
+ confirmationURL: parsed.data.ConfirmationURL,
694
+ validationURL: parsed.data.ValidationURL,
695
+ responseType: parsed.data.ResponseType
696
+ });
697
+ return c.json({
698
+ OriginatorCoversationID: generateOriginatorConversationID(),
699
+ ResponseCode: "0",
700
+ ResponseDescription: "success"
701
+ });
702
+ });
703
+ app.post("/mpesa/c2b/v1/simulate", async (c) => {
704
+ const token = parseBearerToken(c.req.header("authorization"));
705
+ if (!token || !isValidToken(token)) {
706
+ return c.json({ errorCode: "404.001.03", errorMessage: "Invalid Access Token" }, 401);
707
+ }
708
+ const raw = await c.req.json().catch(() => null);
709
+ const parsed = c2bSimulateSchema.safeParse(raw);
710
+ if (!parsed.success) {
711
+ return c.json(
712
+ { errorCode: "400.002.05", errorMessage: "Invalid request payload", errors: parsed.error.flatten() },
713
+ 400
714
+ );
715
+ }
716
+ const result = await runC2BSimulation(c, parsed.data.ShortCode, parsed.data.Amount, parsed.data.Msisdn, parsed.data.BillRefNumber);
717
+ if (result.kind === "no-registration") {
718
+ return c.json(
719
+ { errorCode: "500.001.1001", errorMessage: "No registered URLs found for this shortcode" },
720
+ 500
721
+ );
722
+ }
723
+ return c.json({
724
+ OriginatorCoversationID: result.originatorConversationID,
725
+ ConversationID: result.conversationID,
726
+ ResponseDescription: "Accept the service request successfully."
727
+ });
728
+ });
729
+ app.post("/__mock__/c2b/trigger", async (c) => {
730
+ const raw = await c.req.json().catch(() => null);
731
+ if (!raw) {
732
+ return c.json({ error: "invalid body" }, 400);
733
+ }
734
+ const shortCode = String(raw.ShortCode ?? raw.shortCode ?? "");
735
+ const amount = Number(raw.Amount ?? raw.amount ?? 0);
736
+ const msisdn = String(raw.Msisdn ?? raw.msisdn ?? "");
737
+ const billRef = String(raw.BillRefNumber ?? raw.billRefNumber ?? "TEST");
738
+ if (!shortCode || !amount || !msisdn) {
739
+ return c.json({ error: "ShortCode, Amount, Msisdn required" }, 400);
740
+ }
741
+ const result = await runC2BSimulation(c, shortCode, amount, msisdn, billRef);
742
+ return c.json(result, result.kind === "no-registration" ? 404 : 200);
743
+ });
744
+ return app;
745
+ }
746
+ async function runC2BSimulation(c, shortCode, amount, msisdn, billRef) {
747
+ const reg = registry.get(shortCode);
748
+ if (!reg) return { kind: "no-registration" };
749
+ const conversationID = generateConversationID();
750
+ const originatorConversationID = generateOriginatorConversationID();
751
+ const receipt = generateMpesaReceiptNumber();
752
+ const transactionDate = generateTransactionDate();
753
+ const txn = {
754
+ checkoutRequestID: conversationID,
755
+ merchantRequestID: originatorConversationID,
756
+ conversationID,
757
+ originatorConversationID,
758
+ kind: "c2b",
759
+ amount,
760
+ phoneNumber: msisdn,
761
+ shortCode,
762
+ callbackUrl: reg.confirmationURL,
763
+ state: "pending",
764
+ createdAt: Date.now(),
765
+ callbackAttempts: 0,
766
+ mpesaReceiptNumber: receipt
767
+ };
768
+ c.var.store.put(txn);
769
+ const callbackPayload = {
770
+ TransactionType: "Pay Bill",
771
+ TransID: receipt,
772
+ TransTime: String(transactionDate),
773
+ TransAmount: String(amount),
774
+ BusinessShortCode: shortCode,
775
+ BillRefNumber: billRef,
776
+ InvoiceNumber: "",
777
+ OrgAccountBalance: "0.00",
778
+ ThirdPartyTransID: "",
779
+ MSISDN: msisdn,
780
+ FirstName: "Test",
781
+ MiddleName: "C2B",
782
+ LastName: "Customer"
783
+ };
784
+ let validationOk = true;
785
+ try {
786
+ const res = await fetch(reg.validationURL, {
787
+ method: "POST",
788
+ headers: { "content-type": "application/json" },
789
+ body: JSON.stringify(callbackPayload)
790
+ });
791
+ if (res.ok) {
792
+ const body = await res.json().catch(() => ({}));
793
+ if (body.ResultCode && body.ResultCode !== "0") validationOk = false;
794
+ }
795
+ } catch {
796
+ validationOk = true;
797
+ }
798
+ if (!validationOk) {
799
+ c.var.store.update(conversationID, { state: "user_cancelled", resultCode: 1, resultDesc: "Validation rejected", completedAt: Date.now() });
800
+ return { kind: "rejected", originatorConversationID, conversationID };
801
+ }
802
+ c.var.dispatcher.schedule(
803
+ {
804
+ id: conversationID,
805
+ url: reg.confirmationURL,
806
+ body: callbackPayload,
807
+ scheduledAt: Date.now(),
808
+ attempts: 0,
809
+ maxAttempts: c.var.config.webhookRetry.attempts,
810
+ backoffMs: c.var.config.webhookRetry.backoffMs,
811
+ transactionId: conversationID
812
+ },
813
+ 0
814
+ );
815
+ c.var.store.update(conversationID, { state: "success", resultCode: 0, resultDesc: "Success", completedAt: Date.now() });
816
+ return { kind: "delivered", originatorConversationID, conversationID };
817
+ }
818
+
819
+ // src/routes/b2c.ts
820
+ var import_hono5 = require("hono");
821
+ function b2cRoute() {
822
+ const app = new import_hono5.Hono();
823
+ app.post("/mpesa/b2c/v1/paymentrequest", async (c) => {
824
+ const token = parseBearerToken(c.req.header("authorization"));
825
+ if (!token || !isValidToken(token)) {
826
+ return c.json({ errorCode: "404.001.03", errorMessage: "Invalid Access Token" }, 401);
827
+ }
828
+ const raw = await c.req.json().catch(() => null);
829
+ const parsed = b2cSchema.safeParse(raw);
830
+ if (!parsed.success) {
831
+ return c.json(
832
+ { errorCode: "400.002.05", errorMessage: "Invalid request payload", errors: parsed.error.flatten() },
833
+ 400
834
+ );
835
+ }
836
+ const body = parsed.data;
837
+ const conversationID = generateConversationID();
838
+ const originatorConversationID = generateOriginatorConversationID();
839
+ const scenario = pickScenario(body.PartyB, c.var.config);
840
+ const isSuccess = scenario === "success" || scenario === "slow" || scenario === "callback_retry";
841
+ const record = {
842
+ checkoutRequestID: conversationID,
843
+ merchantRequestID: originatorConversationID,
844
+ conversationID,
845
+ originatorConversationID,
846
+ kind: "b2c",
847
+ amount: body.Amount,
848
+ phoneNumber: body.PartyB,
849
+ shortCode: body.PartyA,
850
+ callbackUrl: body.ResultURL,
851
+ state: isSuccess ? "success" : "system_error",
852
+ createdAt: Date.now(),
853
+ callbackAttempts: 0,
854
+ ...isSuccess ? { mpesaReceiptNumber: generateMpesaReceiptNumber() } : {}
855
+ };
856
+ c.var.store.put(record);
857
+ const callback = {
858
+ Result: {
859
+ ResultType: 0,
860
+ ResultCode: isSuccess ? 0 : 2001,
861
+ ResultDesc: isSuccess ? "The service request is processed successfully." : "The initiator information is invalid.",
862
+ OriginatorConversationID: originatorConversationID,
863
+ ConversationID: conversationID,
864
+ TransactionID: record.mpesaReceiptNumber ?? "N/A",
865
+ ResultParameters: {
866
+ ResultParameter: isSuccess ? [
867
+ { Key: "TransactionAmount", Value: body.Amount },
868
+ { Key: "TransactionReceipt", Value: record.mpesaReceiptNumber ?? "" },
869
+ { Key: "B2CRecipientIsRegisteredCustomer", Value: "Y" },
870
+ { Key: "B2CChargesPaidAccountAvailableFunds", Value: 0 },
871
+ { Key: "ReceiverPartyPublicName", Value: `${body.PartyB} - Test Recipient` },
872
+ { Key: "TransactionCompletedDateTime", Value: (/* @__PURE__ */ new Date()).toISOString() },
873
+ { Key: "B2CUtilityAccountAvailableFunds", Value: 1e6 },
874
+ { Key: "B2CWorkingAccountAvailableFunds", Value: 1e6 }
875
+ ] : []
876
+ },
877
+ ReferenceData: {
878
+ ReferenceItem: { Key: "QueueTimeoutURL", Value: body.QueueTimeOutURL }
879
+ }
880
+ }
881
+ };
882
+ c.var.dispatcher.schedule(
883
+ {
884
+ id: conversationID,
885
+ url: body.ResultURL,
886
+ body: callback,
887
+ scheduledAt: Date.now() + c.var.config.defaultCallbackDelayMs,
888
+ attempts: 0,
889
+ maxAttempts: c.var.config.webhookRetry.attempts,
890
+ backoffMs: c.var.config.webhookRetry.backoffMs,
891
+ transactionId: conversationID
892
+ },
893
+ c.var.config.defaultCallbackDelayMs
894
+ );
895
+ return c.json({
896
+ ConversationID: conversationID,
897
+ OriginatorConversationID: originatorConversationID,
898
+ ResponseCode: "0",
899
+ ResponseDescription: "Accept the service request successfully."
900
+ });
901
+ });
902
+ return app;
903
+ }
904
+
905
+ // src/routes/b2b.ts
906
+ var import_hono6 = require("hono");
907
+ function b2bRoute() {
908
+ const app = new import_hono6.Hono();
909
+ app.post("/mpesa/b2b/v1/paymentrequest", async (c) => {
910
+ const token = parseBearerToken(c.req.header("authorization"));
911
+ if (!token || !isValidToken(token)) {
912
+ return c.json({ errorCode: "404.001.03", errorMessage: "Invalid Access Token" }, 401);
913
+ }
914
+ const raw = await c.req.json().catch(() => null);
915
+ const parsed = b2bSchema.safeParse(raw);
916
+ if (!parsed.success) {
917
+ return c.json(
918
+ { errorCode: "400.002.05", errorMessage: "Invalid request payload", errors: parsed.error.flatten() },
919
+ 400
920
+ );
921
+ }
922
+ const body = parsed.data;
923
+ const conversationID = generateConversationID();
924
+ const originatorConversationID = generateOriginatorConversationID();
925
+ const receipt = generateMpesaReceiptNumber();
926
+ const record = {
927
+ checkoutRequestID: conversationID,
928
+ merchantRequestID: originatorConversationID,
929
+ conversationID,
930
+ originatorConversationID,
931
+ kind: "b2b",
932
+ amount: body.Amount,
933
+ phoneNumber: body.PartyB,
934
+ shortCode: body.PartyA,
935
+ callbackUrl: body.ResultURL,
936
+ state: "success",
937
+ createdAt: Date.now(),
938
+ callbackAttempts: 0,
939
+ mpesaReceiptNumber: receipt
940
+ };
941
+ c.var.store.put(record);
942
+ const callback = {
943
+ Result: {
944
+ ResultType: 0,
945
+ ResultCode: 0,
946
+ ResultDesc: "The service request is processed successfully.",
947
+ OriginatorConversationID: originatorConversationID,
948
+ ConversationID: conversationID,
949
+ TransactionID: receipt,
950
+ ResultParameters: {
951
+ ResultParameter: [
952
+ { Key: "DebitAccountBalance", Value: "Working Account|KES|1000000.00|1000000.00|0.00|0.00" },
953
+ { Key: "Amount", Value: body.Amount },
954
+ { Key: "DebitPartyAffectedAccountBalance", Value: "Working Account|KES|1000000.00|1000000.00|0.00|0.00" },
955
+ { Key: "TransCompletedTime", Value: (/* @__PURE__ */ new Date()).toISOString() },
956
+ { Key: "DebitPartyCharges", Value: "" },
957
+ { Key: "ReceiverPartyPublicName", Value: `${body.PartyB} - Test Business` },
958
+ { Key: "Currency", Value: "KES" },
959
+ { Key: "InitiatorAccountCurrentBalance", Value: "{Amount={CurrencyCode=KES, MinimumAmount=99999900, BasicAmount=999999.00}}" }
960
+ ]
961
+ },
962
+ ReferenceData: {
963
+ ReferenceItem: [
964
+ { Key: "BillReferenceNumber", Value: body.AccountReference ?? "" },
965
+ { Key: "QueueTimeoutURL", Value: body.QueueTimeOutURL }
966
+ ]
967
+ }
968
+ }
969
+ };
970
+ c.var.dispatcher.schedule(
971
+ {
972
+ id: conversationID,
973
+ url: body.ResultURL,
974
+ body: callback,
975
+ scheduledAt: Date.now() + c.var.config.defaultCallbackDelayMs,
976
+ attempts: 0,
977
+ maxAttempts: c.var.config.webhookRetry.attempts,
978
+ backoffMs: c.var.config.webhookRetry.backoffMs,
979
+ transactionId: conversationID
980
+ },
981
+ c.var.config.defaultCallbackDelayMs
982
+ );
983
+ return c.json({
984
+ ConversationID: conversationID,
985
+ OriginatorConversationID: originatorConversationID,
986
+ ResponseCode: "0",
987
+ ResponseDescription: "Accept the service request successfully."
988
+ });
989
+ });
990
+ return app;
991
+ }
992
+
993
+ // src/routes/transaction-status.ts
994
+ var import_hono7 = require("hono");
995
+ function transactionStatusRoute() {
996
+ const app = new import_hono7.Hono();
997
+ app.post("/mpesa/transactionstatus/v1/query", async (c) => {
998
+ const token = parseBearerToken(c.req.header("authorization"));
999
+ if (!token || !isValidToken(token)) {
1000
+ return c.json({ errorCode: "404.001.03", errorMessage: "Invalid Access Token" }, 401);
1001
+ }
1002
+ const raw = await c.req.json().catch(() => null);
1003
+ const parsed = transactionStatusSchema.safeParse(raw);
1004
+ if (!parsed.success) {
1005
+ return c.json(
1006
+ { errorCode: "400.002.05", errorMessage: "Invalid request payload", errors: parsed.error.flatten() },
1007
+ 400
1008
+ );
1009
+ }
1010
+ const body = parsed.data;
1011
+ const conversationID = generateConversationID();
1012
+ const originatorConversationID = generateOriginatorConversationID();
1013
+ const existing = c.var.store.get(body.TransactionID);
1014
+ const callback = {
1015
+ Result: {
1016
+ ResultType: 0,
1017
+ ResultCode: 0,
1018
+ ResultDesc: "The service request is processed successfully.",
1019
+ OriginatorConversationID: originatorConversationID,
1020
+ ConversationID: conversationID,
1021
+ TransactionID: body.TransactionID,
1022
+ ResultParameters: {
1023
+ ResultParameter: [
1024
+ { Key: "ReceiptNo", Value: existing?.mpesaReceiptNumber ?? body.TransactionID },
1025
+ { Key: "ConversationID", Value: existing?.conversationID ?? conversationID },
1026
+ { Key: "FinalisedTime", Value: (/* @__PURE__ */ new Date()).toISOString() },
1027
+ { Key: "Amount", Value: existing?.amount ?? 0 },
1028
+ { Key: "TransactionStatus", Value: existing?.state === "success" ? "Completed" : "Failed" },
1029
+ { Key: "ReasonType", Value: "Salary Payment via API" },
1030
+ { Key: "TransactionReason", Value: body.Remarks },
1031
+ { Key: "DebitPartyCharges", Value: "" },
1032
+ { Key: "DebitAccountType", Value: "Utility Account" },
1033
+ { Key: "InitiatedTime", Value: (/* @__PURE__ */ new Date()).toISOString() },
1034
+ { Key: "OriginatorConversationID", Value: existing?.originatorConversationID ?? originatorConversationID },
1035
+ { Key: "CreditPartyName", Value: existing ? `${existing.phoneNumber} - Test Recipient` : "Test Recipient" },
1036
+ { Key: "DebitPartyName", Value: existing ? `${existing.shortCode} - Test Merchant` : "Test Merchant" }
1037
+ ]
1038
+ },
1039
+ ReferenceData: {
1040
+ ReferenceItem: { Key: "Occasion", Value: body.Occasion ?? "" }
1041
+ }
1042
+ }
1043
+ };
1044
+ c.var.dispatcher.schedule(
1045
+ {
1046
+ id: conversationID,
1047
+ url: body.ResultURL,
1048
+ body: callback,
1049
+ scheduledAt: Date.now() + c.var.config.defaultCallbackDelayMs,
1050
+ attempts: 0,
1051
+ maxAttempts: c.var.config.webhookRetry.attempts,
1052
+ backoffMs: c.var.config.webhookRetry.backoffMs
1053
+ },
1054
+ c.var.config.defaultCallbackDelayMs
1055
+ );
1056
+ return c.json({
1057
+ OriginatorConversationID: originatorConversationID,
1058
+ ConversationID: conversationID,
1059
+ ResponseCode: "0",
1060
+ ResponseDescription: "Accept the service request successfully."
1061
+ });
1062
+ });
1063
+ return app;
1064
+ }
1065
+
1066
+ // src/routes/account-balance.ts
1067
+ var import_hono8 = require("hono");
1068
+ function accountBalanceRoute() {
1069
+ const app = new import_hono8.Hono();
1070
+ app.post("/mpesa/accountbalance/v1/query", async (c) => {
1071
+ const token = parseBearerToken(c.req.header("authorization"));
1072
+ if (!token || !isValidToken(token)) {
1073
+ return c.json({ errorCode: "404.001.03", errorMessage: "Invalid Access Token" }, 401);
1074
+ }
1075
+ const raw = await c.req.json().catch(() => null);
1076
+ const parsed = accountBalanceSchema.safeParse(raw);
1077
+ if (!parsed.success) {
1078
+ return c.json(
1079
+ { errorCode: "400.002.05", errorMessage: "Invalid request payload", errors: parsed.error.flatten() },
1080
+ 400
1081
+ );
1082
+ }
1083
+ const body = parsed.data;
1084
+ const conversationID = generateConversationID();
1085
+ const originatorConversationID = generateOriginatorConversationID();
1086
+ const callback = {
1087
+ Result: {
1088
+ ResultType: 0,
1089
+ ResultCode: 0,
1090
+ ResultDesc: "The service request is processed successfully.",
1091
+ OriginatorConversationID: originatorConversationID,
1092
+ ConversationID: conversationID,
1093
+ TransactionID: "BALANCE-QUERY",
1094
+ ResultParameters: {
1095
+ ResultParameter: [
1096
+ { Key: "AccountBalance", Value: "Working Account|KES|1000000.00|1000000.00|0.00|0.00&Float Account|KES|0.00|0.00|0.00|0.00&Utility Account|KES|1000000.00|1000000.00|0.00|0.00&Charges Paid Account|KES|0.00|0.00|0.00|0.00&Organization Settlement Account|KES|0.00|0.00|0.00|0.00" },
1097
+ { Key: "BOCompletedTime", Value: (/* @__PURE__ */ new Date()).toISOString() }
1098
+ ]
1099
+ },
1100
+ ReferenceData: {
1101
+ ReferenceItem: { Key: "QueueTimeoutURL", Value: body.QueueTimeOutURL }
1102
+ }
1103
+ }
1104
+ };
1105
+ c.var.dispatcher.schedule(
1106
+ {
1107
+ id: conversationID,
1108
+ url: body.ResultURL,
1109
+ body: callback,
1110
+ scheduledAt: Date.now() + c.var.config.defaultCallbackDelayMs,
1111
+ attempts: 0,
1112
+ maxAttempts: c.var.config.webhookRetry.attempts,
1113
+ backoffMs: c.var.config.webhookRetry.backoffMs
1114
+ },
1115
+ c.var.config.defaultCallbackDelayMs
1116
+ );
1117
+ return c.json({
1118
+ OriginatorConversationID: originatorConversationID,
1119
+ ConversationID: conversationID,
1120
+ ResponseCode: "0",
1121
+ ResponseDescription: "Accept the service request successfully."
1122
+ });
1123
+ });
1124
+ return app;
1125
+ }
1126
+
1127
+ // src/routes/reversal.ts
1128
+ var import_hono9 = require("hono");
1129
+ function reversalRoute() {
1130
+ const app = new import_hono9.Hono();
1131
+ app.post("/mpesa/reversal/v1/request", async (c) => {
1132
+ const token = parseBearerToken(c.req.header("authorization"));
1133
+ if (!token || !isValidToken(token)) {
1134
+ return c.json({ errorCode: "404.001.03", errorMessage: "Invalid Access Token" }, 401);
1135
+ }
1136
+ const raw = await c.req.json().catch(() => null);
1137
+ const parsed = reversalSchema.safeParse(raw);
1138
+ if (!parsed.success) {
1139
+ return c.json(
1140
+ { errorCode: "400.002.05", errorMessage: "Invalid request payload", errors: parsed.error.flatten() },
1141
+ 400
1142
+ );
1143
+ }
1144
+ const body = parsed.data;
1145
+ const conversationID = generateConversationID();
1146
+ const originatorConversationID = generateOriginatorConversationID();
1147
+ const callback = {
1148
+ Result: {
1149
+ ResultType: 0,
1150
+ ResultCode: 0,
1151
+ ResultDesc: "The service request is processed successfully.",
1152
+ OriginatorConversationID: originatorConversationID,
1153
+ ConversationID: conversationID,
1154
+ TransactionID: body.TransactionID,
1155
+ ResultParameters: {
1156
+ ResultParameter: [
1157
+ { Key: "DebitAccountBalance", Value: "Working Account|KES|1000000.00|1000000.00|0.00|0.00" },
1158
+ { Key: "Amount", Value: body.Amount },
1159
+ { Key: "TransCompletedTime", Value: (/* @__PURE__ */ new Date()).toISOString() },
1160
+ { Key: "OriginalTransactionID", Value: body.TransactionID },
1161
+ { Key: "Charge", Value: 0 },
1162
+ { Key: "CreditPartyPublicName", Value: `${body.ReceiverParty} - Test Recipient` },
1163
+ { Key: "DebitPartyPublicName", Value: "Test Merchant" }
1164
+ ]
1165
+ },
1166
+ ReferenceData: {
1167
+ ReferenceItem: { Key: "QueueTimeoutURL", Value: body.QueueTimeOutURL }
1168
+ }
1169
+ }
1170
+ };
1171
+ c.var.dispatcher.schedule(
1172
+ {
1173
+ id: conversationID,
1174
+ url: body.ResultURL,
1175
+ body: callback,
1176
+ scheduledAt: Date.now() + c.var.config.defaultCallbackDelayMs,
1177
+ attempts: 0,
1178
+ maxAttempts: c.var.config.webhookRetry.attempts,
1179
+ backoffMs: c.var.config.webhookRetry.backoffMs
1180
+ },
1181
+ c.var.config.defaultCallbackDelayMs
1182
+ );
1183
+ return c.json({
1184
+ OriginatorConversationID: originatorConversationID,
1185
+ ConversationID: conversationID,
1186
+ ResponseCode: "0",
1187
+ ResponseDescription: "Accept the service request successfully."
1188
+ });
1189
+ });
1190
+ return app;
1191
+ }
1192
+
1193
+ // src/routes/dashboard.ts
1194
+ var import_hono10 = require("hono");
1195
+ var import_streaming = require("hono/streaming");
1196
+ var DASHBOARD_HTML = `<!doctype html>
1197
+ <html lang="en">
1198
+ <head>
1199
+ <meta charset="utf-8" />
1200
+ <title>mpesa-mock dashboard</title>
1201
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1202
+ <script src="https://cdn.tailwindcss.com"></script>
1203
+ </head>
1204
+ <body class="bg-slate-950 text-slate-100 font-mono min-h-screen">
1205
+ <div class="max-w-6xl mx-auto p-6">
1206
+ <header class="flex items-center justify-between mb-6">
1207
+ <div>
1208
+ <h1 class="text-2xl font-bold">mpesa-mock <span class="text-emerald-400">\u25CF</span></h1>
1209
+ <p class="text-slate-400 text-sm">Local M-Pesa Daraja emulator \u2014 live transactions</p>
1210
+ </div>
1211
+ <div class="text-right text-xs text-slate-500">
1212
+ <div id="health">checking\u2026</div>
1213
+ <div>SSE: <span id="sse-status">connecting</span></div>
1214
+ </div>
1215
+ </header>
1216
+
1217
+ <section class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
1218
+ <div class="bg-slate-900 rounded-lg p-4 border border-slate-800">
1219
+ <div class="text-xs text-slate-500 uppercase">Transactions</div>
1220
+ <div id="count-total" class="text-3xl font-bold">0</div>
1221
+ </div>
1222
+ <div class="bg-slate-900 rounded-lg p-4 border border-slate-800">
1223
+ <div class="text-xs text-slate-500 uppercase">Pending callbacks</div>
1224
+ <div id="count-pending" class="text-3xl font-bold text-amber-300">0</div>
1225
+ </div>
1226
+ <div class="bg-slate-900 rounded-lg p-4 border border-slate-800">
1227
+ <div class="text-xs text-slate-500 uppercase">Delivered</div>
1228
+ <div id="count-delivered" class="text-3xl font-bold text-emerald-300">0</div>
1229
+ </div>
1230
+ </section>
1231
+
1232
+ <section class="bg-slate-900 rounded-lg border border-slate-800 overflow-hidden">
1233
+ <table class="w-full text-sm">
1234
+ <thead class="bg-slate-800 text-slate-400 text-xs uppercase">
1235
+ <tr>
1236
+ <th class="text-left p-3">Kind</th>
1237
+ <th class="text-left p-3">CheckoutRequestID</th>
1238
+ <th class="text-left p-3">Phone</th>
1239
+ <th class="text-right p-3">Amount</th>
1240
+ <th class="text-left p-3">State</th>
1241
+ <th class="text-right p-3">Cb attempts</th>
1242
+ <th class="text-right p-3">Age</th>
1243
+ </tr>
1244
+ </thead>
1245
+ <tbody id="rows"></tbody>
1246
+ </table>
1247
+ </section>
1248
+
1249
+ <footer class="text-center text-slate-600 text-xs mt-8">
1250
+ mpesa-mock \u2014 not affiliated with Safaricom PLC
1251
+ </footer>
1252
+ </div>
1253
+
1254
+ <script>
1255
+ const stateColors = {
1256
+ success: 'text-emerald-300',
1257
+ pending: 'text-amber-300',
1258
+ user_cancelled: 'text-rose-300',
1259
+ insufficient_funds: 'text-rose-400',
1260
+ wrong_pin: 'text-rose-400',
1261
+ expired: 'text-rose-400',
1262
+ system_error: 'text-rose-500',
1263
+ timeout: 'text-slate-400',
1264
+ };
1265
+
1266
+ function renderRow(t) {
1267
+ const age = Math.round((Date.now() - t.createdAt) / 1000);
1268
+ const colorCls = stateColors[t.state] ?? 'text-slate-200';
1269
+ return \`<tr class="border-t border-slate-800 hover:bg-slate-800/50">
1270
+ <td class="p-3 uppercase text-xs text-slate-400">\${t.kind}</td>
1271
+ <td class="p-3 text-xs">\${t.checkoutRequestID}</td>
1272
+ <td class="p-3">\${t.phoneNumber}</td>
1273
+ <td class="p-3 text-right">\${t.amount.toLocaleString()}</td>
1274
+ <td class="p-3 \${colorCls}">\${t.state}</td>
1275
+ <td class="p-3 text-right">\${t.callbackAttempts}</td>
1276
+ <td class="p-3 text-right text-slate-500">\${age}s</td>
1277
+ </tr>\`;
1278
+ }
1279
+
1280
+ function refresh(data) {
1281
+ const txns = data.transactions ?? [];
1282
+ document.getElementById('count-total').textContent = txns.length;
1283
+ document.getElementById('count-pending').textContent = data.pendingCallbacks ?? 0;
1284
+ document.getElementById('count-delivered').textContent = txns.filter(t => t.callbackDeliveredAt).length;
1285
+ document.getElementById('rows').innerHTML = txns.slice(0, 50).map(renderRow).join('');
1286
+ }
1287
+
1288
+ fetch('/__mock__/health').then(r => r.json()).then(d => {
1289
+ document.getElementById('health').textContent = 'up \xB7 ' + Math.round(d.uptime) + 's';
1290
+ });
1291
+
1292
+ fetch('/__mock__/state').then(r => r.json()).then(refresh);
1293
+
1294
+ const es = new EventSource('/__mock__/events');
1295
+ es.onopen = () => { document.getElementById('sse-status').textContent = 'live'; };
1296
+ es.onerror = () => { document.getElementById('sse-status').textContent = 'disconnected'; };
1297
+ es.onmessage = (e) => { try { refresh(JSON.parse(e.data)); } catch {} };
1298
+ </script>
1299
+ </body>
1300
+ </html>`;
1301
+ function dashboardRoute() {
1302
+ const app = new import_hono10.Hono();
1303
+ app.get("/__mock__/dashboard", (c) => c.html(DASHBOARD_HTML));
1304
+ app.get("/__mock__/state", (c) => {
1305
+ const transactions = c.var.store.list(100);
1306
+ return c.json({
1307
+ transactions,
1308
+ pendingCallbacks: c.var.dispatcher.pendingIds().length
1309
+ });
1310
+ });
1311
+ app.get("/__mock__/events", (c) => {
1312
+ return (0, import_streaming.streamSSE)(c, async (stream) => {
1313
+ const send = async () => {
1314
+ await stream.writeSSE({
1315
+ data: JSON.stringify({
1316
+ transactions: c.var.store.list(100),
1317
+ pendingCallbacks: c.var.dispatcher.pendingIds().length
1318
+ })
1319
+ });
1320
+ };
1321
+ await send();
1322
+ const onChange = () => {
1323
+ void send();
1324
+ };
1325
+ c.var.store.on("change", onChange);
1326
+ c.var.store.on("clear", onChange);
1327
+ const heartbeat = setInterval(() => {
1328
+ void send();
1329
+ }, 5e3);
1330
+ try {
1331
+ while (true) {
1332
+ await stream.sleep(1e3);
1333
+ }
1334
+ } finally {
1335
+ clearInterval(heartbeat);
1336
+ c.var.store.off("change", onChange);
1337
+ c.var.store.off("clear", onChange);
1338
+ }
1339
+ });
1340
+ });
1341
+ app.post("/__mock__/clear", (c) => {
1342
+ c.var.store.clear();
1343
+ return c.json({ cleared: true });
1344
+ });
1345
+ return app;
1346
+ }
1347
+
1348
+ // src/server.ts
1349
+ function defaultConfig(overrides = {}) {
1350
+ return {
1351
+ defaultCallbackDelayMs: overrides.defaultCallbackDelayMs ?? DEFAULTS.callbackDelayMs,
1352
+ scenarios: overrides.scenarios ?? {},
1353
+ webhookRetry: {
1354
+ attempts: overrides.webhookRetry?.attempts ?? DEFAULTS.callbackRetryAttempts,
1355
+ backoffMs: overrides.webhookRetry?.backoffMs ?? DEFAULTS.callbackRetryBackoffMs
1356
+ }
1357
+ };
1358
+ }
1359
+ function createServer(opts = {}) {
1360
+ const store = opts.store ?? new InMemoryStore();
1361
+ const config = defaultConfig(opts.config);
1362
+ const log = opts.quiet ? void 0 : (msg) => console.log(msg);
1363
+ const dispatcher = new WebhookDispatcher({ store, onLog: log });
1364
+ const app = new import_hono11.Hono();
1365
+ if (!opts.quiet) {
1366
+ app.use("*", (0, import_logger.logger)((msg) => console.log(msg)));
1367
+ }
1368
+ app.use("*", async (c, next) => {
1369
+ c.set("store", store);
1370
+ c.set("dispatcher", dispatcher);
1371
+ c.set("config", config);
1372
+ if (log) c.set("log", log);
1373
+ if (opts.recorder) c.set("recorder", opts.recorder);
1374
+ await next();
1375
+ });
1376
+ app.get("/", (c) => c.json({ name: "mpesa-mock", status: "ok" }));
1377
+ app.get("/__mock__/health", (c) => c.json({ status: "ok", uptime: process.uptime() }));
1378
+ app.route("/", oauthRoute());
1379
+ app.route("/", stkPushRoute());
1380
+ app.route("/", stkQueryRoute());
1381
+ app.route("/", c2bRoute());
1382
+ app.route("/", b2cRoute());
1383
+ app.route("/", b2bRoute());
1384
+ app.route("/", transactionStatusRoute());
1385
+ app.route("/", accountBalanceRoute());
1386
+ app.route("/", reversalRoute());
1387
+ app.route("/", dashboardRoute());
1388
+ app.notFound(
1389
+ (c) => c.json(
1390
+ {
1391
+ errorCode: "404.000.01",
1392
+ errorMessage: `Not found: ${c.req.method} ${c.req.path}`
1393
+ },
1394
+ 404
1395
+ )
1396
+ );
1397
+ return { app, store, dispatcher, config };
1398
+ }
1399
+ // Annotate the CommonJS export names for ESM import in node:
1400
+ 0 && (module.exports = {
1401
+ InMemoryStore,
1402
+ WebhookDispatcher,
1403
+ createServer,
1404
+ defaultConfig,
1405
+ pickScenario
1406
+ });
1407
+ //# sourceMappingURL=index.cjs.map