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/LICENSE +25 -0
- package/README.md +180 -0
- package/dist/cli.cjs +2573 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +2574 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +1407 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +167 -0
- package/dist/index.d.ts +167 -0
- package/dist/index.js +1368 -0
- package/dist/index.js.map +1 -0
- package/package.json +75 -0
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
|