pesafy 0.0.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +62 -40
- package/dist/adapters/express.d.ts +5 -81
- package/dist/adapters/express.js +127 -1
- package/dist/adapters/express.js.map +1 -0
- package/dist/adapters/fastify.d.ts +10 -74
- package/dist/adapters/fastify.js +85 -1
- package/dist/adapters/fastify.js.map +1 -0
- package/dist/adapters/hono.d.ts +4 -88
- package/dist/adapters/hono.js +105 -1
- package/dist/adapters/hono.js.map +1 -0
- package/dist/adapters/nextjs.d.ts +28 -176
- package/dist/adapters/nextjs.js +166 -1
- package/dist/adapters/nextjs.js.map +1 -0
- package/dist/cli.mjs +2387 -112
- package/dist/cli.mjs.map +1 -0
- package/dist/errors.mjs +6 -1
- package/dist/errors.mjs.map +1 -0
- package/dist/index.d.ts +4864 -136
- package/dist/index.js +4197 -1
- package/dist/index.js.map +1 -0
- package/dist/phone.mjs +5 -1
- package/dist/phone.mjs.map +1 -0
- package/dist/rolldown-runtime.mjs +18 -0
- package/dist/route-definitions.js +3292 -0
- package/dist/route-definitions.js.map +1 -0
- package/dist/types.d.ts +924 -5
- package/package.json +6 -1
- package/dist/chunk.js +0 -1
- package/dist/encryption.mjs +0 -22
- package/dist/utils.mjs +0 -32
- package/dist/webhook-guard.js +0 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { t as __exportAll } from "./rolldown-runtime.mjs";
|
|
3
|
+
import { n as createError, t as PesafyError } from "./errors.mjs";
|
|
4
|
+
import { t as formatSafaricomPhone } from "./phone.mjs";
|
|
3
5
|
import { createInterface } from "node:readline";
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
7
|
import { resolve } from "node:path";
|
|
8
|
+
import { constants, publicEncrypt } from "node:crypto";
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import { z } from "zod";
|
|
5
11
|
|
|
6
|
-
//#region src/cli/
|
|
12
|
+
//#region src/cli/lib/output.ts
|
|
7
13
|
const C = {
|
|
8
14
|
reset: "\x1B[0m",
|
|
9
15
|
bold: "\x1B[1m",
|
|
@@ -22,6 +28,69 @@ const r = (s) => `${C.red}${s}${C.reset}`;
|
|
|
22
28
|
const b = (s) => `${C.bold}${s}${C.reset}`;
|
|
23
29
|
const c = (s) => `${C.cyan}${s}${C.reset}`;
|
|
24
30
|
const dim = (s) => `${C.dim}${s}${C.reset}`;
|
|
31
|
+
function printJson(data) {
|
|
32
|
+
console.log(JSON.stringify(data, null, 2));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/cli/lib/env.ts
|
|
37
|
+
function loadEnv(envFile) {
|
|
38
|
+
const envPath = resolve(process.cwd(), envFile ?? ".env");
|
|
39
|
+
if (!existsSync(envPath)) return {};
|
|
40
|
+
const lines = readFileSync(envPath, "utf-8").split("\n");
|
|
41
|
+
const env = {};
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
const trimmed = line.trim();
|
|
44
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
45
|
+
const eq = trimmed.indexOf("=");
|
|
46
|
+
if (eq === -1) continue;
|
|
47
|
+
const key = trimmed.slice(0, eq).trim();
|
|
48
|
+
env[key] = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
|
|
49
|
+
}
|
|
50
|
+
return env;
|
|
51
|
+
}
|
|
52
|
+
function applyEnvOverride(env, override) {
|
|
53
|
+
if (!override) return env;
|
|
54
|
+
return {
|
|
55
|
+
...env,
|
|
56
|
+
MPESA_ENVIRONMENT: override
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function requireEnv(env, ...keys) {
|
|
60
|
+
const missing = keys.filter((k) => !env[k]);
|
|
61
|
+
if (missing.length) {
|
|
62
|
+
console.error(`✖ Missing env vars: ${missing.join(", ")}`);
|
|
63
|
+
console.error(" Run: npx pesafy init");
|
|
64
|
+
process.exit(2);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function getBaseUrl(env) {
|
|
68
|
+
return env["MPESA_ENVIRONMENT"] === "production" ? "https://api.safaricom.co.ke" : "https://sandbox.safaricom.co.ke";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/cli/lib/context.ts
|
|
73
|
+
function createCliContext(argv) {
|
|
74
|
+
const args = [];
|
|
75
|
+
let json = false;
|
|
76
|
+
let envFile;
|
|
77
|
+
let envOverride;
|
|
78
|
+
for (let i = 0; i < argv.length; i++) {
|
|
79
|
+
const a = argv[i];
|
|
80
|
+
if (a === "--json") json = true;
|
|
81
|
+
else if (a === "--env-file" && argv[i + 1]) envFile = argv[++i];
|
|
82
|
+
else if (a === "--env" && argv[i + 1]) {
|
|
83
|
+
const v = argv[++i];
|
|
84
|
+
if (v === "sandbox" || v === "production") envOverride = v;
|
|
85
|
+
} else args.push(a ?? "");
|
|
86
|
+
}
|
|
87
|
+
const env = applyEnvOverride(loadEnv(envFile), envOverride);
|
|
88
|
+
return {
|
|
89
|
+
args,
|
|
90
|
+
json,
|
|
91
|
+
env
|
|
92
|
+
};
|
|
93
|
+
}
|
|
25
94
|
function getPkgVersion() {
|
|
26
95
|
try {
|
|
27
96
|
const pkgPath = resolve(new URL("../../package.json", import.meta.url).pathname);
|
|
@@ -30,85 +99,2101 @@ function getPkgVersion() {
|
|
|
30
99
|
return "unknown";
|
|
31
100
|
}
|
|
32
101
|
}
|
|
33
|
-
function prompt(question, defaultVal = "") {
|
|
34
|
-
const rl = createInterface({
|
|
35
|
-
input: process.stdin,
|
|
36
|
-
output: process.stdout
|
|
102
|
+
function prompt(question, defaultVal = "") {
|
|
103
|
+
const rl = createInterface({
|
|
104
|
+
input: process.stdin,
|
|
105
|
+
output: process.stdout
|
|
106
|
+
});
|
|
107
|
+
const displayDefault = defaultVal ? dim(` [${defaultVal}]`) : "";
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
rl.question(`${question}${displayDefault}: `, (answer) => {
|
|
110
|
+
rl.close();
|
|
111
|
+
resolve(answer.trim() || defaultVal);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/cli/lib/daraja.ts
|
|
118
|
+
async function fetchJson(url, method, headers, body) {
|
|
119
|
+
const res = await fetch(url, {
|
|
120
|
+
method,
|
|
121
|
+
headers: {
|
|
122
|
+
"Content-Type": "application/json",
|
|
123
|
+
Accept: "application/json",
|
|
124
|
+
...headers
|
|
125
|
+
},
|
|
126
|
+
...body ? { body: JSON.stringify(body) } : {}
|
|
127
|
+
});
|
|
128
|
+
const text = await res.text();
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(text);
|
|
131
|
+
} catch {
|
|
132
|
+
throw new Error(`Non-JSON response (${res.status}): ${text.slice(0, 200)}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async function getToken(consumerKey, consumerSecret, baseUrl) {
|
|
136
|
+
const creds = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64");
|
|
137
|
+
const data = await fetchJson(`${baseUrl}/oauth/v1/generate?grant_type=client_credentials`, "GET", { Authorization: `Basic ${creds}` });
|
|
138
|
+
if (!data.access_token) throw new Error("No access_token in response");
|
|
139
|
+
return data.access_token;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
//#endregion
|
|
143
|
+
//#region src/core/encryption/security-credentials.ts
|
|
144
|
+
function encryptSecurityCredential(initiatorPassword, certificatePem) {
|
|
145
|
+
try {
|
|
146
|
+
const passwordBuffer = Buffer.from(initiatorPassword, "utf-8");
|
|
147
|
+
return publicEncrypt({
|
|
148
|
+
key: certificatePem,
|
|
149
|
+
padding: constants.RSA_PKCS1_PADDING
|
|
150
|
+
}, passwordBuffer).toString("base64");
|
|
151
|
+
} catch (error) {
|
|
152
|
+
throw new PesafyError({
|
|
153
|
+
code: "ENCRYPTION_FAILED",
|
|
154
|
+
message: "Failed to encrypt security credential. Ensure the certificate PEM is valid and matches the environment (sandbox/production).",
|
|
155
|
+
cause: error
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/core/encryption/index.ts
|
|
162
|
+
var encryption_exports = /* @__PURE__ */ __exportAll({ encryptSecurityCredential: () => encryptSecurityCredential });
|
|
163
|
+
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/utils/http/index.ts
|
|
166
|
+
/** Merge explicit Daraja HTTP options into an httpRequest options object. */
|
|
167
|
+
function withDarajaHttp(options, http) {
|
|
168
|
+
if (!http?.idempotency) return options;
|
|
169
|
+
return {
|
|
170
|
+
...options,
|
|
171
|
+
idempotency: http.idempotency
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const RETRYABLE_STATUSES = new Set([
|
|
175
|
+
429,
|
|
176
|
+
500,
|
|
177
|
+
502,
|
|
178
|
+
503,
|
|
179
|
+
504
|
|
180
|
+
]);
|
|
181
|
+
function sleep(ms) {
|
|
182
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
183
|
+
}
|
|
184
|
+
function withJitter(base) {
|
|
185
|
+
const spread = base * .25;
|
|
186
|
+
return base + (Math.random() * spread * 2 - spread);
|
|
187
|
+
}
|
|
188
|
+
/** Origin + path only (no query) for safe retry logs. */
|
|
189
|
+
function logSafeUrl(url) {
|
|
190
|
+
try {
|
|
191
|
+
const u = new URL(url);
|
|
192
|
+
return `${u.origin}${u.pathname}`;
|
|
193
|
+
} catch {
|
|
194
|
+
return url.split("?")[0] ?? url;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Sends an HTTP request to Daraja and returns parsed JSON.
|
|
199
|
+
* Automatically retries transient failures with exponential back-off.
|
|
200
|
+
*
|
|
201
|
+
* @throws {PesafyError} on non-retryable errors or exhausted retries.
|
|
202
|
+
*/
|
|
203
|
+
async function httpRequest(url, options) {
|
|
204
|
+
const maxRetries = options.retries ?? 4;
|
|
205
|
+
const baseDelay = options.retryDelay ?? 2e3;
|
|
206
|
+
const timeout = options.timeout ?? 3e4;
|
|
207
|
+
const headers = {
|
|
208
|
+
"Content-Type": "application/json",
|
|
209
|
+
Accept: "application/json",
|
|
210
|
+
...options.headers
|
|
211
|
+
};
|
|
212
|
+
const manager = options.idempotency;
|
|
213
|
+
let idempotencyKey = options.idempotencyKey;
|
|
214
|
+
if (options.method === "POST" && manager?.enabled) {
|
|
215
|
+
idempotencyKey = manager.reserve(idempotencyKey);
|
|
216
|
+
const headerName = manager.headerName;
|
|
217
|
+
headers[headerName] = idempotencyKey;
|
|
218
|
+
} else if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
|
|
219
|
+
const init = {
|
|
220
|
+
method: options.method,
|
|
221
|
+
headers,
|
|
222
|
+
...options.body !== void 0 ? { body: JSON.stringify(options.body) } : {}
|
|
223
|
+
};
|
|
224
|
+
let lastError = null;
|
|
225
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
226
|
+
if (attempt > 0) {
|
|
227
|
+
const delay = withJitter(baseDelay * Math.pow(2, attempt - 1));
|
|
228
|
+
console.warn(`[pesafy] Retry ${attempt}/${maxRetries} → ${options.method} ${logSafeUrl(url)} in ${Math.round(delay)} ms`);
|
|
229
|
+
await sleep(delay);
|
|
230
|
+
}
|
|
231
|
+
const controller = new AbortController();
|
|
232
|
+
const tid = setTimeout(() => controller.abort(), timeout);
|
|
233
|
+
let response;
|
|
234
|
+
try {
|
|
235
|
+
response = await fetch(url, {
|
|
236
|
+
...init,
|
|
237
|
+
signal: controller.signal
|
|
238
|
+
});
|
|
239
|
+
} catch (err) {
|
|
240
|
+
clearTimeout(tid);
|
|
241
|
+
if (err instanceof Error && err.name === "AbortError") lastError = new PesafyError({
|
|
242
|
+
code: "TIMEOUT",
|
|
243
|
+
message: `Request to ${url} timed out after ${timeout} ms`,
|
|
244
|
+
cause: err,
|
|
245
|
+
retryable: true
|
|
246
|
+
});
|
|
247
|
+
else lastError = new PesafyError({
|
|
248
|
+
code: "NETWORK_ERROR",
|
|
249
|
+
message: `Network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
250
|
+
cause: err,
|
|
251
|
+
retryable: true
|
|
252
|
+
});
|
|
253
|
+
if (attempt < maxRetries) continue;
|
|
254
|
+
if (idempotencyKey && manager?.enabled) manager.release(idempotencyKey);
|
|
255
|
+
throw lastError;
|
|
256
|
+
} finally {
|
|
257
|
+
clearTimeout(tid);
|
|
258
|
+
}
|
|
259
|
+
let rawText = "";
|
|
260
|
+
let data = null;
|
|
261
|
+
const ct = response.headers.get("content-type") ?? "";
|
|
262
|
+
try {
|
|
263
|
+
rawText = await response.text();
|
|
264
|
+
if (rawText) data = ct.includes("application/json") ? JSON.parse(rawText) : rawText;
|
|
265
|
+
} catch {
|
|
266
|
+
data = rawText || null;
|
|
267
|
+
}
|
|
268
|
+
const responseHeaders = {};
|
|
269
|
+
response.headers.forEach((v, k) => {
|
|
270
|
+
responseHeaders[k] = v;
|
|
271
|
+
});
|
|
272
|
+
if (response.ok) {
|
|
273
|
+
if (idempotencyKey && manager?.enabled) manager.complete(idempotencyKey);
|
|
274
|
+
return {
|
|
275
|
+
data,
|
|
276
|
+
status: response.status,
|
|
277
|
+
headers: responseHeaders
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const isTransient = RETRYABLE_STATUSES.has(response.status);
|
|
281
|
+
const daraja = typeof data === "object" && data !== null ? data : {};
|
|
282
|
+
const message = daraja["errorMessage"] ?? daraja["ResponseDescription"] ?? daraja["resultDesc"] ?? rawText ?? `HTTP ${response.status}`;
|
|
283
|
+
lastError = new PesafyError({
|
|
284
|
+
code: isTransient ? "REQUEST_FAILED" : "API_ERROR",
|
|
285
|
+
message,
|
|
286
|
+
statusCode: response.status,
|
|
287
|
+
response: data,
|
|
288
|
+
retryable: isTransient,
|
|
289
|
+
...typeof daraja["requestId"] === "string" ? { requestId: daraja["requestId"] } : {}
|
|
290
|
+
});
|
|
291
|
+
if (isTransient && attempt < maxRetries) continue;
|
|
292
|
+
if (idempotencyKey && manager?.enabled) manager.release(idempotencyKey);
|
|
293
|
+
throw lastError;
|
|
294
|
+
}
|
|
295
|
+
if (idempotencyKey && manager?.enabled) manager.release(idempotencyKey);
|
|
296
|
+
throw lastError;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
//#endregion
|
|
300
|
+
//#region src/core/auth/types.ts
|
|
301
|
+
/**
|
|
302
|
+
* Daraja Authorization API error codes
|
|
303
|
+
* Documented at: https://developer.safaricom.co.ke/APIs/Authorization
|
|
304
|
+
*
|
|
305
|
+
* These are returned in the `errorCode` field of a 400 error response body
|
|
306
|
+
* when the OAuth token request is malformed.
|
|
307
|
+
*/
|
|
308
|
+
const AUTH_ERROR_CODES = {
|
|
309
|
+
INVALID_AUTH_TYPE: "400.008.01",
|
|
310
|
+
INVALID_GRANT_TYPE: "400.008.02"
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
//#endregion
|
|
314
|
+
//#region src/core/auth/token-manager.ts
|
|
315
|
+
/** Refresh the token this many seconds before it actually expires */
|
|
316
|
+
const TOKEN_BUFFER_SECONDS = 60;
|
|
317
|
+
var TokenManager = class {
|
|
318
|
+
consumerKey;
|
|
319
|
+
consumerSecret;
|
|
320
|
+
baseUrl;
|
|
321
|
+
cachedToken = null;
|
|
322
|
+
tokenExpiresAt = 0;
|
|
323
|
+
constructor(consumerKey, consumerSecret, baseUrl) {
|
|
324
|
+
this.consumerKey = consumerKey;
|
|
325
|
+
this.consumerSecret = consumerSecret;
|
|
326
|
+
this.baseUrl = baseUrl;
|
|
327
|
+
}
|
|
328
|
+
getBasicAuthHeader() {
|
|
329
|
+
const credentials = `${this.consumerKey}:${this.consumerSecret}`;
|
|
330
|
+
return `Basic ${Buffer.from(credentials, "utf-8").toString("base64")}`;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Maps Daraja-specific auth error codes (400.008.01 / 400.008.02) to
|
|
334
|
+
* descriptive PesafyError messages so callers get actionable feedback.
|
|
335
|
+
*
|
|
336
|
+
* Always throws — the `never` return type signals this to TypeScript.
|
|
337
|
+
*/
|
|
338
|
+
mapAuthError(error) {
|
|
339
|
+
if (error instanceof PesafyError) {
|
|
340
|
+
if (error.code === "AUTH_FAILED") throw error;
|
|
341
|
+
const raw = error.response;
|
|
342
|
+
if (raw && typeof raw === "object") {
|
|
343
|
+
const errorCode = raw["errorCode"] ?? raw["error_code"];
|
|
344
|
+
if (errorCode === AUTH_ERROR_CODES.INVALID_AUTH_TYPE) throw new PesafyError({
|
|
345
|
+
code: "AUTH_FAILED",
|
|
346
|
+
message: "Invalid authentication type (400.008.01). Use Basic authentication: Authorization: Basic <Base64(consumerKey:consumerSecret)>.",
|
|
347
|
+
...error.statusCode !== void 0 && { statusCode: error.statusCode },
|
|
348
|
+
response: error.response
|
|
349
|
+
});
|
|
350
|
+
if (errorCode === AUTH_ERROR_CODES.INVALID_GRANT_TYPE) throw new PesafyError({
|
|
351
|
+
code: "AUTH_FAILED",
|
|
352
|
+
message: "Invalid grant type (400.008.02). Set grant_type=client_credentials in the request query parameters.",
|
|
353
|
+
...error.statusCode !== void 0 && { statusCode: error.statusCode },
|
|
354
|
+
response: error.response
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Returns a valid access token, fetching a new one when the cached token
|
|
363
|
+
* is absent or within TOKEN_BUFFER_SECONDS of expiry.
|
|
364
|
+
*
|
|
365
|
+
* Daraja endpoint: GET /oauth/v1/generate?grant_type=client_credentials
|
|
366
|
+
* Auth: Basic Base64(consumerKey:consumerSecret)
|
|
367
|
+
* Token lifetime: 3599 seconds (Daraja docs)
|
|
368
|
+
*/
|
|
369
|
+
async getAccessToken() {
|
|
370
|
+
const now = Date.now() / 1e3;
|
|
371
|
+
if (this.cachedToken && this.tokenExpiresAt > now + TOKEN_BUFFER_SECONDS) return this.cachedToken;
|
|
372
|
+
const url = `${this.baseUrl}/oauth/v1/generate?grant_type=client_credentials`;
|
|
373
|
+
try {
|
|
374
|
+
const response = await httpRequest(url, {
|
|
375
|
+
method: "GET",
|
|
376
|
+
headers: { Authorization: this.getBasicAuthHeader() }
|
|
377
|
+
});
|
|
378
|
+
const { access_token, expires_in } = response.data;
|
|
379
|
+
if (!access_token) throw new PesafyError({
|
|
380
|
+
code: "AUTH_FAILED",
|
|
381
|
+
message: "Daraja did not return an access token. Verify your consumer key and consumer secret.",
|
|
382
|
+
response: response.data
|
|
383
|
+
});
|
|
384
|
+
this.cachedToken = access_token;
|
|
385
|
+
this.tokenExpiresAt = now + (expires_in ?? 3600);
|
|
386
|
+
return this.cachedToken;
|
|
387
|
+
} catch (error) {
|
|
388
|
+
return this.mapAuthError(error);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/** Force token refresh on the next call (e.g. after a 401 response) */
|
|
392
|
+
clearCache() {
|
|
393
|
+
this.cachedToken = null;
|
|
394
|
+
this.tokenExpiresAt = 0;
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
//#endregion
|
|
399
|
+
//#region src/core/idempotency/generate-key.ts
|
|
400
|
+
/**
|
|
401
|
+
* Generate idempotency keys for Daraja mutating requests.
|
|
402
|
+
*/
|
|
403
|
+
/** UUID v4 idempotency key, optionally prefixed for debugging. */
|
|
404
|
+
function generateIdempotencyKey(prefix) {
|
|
405
|
+
const id = crypto.randomUUID();
|
|
406
|
+
return prefix ? `${prefix}-${id}` : id;
|
|
407
|
+
}
|
|
408
|
+
/** Daraja OriginatorConversationID — unique per async API request. */
|
|
409
|
+
function generateOriginatorConversationId() {
|
|
410
|
+
return generateIdempotencyKey("pesafy");
|
|
411
|
+
}
|
|
412
|
+
/** B2B Express RequestRefID — unique per checkout request. */
|
|
413
|
+
function generateRequestRefId() {
|
|
414
|
+
return crypto.randomUUID();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
//#endregion
|
|
418
|
+
//#region src/core/idempotency/store.ts
|
|
419
|
+
var InMemoryIdempotencyStore = class {
|
|
420
|
+
entries = /* @__PURE__ */ new Map();
|
|
421
|
+
get(key) {
|
|
422
|
+
return this.entries.get(key);
|
|
423
|
+
}
|
|
424
|
+
set(key, entry) {
|
|
425
|
+
this.entries.set(key, entry);
|
|
426
|
+
}
|
|
427
|
+
delete(key) {
|
|
428
|
+
this.entries.delete(key);
|
|
429
|
+
}
|
|
430
|
+
/** Remove entries older than ttlMs. */
|
|
431
|
+
prune(ttlMs) {
|
|
432
|
+
const cutoff = Date.now() - ttlMs;
|
|
433
|
+
for (const [key, entry] of this.entries) if (entry.createdAt < cutoff) this.entries.delete(key);
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region src/core/idempotency/manager.ts
|
|
439
|
+
const DEFAULT_TTL_MS = 1440 * 60 * 1e3;
|
|
440
|
+
var IdempotencyManager = class {
|
|
441
|
+
enabled;
|
|
442
|
+
headerName;
|
|
443
|
+
ttlMs;
|
|
444
|
+
store;
|
|
445
|
+
generateKey;
|
|
446
|
+
constructor(config = {}) {
|
|
447
|
+
this.enabled = config.enabled !== false;
|
|
448
|
+
this.headerName = config.headerName ?? "Idempotency-Key";
|
|
449
|
+
this.ttlMs = config.ttlMs ?? DEFAULT_TTL_MS;
|
|
450
|
+
this.store = config.store ?? new InMemoryIdempotencyStore();
|
|
451
|
+
this.generateKey = config.generateKey ?? generateIdempotencyKey;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Reserve an idempotency key before the HTTP call.
|
|
455
|
+
* @throws PesafyError with IDEMPOTENCY_ERROR if duplicate in-flight/completed within TTL.
|
|
456
|
+
*/
|
|
457
|
+
reserve(key) {
|
|
458
|
+
if (!this.enabled) return key ?? this.generateKey();
|
|
459
|
+
this.pruneExpired();
|
|
460
|
+
const resolved = key ?? this.generateKey();
|
|
461
|
+
const existing = this.store.get(resolved);
|
|
462
|
+
if (existing) {
|
|
463
|
+
if (Date.now() - existing.createdAt < this.ttlMs) throw new PesafyError({
|
|
464
|
+
code: "IDEMPOTENCY_ERROR",
|
|
465
|
+
message: `Duplicate request detected for idempotency key "${resolved}".`
|
|
466
|
+
});
|
|
467
|
+
this.store.delete(resolved);
|
|
468
|
+
}
|
|
469
|
+
this.store.set(resolved, {
|
|
470
|
+
key: resolved,
|
|
471
|
+
createdAt: Date.now()
|
|
472
|
+
});
|
|
473
|
+
return resolved;
|
|
474
|
+
}
|
|
475
|
+
/** Mark key as successfully completed. */
|
|
476
|
+
complete(key) {
|
|
477
|
+
if (!this.enabled) return;
|
|
478
|
+
const entry = this.store.get(key);
|
|
479
|
+
if (entry) this.store.set(key, {
|
|
480
|
+
...entry,
|
|
481
|
+
completedAt: Date.now()
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
/** Release reservation on failure so callers can retry with same key. */
|
|
485
|
+
release(key) {
|
|
486
|
+
if (!this.enabled) return;
|
|
487
|
+
this.store.delete(key);
|
|
488
|
+
}
|
|
489
|
+
pruneExpired() {
|
|
490
|
+
if (this.store instanceof InMemoryIdempotencyStore) this.store.prune(this.ttlMs);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
//#endregion
|
|
495
|
+
//#region src/types/branded.ts
|
|
496
|
+
function ok(data) {
|
|
497
|
+
return {
|
|
498
|
+
ok: true,
|
|
499
|
+
data
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
function err(error) {
|
|
503
|
+
return {
|
|
504
|
+
ok: false,
|
|
505
|
+
error
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
//#endregion
|
|
510
|
+
//#region src/core/validation/zod-error.ts
|
|
511
|
+
/** Map Zod validation failures to PesafyError. */
|
|
512
|
+
function zodToPesafyError(error, label = "Request") {
|
|
513
|
+
return new PesafyError({
|
|
514
|
+
code: "VALIDATION_ERROR",
|
|
515
|
+
message: `${label} validation failed: ${error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`,
|
|
516
|
+
cause: error
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
/** Parse with Zod; throw PesafyError on failure. */
|
|
520
|
+
function parseWithSchema(schema, data, label) {
|
|
521
|
+
const result = schema.safeParse(data);
|
|
522
|
+
if (!result.success) throw zodToPesafyError(result.error, label);
|
|
523
|
+
return result.data;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
//#endregion
|
|
527
|
+
//#region src/schemas/common.ts
|
|
528
|
+
const EnvironmentSchema = z.enum(["sandbox", "production"]);
|
|
529
|
+
const MsisdnSchema = z.string().min(10).regex(/^254\d{9}$/, "Must be Safaricom format 2547XXXXXXXX");
|
|
530
|
+
const KesAmountSchema = z.number().finite().positive().refine((n) => Math.round(n) >= 1, { message: "amount must round to at least 1 KES" });
|
|
531
|
+
const NonEmptyStringSchema = z.string().trim().min(1);
|
|
532
|
+
const UrlSchema = z.string().url();
|
|
533
|
+
const DarajaErrorResponseSchema = z.object({
|
|
534
|
+
errorMessage: z.string().optional(),
|
|
535
|
+
ResponseDescription: z.string().optional(),
|
|
536
|
+
resultDesc: z.string().optional(),
|
|
537
|
+
requestId: z.string().optional()
|
|
538
|
+
}).passthrough();
|
|
539
|
+
|
|
540
|
+
//#endregion
|
|
541
|
+
//#region src/schemas/async-apis.ts
|
|
542
|
+
const IdentifierTypeSchema = z.enum([
|
|
543
|
+
"1",
|
|
544
|
+
"2",
|
|
545
|
+
"4"
|
|
546
|
+
]);
|
|
547
|
+
const AsyncApiResponseSchema = z.object({
|
|
548
|
+
ConversationID: z.string().optional(),
|
|
549
|
+
OriginatorConversationID: z.string().optional(),
|
|
550
|
+
ResponseCode: z.string(),
|
|
551
|
+
ResponseDescription: z.string()
|
|
552
|
+
}).passthrough();
|
|
553
|
+
const TransactionStatusRequestSchema = z.object({
|
|
554
|
+
transactionId: z.string().optional(),
|
|
555
|
+
originalConversationId: z.string().optional(),
|
|
556
|
+
partyA: NonEmptyStringSchema,
|
|
557
|
+
identifierType: IdentifierTypeSchema,
|
|
558
|
+
resultUrl: UrlSchema,
|
|
559
|
+
queueTimeOutUrl: UrlSchema,
|
|
560
|
+
commandId: z.literal("TransactionStatusQuery").optional(),
|
|
561
|
+
remarks: z.string().optional(),
|
|
562
|
+
occasion: z.string().optional()
|
|
563
|
+
}).superRefine((data, ctx) => {
|
|
564
|
+
if (!data.transactionId?.trim() && !data.originalConversationId?.trim()) ctx.addIssue({
|
|
565
|
+
code: "custom",
|
|
566
|
+
message: "Either transactionId (M-Pesa Receipt Number) or originalConversationId is required",
|
|
567
|
+
path: ["transactionId"]
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
const TransactionStatusResponseSchema = AsyncApiResponseSchema;
|
|
571
|
+
const AccountBalanceRequestSchema = z.object({
|
|
572
|
+
partyA: NonEmptyStringSchema,
|
|
573
|
+
identifierType: IdentifierTypeSchema,
|
|
574
|
+
resultUrl: UrlSchema,
|
|
575
|
+
queueTimeOutUrl: UrlSchema,
|
|
576
|
+
remarks: z.string().optional()
|
|
577
|
+
});
|
|
578
|
+
const AccountBalanceResponseSchema = AsyncApiResponseSchema;
|
|
579
|
+
const ReversalRequestSchema = z.object({
|
|
580
|
+
transactionId: NonEmptyStringSchema,
|
|
581
|
+
receiverParty: NonEmptyStringSchema,
|
|
582
|
+
receiverIdentifierType: z.literal("11").optional(),
|
|
583
|
+
amount: KesAmountSchema,
|
|
584
|
+
resultUrl: UrlSchema,
|
|
585
|
+
queueTimeOutUrl: UrlSchema,
|
|
586
|
+
remarks: z.string().optional(),
|
|
587
|
+
occasion: z.string().optional()
|
|
588
|
+
}).superRefine((data, ctx) => {
|
|
589
|
+
if (data.receiverIdentifierType !== void 0 && data.receiverIdentifierType !== "11") ctx.addIssue({
|
|
590
|
+
code: "custom",
|
|
591
|
+
message: "receiverIdentifierType must be \"11\" for the Reversals API",
|
|
592
|
+
path: ["receiverIdentifierType"]
|
|
593
|
+
});
|
|
594
|
+
const remarks = data.remarks ?? "Transaction Reversal";
|
|
595
|
+
if (remarks.length < 2 || remarks.length > 100) ctx.addIssue({
|
|
596
|
+
code: "custom",
|
|
597
|
+
message: "remarks must be between 2 and 100 characters",
|
|
598
|
+
path: ["remarks"]
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
const ReversalResponseSchema = AsyncApiResponseSchema;
|
|
602
|
+
const TaxRemittanceRequestSchema = z.object({
|
|
603
|
+
amount: KesAmountSchema,
|
|
604
|
+
partyA: NonEmptyStringSchema,
|
|
605
|
+
partyB: z.string().optional(),
|
|
606
|
+
accountReference: NonEmptyStringSchema,
|
|
607
|
+
resultUrl: UrlSchema,
|
|
608
|
+
queueTimeOutUrl: UrlSchema,
|
|
609
|
+
remarks: z.string().optional()
|
|
610
|
+
});
|
|
611
|
+
const TaxRemittanceResponseSchema = AsyncApiResponseSchema;
|
|
612
|
+
const DynamicQRRequestSchema = z.object({
|
|
613
|
+
merchantName: NonEmptyStringSchema,
|
|
614
|
+
refNo: NonEmptyStringSchema,
|
|
615
|
+
amount: KesAmountSchema,
|
|
616
|
+
trxCode: z.enum([
|
|
617
|
+
"BG",
|
|
618
|
+
"WA",
|
|
619
|
+
"PB",
|
|
620
|
+
"SM",
|
|
621
|
+
"SB"
|
|
622
|
+
]),
|
|
623
|
+
cpi: NonEmptyStringSchema,
|
|
624
|
+
size: z.number().int().min(1).max(1e3).optional()
|
|
625
|
+
});
|
|
626
|
+
const DynamicQRResponseSchema = z.object({
|
|
627
|
+
ResponseCode: z.string(),
|
|
628
|
+
RequestID: z.string().optional(),
|
|
629
|
+
ResponseDescription: z.string(),
|
|
630
|
+
QRCode: z.string().optional()
|
|
631
|
+
}).passthrough();
|
|
632
|
+
|
|
633
|
+
//#endregion
|
|
634
|
+
//#region src/mpesa/account-balance/query.ts
|
|
635
|
+
/**
|
|
636
|
+
* Account Balance Query — checks the balance of an M-PESA shortcode.
|
|
637
|
+
*
|
|
638
|
+
* API: POST /mpesa/accountbalance/v1/query
|
|
639
|
+
*
|
|
640
|
+
* This is ASYNCHRONOUS. The sync response only confirms receipt.
|
|
641
|
+
* Balance data arrives via POST to your ResultURL.
|
|
642
|
+
*
|
|
643
|
+
* Required org portal role: "Balance Query ORG API" (Account Balance ORG API initiator)
|
|
644
|
+
*
|
|
645
|
+
* Ref: https://sandbox.safaricom.co.ke/mpesa/accountbalance/v1/query
|
|
646
|
+
*/
|
|
647
|
+
async function queryAccountBalance(baseUrl, accessToken, securityCredential, initiatorName, request, http) {
|
|
648
|
+
const validated = parseWithSchema(AccountBalanceRequestSchema, request, "Account Balance request");
|
|
649
|
+
const payload = {
|
|
650
|
+
Initiator: initiatorName,
|
|
651
|
+
SecurityCredential: securityCredential,
|
|
652
|
+
CommandID: "AccountBalance",
|
|
653
|
+
PartyA: String(validated.partyA.trim()),
|
|
654
|
+
IdentifierType: validated.identifierType,
|
|
655
|
+
ResultURL: validated.resultUrl,
|
|
656
|
+
QueueTimeOutURL: validated.queueTimeOutUrl,
|
|
657
|
+
Remarks: validated.remarks ?? "Account Balance Query"
|
|
658
|
+
};
|
|
659
|
+
const { data } = await httpRequest(`${baseUrl}/mpesa/accountbalance/v1/query`, withDarajaHttp({
|
|
660
|
+
method: "POST",
|
|
661
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
662
|
+
body: payload
|
|
663
|
+
}, http));
|
|
664
|
+
return parseWithSchema(AccountBalanceResponseSchema, data, "Account Balance response");
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
//#endregion
|
|
668
|
+
//#region src/schemas/b2b.ts
|
|
669
|
+
const B2BAsyncResponseSchema = z.object({
|
|
670
|
+
ConversationID: z.string(),
|
|
671
|
+
OriginatorConversationID: z.string(),
|
|
672
|
+
ResponseCode: z.string(),
|
|
673
|
+
ResponseDescription: z.string()
|
|
674
|
+
}).passthrough();
|
|
675
|
+
const B2BPaymentBaseSchema = z.object({
|
|
676
|
+
amount: KesAmountSchema,
|
|
677
|
+
partyA: NonEmptyStringSchema,
|
|
678
|
+
partyB: NonEmptyStringSchema,
|
|
679
|
+
accountReference: NonEmptyStringSchema,
|
|
680
|
+
requester: z.string().optional(),
|
|
681
|
+
remarks: z.string().optional(),
|
|
682
|
+
resultUrl: UrlSchema,
|
|
683
|
+
queueTimeOutUrl: UrlSchema,
|
|
684
|
+
occasion: z.string().optional()
|
|
685
|
+
});
|
|
686
|
+
const B2BBuyGoodsRequestSchema = B2BPaymentBaseSchema.extend({ commandId: z.literal("BusinessBuyGoods") });
|
|
687
|
+
const B2BPayBillRequestSchema = B2BPaymentBaseSchema.extend({ commandId: z.literal("BusinessPayBill") });
|
|
688
|
+
const B2BBuyGoodsResponseSchema = B2BAsyncResponseSchema;
|
|
689
|
+
const B2BPayBillResponseSchema = B2BAsyncResponseSchema;
|
|
690
|
+
const B2BExpressCheckoutRequestSchema = z.object({
|
|
691
|
+
primaryShortCode: NonEmptyStringSchema,
|
|
692
|
+
receiverShortCode: NonEmptyStringSchema,
|
|
693
|
+
amount: KesAmountSchema,
|
|
694
|
+
paymentRef: NonEmptyStringSchema,
|
|
695
|
+
callbackUrl: UrlSchema,
|
|
696
|
+
partnerName: NonEmptyStringSchema,
|
|
697
|
+
requestRefId: NonEmptyStringSchema.optional()
|
|
698
|
+
});
|
|
699
|
+
const B2BExpressCheckoutResponseSchema = z.object({
|
|
700
|
+
code: z.string(),
|
|
701
|
+
status: z.string()
|
|
702
|
+
}).passthrough();
|
|
703
|
+
const B2BResponseSchema = z.object({
|
|
704
|
+
ConversationID: z.string().optional(),
|
|
705
|
+
OriginatorConversationID: z.string().optional(),
|
|
706
|
+
ResponseCode: z.string(),
|
|
707
|
+
ResponseDescription: z.string()
|
|
708
|
+
}).passthrough();
|
|
709
|
+
|
|
710
|
+
//#endregion
|
|
711
|
+
//#region src/mpesa/b2b-express-checkout/initiate.ts
|
|
712
|
+
/**
|
|
713
|
+
* src/mpesa/b2b-express-checkout/initiate.ts
|
|
714
|
+
*
|
|
715
|
+
* B2B Express Checkout USSD Push to Till implementation.
|
|
716
|
+
*/
|
|
717
|
+
async function initiateB2BExpressCheckout(baseUrl, accessToken, request, http) {
|
|
718
|
+
const validated = parseWithSchema(B2BExpressCheckoutRequestSchema, request, "B2B Express Checkout request");
|
|
719
|
+
const amount = Math.round(validated.amount);
|
|
720
|
+
const payload = {
|
|
721
|
+
primaryShortCode: String(validated.primaryShortCode),
|
|
722
|
+
receiverShortCode: String(validated.receiverShortCode),
|
|
723
|
+
amount: String(amount),
|
|
724
|
+
paymentRef: validated.paymentRef,
|
|
725
|
+
callbackUrl: validated.callbackUrl,
|
|
726
|
+
partnerName: validated.partnerName,
|
|
727
|
+
RequestRefID: validated.requestRefId ?? generateRequestRefId()
|
|
728
|
+
};
|
|
729
|
+
const { data } = await httpRequest(`${baseUrl}/v1/ussdpush/get-msisdn`, withDarajaHttp({
|
|
730
|
+
method: "POST",
|
|
731
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
732
|
+
body: payload
|
|
733
|
+
}, http));
|
|
734
|
+
return parseWithSchema(B2BExpressCheckoutResponseSchema, data, "B2B Express Checkout response");
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
//#endregion
|
|
738
|
+
//#region src/mpesa/b2b-buy-goods/payment.ts
|
|
739
|
+
/**
|
|
740
|
+
* src/mpesa/b2b-buy-goods/payment.ts
|
|
741
|
+
*
|
|
742
|
+
* Initiates a Business Buy Goods payment via Safaricom Daraja.
|
|
743
|
+
* Endpoint: POST /mpesa/b2b/v1/paymentrequest
|
|
744
|
+
*
|
|
745
|
+
* Strictly follows the Safaricom Daraja Business Buy Goods API documentation:
|
|
746
|
+
* - CommandID must be "BusinessBuyGoods"
|
|
747
|
+
* - SenderIdentifierType is always "4" (hardcoded per docs)
|
|
748
|
+
* - RecieverIdentifierType is always "4" (hardcoded per docs)
|
|
749
|
+
* - Amount is sent as a string per the JSON spec
|
|
750
|
+
* - AccountReference is truncated to max 13 characters per docs
|
|
751
|
+
* - Requester and Occassion are optional
|
|
752
|
+
*/
|
|
753
|
+
/** Daraja Business Buy Goods endpoint — same as Pay Bill per docs */
|
|
754
|
+
const B2B_BUY_GOODS_ENDPOINT = "/mpesa/b2b/v1/paymentrequest";
|
|
755
|
+
/**
|
|
756
|
+
* Per documentation: SenderIdentifierType and RecieverIdentifierType
|
|
757
|
+
* must always be "4" (Organisation ShortCode). Not configurable.
|
|
758
|
+
*/
|
|
759
|
+
const IDENTIFIER_TYPE$2 = "4";
|
|
760
|
+
/**
|
|
761
|
+
* Initiates a Business Buy Goods payment request.
|
|
762
|
+
*
|
|
763
|
+
* Moves money from your MMF/Working account to the recipient's merchant account
|
|
764
|
+
* (till number, merchant store number, or Merchant HO).
|
|
765
|
+
* The sync response is acknowledgement only — the result arrives via resultUrl.
|
|
766
|
+
*
|
|
767
|
+
* @param baseUrl - Daraja base URL (sandbox or production)
|
|
768
|
+
* @param accessToken - Valid OAuth Bearer token
|
|
769
|
+
* @param securityCredential - RSA-encrypted initiator password (base64)
|
|
770
|
+
* @param initiatorName - M-Pesa API operator username with B2B role
|
|
771
|
+
* @param request - Business Buy Goods request parameters
|
|
772
|
+
* @returns Synchronous acknowledgement response from Daraja
|
|
773
|
+
* @throws {PesafyError} VALIDATION_ERROR for invalid input before HTTP call
|
|
774
|
+
* @throws {PesafyError} From httpRequest on network / API errors
|
|
775
|
+
*/
|
|
776
|
+
async function initiateB2BBuyGoods(baseUrl, accessToken, securityCredential, initiatorName, request, http) {
|
|
777
|
+
const validated = parseWithSchema(B2BBuyGoodsRequestSchema, request, "B2B Buy Goods request");
|
|
778
|
+
const amount = Math.round(validated.amount);
|
|
779
|
+
const payload = {
|
|
780
|
+
Initiator: initiatorName,
|
|
781
|
+
SecurityCredential: securityCredential,
|
|
782
|
+
CommandID: validated.commandId,
|
|
783
|
+
SenderIdentifierType: IDENTIFIER_TYPE$2,
|
|
784
|
+
RecieverIdentifierType: IDENTIFIER_TYPE$2,
|
|
785
|
+
Amount: String(amount),
|
|
786
|
+
PartyA: String(validated.partyA),
|
|
787
|
+
PartyB: String(validated.partyB),
|
|
788
|
+
AccountReference: validated.accountReference.slice(0, 13),
|
|
789
|
+
Remarks: validated.remarks ?? "Business Buy Goods",
|
|
790
|
+
QueueTimeOutURL: validated.queueTimeOutUrl,
|
|
791
|
+
ResultURL: validated.resultUrl
|
|
792
|
+
};
|
|
793
|
+
if (validated.requester?.trim()) payload["Requester"] = String(validated.requester);
|
|
794
|
+
if (validated.occasion?.trim()) payload["Occassion"] = validated.occasion;
|
|
795
|
+
const { data } = await httpRequest(`${baseUrl}${B2B_BUY_GOODS_ENDPOINT}`, withDarajaHttp({
|
|
796
|
+
method: "POST",
|
|
797
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
798
|
+
body: payload
|
|
799
|
+
}, http));
|
|
800
|
+
return parseWithSchema(B2BBuyGoodsResponseSchema, data, "B2B Buy Goods response");
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
//#endregion
|
|
804
|
+
//#region src/mpesa/b2b-pay-bill/payment.ts
|
|
805
|
+
/**
|
|
806
|
+
* src/mpesa/b2b-pay-bill/payment.ts
|
|
807
|
+
*
|
|
808
|
+
* Initiates a Business Pay Bill payment via Safaricom Daraja.
|
|
809
|
+
* Endpoint: POST /mpesa/b2b/v1/paymentrequest
|
|
810
|
+
*
|
|
811
|
+
* Strictly follows the Safaricom Daraja Business Pay Bill API documentation:
|
|
812
|
+
* - CommandID must be "BusinessPayBill"
|
|
813
|
+
* - SenderIdentifierType is always "4" (hardcoded per docs)
|
|
814
|
+
* - RecieverIdentifierType is always "4" (hardcoded per docs)
|
|
815
|
+
* - Amount is sent as a string per the JSON spec
|
|
816
|
+
* - AccountReference is truncated to max 13 characters per docs
|
|
817
|
+
* - Requester and Occassion are optional
|
|
818
|
+
*/
|
|
819
|
+
/** Daraja Business Pay Bill endpoint */
|
|
820
|
+
const B2B_PAY_BILL_ENDPOINT = "/mpesa/b2b/v1/paymentrequest";
|
|
821
|
+
/**
|
|
822
|
+
* Per documentation: SenderIdentifierType and RecieverIdentifierType
|
|
823
|
+
* must always be "4" (Organisation ShortCode). Not configurable.
|
|
824
|
+
*/
|
|
825
|
+
const IDENTIFIER_TYPE$1 = "4";
|
|
826
|
+
/**
|
|
827
|
+
* Initiates a Business Pay Bill payment request.
|
|
828
|
+
*
|
|
829
|
+
* Moves money from your MMF/Working account to the recipient's utility account.
|
|
830
|
+
* The sync response is acknowledgement only — the result arrives via resultUrl.
|
|
831
|
+
*
|
|
832
|
+
* @param baseUrl - Daraja base URL (sandbox or production)
|
|
833
|
+
* @param accessToken - Valid OAuth Bearer token
|
|
834
|
+
* @param securityCredential - RSA-encrypted initiator password (base64)
|
|
835
|
+
* @param initiatorName - M-Pesa API operator username with B2B role
|
|
836
|
+
* @param request - Business Pay Bill request parameters
|
|
837
|
+
* @returns Synchronous acknowledgement response from Daraja
|
|
838
|
+
* @throws {PesafyError} VALIDATION_ERROR for invalid input before HTTP call
|
|
839
|
+
* @throws {PesafyError} From httpRequest on network / API errors
|
|
840
|
+
*/
|
|
841
|
+
async function initiateB2BPayBill(baseUrl, accessToken, securityCredential, initiatorName, request, http) {
|
|
842
|
+
const validated = parseWithSchema(B2BPayBillRequestSchema, request, "B2B Pay Bill request");
|
|
843
|
+
const amount = Math.round(validated.amount);
|
|
844
|
+
const payload = {
|
|
845
|
+
Initiator: initiatorName,
|
|
846
|
+
SecurityCredential: securityCredential,
|
|
847
|
+
CommandID: validated.commandId,
|
|
848
|
+
SenderIdentifierType: IDENTIFIER_TYPE$1,
|
|
849
|
+
RecieverIdentifierType: IDENTIFIER_TYPE$1,
|
|
850
|
+
Amount: String(amount),
|
|
851
|
+
PartyA: String(validated.partyA),
|
|
852
|
+
PartyB: String(validated.partyB),
|
|
853
|
+
AccountReference: validated.accountReference.slice(0, 13),
|
|
854
|
+
Remarks: validated.remarks ?? "Business Pay Bill",
|
|
855
|
+
QueueTimeOutURL: validated.queueTimeOutUrl,
|
|
856
|
+
ResultURL: validated.resultUrl
|
|
857
|
+
};
|
|
858
|
+
if (validated.requester?.trim()) payload["Requester"] = String(validated.requester);
|
|
859
|
+
if (validated.occasion?.trim()) payload["Occassion"] = validated.occasion;
|
|
860
|
+
const { data } = await httpRequest(`${baseUrl}${B2B_PAY_BILL_ENDPOINT}`, withDarajaHttp({
|
|
861
|
+
method: "POST",
|
|
862
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
863
|
+
body: payload
|
|
864
|
+
}, http));
|
|
865
|
+
return parseWithSchema(B2BPayBillResponseSchema, data, "B2B Pay Bill response");
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
//#endregion
|
|
869
|
+
//#region src/schemas/b2c.ts
|
|
870
|
+
const B2CAsyncResponseSchema = z.object({
|
|
871
|
+
ConversationID: z.string(),
|
|
872
|
+
OriginatorConversationID: z.string(),
|
|
873
|
+
ResponseCode: z.string(),
|
|
874
|
+
ResponseDescription: z.string()
|
|
875
|
+
}).passthrough();
|
|
876
|
+
/** B2C Account Top Up (BusinessPayToBulk) */
|
|
877
|
+
const B2CRequestSchema = z.object({
|
|
878
|
+
commandId: z.literal("BusinessPayToBulk"),
|
|
879
|
+
amount: KesAmountSchema,
|
|
880
|
+
partyA: NonEmptyStringSchema,
|
|
881
|
+
partyB: NonEmptyStringSchema,
|
|
882
|
+
accountReference: NonEmptyStringSchema,
|
|
883
|
+
requester: z.string().optional(),
|
|
884
|
+
remarks: z.string().optional(),
|
|
885
|
+
resultUrl: UrlSchema,
|
|
886
|
+
queueTimeOutUrl: UrlSchema
|
|
887
|
+
});
|
|
888
|
+
const B2CResponseSchema = B2CAsyncResponseSchema;
|
|
889
|
+
const B2CDisbursementRequestSchema = z.object({
|
|
890
|
+
commandId: z.enum([
|
|
891
|
+
"BusinessPayment",
|
|
892
|
+
"SalaryPayment",
|
|
893
|
+
"PromotionPayment"
|
|
894
|
+
]),
|
|
895
|
+
amount: z.number().finite().positive(),
|
|
896
|
+
partyA: NonEmptyStringSchema,
|
|
897
|
+
partyB: NonEmptyStringSchema,
|
|
898
|
+
remarks: NonEmptyStringSchema,
|
|
899
|
+
queueTimeOutUrl: UrlSchema,
|
|
900
|
+
resultUrl: UrlSchema,
|
|
901
|
+
originatorConversationId: NonEmptyStringSchema.optional(),
|
|
902
|
+
occasion: z.string().optional()
|
|
903
|
+
});
|
|
904
|
+
const B2CDisbursementResponseSchema = B2CAsyncResponseSchema;
|
|
905
|
+
|
|
906
|
+
//#endregion
|
|
907
|
+
//#region src/mpesa/b2c/payment.ts
|
|
908
|
+
/**
|
|
909
|
+
* src/mpesa/b2c/payment.ts
|
|
910
|
+
*
|
|
911
|
+
* Initiates a B2C Account Top Up via Safaricom Daraja.
|
|
912
|
+
* Endpoint: POST /mpesa/b2b/v1/paymentrequest
|
|
913
|
+
*
|
|
914
|
+
* Strictly follows the Safaricom Daraja B2C Account Top Up API documentation:
|
|
915
|
+
* - CommandID must be "BusinessPayToBulk"
|
|
916
|
+
* - SenderIdentifierType is always "4" (hardcoded per docs)
|
|
917
|
+
* - RecieverIdentifierType is always "4" (hardcoded per docs)
|
|
918
|
+
* - Amount is sent as a string per the JSON spec
|
|
919
|
+
* - Requester is optional
|
|
920
|
+
*/
|
|
921
|
+
/** The only endpoint documented for this API */
|
|
922
|
+
const B2C_ENDPOINT = "/mpesa/b2b/v1/paymentrequest";
|
|
923
|
+
/**
|
|
924
|
+
* Per documentation: SenderIdentifierType and RecieverIdentifierType
|
|
925
|
+
* must always be "4" (Organisation ShortCode). Not configurable.
|
|
926
|
+
*/
|
|
927
|
+
const IDENTIFIER_TYPE = "4";
|
|
928
|
+
/**
|
|
929
|
+
* Initiates a B2C Account Top Up payment request.
|
|
930
|
+
*
|
|
931
|
+
* @param baseUrl - Daraja base URL (sandbox or production)
|
|
932
|
+
* @param accessToken - Valid OAuth Bearer token
|
|
933
|
+
* @param securityCredential - RSA-encrypted initiator password (base64)
|
|
934
|
+
* @param initiatorName - M-Pesa API operator username with B2B role
|
|
935
|
+
* @param request - B2C top-up request parameters
|
|
936
|
+
* @returns Synchronous acknowledgement response from Daraja
|
|
937
|
+
* @throws {PesafyError} VALIDATION_ERROR for invalid input before HTTP call
|
|
938
|
+
* @throws {PesafyError} From httpRequest on network / API errors
|
|
939
|
+
*/
|
|
940
|
+
async function initiateB2CPayment(baseUrl, accessToken, securityCredential, initiatorName, request, http) {
|
|
941
|
+
const validated = parseWithSchema(B2CRequestSchema, request, "B2C request");
|
|
942
|
+
const amount = Math.round(validated.amount);
|
|
943
|
+
const payload = {
|
|
944
|
+
Initiator: initiatorName,
|
|
945
|
+
SecurityCredential: securityCredential,
|
|
946
|
+
CommandID: validated.commandId,
|
|
947
|
+
SenderIdentifierType: IDENTIFIER_TYPE,
|
|
948
|
+
RecieverIdentifierType: IDENTIFIER_TYPE,
|
|
949
|
+
Amount: String(amount),
|
|
950
|
+
PartyA: String(validated.partyA),
|
|
951
|
+
PartyB: String(validated.partyB),
|
|
952
|
+
AccountReference: validated.accountReference,
|
|
953
|
+
Remarks: validated.remarks ?? "B2C Account Top Up",
|
|
954
|
+
QueueTimeOutURL: validated.queueTimeOutUrl,
|
|
955
|
+
ResultURL: validated.resultUrl
|
|
956
|
+
};
|
|
957
|
+
if (validated.requester?.trim()) payload["Requester"] = String(validated.requester);
|
|
958
|
+
const { data } = await httpRequest(`${baseUrl}${B2C_ENDPOINT}`, withDarajaHttp({
|
|
959
|
+
method: "POST",
|
|
960
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
961
|
+
body: payload
|
|
962
|
+
}, http));
|
|
963
|
+
return parseWithSchema(B2CResponseSchema, data, "B2C response");
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
//#endregion
|
|
967
|
+
//#region src/mpesa/b2c-disbursement/payment.ts
|
|
968
|
+
/**
|
|
969
|
+
* src/mpesa/b2c-disbursement/payment.ts
|
|
970
|
+
*
|
|
971
|
+
* Initiates a B2C Disbursement payment (Salary / Cashback / Promotion).
|
|
972
|
+
* Endpoint: POST /mpesa/b2c/v3/paymentrequest
|
|
973
|
+
*/
|
|
974
|
+
const B2C_DISBURSEMENT_ENDPOINT = "/mpesa/b2c/v3/paymentrequest";
|
|
975
|
+
const VALID_COMMAND_IDS = new Set([
|
|
976
|
+
"BusinessPayment",
|
|
977
|
+
"SalaryPayment",
|
|
978
|
+
"PromotionPayment"
|
|
979
|
+
]);
|
|
980
|
+
/**
|
|
981
|
+
* Initiates a B2C disbursement payment request.
|
|
982
|
+
*
|
|
983
|
+
* @param baseUrl - Daraja base URL (sandbox or production)
|
|
984
|
+
* @param accessToken - Valid OAuth Bearer token
|
|
985
|
+
* @param securityCredential - RSA-encrypted initiator password (base64)
|
|
986
|
+
* @param initiatorName - M-Pesa API operator username
|
|
987
|
+
* @param request - B2C disbursement request parameters
|
|
988
|
+
*/
|
|
989
|
+
async function initiateB2CDisbursement(baseUrl, accessToken, securityCredential, initiatorName, request, http) {
|
|
990
|
+
const originatorConversationId = request.originatorConversationId?.trim() || generateOriginatorConversationId();
|
|
991
|
+
const validated = parseWithSchema(B2CDisbursementRequestSchema, {
|
|
992
|
+
...request,
|
|
993
|
+
originatorConversationId
|
|
994
|
+
}, "B2C Disbursement request");
|
|
995
|
+
if (!validated.commandId || !VALID_COMMAND_IDS.has(validated.commandId)) throw createError({
|
|
996
|
+
code: "VALIDATION_ERROR",
|
|
997
|
+
message: `commandId must be one of: BusinessPayment, SalaryPayment, PromotionPayment. Got "${validated.commandId}".`
|
|
998
|
+
});
|
|
999
|
+
const amount = Math.round(validated.amount);
|
|
1000
|
+
if (!Number.isFinite(amount) || amount < 10) throw createError({
|
|
1001
|
+
code: "VALIDATION_ERROR",
|
|
1002
|
+
message: `amount must be ≥ 10 KES (got ${validated.amount} which rounds to ${amount}).`
|
|
1003
|
+
});
|
|
1004
|
+
if (!validated.partyA?.trim()) throw createError({
|
|
1005
|
+
code: "VALIDATION_ERROR",
|
|
1006
|
+
message: "partyA is required — the sending organisation shortcode."
|
|
1007
|
+
});
|
|
1008
|
+
if (!validated.partyB?.trim()) throw createError({
|
|
1009
|
+
code: "VALIDATION_ERROR",
|
|
1010
|
+
message: "partyB is required — the receiving customer MSISDN (2547XXXXXXXX)."
|
|
1011
|
+
});
|
|
1012
|
+
if (!validated.remarks?.trim()) throw createError({
|
|
1013
|
+
code: "VALIDATION_ERROR",
|
|
1014
|
+
message: "remarks is required (2–100 characters)."
|
|
1015
|
+
});
|
|
1016
|
+
if (!validated.resultUrl?.trim()) throw createError({
|
|
1017
|
+
code: "VALIDATION_ERROR",
|
|
1018
|
+
message: "resultUrl is required — Safaricom POSTs the async result here."
|
|
1019
|
+
});
|
|
1020
|
+
if (!validated.queueTimeOutUrl?.trim()) throw createError({
|
|
1021
|
+
code: "VALIDATION_ERROR",
|
|
1022
|
+
message: "queueTimeOutUrl is required — Safaricom calls this on request timeout."
|
|
1023
|
+
});
|
|
1024
|
+
const payload = {
|
|
1025
|
+
OriginatorConversationID: originatorConversationId,
|
|
1026
|
+
InitiatorName: initiatorName,
|
|
1027
|
+
SecurityCredential: securityCredential,
|
|
1028
|
+
CommandID: validated.commandId,
|
|
1029
|
+
Amount: amount,
|
|
1030
|
+
PartyA: String(validated.partyA),
|
|
1031
|
+
PartyB: String(validated.partyB),
|
|
1032
|
+
Remarks: validated.remarks,
|
|
1033
|
+
QueueTimeOutURL: validated.queueTimeOutUrl,
|
|
1034
|
+
ResultURL: validated.resultUrl
|
|
1035
|
+
};
|
|
1036
|
+
if (validated.occasion?.trim()) payload["Occassion"] = validated.occasion;
|
|
1037
|
+
const { data } = await httpRequest(`${baseUrl}${B2C_DISBURSEMENT_ENDPOINT}`, withDarajaHttp({
|
|
1038
|
+
method: "POST",
|
|
1039
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1040
|
+
body: payload
|
|
1041
|
+
}, http));
|
|
1042
|
+
return parseWithSchema(B2CDisbursementResponseSchema, data, "B2C Disbursement response");
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
//#endregion
|
|
1046
|
+
//#region src/schemas/bill-manager.ts
|
|
1047
|
+
const SendRemindersSchema = z.enum(["0", "1"]);
|
|
1048
|
+
const BillManagerOptInRequestSchema = z.object({
|
|
1049
|
+
shortcode: NonEmptyStringSchema,
|
|
1050
|
+
email: NonEmptyStringSchema,
|
|
1051
|
+
officialContact: NonEmptyStringSchema,
|
|
1052
|
+
sendReminders: SendRemindersSchema,
|
|
1053
|
+
logo: z.string().optional(),
|
|
1054
|
+
callbackUrl: UrlSchema
|
|
1055
|
+
});
|
|
1056
|
+
const BillManagerOptInResponseSchema = z.object({
|
|
1057
|
+
app_key: z.string().optional(),
|
|
1058
|
+
resmsg: z.string(),
|
|
1059
|
+
rescode: z.string()
|
|
1060
|
+
}).passthrough();
|
|
1061
|
+
const BillManagerUpdateOptInRequestSchema = BillManagerOptInRequestSchema;
|
|
1062
|
+
const BillManagerUpdateOptInResponseSchema = z.object({
|
|
1063
|
+
resmsg: z.string(),
|
|
1064
|
+
rescode: z.string()
|
|
1065
|
+
}).passthrough();
|
|
1066
|
+
const BillManagerInvoiceItemSchema = z.object({
|
|
1067
|
+
itemName: NonEmptyStringSchema,
|
|
1068
|
+
amount: KesAmountSchema
|
|
1069
|
+
});
|
|
1070
|
+
const BillManagerSingleInvoiceRequestSchema = z.object({
|
|
1071
|
+
externalReference: NonEmptyStringSchema,
|
|
1072
|
+
billedFullName: NonEmptyStringSchema,
|
|
1073
|
+
billedPhoneNumber: NonEmptyStringSchema,
|
|
1074
|
+
billedPeriod: NonEmptyStringSchema,
|
|
1075
|
+
invoiceName: NonEmptyStringSchema,
|
|
1076
|
+
dueDate: NonEmptyStringSchema,
|
|
1077
|
+
accountReference: NonEmptyStringSchema,
|
|
1078
|
+
amount: KesAmountSchema,
|
|
1079
|
+
invoiceItems: z.array(BillManagerInvoiceItemSchema).optional()
|
|
1080
|
+
});
|
|
1081
|
+
const BillManagerSingleInvoiceResponseSchema = z.object({
|
|
1082
|
+
Status_Message: z.string().optional(),
|
|
1083
|
+
resmsg: z.string(),
|
|
1084
|
+
rescode: z.string()
|
|
1085
|
+
}).passthrough();
|
|
1086
|
+
const BillManagerBulkInvoiceRequestSchema = z.object({ invoices: z.array(BillManagerSingleInvoiceRequestSchema).min(1).max(1e3) });
|
|
1087
|
+
const BillManagerBulkInvoiceResponseSchema = z.object({
|
|
1088
|
+
Status_Message: z.string().optional(),
|
|
1089
|
+
resmsg: z.string(),
|
|
1090
|
+
rescode: z.string()
|
|
1091
|
+
}).passthrough();
|
|
1092
|
+
const BillManagerCancelInvoiceRequestSchema = z.object({ externalReference: NonEmptyStringSchema });
|
|
1093
|
+
const BillManagerCancelInvoiceResponseSchema = z.object({
|
|
1094
|
+
Status_Message: z.string().optional(),
|
|
1095
|
+
resmsg: z.string(),
|
|
1096
|
+
rescode: z.string(),
|
|
1097
|
+
errors: z.array(z.unknown()).optional()
|
|
1098
|
+
}).passthrough();
|
|
1099
|
+
const BillManagerCancelBulkInvoiceRequestSchema = z.object({ externalReferences: z.array(NonEmptyStringSchema).min(1) });
|
|
1100
|
+
const BillManagerCancelBulkInvoiceResponseSchema = z.object({
|
|
1101
|
+
Status_Message: z.string().optional(),
|
|
1102
|
+
resmsg: z.string(),
|
|
1103
|
+
rescode: z.string(),
|
|
1104
|
+
errors: z.array(z.unknown()).optional()
|
|
1105
|
+
}).passthrough();
|
|
1106
|
+
const BillManagerReconciliationRequestSchema = z.object({
|
|
1107
|
+
paymentDate: NonEmptyStringSchema,
|
|
1108
|
+
paidAmount: NonEmptyStringSchema,
|
|
1109
|
+
accountReference: NonEmptyStringSchema,
|
|
1110
|
+
transactionId: NonEmptyStringSchema,
|
|
1111
|
+
phoneNumber: NonEmptyStringSchema,
|
|
1112
|
+
fullName: NonEmptyStringSchema,
|
|
1113
|
+
invoiceName: NonEmptyStringSchema,
|
|
1114
|
+
externalReference: NonEmptyStringSchema
|
|
1115
|
+
});
|
|
1116
|
+
const BillManagerReconciliationResponseSchema = z.object({
|
|
1117
|
+
resmsg: z.string(),
|
|
1118
|
+
rescode: z.string()
|
|
1119
|
+
}).passthrough();
|
|
1120
|
+
|
|
1121
|
+
//#endregion
|
|
1122
|
+
//#region src/mpesa/bill-manager/invoice.ts
|
|
1123
|
+
/**
|
|
1124
|
+
* src/mpesa/bill-manager/invoice.ts
|
|
1125
|
+
*
|
|
1126
|
+
* Bill Manager — opt-in, invoice creation/cancellation, and payment reconciliation.
|
|
1127
|
+
*
|
|
1128
|
+
* Strictly aligned with Safaricom Daraja Bill Manager API documentation.
|
|
1129
|
+
*
|
|
1130
|
+
* APIs:
|
|
1131
|
+
* POST /v1/billmanager-invoice/optin — Opt-in shortcode
|
|
1132
|
+
* POST /v1/billmanager-invoice/change-optin-details — Update opt-in details
|
|
1133
|
+
* POST /v1/billmanager-invoice/single-invoicing — Send a single invoice
|
|
1134
|
+
* POST /v1/billmanager-invoice/bulk-invoicing — Send bulk invoices (up to 1000)
|
|
1135
|
+
* POST /v1/billmanager-invoice/cancel-single-invoice — Cancel a single invoice
|
|
1136
|
+
* POST /v1/billmanager-invoice/cancel-bulk-invoices — Cancel multiple invoices
|
|
1137
|
+
* POST /v1/billmanager-invoice/reconciliation — Acknowledge a payment
|
|
1138
|
+
*/
|
|
1139
|
+
async function billManagerOptIn(baseUrl, accessToken, request, http) {
|
|
1140
|
+
const validated = parseWithSchema(BillManagerOptInRequestSchema, request, "Bill Manager opt-in request");
|
|
1141
|
+
const payload = {
|
|
1142
|
+
shortcode: validated.shortcode,
|
|
1143
|
+
email: validated.email,
|
|
1144
|
+
officialContact: validated.officialContact,
|
|
1145
|
+
sendReminders: validated.sendReminders,
|
|
1146
|
+
logo: validated.logo ?? "",
|
|
1147
|
+
callbackurl: validated.callbackUrl
|
|
1148
|
+
};
|
|
1149
|
+
const { data } = await httpRequest(`${baseUrl}/v1/billmanager-invoice/optin`, withDarajaHttp({
|
|
1150
|
+
method: "POST",
|
|
1151
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1152
|
+
body: payload
|
|
1153
|
+
}, http));
|
|
1154
|
+
return parseWithSchema(BillManagerOptInResponseSchema, data, "Bill Manager opt-in response");
|
|
1155
|
+
}
|
|
1156
|
+
async function updateOptIn(baseUrl, accessToken, request, http) {
|
|
1157
|
+
const validated = parseWithSchema(BillManagerUpdateOptInRequestSchema, request, "Bill Manager update opt-in request");
|
|
1158
|
+
const payload = {
|
|
1159
|
+
shortcode: validated.shortcode,
|
|
1160
|
+
email: validated.email,
|
|
1161
|
+
officialContact: validated.officialContact,
|
|
1162
|
+
sendReminders: validated.sendReminders,
|
|
1163
|
+
logo: validated.logo ?? "",
|
|
1164
|
+
callbackurl: validated.callbackUrl
|
|
1165
|
+
};
|
|
1166
|
+
const { data } = await httpRequest(`${baseUrl}/v1/billmanager-invoice/change-optin-details`, withDarajaHttp({
|
|
1167
|
+
method: "POST",
|
|
1168
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1169
|
+
body: payload
|
|
1170
|
+
}, http));
|
|
1171
|
+
return parseWithSchema(BillManagerUpdateOptInResponseSchema, data, "Bill Manager update opt-in response");
|
|
1172
|
+
}
|
|
1173
|
+
async function sendSingleInvoice(baseUrl, accessToken, request, http) {
|
|
1174
|
+
const validated = parseWithSchema(BillManagerSingleInvoiceRequestSchema, request, "Bill Manager single invoice request");
|
|
1175
|
+
const amount = Math.round(validated.amount);
|
|
1176
|
+
const payload = {
|
|
1177
|
+
externalReference: validated.externalReference,
|
|
1178
|
+
billedFullName: validated.billedFullName,
|
|
1179
|
+
billedPhoneNumber: validated.billedPhoneNumber,
|
|
1180
|
+
billedPeriod: validated.billedPeriod,
|
|
1181
|
+
invoiceName: validated.invoiceName,
|
|
1182
|
+
dueDate: validated.dueDate,
|
|
1183
|
+
accountReference: validated.accountReference,
|
|
1184
|
+
amount: String(amount),
|
|
1185
|
+
invoiceItems: validated.invoiceItems?.map((i) => ({
|
|
1186
|
+
itemName: i.itemName,
|
|
1187
|
+
amount: String(Math.round(i.amount))
|
|
1188
|
+
})) ?? []
|
|
1189
|
+
};
|
|
1190
|
+
const { data } = await httpRequest(`${baseUrl}/v1/billmanager-invoice/single-invoicing`, withDarajaHttp({
|
|
1191
|
+
method: "POST",
|
|
1192
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1193
|
+
body: payload
|
|
1194
|
+
}, http));
|
|
1195
|
+
return parseWithSchema(BillManagerSingleInvoiceResponseSchema, data, "Bill Manager single invoice response");
|
|
1196
|
+
}
|
|
1197
|
+
async function sendBulkInvoices(baseUrl, accessToken, request, http) {
|
|
1198
|
+
const payload = parseWithSchema(BillManagerBulkInvoiceRequestSchema, request, "Bill Manager bulk invoice request").invoices.map((inv) => ({
|
|
1199
|
+
externalReference: inv.externalReference,
|
|
1200
|
+
billedFullName: inv.billedFullName,
|
|
1201
|
+
billedPhoneNumber: inv.billedPhoneNumber,
|
|
1202
|
+
billedPeriod: inv.billedPeriod,
|
|
1203
|
+
invoiceName: inv.invoiceName,
|
|
1204
|
+
dueDate: inv.dueDate,
|
|
1205
|
+
accountReference: inv.accountReference,
|
|
1206
|
+
amount: String(Math.round(inv.amount)),
|
|
1207
|
+
invoiceItems: inv.invoiceItems?.map((item) => ({
|
|
1208
|
+
itemName: item.itemName,
|
|
1209
|
+
amount: String(Math.round(item.amount))
|
|
1210
|
+
})) ?? []
|
|
1211
|
+
}));
|
|
1212
|
+
const { data } = await httpRequest(`${baseUrl}/v1/billmanager-invoice/bulk-invoicing`, withDarajaHttp({
|
|
1213
|
+
method: "POST",
|
|
1214
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1215
|
+
body: payload
|
|
1216
|
+
}, http));
|
|
1217
|
+
return parseWithSchema(BillManagerBulkInvoiceResponseSchema, data, "Bill Manager bulk invoice response");
|
|
1218
|
+
}
|
|
1219
|
+
async function cancelInvoice(baseUrl, accessToken, request, http) {
|
|
1220
|
+
const validated = parseWithSchema(BillManagerCancelInvoiceRequestSchema, request, "Bill Manager cancel invoice request");
|
|
1221
|
+
const { data } = await httpRequest(`${baseUrl}/v1/billmanager-invoice/cancel-single-invoice`, withDarajaHttp({
|
|
1222
|
+
method: "POST",
|
|
1223
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1224
|
+
body: { externalReference: validated.externalReference }
|
|
1225
|
+
}, http));
|
|
1226
|
+
return parseWithSchema(BillManagerCancelInvoiceResponseSchema, data, "Bill Manager cancel invoice response");
|
|
1227
|
+
}
|
|
1228
|
+
async function cancelBulkInvoices(baseUrl, accessToken, request, http) {
|
|
1229
|
+
const payload = parseWithSchema(BillManagerCancelBulkInvoiceRequestSchema, request, "Bill Manager cancel bulk invoices request").externalReferences.map((ref) => ({ externalReference: ref }));
|
|
1230
|
+
const { data } = await httpRequest(`${baseUrl}/v1/billmanager-invoice/cancel-bulk-invoices`, withDarajaHttp({
|
|
1231
|
+
method: "POST",
|
|
1232
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1233
|
+
body: payload
|
|
1234
|
+
}, http));
|
|
1235
|
+
return parseWithSchema(BillManagerCancelBulkInvoiceResponseSchema, data, "Bill Manager cancel bulk invoices response");
|
|
1236
|
+
}
|
|
1237
|
+
async function reconcilePayment(baseUrl, accessToken, request, http) {
|
|
1238
|
+
const validated = parseWithSchema(BillManagerReconciliationRequestSchema, request, "Bill Manager reconciliation request");
|
|
1239
|
+
const payload = {
|
|
1240
|
+
paymentDate: validated.paymentDate,
|
|
1241
|
+
paidAmount: validated.paidAmount,
|
|
1242
|
+
accountReference: validated.accountReference,
|
|
1243
|
+
transactionId: validated.transactionId,
|
|
1244
|
+
phoneNumber: validated.phoneNumber,
|
|
1245
|
+
fullName: validated.fullName,
|
|
1246
|
+
invoiceName: validated.invoiceName,
|
|
1247
|
+
externalReference: validated.externalReference
|
|
1248
|
+
};
|
|
1249
|
+
const { data } = await httpRequest(`${baseUrl}/v1/billmanager-invoice/reconciliation`, withDarajaHttp({
|
|
1250
|
+
method: "POST",
|
|
1251
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1252
|
+
body: payload
|
|
1253
|
+
}, http));
|
|
1254
|
+
return parseWithSchema(BillManagerReconciliationResponseSchema, data, "Bill Manager reconciliation response");
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
//#endregion
|
|
1258
|
+
//#region src/schemas/c2b.ts
|
|
1259
|
+
const C2BRegisterUrlRequestSchema = z.object({
|
|
1260
|
+
shortCode: NonEmptyStringSchema,
|
|
1261
|
+
responseType: z.enum(["Completed", "Cancelled"]),
|
|
1262
|
+
confirmationUrl: UrlSchema,
|
|
1263
|
+
validationUrl: UrlSchema,
|
|
1264
|
+
apiVersion: z.enum(["v1", "v2"]).optional()
|
|
1265
|
+
});
|
|
1266
|
+
const C2BBaseResponseSchema = z.object({
|
|
1267
|
+
OriginatorCoversationID: z.string(),
|
|
1268
|
+
ResponseCode: z.string(),
|
|
1269
|
+
ResponseDescription: z.string()
|
|
1270
|
+
}).passthrough();
|
|
1271
|
+
const C2BRegisterUrlResponseSchema = C2BBaseResponseSchema;
|
|
1272
|
+
const C2BSimulateResponseSchema = C2BBaseResponseSchema;
|
|
1273
|
+
const C2BSimulateRequestSchema = z.object({
|
|
1274
|
+
shortCode: z.union([NonEmptyStringSchema, z.number()]),
|
|
1275
|
+
commandId: z.enum(["CustomerPayBillOnline", "CustomerBuyGoodsOnline"]),
|
|
1276
|
+
amount: KesAmountSchema,
|
|
1277
|
+
msisdn: z.union([NonEmptyStringSchema, z.number()]),
|
|
1278
|
+
billRefNumber: z.union([NonEmptyStringSchema, z.null()]).optional(),
|
|
1279
|
+
apiVersion: z.enum(["v1", "v2"]).optional()
|
|
1280
|
+
}).superRefine((data, ctx) => {
|
|
1281
|
+
if (data.commandId === "CustomerPayBillOnline" && !data.billRefNumber?.trim()) ctx.addIssue({
|
|
1282
|
+
code: "custom",
|
|
1283
|
+
message: "billRefNumber is required for CustomerPayBillOnline",
|
|
1284
|
+
path: ["billRefNumber"]
|
|
1285
|
+
});
|
|
1286
|
+
});
|
|
1287
|
+
const C2BValidationWebhookSchema = z.object({
|
|
1288
|
+
TransactionType: z.string(),
|
|
1289
|
+
TransID: z.string(),
|
|
1290
|
+
TransTime: z.string(),
|
|
1291
|
+
TransAmount: z.union([z.string(), z.number()]),
|
|
1292
|
+
BusinessShortCode: z.string(),
|
|
1293
|
+
BillRefNumber: z.string().optional(),
|
|
1294
|
+
MSISDN: z.string()
|
|
1295
|
+
}).passthrough();
|
|
1296
|
+
|
|
1297
|
+
//#endregion
|
|
1298
|
+
//#region src/mpesa/c2b/register-url.ts
|
|
1299
|
+
/**
|
|
1300
|
+
* src/mpesa/c2b/register-url.ts
|
|
1301
|
+
*
|
|
1302
|
+
* C2B Register URL implementation.
|
|
1303
|
+
* Strictly aligned with Safaricom Daraja C2B Register URL API documentation.
|
|
1304
|
+
*
|
|
1305
|
+
* Endpoint (v1 — documented primary):
|
|
1306
|
+
* Sandbox: POST https://sandbox.safaricom.co.ke/mpesa/c2b/v1/registerurl
|
|
1307
|
+
* Production: POST https://api.safaricom.co.ke/mpesa/c2b/v1/registerurl
|
|
1308
|
+
*
|
|
1309
|
+
* Also supports v2 via the apiVersion option.
|
|
1310
|
+
*/
|
|
1311
|
+
/**
|
|
1312
|
+
* Forbidden URL keywords per Daraja documentation:
|
|
1313
|
+
* "Avoid keywords such as M-PESA, M-Pesa, Safaricom, exe, exec, cme, or variants in your URLs."
|
|
1314
|
+
*
|
|
1315
|
+
* We lowercase-compare, so "MPESA", "Mpesa", "mPeSa" are all caught.
|
|
1316
|
+
*
|
|
1317
|
+
* Additional blocked keywords (documented variants): cmd, sql, query
|
|
1318
|
+
*/
|
|
1319
|
+
const FORBIDDEN_URL_KEYWORDS = [
|
|
1320
|
+
"mpesa",
|
|
1321
|
+
"safaricom",
|
|
1322
|
+
"exec",
|
|
1323
|
+
"exe",
|
|
1324
|
+
"cme",
|
|
1325
|
+
"cmd",
|
|
1326
|
+
"sql",
|
|
1327
|
+
"query"
|
|
1328
|
+
];
|
|
1329
|
+
/**
|
|
1330
|
+
* Validates a callback URL against Daraja's documented URL requirements.
|
|
1331
|
+
* Throws PesafyError if the URL violates any documented rule.
|
|
1332
|
+
*/
|
|
1333
|
+
function validateCallbackUrl(url, fieldName) {
|
|
1334
|
+
if (!url || !url.trim()) throw createError({
|
|
1335
|
+
code: "VALIDATION_ERROR",
|
|
1336
|
+
message: `${fieldName} is required`
|
|
1337
|
+
});
|
|
1338
|
+
const lower = url.toLowerCase();
|
|
1339
|
+
for (const keyword of FORBIDDEN_URL_KEYWORDS) if (lower.includes(keyword)) throw createError({
|
|
1340
|
+
code: "VALIDATION_ERROR",
|
|
1341
|
+
message: `${fieldName} must not contain the keyword "${keyword}". Daraja rejects URLs containing: mpesa, safaricom, exe, exec, cme (and variants: cmd, sql, query).`
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Registers C2B Confirmation and Validation URLs with Safaricom.
|
|
1346
|
+
*
|
|
1347
|
+
* Per Daraja documentation:
|
|
1348
|
+
* - Sandbox: may be called multiple times (URLs can be overwritten).
|
|
1349
|
+
* - Production: one-time call. To change URLs, delete existing on the portal
|
|
1350
|
+
* or email apisupport@safaricom.co.ke, then re-register.
|
|
1351
|
+
* - ResponseType must be sentence-case: "Completed" or "Cancelled".
|
|
1352
|
+
* - Both URLs must be publicly accessible and internet-reachable.
|
|
1353
|
+
* - Production requires HTTPS; Sandbox allows HTTP.
|
|
1354
|
+
* - Do not use public URL testers (ngrok, mockbin, requestbin) — they are blocked.
|
|
1355
|
+
* - The Validation URL is only called when external validation is enabled.
|
|
1356
|
+
* To activate, email apisupport@safaricom.co.ke.
|
|
1357
|
+
* - If M-PESA cannot reach your Validation URL within ~8 seconds, it defaults
|
|
1358
|
+
* to the ResponseType action set during registration.
|
|
1359
|
+
*
|
|
1360
|
+
* @param baseUrl - Daraja environment base URL
|
|
1361
|
+
* @param accessToken - Valid OAuth bearer token from Authorization API
|
|
1362
|
+
* @param request - Registration parameters
|
|
1363
|
+
* @returns - Daraja registration response (ResponseCode "0" = success)
|
|
1364
|
+
*/
|
|
1365
|
+
async function registerC2BUrls(baseUrl, accessToken, request, http) {
|
|
1366
|
+
const validated = parseWithSchema(C2BRegisterUrlRequestSchema, request, "C2B Register URL request");
|
|
1367
|
+
validateCallbackUrl(validated.confirmationUrl, "confirmationUrl");
|
|
1368
|
+
validateCallbackUrl(validated.validationUrl, "validationUrl");
|
|
1369
|
+
const version = validated.apiVersion ?? "v2";
|
|
1370
|
+
const payload = {
|
|
1371
|
+
ShortCode: String(validated.shortCode),
|
|
1372
|
+
ResponseType: validated.responseType,
|
|
1373
|
+
ConfirmationURL: validated.confirmationUrl,
|
|
1374
|
+
ValidationURL: validated.validationUrl
|
|
1375
|
+
};
|
|
1376
|
+
const { data } = await httpRequest(`${baseUrl}/mpesa/c2b/${version}/registerurl`, withDarajaHttp({
|
|
1377
|
+
method: "POST",
|
|
1378
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1379
|
+
body: payload
|
|
1380
|
+
}, http));
|
|
1381
|
+
return parseWithSchema(C2BRegisterUrlResponseSchema, data, "C2B Register URL response");
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
//#endregion
|
|
1385
|
+
//#region src/mpesa/c2b/simulate.ts
|
|
1386
|
+
/**
|
|
1387
|
+
* src/mpesa/c2b/simulate.ts
|
|
1388
|
+
*
|
|
1389
|
+
* C2B Simulate implementation (Sandbox ONLY).
|
|
1390
|
+
* Strictly aligned with Safaricom Daraja C2B API documentation.
|
|
1391
|
+
*
|
|
1392
|
+
* Per docs: "NB: Simulation is not supported on production."
|
|
1393
|
+
*
|
|
1394
|
+
* Endpoint (v2, sandbox only):
|
|
1395
|
+
* POST https://sandbox.safaricom.co.ke/mpesa/c2b/v2/simulate
|
|
1396
|
+
*/
|
|
1397
|
+
/**
|
|
1398
|
+
* Simulates a C2B customer payment. SANDBOX ONLY.
|
|
1399
|
+
*
|
|
1400
|
+
* Daraja payload shape:
|
|
1401
|
+
* {
|
|
1402
|
+
* "ShortCode": 600984, ← numeric
|
|
1403
|
+
* "CommandID": "CustomerPayBillOnline",
|
|
1404
|
+
* "Amount": 1, ← numeric, whole number ≥ 1
|
|
1405
|
+
* "Msisdn": 254708374149, ← numeric
|
|
1406
|
+
* "BillRefNumber": "AccountRef" ← Paybill only; OMIT for BuyGoods
|
|
1407
|
+
* }
|
|
1408
|
+
*
|
|
1409
|
+
* CRITICAL — BillRefNumber handling (per docs):
|
|
1410
|
+
* "Account reference for Customer paybills and null for customer buy goods"
|
|
1411
|
+
* We omit the key entirely for BuyGoods (not null, not "") because Daraja
|
|
1412
|
+
* validates field presence and rejects even null/empty values for Buy Goods.
|
|
1413
|
+
*
|
|
1414
|
+
* @param baseUrl - Must be the sandbox base URL
|
|
1415
|
+
* @param accessToken - Valid OAuth bearer token from Authorization API
|
|
1416
|
+
* @param request - Simulation parameters
|
|
1417
|
+
* @returns - Daraja simulate response (ResponseCode "0" = accepted)
|
|
1418
|
+
*/
|
|
1419
|
+
async function simulateC2B(baseUrl, accessToken, request, http) {
|
|
1420
|
+
const validated = parseWithSchema(C2BSimulateRequestSchema, request, "C2B Simulate request");
|
|
1421
|
+
if (!baseUrl.includes("sandbox")) throw createError({
|
|
1422
|
+
code: "VALIDATION_ERROR",
|
|
1423
|
+
message: "C2B simulate is only available in the Sandbox environment (per Daraja docs). In production, customers initiate payments directly via M-PESA App, USSD, or SIM Toolkit."
|
|
1424
|
+
});
|
|
1425
|
+
const amount = Math.round(validated.amount);
|
|
1426
|
+
const isBuyGoods = validated.commandId === "CustomerBuyGoodsOnline";
|
|
1427
|
+
const version = validated.apiVersion ?? "v2";
|
|
1428
|
+
const payload = {
|
|
1429
|
+
ShortCode: Number(validated.shortCode),
|
|
1430
|
+
CommandID: validated.commandId,
|
|
1431
|
+
Amount: amount,
|
|
1432
|
+
Msisdn: Number(validated.msisdn)
|
|
1433
|
+
};
|
|
1434
|
+
if (!isBuyGoods) payload["BillRefNumber"] = validated.billRefNumber.trim();
|
|
1435
|
+
const { data } = await httpRequest(`${baseUrl}/mpesa/c2b/${version}/simulate`, withDarajaHttp({
|
|
1436
|
+
method: "POST",
|
|
1437
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1438
|
+
body: payload
|
|
1439
|
+
}, http));
|
|
1440
|
+
return parseWithSchema(C2BSimulateResponseSchema, data, "C2B Simulate response");
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
//#endregion
|
|
1444
|
+
//#region src/mpesa/dynamic-qr/generate.ts
|
|
1445
|
+
/**
|
|
1446
|
+
* src/mpesa/dynamic-qr/generate.ts
|
|
1447
|
+
*
|
|
1448
|
+
* Core logic for the Safaricom Daraja Dynamic QR Code API.
|
|
1449
|
+
*
|
|
1450
|
+
* API: POST /mpesa/qrcode/v1/generate
|
|
1451
|
+
*
|
|
1452
|
+
* Error codes from Daraja docs:
|
|
1453
|
+
* 404.001.04 — Invalid Authentication Header
|
|
1454
|
+
* 400.002.05 — Invalid Request Payload
|
|
1455
|
+
* 400.003.01 — Invalid Access Token
|
|
1456
|
+
*/
|
|
1457
|
+
/**
|
|
1458
|
+
* Maps Daraja-specific error codes to structured PesafyErrors with
|
|
1459
|
+
* actionable developer guidance.
|
|
1460
|
+
*
|
|
1461
|
+
* @internal
|
|
1462
|
+
*/
|
|
1463
|
+
function mapDarajaError(errorCode, errorMessage) {
|
|
1464
|
+
switch (errorCode) {
|
|
1465
|
+
case "404.001.04": return new PesafyError({
|
|
1466
|
+
code: "AUTH_FAILED",
|
|
1467
|
+
message: `Daraja rejected the request due to an invalid authentication header. Ensure the Dynamic QR endpoint is called with POST and that the Authorization: Bearer <token> header is present. Daraja: "${errorMessage}"`,
|
|
1468
|
+
statusCode: 404
|
|
1469
|
+
});
|
|
1470
|
+
case "400.003.01": return new PesafyError({
|
|
1471
|
+
code: "AUTH_FAILED",
|
|
1472
|
+
message: `The M-PESA access token is invalid or has expired. Call clearTokenCache() on the Mpesa instance to force a token refresh and retry the request. Daraja: "${errorMessage}"`,
|
|
1473
|
+
statusCode: 401
|
|
1474
|
+
});
|
|
1475
|
+
case "400.002.05": return new PesafyError({
|
|
1476
|
+
code: "VALIDATION_ERROR",
|
|
1477
|
+
message: `Daraja rejected the request payload as malformed. Verify that all required fields (MerchantName, RefNo, Amount, TrxCode, CPI, Size) are present and have correct types. Daraja: "${errorMessage}"`,
|
|
1478
|
+
statusCode: 400
|
|
1479
|
+
});
|
|
1480
|
+
default: return new PesafyError({
|
|
1481
|
+
code: "REQUEST_FAILED",
|
|
1482
|
+
message: `Dynamic QR request failed (${errorCode}): ${errorMessage}`,
|
|
1483
|
+
statusCode: 400
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Returns `true` when the raw response body looks like a Daraja error object.
|
|
1489
|
+
* @internal
|
|
1490
|
+
*/
|
|
1491
|
+
function isDarajaError(body) {
|
|
1492
|
+
return typeof body === "object" && body !== null && "errorCode" in body && typeof body.errorCode === "string";
|
|
1493
|
+
}
|
|
1494
|
+
/**
|
|
1495
|
+
* Returns `true` when the response has the required QR success fields.
|
|
1496
|
+
* @internal
|
|
1497
|
+
*/
|
|
1498
|
+
function isDarajaSuccess(body) {
|
|
1499
|
+
return typeof body === "object" && body !== null && "ResponseCode" in body && "QRCode" in body && typeof body.QRCode === "string" && body.QRCode.length > 0;
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* Generates a Dynamic M-PESA QR Code via the Safaricom Daraja API.
|
|
1503
|
+
*
|
|
1504
|
+
* The QR code can be rendered directly in a browser as a base64 PNG:
|
|
1505
|
+
* ```html
|
|
1506
|
+
* <img src="data:image/png;base64,{response.QRCode}" />
|
|
1507
|
+
* ```
|
|
1508
|
+
* Or written to disk:
|
|
1509
|
+
* ```ts
|
|
1510
|
+
* import { writeFileSync } from 'node:fs'
|
|
1511
|
+
* writeFileSync('qr.png', Buffer.from(response.QRCode, 'base64'))
|
|
1512
|
+
* ```
|
|
1513
|
+
*
|
|
1514
|
+
* @param baseUrl - Daraja base URL (`https://sandbox.safaricom.co.ke` or
|
|
1515
|
+
* `https://api.safaricom.co.ke`)
|
|
1516
|
+
* @param accessToken - Valid Daraja OAuth2 Bearer token
|
|
1517
|
+
* @param request - QR generation parameters (see {@link DynamicQRRequest})
|
|
1518
|
+
* @returns - Daraja response including the base64-encoded QR image
|
|
1519
|
+
*
|
|
1520
|
+
* @throws {PesafyError} `VALIDATION_ERROR` — payload failed pre-flight checks
|
|
1521
|
+
* @throws {PesafyError} `AUTH_FAILED` — bad/expired token or wrong headers
|
|
1522
|
+
* @throws {PesafyError} `REQUEST_FAILED` — unexpected Daraja error
|
|
1523
|
+
*
|
|
1524
|
+
* @example
|
|
1525
|
+
* ```ts
|
|
1526
|
+
* const response = await generateDynamicQR(
|
|
1527
|
+
* 'https://sandbox.safaricom.co.ke',
|
|
1528
|
+
* accessToken,
|
|
1529
|
+
* {
|
|
1530
|
+
* merchantName: 'Test Supermarket',
|
|
1531
|
+
* refNo: 'INV-001',
|
|
1532
|
+
* amount: 500,
|
|
1533
|
+
* trxCode: 'BG',
|
|
1534
|
+
* cpi: '373132',
|
|
1535
|
+
* size: 300,
|
|
1536
|
+
* },
|
|
1537
|
+
* )
|
|
1538
|
+
* console.log(response.QRCode) // base64 PNG
|
|
1539
|
+
* ```
|
|
1540
|
+
*/
|
|
1541
|
+
async function generateDynamicQR(baseUrl, accessToken, request, http) {
|
|
1542
|
+
const validated = parseWithSchema(DynamicQRRequestSchema, request, "Dynamic QR request");
|
|
1543
|
+
if (!accessToken || typeof accessToken !== "string" || accessToken.trim().length === 0) throw new PesafyError({
|
|
1544
|
+
code: "AUTH_FAILED",
|
|
1545
|
+
message: "accessToken is required. Obtain one via the Daraja Authorization API (GET /oauth/v1/generate?grant_type=client_credentials)."
|
|
37
1546
|
});
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
1547
|
+
const size = validated.size ?? 300;
|
|
1548
|
+
const amount = Math.round(validated.amount);
|
|
1549
|
+
const payload = {
|
|
1550
|
+
MerchantName: validated.merchantName.trim(),
|
|
1551
|
+
RefNo: validated.refNo.trim(),
|
|
1552
|
+
Amount: amount,
|
|
1553
|
+
TrxCode: validated.trxCode,
|
|
1554
|
+
CPI: validated.cpi.trim(),
|
|
1555
|
+
Size: String(size)
|
|
1556
|
+
};
|
|
1557
|
+
const { data } = await httpRequest(`${baseUrl}/mpesa/qrcode/v1/generate`, withDarajaHttp({
|
|
1558
|
+
method: "POST",
|
|
1559
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1560
|
+
body: payload
|
|
1561
|
+
}, http));
|
|
1562
|
+
if (isDarajaError(data)) throw mapDarajaError(data.errorCode, data.errorMessage);
|
|
1563
|
+
if (!isDarajaSuccess(data)) throw new PesafyError({
|
|
1564
|
+
code: "REQUEST_FAILED",
|
|
1565
|
+
message: `Daraja returned an unexpected response structure for the Dynamic QR request. The response was missing required fields (ResponseCode, QRCode). Raw response: ${JSON.stringify(data).slice(0, 300)}`
|
|
44
1566
|
});
|
|
1567
|
+
return parseWithSchema(DynamicQRResponseSchema, data, "Dynamic QR response");
|
|
45
1568
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
1569
|
+
|
|
1570
|
+
//#endregion
|
|
1571
|
+
//#region src/mpesa/reversal/types.ts
|
|
1572
|
+
/**
|
|
1573
|
+
* src/mpesa/reversal/types.ts
|
|
1574
|
+
*
|
|
1575
|
+
* Transaction Reversal types, constants, and helpers.
|
|
1576
|
+
*
|
|
1577
|
+
* API: POST /mpesa/reversal/v1/request
|
|
1578
|
+
*
|
|
1579
|
+
* Reverses a completed M-PESA C2B transaction. The API is ASYNCHRONOUS —
|
|
1580
|
+
* the synchronous response is only an acknowledgement. The actual reversal
|
|
1581
|
+
* result is POSTed to your ResultURL after processing.
|
|
1582
|
+
*
|
|
1583
|
+
* Required org portal role: "Org Reversals Initiator"
|
|
1584
|
+
*
|
|
1585
|
+
* Per Daraja docs:
|
|
1586
|
+
* RecieverIdentifierType MUST always be "11" for reversals.
|
|
1587
|
+
* CommandID MUST always be "TransactionReversal".
|
|
1588
|
+
*
|
|
1589
|
+
* Ref: Reversals — Safaricom Daraja Developer Portal
|
|
1590
|
+
*/
|
|
1591
|
+
/**
|
|
1592
|
+
* CommandID for the Reversals API.
|
|
1593
|
+
* Only "TransactionReversal" is allowed per Daraja docs.
|
|
1594
|
+
*/
|
|
1595
|
+
const REVERSAL_COMMAND_ID = "TransactionReversal";
|
|
1596
|
+
|
|
1597
|
+
//#endregion
|
|
1598
|
+
//#region src/mpesa/reversal/request.ts
|
|
1599
|
+
/**
|
|
1600
|
+
* src/mpesa/reversal/request.ts
|
|
1601
|
+
*
|
|
1602
|
+
* Transaction Reversal — reverses a completed M-PESA C2B transaction.
|
|
1603
|
+
*
|
|
1604
|
+
* API: POST /mpesa/reversal/v1/request
|
|
1605
|
+
*
|
|
1606
|
+
* ASYNCHRONOUS: The synchronous response is acknowledgement only.
|
|
1607
|
+
* The actual reversal result is POSTed to your ResultURL after processing.
|
|
1608
|
+
*
|
|
1609
|
+
* Per Daraja docs:
|
|
1610
|
+
* - CommandID is always "TransactionReversal"
|
|
1611
|
+
* - RecieverIdentifierType is always "11" (Organisation ShortCode for reversals)
|
|
1612
|
+
* - Amount is sent as a string per the Daraja sample payload
|
|
1613
|
+
* - Remarks must be 2–100 characters
|
|
1614
|
+
* - Cannot be used for B2C reversals (those are done on the M-PESA portal)
|
|
1615
|
+
*
|
|
1616
|
+
* Required org portal role: "Org Reversals Initiator"
|
|
1617
|
+
*/
|
|
1618
|
+
async function requestReversal(baseUrl, accessToken, securityCredential, initiatorName, request, http) {
|
|
1619
|
+
const validated = parseWithSchema(ReversalRequestSchema, request, "Reversal request");
|
|
1620
|
+
const amount = Math.round(validated.amount);
|
|
1621
|
+
const remarks = validated.remarks ?? "Transaction Reversal";
|
|
1622
|
+
const payload = {
|
|
1623
|
+
Initiator: initiatorName,
|
|
1624
|
+
SecurityCredential: securityCredential,
|
|
1625
|
+
CommandID: REVERSAL_COMMAND_ID,
|
|
1626
|
+
TransactionID: validated.transactionId,
|
|
1627
|
+
Amount: String(amount),
|
|
1628
|
+
ReceiverParty: String(validated.receiverParty),
|
|
1629
|
+
RecieverIdentifierType: "11",
|
|
1630
|
+
ResultURL: validated.resultUrl,
|
|
1631
|
+
QueueTimeOutURL: validated.queueTimeOutUrl,
|
|
1632
|
+
Remarks: remarks
|
|
1633
|
+
};
|
|
1634
|
+
if (validated.occasion !== void 0 && validated.occasion !== null) payload["Occasion"] = validated.occasion;
|
|
1635
|
+
const { data } = await httpRequest(`${baseUrl}/mpesa/reversal/v1/request`, withDarajaHttp({
|
|
1636
|
+
method: "POST",
|
|
1637
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1638
|
+
body: payload
|
|
1639
|
+
}, http));
|
|
1640
|
+
return parseWithSchema(ReversalResponseSchema, data, "Reversal response");
|
|
60
1641
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
1642
|
+
|
|
1643
|
+
//#endregion
|
|
1644
|
+
//#region src/schemas/stk-push.ts
|
|
1645
|
+
const TransactionTypeSchema = z.enum(["CustomerPayBillOnline", "CustomerBuyGoodsOnline"]);
|
|
1646
|
+
const StkPushRequestSchema = z.object({
|
|
1647
|
+
amount: z.number().finite({ message: "amount must be a finite number (not NaN or Infinity)" }),
|
|
1648
|
+
phoneNumber: NonEmptyStringSchema,
|
|
1649
|
+
shortCode: NonEmptyStringSchema,
|
|
1650
|
+
passKey: NonEmptyStringSchema,
|
|
1651
|
+
callbackUrl: UrlSchema,
|
|
1652
|
+
accountReference: NonEmptyStringSchema,
|
|
1653
|
+
transactionDesc: NonEmptyStringSchema,
|
|
1654
|
+
transactionType: TransactionTypeSchema.optional(),
|
|
1655
|
+
partyB: z.string().optional()
|
|
1656
|
+
});
|
|
1657
|
+
const StkPushResponseSchema = z.object({
|
|
1658
|
+
MerchantRequestID: z.string(),
|
|
1659
|
+
CheckoutRequestID: z.string(),
|
|
1660
|
+
ResponseCode: z.string(),
|
|
1661
|
+
ResponseDescription: z.string(),
|
|
1662
|
+
CustomerMessage: z.string().optional()
|
|
1663
|
+
}).passthrough();
|
|
1664
|
+
const StkQueryRequestSchema = z.object({
|
|
1665
|
+
checkoutRequestId: NonEmptyStringSchema,
|
|
1666
|
+
shortCode: NonEmptyStringSchema,
|
|
1667
|
+
passKey: NonEmptyStringSchema
|
|
1668
|
+
});
|
|
1669
|
+
const StkQueryResponseSchema = z.object({
|
|
1670
|
+
ResponseCode: z.string(),
|
|
1671
|
+
ResponseDescription: z.string(),
|
|
1672
|
+
MerchantRequestID: z.string().optional(),
|
|
1673
|
+
CheckoutRequestID: z.string().optional(),
|
|
1674
|
+
ResultCode: z.union([z.string(), z.number()]).optional(),
|
|
1675
|
+
ResultDesc: z.string().optional()
|
|
1676
|
+
}).passthrough();
|
|
1677
|
+
const StkPushWebhookSchema = z.object({ Body: z.object({ stkCallback: z.object({
|
|
1678
|
+
MerchantRequestID: z.string(),
|
|
1679
|
+
CheckoutRequestID: z.string(),
|
|
1680
|
+
ResultCode: z.number(),
|
|
1681
|
+
ResultDesc: z.string(),
|
|
1682
|
+
CallbackMetadata: z.object({ Item: z.array(z.object({
|
|
1683
|
+
Name: z.string(),
|
|
1684
|
+
Value: z.union([z.string(), z.number()])
|
|
1685
|
+
})) }).optional()
|
|
1686
|
+
}).passthrough() }) });
|
|
1687
|
+
|
|
1688
|
+
//#endregion
|
|
1689
|
+
//#region src/mpesa/stk-push/types.ts
|
|
1690
|
+
/**
|
|
1691
|
+
* M-PESA transaction limits as documented by Safaricom Daraja.
|
|
1692
|
+
*
|
|
1693
|
+
* | Limit | Value |
|
|
1694
|
+
* |------------------|-----------|
|
|
1695
|
+
* | Min per tx | KES 1 |
|
|
1696
|
+
* | Max per tx | KES 250 000|
|
|
1697
|
+
* | Max daily | KES 500 000|
|
|
1698
|
+
* | Max balance | KES 500 000|
|
|
1699
|
+
*/
|
|
1700
|
+
const STK_PUSH_LIMITS = {
|
|
1701
|
+
MIN_AMOUNT: 1,
|
|
1702
|
+
MAX_AMOUNT: 25e4
|
|
1703
|
+
};
|
|
1704
|
+
|
|
1705
|
+
//#endregion
|
|
1706
|
+
//#region src/mpesa/stk-push/utils.ts
|
|
1707
|
+
var utils_exports = /* @__PURE__ */ __exportAll({
|
|
1708
|
+
getStkPushPassword: () => getStkPushPassword,
|
|
1709
|
+
getTimestamp: () => getTimestamp
|
|
1710
|
+
});
|
|
1711
|
+
/**
|
|
1712
|
+
* Generates the STK Push password.
|
|
1713
|
+
* Formula: Base64( Shortcode + Passkey + Timestamp )
|
|
1714
|
+
*
|
|
1715
|
+
* Uses btoa() — works in Node.js ≥18, Bun, browsers, and edge runtimes.
|
|
1716
|
+
*/
|
|
1717
|
+
function getStkPushPassword(shortCode, passKey, timestamp) {
|
|
1718
|
+
return btoa(`${shortCode}${passKey}${timestamp}`);
|
|
68
1719
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
1720
|
+
/**
|
|
1721
|
+
* Returns a Daraja-compatible timestamp: YYYYMMDDHHmmss
|
|
1722
|
+
*
|
|
1723
|
+
* Call this ONCE per request and reuse the result.
|
|
1724
|
+
*/
|
|
1725
|
+
function getTimestamp() {
|
|
1726
|
+
const now = /* @__PURE__ */ new Date();
|
|
1727
|
+
const pad = (n) => n.toString().padStart(2, "0");
|
|
1728
|
+
return [
|
|
1729
|
+
now.getFullYear(),
|
|
1730
|
+
pad(now.getMonth() + 1),
|
|
1731
|
+
pad(now.getDate()),
|
|
1732
|
+
pad(now.getHours()),
|
|
1733
|
+
pad(now.getMinutes()),
|
|
1734
|
+
pad(now.getSeconds())
|
|
1735
|
+
].join("");
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
//#endregion
|
|
1739
|
+
//#region src/mpesa/stk-push/stk-push.ts
|
|
1740
|
+
/**
|
|
1741
|
+
* src/mpesa/stk-push/stk-push.ts
|
|
1742
|
+
*
|
|
1743
|
+
* Initiates an STK Push (M-PESA Express) payment.
|
|
1744
|
+
*
|
|
1745
|
+
* Daraja endpoint: POST /mpesa/stkpush/v1/processrequest
|
|
1746
|
+
*
|
|
1747
|
+
* Transaction limits (Daraja docs):
|
|
1748
|
+
* Min per transaction: KES 1
|
|
1749
|
+
* Max per transaction: KES 250,000
|
|
1750
|
+
*/
|
|
1751
|
+
async function processStkPush(baseUrl, accessToken, request, http) {
|
|
1752
|
+
const validated = parseWithSchema(StkPushRequestSchema, request, "STK Push request");
|
|
1753
|
+
if (!Number.isFinite(validated.amount)) throw new PesafyError({
|
|
1754
|
+
code: "VALIDATION_ERROR",
|
|
1755
|
+
message: `amount must be a finite number (got ${validated.amount}).`
|
|
78
1756
|
});
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
1757
|
+
const amount = Math.round(validated.amount);
|
|
1758
|
+
if (amount < STK_PUSH_LIMITS.MIN_AMOUNT) throw new PesafyError({
|
|
1759
|
+
code: "VALIDATION_ERROR",
|
|
1760
|
+
message: `Amount must be at least KES ${STK_PUSH_LIMITS.MIN_AMOUNT} (got ${validated.amount} which rounds to ${amount}).`
|
|
1761
|
+
});
|
|
1762
|
+
if (amount > STK_PUSH_LIMITS.MAX_AMOUNT) throw new PesafyError({
|
|
1763
|
+
code: "VALIDATION_ERROR",
|
|
1764
|
+
message: `Amount must not exceed KES ${STK_PUSH_LIMITS.MAX_AMOUNT.toLocaleString()} per transaction as per Safaricom Daraja limits (got ${validated.amount} which rounds to ${amount}).`
|
|
1765
|
+
});
|
|
1766
|
+
const timestamp = getTimestamp();
|
|
1767
|
+
const partyB = validated.partyB ?? validated.shortCode;
|
|
1768
|
+
const body = {
|
|
1769
|
+
BusinessShortCode: validated.shortCode,
|
|
1770
|
+
Password: getStkPushPassword(validated.shortCode, validated.passKey, timestamp),
|
|
1771
|
+
Timestamp: timestamp,
|
|
1772
|
+
TransactionType: validated.transactionType ?? "CustomerPayBillOnline",
|
|
1773
|
+
Amount: amount,
|
|
1774
|
+
PartyA: formatSafaricomPhone(validated.phoneNumber),
|
|
1775
|
+
PartyB: partyB,
|
|
1776
|
+
PhoneNumber: formatSafaricomPhone(validated.phoneNumber),
|
|
1777
|
+
CallBackURL: validated.callbackUrl,
|
|
1778
|
+
AccountReference: validated.accountReference.slice(0, 12),
|
|
1779
|
+
TransactionDesc: validated.transactionDesc.slice(0, 13)
|
|
1780
|
+
};
|
|
1781
|
+
const { data } = await httpRequest(`${baseUrl}/mpesa/stkpush/v1/processrequest`, withDarajaHttp({
|
|
1782
|
+
method: "POST",
|
|
1783
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1784
|
+
body,
|
|
1785
|
+
retries: 5,
|
|
1786
|
+
retryDelay: 3e3
|
|
1787
|
+
}, http));
|
|
1788
|
+
return parseWithSchema(StkPushResponseSchema, data, "STK Push response");
|
|
85
1789
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
1790
|
+
|
|
1791
|
+
//#endregion
|
|
1792
|
+
//#region src/mpesa/stk-push/stk-query.ts
|
|
1793
|
+
async function queryStkPush(baseUrl, accessToken, request, http) {
|
|
1794
|
+
const validated = parseWithSchema(StkQueryRequestSchema, request, "STK Query request");
|
|
1795
|
+
const timestamp = getTimestamp();
|
|
1796
|
+
const body = {
|
|
1797
|
+
BusinessShortCode: validated.shortCode,
|
|
1798
|
+
Password: getStkPushPassword(validated.shortCode, validated.passKey, timestamp),
|
|
1799
|
+
Timestamp: timestamp,
|
|
1800
|
+
CheckoutRequestID: validated.checkoutRequestId
|
|
1801
|
+
};
|
|
1802
|
+
const { data } = await httpRequest(`${baseUrl}/mpesa/stkpushquery/v1/query`, withDarajaHttp({
|
|
1803
|
+
method: "POST",
|
|
1804
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1805
|
+
body
|
|
1806
|
+
}, http));
|
|
1807
|
+
return parseWithSchema(StkQueryResponseSchema, data, "STK Query response");
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
//#endregion
|
|
1811
|
+
//#region src/mpesa/tax-remittance/remit-tax.ts
|
|
1812
|
+
/** KRA's M-PESA shortcode — the only allowed PartyB for tax remittance */
|
|
1813
|
+
const KRA_SHORTCODE = "572572";
|
|
1814
|
+
/** The only CommandID accepted by the Tax Remittance API */
|
|
1815
|
+
const TAX_COMMAND_ID = "PayTaxToKRA";
|
|
1816
|
+
/**
|
|
1817
|
+
* Remits tax to Kenya Revenue Authority (KRA) via M-PESA.
|
|
1818
|
+
*
|
|
1819
|
+
* Endpoint: POST /mpesa/b2b/v1/remittax
|
|
1820
|
+
*
|
|
1821
|
+
* @param baseUrl - Daraja base URL (sandbox or production)
|
|
1822
|
+
* @param accessToken - Valid OAuth bearer token
|
|
1823
|
+
* @param securityCredential - RSA-encrypted initiator password (base64)
|
|
1824
|
+
* @param initiatorName - M-PESA org portal API operator username
|
|
1825
|
+
* @param request - Tax remittance parameters
|
|
1826
|
+
* @returns - Daraja synchronous acknowledgement response
|
|
1827
|
+
*
|
|
1828
|
+
* @example
|
|
1829
|
+
* const response = await remitTax(
|
|
1830
|
+
* 'https://sandbox.safaricom.co.ke',
|
|
1831
|
+
* accessToken,
|
|
1832
|
+
* securityCredential,
|
|
1833
|
+
* 'TaxPayer',
|
|
1834
|
+
* {
|
|
1835
|
+
* amount: 239,
|
|
1836
|
+
* partyA: '888880',
|
|
1837
|
+
* accountReference: '353353',
|
|
1838
|
+
* resultUrl: 'https://example.org/b2b/remittax/result/',
|
|
1839
|
+
* queueTimeOutUrl: 'https://example.org/b2b/remittax/queue/',
|
|
1840
|
+
* },
|
|
1841
|
+
* )
|
|
1842
|
+
*/
|
|
1843
|
+
async function remitTax(baseUrl, accessToken, securityCredential, initiatorName, request, http) {
|
|
1844
|
+
const validated = parseWithSchema(TaxRemittanceRequestSchema, request, "Tax Remittance request");
|
|
1845
|
+
const amount = Math.round(validated.amount);
|
|
1846
|
+
const payload = {
|
|
1847
|
+
Initiator: initiatorName,
|
|
1848
|
+
SecurityCredential: securityCredential,
|
|
1849
|
+
CommandID: TAX_COMMAND_ID,
|
|
1850
|
+
SenderIdentifierType: "4",
|
|
1851
|
+
RecieverIdentifierType: "4",
|
|
1852
|
+
Amount: String(amount),
|
|
1853
|
+
PartyA: String(validated.partyA),
|
|
1854
|
+
PartyB: validated.partyB ?? "572572",
|
|
1855
|
+
AccountReference: validated.accountReference,
|
|
1856
|
+
Remarks: validated.remarks ?? "Tax Remittance",
|
|
1857
|
+
QueueTimeOutURL: validated.queueTimeOutUrl,
|
|
1858
|
+
ResultURL: validated.resultUrl
|
|
1859
|
+
};
|
|
1860
|
+
const { data } = await httpRequest(`${baseUrl}/mpesa/b2b/v1/remittax`, withDarajaHttp({
|
|
1861
|
+
method: "POST",
|
|
1862
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1863
|
+
body: payload
|
|
1864
|
+
}, http));
|
|
1865
|
+
return parseWithSchema(TaxRemittanceResponseSchema, data, "Tax Remittance response");
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
//#endregion
|
|
1869
|
+
//#region src/mpesa/transaction-status/query.ts
|
|
1870
|
+
/**
|
|
1871
|
+
* Transaction Status Query implementation
|
|
1872
|
+
*
|
|
1873
|
+
* API: POST /mpesa/transactionstatus/v1/query
|
|
1874
|
+
*
|
|
1875
|
+
* This is ASYNCHRONOUS. The synchronous response only acknowledges receipt.
|
|
1876
|
+
* Final results arrive via POST to your ResultURL.
|
|
1877
|
+
*
|
|
1878
|
+
* Required M-PESA org portal role: "Transaction Status query ORG API"
|
|
1879
|
+
*
|
|
1880
|
+
* Reconciliation options (at least one required):
|
|
1881
|
+
* - transactionId — M-Pesa Receipt Number (e.g. "NEF61H8J60")
|
|
1882
|
+
* - originalConversationId — OriginatorConversationID from the original call
|
|
1883
|
+
*/
|
|
1884
|
+
async function queryTransactionStatus(baseUrl, token, securityCredential, initiator, request, http) {
|
|
1885
|
+
const validated = parseWithSchema(TransactionStatusRequestSchema, request, "Transaction Status request");
|
|
1886
|
+
const payload = {
|
|
1887
|
+
Initiator: initiator,
|
|
1888
|
+
SecurityCredential: securityCredential,
|
|
1889
|
+
CommandID: validated.commandId ?? "TransactionStatusQuery",
|
|
1890
|
+
TransactionID: validated.transactionId ?? "",
|
|
1891
|
+
OriginalConversationID: validated.originalConversationId ?? "",
|
|
1892
|
+
PartyA: validated.partyA,
|
|
1893
|
+
IdentifierType: validated.identifierType,
|
|
1894
|
+
ResultURL: validated.resultUrl,
|
|
1895
|
+
QueueTimeOutURL: validated.queueTimeOutUrl,
|
|
1896
|
+
Remarks: validated.remarks ?? "Transaction Status Query",
|
|
1897
|
+
Occasion: validated.occasion ?? ""
|
|
1898
|
+
};
|
|
1899
|
+
const { data } = await httpRequest(`${baseUrl}/mpesa/transactionstatus/v1/query`, withDarajaHttp({
|
|
1900
|
+
method: "POST",
|
|
1901
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1902
|
+
body: payload
|
|
1903
|
+
}, http));
|
|
1904
|
+
return parseWithSchema(TransactionStatusResponseSchema, data, "Transaction Status response");
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
//#endregion
|
|
1908
|
+
//#region src/mpesa/types.ts
|
|
1909
|
+
const DARAJA_BASE_URLS = {
|
|
1910
|
+
sandbox: "https://sandbox.safaricom.co.ke",
|
|
1911
|
+
production: "https://api.safaricom.co.ke"
|
|
1912
|
+
};
|
|
1913
|
+
|
|
1914
|
+
//#endregion
|
|
1915
|
+
//#region src/mpesa/index.ts
|
|
1916
|
+
/**
|
|
1917
|
+
* src/mpesa/index.ts
|
|
1918
|
+
*
|
|
1919
|
+
* Primary M-PESA module entry point.
|
|
1920
|
+
*
|
|
1921
|
+
* Exports:
|
|
1922
|
+
* 1. Mpesa class — the main SDK client
|
|
1923
|
+
* 2. All submodule APIs, types, constants, and helpers
|
|
1924
|
+
*
|
|
1925
|
+
* Submodules re-exported here:
|
|
1926
|
+
* - account-balance
|
|
1927
|
+
* - b2b-buy-goods
|
|
1928
|
+
* - b2b-express-checkout
|
|
1929
|
+
* - b2b-pay-bill
|
|
1930
|
+
* - b2c (Account Top Up)
|
|
1931
|
+
* - b2c-disbursement
|
|
1932
|
+
* - bill-manager
|
|
1933
|
+
* - c2b
|
|
1934
|
+
* - dynamic-qr
|
|
1935
|
+
* - reversal
|
|
1936
|
+
* - stk-push
|
|
1937
|
+
* - tax-remittance
|
|
1938
|
+
* - transaction-status
|
|
1939
|
+
* - webhooks
|
|
1940
|
+
*/
|
|
1941
|
+
var Mpesa = class {
|
|
1942
|
+
config;
|
|
1943
|
+
tokenManager;
|
|
1944
|
+
baseUrl;
|
|
1945
|
+
idempotencyManager;
|
|
1946
|
+
constructor(config) {
|
|
1947
|
+
if (!config.consumerKey || !config.consumerSecret) throw new PesafyError({
|
|
1948
|
+
code: "INVALID_CREDENTIALS",
|
|
1949
|
+
message: "consumerKey and consumerSecret are required."
|
|
1950
|
+
});
|
|
1951
|
+
this.config = config;
|
|
1952
|
+
this.baseUrl = DARAJA_BASE_URLS[config.environment];
|
|
1953
|
+
this.tokenManager = new TokenManager(config.consumerKey, config.consumerSecret, this.baseUrl);
|
|
1954
|
+
this.idempotencyManager = new IdempotencyManager(config.idempotency);
|
|
1955
|
+
}
|
|
1956
|
+
/** Idempotency options passed to all outbound Daraja HTTP calls. */
|
|
1957
|
+
darajaHttp() {
|
|
1958
|
+
return { idempotency: this.idempotencyManager };
|
|
1959
|
+
}
|
|
1960
|
+
getToken() {
|
|
1961
|
+
return this.tokenManager.getAccessToken();
|
|
1962
|
+
}
|
|
1963
|
+
async buildSecurityCredential() {
|
|
1964
|
+
if (this.config.securityCredential) return this.config.securityCredential;
|
|
1965
|
+
if (!this.config.initiatorPassword) throw new PesafyError({
|
|
1966
|
+
code: "INVALID_CREDENTIALS",
|
|
1967
|
+
message: "Provide securityCredential (pre-encrypted) OR (initiatorPassword + certificatePath/certificatePem)."
|
|
1968
|
+
});
|
|
1969
|
+
let cert;
|
|
1970
|
+
if (this.config.certificatePem) cert = this.config.certificatePem;
|
|
1971
|
+
else if (this.config.certificatePath) cert = await readFile(this.config.certificatePath, "utf-8");
|
|
1972
|
+
else throw new PesafyError({
|
|
1973
|
+
code: "INVALID_CREDENTIALS",
|
|
1974
|
+
message: "certificatePath or certificatePem is required to encrypt the initiator password."
|
|
1975
|
+
});
|
|
1976
|
+
return encryptSecurityCredential(this.config.initiatorPassword, cert);
|
|
1977
|
+
}
|
|
1978
|
+
requireInitiator(forApi) {
|
|
1979
|
+
const name = this.config.initiatorName ?? "";
|
|
1980
|
+
if (!name) throw new PesafyError({
|
|
1981
|
+
code: "VALIDATION_ERROR",
|
|
1982
|
+
message: `initiatorName is required for ${forApi}.`
|
|
1983
|
+
});
|
|
1984
|
+
return name;
|
|
1985
|
+
}
|
|
1986
|
+
async stkPushSafe(request) {
|
|
1987
|
+
try {
|
|
1988
|
+
return ok(await this.stkPush(request));
|
|
1989
|
+
} catch (e) {
|
|
1990
|
+
return err(e);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
async accountBalanceSafe(request) {
|
|
1994
|
+
try {
|
|
1995
|
+
return ok(await this.accountBalance(request));
|
|
1996
|
+
} catch (e) {
|
|
1997
|
+
return err(e);
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
async stkPush(request) {
|
|
2001
|
+
const shortCode = this.config.lipaNaMpesaShortCode ?? "";
|
|
2002
|
+
const passKey = this.config.lipaNaMpesaPassKey ?? "";
|
|
2003
|
+
if (!shortCode || !passKey) throw new PesafyError({
|
|
2004
|
+
code: "VALIDATION_ERROR",
|
|
2005
|
+
message: "lipaNaMpesaShortCode and lipaNaMpesaPassKey are required for STK Push."
|
|
2006
|
+
});
|
|
2007
|
+
const token = await this.getToken();
|
|
2008
|
+
return processStkPush(this.baseUrl, token, {
|
|
2009
|
+
...request,
|
|
2010
|
+
shortCode,
|
|
2011
|
+
passKey
|
|
2012
|
+
}, this.darajaHttp());
|
|
2013
|
+
}
|
|
2014
|
+
async stkQuery(request) {
|
|
2015
|
+
const shortCode = this.config.lipaNaMpesaShortCode ?? "";
|
|
2016
|
+
const passKey = this.config.lipaNaMpesaPassKey ?? "";
|
|
2017
|
+
if (!shortCode || !passKey) throw new PesafyError({
|
|
2018
|
+
code: "VALIDATION_ERROR",
|
|
2019
|
+
message: "lipaNaMpesaShortCode and lipaNaMpesaPassKey are required for STK Query."
|
|
2020
|
+
});
|
|
2021
|
+
const token = await this.getToken();
|
|
2022
|
+
return queryStkPush(this.baseUrl, token, {
|
|
2023
|
+
...request,
|
|
2024
|
+
shortCode,
|
|
2025
|
+
passKey
|
|
2026
|
+
}, this.darajaHttp());
|
|
2027
|
+
}
|
|
2028
|
+
async transactionStatus(request) {
|
|
2029
|
+
const initiator = this.requireInitiator("Transaction Status");
|
|
2030
|
+
const [token, cred] = await Promise.all([this.getToken(), this.buildSecurityCredential()]);
|
|
2031
|
+
return queryTransactionStatus(this.baseUrl, token, cred, initiator, request, this.darajaHttp());
|
|
2032
|
+
}
|
|
2033
|
+
async accountBalance(request) {
|
|
2034
|
+
const initiator = this.requireInitiator("Account Balance");
|
|
2035
|
+
const [token, cred] = await Promise.all([this.getToken(), this.buildSecurityCredential()]);
|
|
2036
|
+
return queryAccountBalance(this.baseUrl, token, cred, initiator, request, this.darajaHttp());
|
|
2037
|
+
}
|
|
2038
|
+
async reverseTransaction(request) {
|
|
2039
|
+
const initiator = this.requireInitiator("Reversal");
|
|
2040
|
+
const [token, cred] = await Promise.all([this.getToken(), this.buildSecurityCredential()]);
|
|
2041
|
+
return requestReversal(this.baseUrl, token, cred, initiator, request, this.darajaHttp());
|
|
2042
|
+
}
|
|
2043
|
+
async generateDynamicQR(request) {
|
|
2044
|
+
const token = await this.getToken();
|
|
2045
|
+
return generateDynamicQR(this.baseUrl, token, request, this.darajaHttp());
|
|
2046
|
+
}
|
|
2047
|
+
async registerC2BUrls(request) {
|
|
2048
|
+
const token = await this.getToken();
|
|
2049
|
+
return registerC2BUrls(this.baseUrl, token, request, this.darajaHttp());
|
|
2050
|
+
}
|
|
2051
|
+
async simulateC2B(request) {
|
|
2052
|
+
const token = await this.getToken();
|
|
2053
|
+
return simulateC2B(this.baseUrl, token, request, this.darajaHttp());
|
|
2054
|
+
}
|
|
2055
|
+
async remitTax(request) {
|
|
2056
|
+
const initiator = this.requireInitiator("Tax Remittance");
|
|
2057
|
+
const [token, cred] = await Promise.all([this.getToken(), this.buildSecurityCredential()]);
|
|
2058
|
+
return remitTax(this.baseUrl, token, cred, initiator, request, this.darajaHttp());
|
|
2059
|
+
}
|
|
2060
|
+
async b2bExpressCheckout(request) {
|
|
2061
|
+
const token = await this.getToken();
|
|
2062
|
+
return initiateB2BExpressCheckout(this.baseUrl, token, request, this.darajaHttp());
|
|
2063
|
+
}
|
|
2064
|
+
async b2bBuyGoods(request) {
|
|
2065
|
+
const initiator = this.requireInitiator("B2B Buy Goods");
|
|
2066
|
+
const [token, cred] = await Promise.all([this.getToken(), this.buildSecurityCredential()]);
|
|
2067
|
+
return initiateB2BBuyGoods(this.baseUrl, token, cred, initiator, request, this.darajaHttp());
|
|
2068
|
+
}
|
|
2069
|
+
async b2bPayBill(request) {
|
|
2070
|
+
const initiator = this.requireInitiator("B2B Pay Bill");
|
|
2071
|
+
const [token, cred] = await Promise.all([this.getToken(), this.buildSecurityCredential()]);
|
|
2072
|
+
return initiateB2BPayBill(this.baseUrl, token, cred, initiator, request, this.darajaHttp());
|
|
2073
|
+
}
|
|
2074
|
+
async b2cPayment(request) {
|
|
2075
|
+
const initiator = this.requireInitiator("B2C Payment");
|
|
2076
|
+
const [token, cred] = await Promise.all([this.getToken(), this.buildSecurityCredential()]);
|
|
2077
|
+
return initiateB2CPayment(this.baseUrl, token, cred, initiator, request, this.darajaHttp());
|
|
2078
|
+
}
|
|
2079
|
+
async b2cDisbursement(request) {
|
|
2080
|
+
const initiator = this.requireInitiator("B2C Disbursement");
|
|
2081
|
+
const req = {
|
|
2082
|
+
...request,
|
|
2083
|
+
originatorConversationId: request.originatorConversationId ?? generateOriginatorConversationId()
|
|
2084
|
+
};
|
|
2085
|
+
const [token, cred] = await Promise.all([this.getToken(), this.buildSecurityCredential()]);
|
|
2086
|
+
return initiateB2CDisbursement(this.baseUrl, token, cred, initiator, req, this.darajaHttp());
|
|
2087
|
+
}
|
|
2088
|
+
async billManagerOptIn(request) {
|
|
2089
|
+
const token = await this.getToken();
|
|
2090
|
+
return billManagerOptIn(this.baseUrl, token, request, this.darajaHttp());
|
|
2091
|
+
}
|
|
2092
|
+
async updateOptIn(request) {
|
|
2093
|
+
const token = await this.getToken();
|
|
2094
|
+
return updateOptIn(this.baseUrl, token, request, this.darajaHttp());
|
|
2095
|
+
}
|
|
2096
|
+
async sendInvoice(request) {
|
|
2097
|
+
const token = await this.getToken();
|
|
2098
|
+
return sendSingleInvoice(this.baseUrl, token, request, this.darajaHttp());
|
|
2099
|
+
}
|
|
2100
|
+
async sendBulkInvoices(request) {
|
|
2101
|
+
const token = await this.getToken();
|
|
2102
|
+
return sendBulkInvoices(this.baseUrl, token, request, this.darajaHttp());
|
|
2103
|
+
}
|
|
2104
|
+
async cancelInvoice(request) {
|
|
2105
|
+
const token = await this.getToken();
|
|
2106
|
+
return cancelInvoice(this.baseUrl, token, request, this.darajaHttp());
|
|
2107
|
+
}
|
|
2108
|
+
async cancelBulkInvoices(request) {
|
|
2109
|
+
const token = await this.getToken();
|
|
2110
|
+
return cancelBulkInvoices(this.baseUrl, token, request, this.darajaHttp());
|
|
2111
|
+
}
|
|
2112
|
+
async reconcilePayment(request) {
|
|
2113
|
+
const token = await this.getToken();
|
|
2114
|
+
return reconcilePayment(this.baseUrl, token, request, this.darajaHttp());
|
|
2115
|
+
}
|
|
2116
|
+
clearTokenCache() {
|
|
2117
|
+
this.tokenManager.clearCache();
|
|
2118
|
+
}
|
|
2119
|
+
get environment() {
|
|
2120
|
+
return this.config.environment;
|
|
2121
|
+
}
|
|
2122
|
+
};
|
|
2123
|
+
|
|
2124
|
+
//#endregion
|
|
2125
|
+
//#region src/cli/lib/mpesa-from-env.ts
|
|
2126
|
+
async function mpesaFromEnv(env, opts = {}) {
|
|
2127
|
+
requireEnv(env, "MPESA_CONSUMER_KEY", "MPESA_CONSUMER_SECRET", "MPESA_ENVIRONMENT");
|
|
2128
|
+
const config = {
|
|
2129
|
+
consumerKey: env["MPESA_CONSUMER_KEY"],
|
|
2130
|
+
consumerSecret: env["MPESA_CONSUMER_SECRET"],
|
|
2131
|
+
environment: env["MPESA_ENVIRONMENT"] === "production" ? "production" : "sandbox",
|
|
2132
|
+
lipaNaMpesaShortCode: env["MPESA_SHORTCODE"] ?? "",
|
|
2133
|
+
lipaNaMpesaPassKey: env["MPESA_PASSKEY"] ?? ""
|
|
2134
|
+
};
|
|
2135
|
+
if (opts.requireInitiator) {
|
|
2136
|
+
requireEnv(env, "MPESA_INITIATOR_NAME", "MPESA_INITIATOR_PASSWORD", "MPESA_CERTIFICATE_PATH");
|
|
2137
|
+
const certPath = resolve(process.cwd(), env["MPESA_CERTIFICATE_PATH"]);
|
|
2138
|
+
if (!existsSync(certPath)) {
|
|
2139
|
+
console.error(`✖ Certificate not found: ${certPath}`);
|
|
2140
|
+
process.exit(2);
|
|
2141
|
+
}
|
|
2142
|
+
const pem = await readFile(certPath, "utf-8");
|
|
2143
|
+
const initiatorName = env["MPESA_INITIATOR_NAME"];
|
|
2144
|
+
const initiatorPassword = env["MPESA_INITIATOR_PASSWORD"];
|
|
2145
|
+
config.initiatorName = initiatorName;
|
|
2146
|
+
config.initiatorPassword = initiatorPassword;
|
|
2147
|
+
config.securityCredential = encryptSecurityCredential(initiatorPassword, pem);
|
|
2148
|
+
}
|
|
2149
|
+
return new Mpesa(config);
|
|
91
2150
|
}
|
|
92
|
-
|
|
2151
|
+
|
|
2152
|
+
//#endregion
|
|
2153
|
+
//#region src/cli/commands/version.ts
|
|
2154
|
+
async function cmdVersion(ctx) {
|
|
93
2155
|
console.log(`pesafy v${getPkgVersion()}`);
|
|
94
2156
|
}
|
|
95
|
-
|
|
2157
|
+
|
|
2158
|
+
//#endregion
|
|
2159
|
+
//#region src/cli/commands/help.ts
|
|
2160
|
+
async function cmdHelp(ctx) {
|
|
96
2161
|
console.log(`
|
|
97
2162
|
${b(`pesafy`)} ${dim(`v${getPkgVersion()}`)} — M-PESA Daraja SDK CLI
|
|
98
2163
|
|
|
99
|
-
${b("
|
|
100
|
-
${g("init")} Scaffold .env
|
|
101
|
-
${g("doctor")} Validate .env
|
|
102
|
-
${g("token")} Print
|
|
103
|
-
${g("encrypt")} Encrypt
|
|
104
|
-
${g("validate-phone")} ${c("<phone>")}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
${g("
|
|
108
|
-
${g("
|
|
109
|
-
${g("
|
|
110
|
-
${g("
|
|
111
|
-
|
|
2164
|
+
${b("SETUP")}
|
|
2165
|
+
${g("init")} Scaffold .env interactively
|
|
2166
|
+
${g("doctor")} Validate .env configuration
|
|
2167
|
+
${g("token")} Print Daraja OAuth token
|
|
2168
|
+
${g("encrypt")} Encrypt initiator password → SecurityCredential
|
|
2169
|
+
${g("validate-phone")} ${c("<phone>")} Normalise Kenyan MSISDN
|
|
2170
|
+
|
|
2171
|
+
${b("PAYMENTS")}
|
|
2172
|
+
${g("stk-push")} STK Push (M-PESA Express)
|
|
2173
|
+
${g("stk-query")} ${c("<checkoutId>")} Query STK status
|
|
2174
|
+
${g("b2b-checkout")} B2B Express Checkout
|
|
2175
|
+
${g("qr-generate")} Dynamic QR (JSON body optional)
|
|
2176
|
+
|
|
2177
|
+
${b("ASYNC")}
|
|
2178
|
+
${g("balance")} Account balance query
|
|
2179
|
+
${g("reversal")} ${c("<txId>")} Transaction reversal
|
|
2180
|
+
${g("tx-status")} Transaction status query
|
|
2181
|
+
${g("b2c-payment")} B2C account top-up
|
|
2182
|
+
${g("b2c-disburse")} B2C disbursement
|
|
2183
|
+
${g("tax-remit")} KRA tax remittance
|
|
2184
|
+
${g("register-c2b-urls")} Register C2B URLs
|
|
2185
|
+
${g("simulate-c2b")} C2B simulate (sandbox)
|
|
2186
|
+
|
|
2187
|
+
${b("BILLS")}
|
|
2188
|
+
${g("bills")} ${c("<optin|invoice|bulk|reconcile>")} Bill Manager API
|
|
2189
|
+
|
|
2190
|
+
${b("FLAGS")}
|
|
2191
|
+
${g("--json")} Machine-readable output
|
|
2192
|
+
${g("--env-file")} ${c("<path>")} Alternate .env path
|
|
2193
|
+
${g("--env")} ${c("sandbox|production")} Override MPESA_ENVIRONMENT
|
|
2194
|
+
|
|
2195
|
+
${b("UTILS")}
|
|
2196
|
+
${g("version")} Print version
|
|
112
2197
|
${g("help")} Show this help
|
|
113
2198
|
|
|
114
2199
|
${b("ENVIRONMENT")}
|
|
@@ -150,7 +2235,10 @@ ${b("EXAMPLES")}
|
|
|
150
2235
|
${dim("$ npx pesafy validate-phone 0712345678")}
|
|
151
2236
|
`);
|
|
152
2237
|
}
|
|
153
|
-
|
|
2238
|
+
|
|
2239
|
+
//#endregion
|
|
2240
|
+
//#region src/cli/commands/init.ts
|
|
2241
|
+
async function cmdInit(ctx) {
|
|
154
2242
|
console.log(`\n${b("🚀 pesafy — Interactive Setup")}\n`);
|
|
155
2243
|
console.log(dim("This will create a .env file in the current directory.\n"));
|
|
156
2244
|
const content = `# pesafy — M-PESA Daraja configuration
|
|
@@ -163,7 +2251,7 @@ MPESA_CONSUMER_SECRET=${await prompt("Consumer Secret")}
|
|
|
163
2251
|
# STK Push (M-PESA Express)
|
|
164
2252
|
MPESA_SHORTCODE=${await prompt("Lipa Na M-PESA Shortcode (for STK Push)", "174379")}
|
|
165
2253
|
MPESA_PASSKEY=${await prompt("Lipa Na M-PESA Passkey")}
|
|
166
|
-
MPESA_CALLBACK_URL=${await prompt("STK Push Callback URL", "https://
|
|
2254
|
+
MPESA_CALLBACK_URL=${await prompt("STK Push Callback URL", "https://example.com/api/mpesa/callback")}
|
|
167
2255
|
|
|
168
2256
|
# Initiator (Account Balance / B2C / Reversal / Transaction Status / Tax Remittance)
|
|
169
2257
|
# Required org portal role for Account Balance: "Balance Query ORG API"
|
|
@@ -172,8 +2260,8 @@ MPESA_INITIATOR_PASSWORD=${await prompt("Initiator Password (leave blank if not
|
|
|
172
2260
|
MPESA_CERTIFICATE_PATH=${await prompt("Certificate path (leave blank to skip)", "./SandboxCertificate.cer")}
|
|
173
2261
|
|
|
174
2262
|
# Async API result endpoints (Account Balance, Reversal, B2C)
|
|
175
|
-
MPESA_RESULT_URL=${await prompt("Result URL (for async APIs — Account Balance, Reversal, B2C)", "https://
|
|
176
|
-
MPESA_QUEUE_TIMEOUT_URL=${await prompt("Queue Timeout URL", "https://
|
|
2263
|
+
MPESA_RESULT_URL=${await prompt("Result URL (for async APIs — Account Balance, Reversal, B2C)", "https://example.com/api/mpesa/result")}
|
|
2264
|
+
MPESA_QUEUE_TIMEOUT_URL=${await prompt("Queue Timeout URL", "https://example.com/api/mpesa/timeout")}
|
|
177
2265
|
`;
|
|
178
2266
|
const envPath = resolve(process.cwd(), ".env");
|
|
179
2267
|
if (existsSync(envPath)) {
|
|
@@ -185,9 +2273,12 @@ MPESA_QUEUE_TIMEOUT_URL=${await prompt("Queue Timeout URL", "https://yourdomain.
|
|
|
185
2273
|
writeFileSync(envPath, content);
|
|
186
2274
|
console.log(`\n${g("✔ .env created")} — run ${c("npx pesafy doctor")} to validate.\n`);
|
|
187
2275
|
}
|
|
188
|
-
|
|
2276
|
+
|
|
2277
|
+
//#endregion
|
|
2278
|
+
//#region src/cli/commands/doctor.ts
|
|
2279
|
+
async function cmdDoctor(ctx) {
|
|
189
2280
|
console.log(`\n${b("🩺 pesafy doctor")}\n`);
|
|
190
|
-
const env =
|
|
2281
|
+
const env = ctx.env;
|
|
191
2282
|
let ok = true;
|
|
192
2283
|
function check(key, hint = "") {
|
|
193
2284
|
if (env[key]) console.log(`${g("✔")} ${key}`);
|
|
@@ -257,14 +2348,24 @@ async function cmdDoctor() {
|
|
|
257
2348
|
process.exit(1);
|
|
258
2349
|
}
|
|
259
2350
|
}
|
|
260
|
-
|
|
261
|
-
|
|
2351
|
+
|
|
2352
|
+
//#endregion
|
|
2353
|
+
//#region src/cli/commands/token.ts
|
|
2354
|
+
async function cmdToken(ctx) {
|
|
2355
|
+
const env = ctx.env;
|
|
262
2356
|
requireEnv(env, "MPESA_CONSUMER_KEY", "MPESA_CONSUMER_SECRET", "MPESA_ENVIRONMENT");
|
|
263
|
-
const baseUrl = env
|
|
2357
|
+
const baseUrl = getBaseUrl(env);
|
|
264
2358
|
process.stdout.write(dim("Fetching token…"));
|
|
265
2359
|
try {
|
|
266
2360
|
const token = await getToken(env["MPESA_CONSUMER_KEY"], env["MPESA_CONSUMER_SECRET"], baseUrl);
|
|
267
2361
|
process.stdout.write("\r");
|
|
2362
|
+
if (ctx.json) {
|
|
2363
|
+
printJson({
|
|
2364
|
+
access_token: token,
|
|
2365
|
+
expires_in: 3600
|
|
2366
|
+
});
|
|
2367
|
+
return;
|
|
2368
|
+
}
|
|
268
2369
|
console.log(`\n${b("Access Token:")}\n\n${c(token)}\n`);
|
|
269
2370
|
console.log(dim("Token is valid for 3600 seconds (1 hour)."));
|
|
270
2371
|
} catch (e) {
|
|
@@ -273,8 +2374,12 @@ async function cmdToken() {
|
|
|
273
2374
|
process.exit(1);
|
|
274
2375
|
}
|
|
275
2376
|
}
|
|
276
|
-
|
|
277
|
-
|
|
2377
|
+
|
|
2378
|
+
//#endregion
|
|
2379
|
+
//#region src/cli/commands/encrypt.ts
|
|
2380
|
+
async function cmdEncrypt(ctx) {
|
|
2381
|
+
const args = ctx.args;
|
|
2382
|
+
const env = ctx.env;
|
|
278
2383
|
let password = args[0];
|
|
279
2384
|
let certPath = args[1] ?? env["MPESA_CERTIFICATE_PATH"];
|
|
280
2385
|
if (!password) password = await prompt("Initiator password to encrypt");
|
|
@@ -284,7 +2389,7 @@ async function cmdEncrypt(args) {
|
|
|
284
2389
|
process.exit(1);
|
|
285
2390
|
}
|
|
286
2391
|
try {
|
|
287
|
-
const { encryptSecurityCredential } = await
|
|
2392
|
+
const { encryptSecurityCredential } = await Promise.resolve().then(() => encryption_exports);
|
|
288
2393
|
const { readFile } = await import("node:fs/promises");
|
|
289
2394
|
const pem = await readFile(resolve(process.cwd(), certPath), "utf-8");
|
|
290
2395
|
const credential = encryptSecurityCredential(password, pem);
|
|
@@ -295,11 +2400,14 @@ async function cmdEncrypt(args) {
|
|
|
295
2400
|
process.exit(1);
|
|
296
2401
|
}
|
|
297
2402
|
}
|
|
298
|
-
|
|
299
|
-
|
|
2403
|
+
|
|
2404
|
+
//#endregion
|
|
2405
|
+
//#region src/cli/commands/validate-phone.ts
|
|
2406
|
+
async function cmdValidatePhone(ctx) {
|
|
2407
|
+
let phone = ctx.args[0];
|
|
300
2408
|
if (!phone) phone = await prompt("Phone number to validate");
|
|
301
2409
|
try {
|
|
302
|
-
const { formatSafaricomPhone } = await import("./phone.mjs");
|
|
2410
|
+
const { formatSafaricomPhone } = await import("./phone.mjs").then((n) => n.n);
|
|
303
2411
|
const normalised = formatSafaricomPhone(phone);
|
|
304
2412
|
console.log(`\n${g("✔")} ${b(phone)} → ${c(normalised)}\n`);
|
|
305
2413
|
} catch (e) {
|
|
@@ -307,8 +2415,12 @@ async function cmdValidatePhone(args) {
|
|
|
307
2415
|
process.exit(1);
|
|
308
2416
|
}
|
|
309
2417
|
}
|
|
310
|
-
|
|
311
|
-
|
|
2418
|
+
|
|
2419
|
+
//#endregion
|
|
2420
|
+
//#region src/cli/commands/stk-push.ts
|
|
2421
|
+
async function cmdStkPush(ctx) {
|
|
2422
|
+
const args = ctx.args;
|
|
2423
|
+
const env = ctx.env;
|
|
312
2424
|
requireEnv(env, "MPESA_CONSUMER_KEY", "MPESA_CONSUMER_SECRET", "MPESA_ENVIRONMENT", "MPESA_SHORTCODE", "MPESA_PASSKEY", "MPESA_CALLBACK_URL");
|
|
313
2425
|
const getArg = (flag) => {
|
|
314
2426
|
const idx = args.indexOf(flag);
|
|
@@ -324,9 +2436,9 @@ async function cmdStkPush(args) {
|
|
|
324
2436
|
const baseUrl = env["MPESA_ENVIRONMENT"] === "production" ? "https://api.safaricom.co.ke" : "https://sandbox.safaricom.co.ke";
|
|
325
2437
|
console.log(dim("\nFetching token…"));
|
|
326
2438
|
const token = await getToken(env["MPESA_CONSUMER_KEY"], env["MPESA_CONSUMER_SECRET"], baseUrl);
|
|
327
|
-
const { formatSafaricomPhone } = await import("./phone.mjs");
|
|
2439
|
+
const { formatSafaricomPhone } = await import("./phone.mjs").then((n) => n.n);
|
|
328
2440
|
const msisdn = formatSafaricomPhone(phone);
|
|
329
|
-
const { getStkPushPassword, getTimestamp } = await
|
|
2441
|
+
const { getStkPushPassword, getTimestamp } = await Promise.resolve().then(() => utils_exports);
|
|
330
2442
|
const timestamp = getTimestamp();
|
|
331
2443
|
const password = getStkPushPassword(env["MPESA_SHORTCODE"], env["MPESA_PASSKEY"], timestamp);
|
|
332
2444
|
console.log(dim("Sending STK Push…\n"));
|
|
@@ -359,14 +2471,18 @@ async function cmdStkPush(args) {
|
|
|
359
2471
|
process.exit(1);
|
|
360
2472
|
}
|
|
361
2473
|
}
|
|
362
|
-
|
|
363
|
-
|
|
2474
|
+
|
|
2475
|
+
//#endregion
|
|
2476
|
+
//#region src/cli/commands/stk-query.ts
|
|
2477
|
+
async function cmdStkQuery(ctx) {
|
|
2478
|
+
const args = ctx.args;
|
|
2479
|
+
const env = ctx.env;
|
|
364
2480
|
requireEnv(env, "MPESA_CONSUMER_KEY", "MPESA_CONSUMER_SECRET", "MPESA_ENVIRONMENT", "MPESA_SHORTCODE", "MPESA_PASSKEY");
|
|
365
2481
|
let checkoutId = args[0];
|
|
366
2482
|
if (!checkoutId) checkoutId = await prompt("CheckoutRequestID");
|
|
367
2483
|
const baseUrl = env["MPESA_ENVIRONMENT"] === "production" ? "https://api.safaricom.co.ke" : "https://sandbox.safaricom.co.ke";
|
|
368
2484
|
const token = await getToken(env["MPESA_CONSUMER_KEY"], env["MPESA_CONSUMER_SECRET"], baseUrl);
|
|
369
|
-
const { getStkPushPassword, getTimestamp } = await
|
|
2485
|
+
const { getStkPushPassword, getTimestamp } = await Promise.resolve().then(() => utils_exports);
|
|
370
2486
|
const timestamp = getTimestamp();
|
|
371
2487
|
const password = getStkPushPassword(env["MPESA_SHORTCODE"], env["MPESA_PASSKEY"], timestamp);
|
|
372
2488
|
try {
|
|
@@ -406,8 +2522,12 @@ async function cmdStkQuery(args) {
|
|
|
406
2522
|
* --identifier-type <type> — "1" MSISDN | "2" Till | "4" ShortCode (default: "4")
|
|
407
2523
|
* --remarks <text> — Optional remarks (default: "Balance query via pesafy CLI")
|
|
408
2524
|
*/
|
|
409
|
-
|
|
410
|
-
|
|
2525
|
+
|
|
2526
|
+
//#endregion
|
|
2527
|
+
//#region src/cli/commands/balance.ts
|
|
2528
|
+
async function cmdBalance(ctx) {
|
|
2529
|
+
const args = ctx.args;
|
|
2530
|
+
const env = ctx.env;
|
|
411
2531
|
requireEnv(env, "MPESA_CONSUMER_KEY", "MPESA_CONSUMER_SECRET", "MPESA_ENVIRONMENT", "MPESA_INITIATOR_NAME", "MPESA_INITIATOR_PASSWORD");
|
|
412
2532
|
const getArg = (flag) => {
|
|
413
2533
|
const idx = args.indexOf(flag);
|
|
@@ -438,7 +2558,7 @@ async function cmdBalance(args) {
|
|
|
438
2558
|
const baseUrl = env["MPESA_ENVIRONMENT"] === "production" ? "https://api.safaricom.co.ke" : "https://sandbox.safaricom.co.ke";
|
|
439
2559
|
console.log(dim("\nFetching token…"));
|
|
440
2560
|
const token = await getToken(env["MPESA_CONSUMER_KEY"], env["MPESA_CONSUMER_SECRET"], baseUrl);
|
|
441
|
-
const { encryptSecurityCredential } = await
|
|
2561
|
+
const { encryptSecurityCredential } = await Promise.resolve().then(() => encryption_exports);
|
|
442
2562
|
const { readFile } = await import("node:fs/promises");
|
|
443
2563
|
const certPath = env["MPESA_CERTIFICATE_PATH"] ?? "./SandboxCertificate.cer";
|
|
444
2564
|
if (!existsSync(resolve(process.cwd(), certPath))) {
|
|
@@ -501,8 +2621,12 @@ async function cmdBalance(args) {
|
|
|
501
2621
|
process.exit(1);
|
|
502
2622
|
}
|
|
503
2623
|
}
|
|
504
|
-
|
|
505
|
-
|
|
2624
|
+
|
|
2625
|
+
//#endregion
|
|
2626
|
+
//#region src/cli/commands/register-c2b-urls.ts
|
|
2627
|
+
async function cmdRegisterC2BUrls(ctx) {
|
|
2628
|
+
const args = ctx.args;
|
|
2629
|
+
const env = ctx.env;
|
|
506
2630
|
requireEnv(env, "MPESA_CONSUMER_KEY", "MPESA_CONSUMER_SECRET", "MPESA_ENVIRONMENT");
|
|
507
2631
|
const shortCode = args[0] ?? env["MPESA_SHORTCODE"] ?? await prompt("Shortcode");
|
|
508
2632
|
const confirmationUrl = args[1] ?? await prompt("Confirmation URL");
|
|
@@ -527,8 +2651,12 @@ async function cmdRegisterC2BUrls(args) {
|
|
|
527
2651
|
process.exit(1);
|
|
528
2652
|
}
|
|
529
2653
|
}
|
|
530
|
-
|
|
531
|
-
|
|
2654
|
+
|
|
2655
|
+
//#endregion
|
|
2656
|
+
//#region src/cli/commands/simulate-c2b.ts
|
|
2657
|
+
async function cmdSimulateC2B(ctx) {
|
|
2658
|
+
const args = ctx.args;
|
|
2659
|
+
const env = ctx.env;
|
|
532
2660
|
requireEnv(env, "MPESA_CONSUMER_KEY", "MPESA_CONSUMER_SECRET");
|
|
533
2661
|
if (env["MPESA_ENVIRONMENT"] === "production") {
|
|
534
2662
|
console.error(r("✖ C2B simulate is only available in sandbox."));
|
|
@@ -560,8 +2688,12 @@ async function cmdSimulateC2B(args) {
|
|
|
560
2688
|
process.exit(1);
|
|
561
2689
|
}
|
|
562
2690
|
}
|
|
563
|
-
|
|
564
|
-
|
|
2691
|
+
|
|
2692
|
+
//#endregion
|
|
2693
|
+
//#region src/cli/commands/reversal.ts
|
|
2694
|
+
async function cmdReversal(ctx) {
|
|
2695
|
+
const args = ctx.args;
|
|
2696
|
+
const env = ctx.env;
|
|
565
2697
|
requireEnv(env, "MPESA_CONSUMER_KEY", "MPESA_CONSUMER_SECRET", "MPESA_ENVIRONMENT", "MPESA_INITIATOR_NAME", "MPESA_INITIATOR_PASSWORD");
|
|
566
2698
|
const transactionId = args[0] ?? await prompt("Transaction ID to reverse");
|
|
567
2699
|
const receiverParty = args[1] ?? env["MPESA_SHORTCODE"] ?? await prompt("Receiver Party (shortcode)");
|
|
@@ -570,7 +2702,7 @@ async function cmdReversal(args) {
|
|
|
570
2702
|
const queueTimeoutUrl = env["MPESA_QUEUE_TIMEOUT_URL"] ?? await prompt("Queue Timeout URL");
|
|
571
2703
|
const baseUrl = env["MPESA_ENVIRONMENT"] === "production" ? "https://api.safaricom.co.ke" : "https://sandbox.safaricom.co.ke";
|
|
572
2704
|
const token = await getToken(env["MPESA_CONSUMER_KEY"], env["MPESA_CONSUMER_SECRET"], baseUrl);
|
|
573
|
-
const { encryptSecurityCredential } = await
|
|
2705
|
+
const { encryptSecurityCredential } = await Promise.resolve().then(() => encryption_exports);
|
|
574
2706
|
const { readFile } = await import("node:fs/promises");
|
|
575
2707
|
const certPath = env["MPESA_CERTIFICATE_PATH"] ?? "./SandboxCertificate.cer";
|
|
576
2708
|
const pem = await readFile(resolve(process.cwd(), certPath), "utf-8");
|
|
@@ -599,22 +2731,164 @@ async function cmdReversal(args) {
|
|
|
599
2731
|
process.exit(1);
|
|
600
2732
|
}
|
|
601
2733
|
}
|
|
2734
|
+
|
|
2735
|
+
//#endregion
|
|
2736
|
+
//#region src/cli/commands/mpesa-api.ts
|
|
2737
|
+
function getArg(args, flag) {
|
|
2738
|
+
const idx = args.indexOf(flag);
|
|
2739
|
+
return idx !== -1 ? args[idx + 1] : void 0;
|
|
2740
|
+
}
|
|
2741
|
+
async function cmdTxStatus(ctx) {
|
|
2742
|
+
const mpesa = await mpesaFromEnv(ctx.env, { requireInitiator: true });
|
|
2743
|
+
requireEnv(ctx.env, "MPESA_RESULT_URL", "MPESA_QUEUE_TIMEOUT_URL");
|
|
2744
|
+
const partyA = getArg(ctx.args, "--party-a") ?? ctx.args[0] ?? ctx.env["MPESA_SHORTCODE"] ?? "";
|
|
2745
|
+
const transactionId = getArg(ctx.args, "--tx") ?? ctx.args[1];
|
|
2746
|
+
const result = await mpesa.transactionStatus({
|
|
2747
|
+
...transactionId ? { transactionId } : {},
|
|
2748
|
+
partyA,
|
|
2749
|
+
identifierType: getArg(ctx.args, "--identifier-type") ?? "4",
|
|
2750
|
+
resultUrl: ctx.env["MPESA_RESULT_URL"],
|
|
2751
|
+
queueTimeOutUrl: ctx.env["MPESA_QUEUE_TIMEOUT_URL"]
|
|
2752
|
+
});
|
|
2753
|
+
if (ctx.json) printJson(result);
|
|
2754
|
+
else console.log(`${g("✔")} Transaction status query submitted\n`, result);
|
|
2755
|
+
}
|
|
2756
|
+
async function cmdB2cPayment(ctx) {
|
|
2757
|
+
const mpesa = await mpesaFromEnv(ctx.env, { requireInitiator: true });
|
|
2758
|
+
requireEnv(ctx.env, "MPESA_RESULT_URL", "MPESA_QUEUE_TIMEOUT_URL");
|
|
2759
|
+
const amount = Number(getArg(ctx.args, "--amount") ?? ctx.args[0] ?? 0);
|
|
2760
|
+
const partyB = getArg(ctx.args, "--phone") ?? ctx.args[1] ?? "";
|
|
2761
|
+
const result = await mpesa.b2cPayment({
|
|
2762
|
+
commandId: "BusinessPayToBulk",
|
|
2763
|
+
amount,
|
|
2764
|
+
partyA: getArg(ctx.args, "--party-a") ?? ctx.env["MPESA_SHORTCODE"] ?? "",
|
|
2765
|
+
partyB,
|
|
2766
|
+
accountReference: getArg(ctx.args, "--ref") ?? `CLI-${Date.now()}`,
|
|
2767
|
+
resultUrl: ctx.env["MPESA_RESULT_URL"],
|
|
2768
|
+
queueTimeOutUrl: ctx.env["MPESA_QUEUE_TIMEOUT_URL"]
|
|
2769
|
+
});
|
|
2770
|
+
if (ctx.json) printJson(result);
|
|
2771
|
+
else console.log(`${g("✔")} B2C payment submitted\n`, result);
|
|
2772
|
+
}
|
|
2773
|
+
async function cmdB2cDisburse(ctx) {
|
|
2774
|
+
const mpesa = await mpesaFromEnv(ctx.env, { requireInitiator: true });
|
|
2775
|
+
requireEnv(ctx.env, "MPESA_RESULT_URL", "MPESA_QUEUE_TIMEOUT_URL");
|
|
2776
|
+
const result = await mpesa.b2cDisbursement({
|
|
2777
|
+
originatorConversationId: getArg(ctx.args, "--originator-id") ?? await prompt("OriginatorConversationID"),
|
|
2778
|
+
commandId: getArg(ctx.args, "--command") ?? "BusinessPayment",
|
|
2779
|
+
amount: Number(getArg(ctx.args, "--amount") ?? ctx.args[0] ?? 0),
|
|
2780
|
+
partyA: getArg(ctx.args, "--party-a") ?? ctx.env["MPESA_SHORTCODE"] ?? "",
|
|
2781
|
+
partyB: getArg(ctx.args, "--phone") ?? ctx.args[1] ?? "",
|
|
2782
|
+
remarks: getArg(ctx.args, "--remarks") ?? "pesafy CLI",
|
|
2783
|
+
resultUrl: ctx.env["MPESA_RESULT_URL"],
|
|
2784
|
+
queueTimeOutUrl: ctx.env["MPESA_QUEUE_TIMEOUT_URL"]
|
|
2785
|
+
});
|
|
2786
|
+
if (ctx.json) printJson(result);
|
|
2787
|
+
else console.log(`${g("✔")} B2C disbursement submitted\n`, result);
|
|
2788
|
+
}
|
|
2789
|
+
async function cmdB2bCheckout(ctx) {
|
|
2790
|
+
const result = await (await mpesaFromEnv(ctx.env)).b2bExpressCheckout({
|
|
2791
|
+
primaryShortCode: getArg(ctx.args, "--primary") ?? ctx.env["MPESA_SHORTCODE"] ?? "",
|
|
2792
|
+
receiverShortCode: getArg(ctx.args, "--receiver") ?? ctx.env["MPESA_SHORTCODE"] ?? "",
|
|
2793
|
+
amount: Number(getArg(ctx.args, "--amount") ?? 0),
|
|
2794
|
+
paymentRef: getArg(ctx.args, "--ref") ?? `REF-${Date.now()}`,
|
|
2795
|
+
callbackUrl: getArg(ctx.args, "--callback") ?? ctx.env["MPESA_CALLBACK_URL"] ?? "",
|
|
2796
|
+
partnerName: getArg(ctx.args, "--partner") ?? "pesafy"
|
|
2797
|
+
});
|
|
2798
|
+
if (ctx.json) printJson(result);
|
|
2799
|
+
else console.log(`${g("✔")} B2B checkout initiated\n`, result);
|
|
2800
|
+
}
|
|
2801
|
+
async function cmdTaxRemit(ctx) {
|
|
2802
|
+
const mpesa = await mpesaFromEnv(ctx.env, { requireInitiator: true });
|
|
2803
|
+
requireEnv(ctx.env, "MPESA_RESULT_URL", "MPESA_QUEUE_TIMEOUT_URL");
|
|
2804
|
+
const result = await mpesa.remitTax({
|
|
2805
|
+
amount: Number(getArg(ctx.args, "--amount") ?? 0),
|
|
2806
|
+
partyA: getArg(ctx.args, "--party-a") ?? ctx.env["MPESA_SHORTCODE"] ?? "",
|
|
2807
|
+
accountReference: getArg(ctx.args, "--prn") ?? await prompt("KRA PRN"),
|
|
2808
|
+
resultUrl: ctx.env["MPESA_RESULT_URL"],
|
|
2809
|
+
queueTimeOutUrl: ctx.env["MPESA_QUEUE_TIMEOUT_URL"]
|
|
2810
|
+
});
|
|
2811
|
+
if (ctx.json) printJson(result);
|
|
2812
|
+
else console.log(`${g("✔")} Tax remittance submitted\n`, result);
|
|
2813
|
+
}
|
|
2814
|
+
async function cmdQrGenerate(ctx) {
|
|
2815
|
+
const result = await (await mpesaFromEnv(ctx.env)).generateDynamicQR(ctx.args[0] ? JSON.parse(ctx.args[0]) : {});
|
|
2816
|
+
if (ctx.json) printJson(result);
|
|
2817
|
+
else console.log(`${g("✔")} Dynamic QR generated\n`, result);
|
|
2818
|
+
}
|
|
2819
|
+
async function cmdBills(ctx) {
|
|
2820
|
+
const sub = ctx.args[0];
|
|
2821
|
+
const mpesa = await mpesaFromEnv(ctx.env);
|
|
2822
|
+
const body = ctx.args[1] ? JSON.parse(ctx.args[1]) : {};
|
|
2823
|
+
let result;
|
|
2824
|
+
switch (sub) {
|
|
2825
|
+
case "optin":
|
|
2826
|
+
result = await mpesa.billManagerOptIn(body);
|
|
2827
|
+
break;
|
|
2828
|
+
case "invoice":
|
|
2829
|
+
result = await mpesa.sendInvoice(body);
|
|
2830
|
+
break;
|
|
2831
|
+
case "bulk":
|
|
2832
|
+
result = await mpesa.sendBulkInvoices(body);
|
|
2833
|
+
break;
|
|
2834
|
+
case "reconcile":
|
|
2835
|
+
result = await mpesa.reconcilePayment(body);
|
|
2836
|
+
break;
|
|
2837
|
+
default:
|
|
2838
|
+
console.error(r("Usage: pesafy bills <optin|invoice|bulk|reconcile> [json-body]"));
|
|
2839
|
+
process.exit(1);
|
|
2840
|
+
}
|
|
2841
|
+
if (ctx.json) printJson(result);
|
|
2842
|
+
else console.log(`${g("✔")} Bill manager ${sub}\n`, result);
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
//#endregion
|
|
2846
|
+
//#region src/cli/index.ts
|
|
2847
|
+
const GLOBAL_FLAGS = new Set([
|
|
2848
|
+
"--json",
|
|
2849
|
+
"--env-file",
|
|
2850
|
+
"--env"
|
|
2851
|
+
]);
|
|
602
2852
|
async function main() {
|
|
603
|
-
const
|
|
604
|
-
const
|
|
2853
|
+
const rawArgv = process.argv.slice(2);
|
|
2854
|
+
const globalArgv = [];
|
|
2855
|
+
let command = "help";
|
|
2856
|
+
const commandArgs = [];
|
|
2857
|
+
for (let i = 0; i < rawArgv.length; i++) {
|
|
2858
|
+
const a = rawArgv[i];
|
|
2859
|
+
if (GLOBAL_FLAGS.has(a)) {
|
|
2860
|
+
globalArgv.push(a);
|
|
2861
|
+
if (a === "--env-file" || a === "--env") globalArgv.push(rawArgv[++i] ?? "");
|
|
2862
|
+
continue;
|
|
2863
|
+
}
|
|
2864
|
+
if (command === "help" && !a.startsWith("--")) {
|
|
2865
|
+
command = a;
|
|
2866
|
+
continue;
|
|
2867
|
+
}
|
|
2868
|
+
commandArgs.push(a);
|
|
2869
|
+
}
|
|
2870
|
+
const ctx = createCliContext([...globalArgv, ...commandArgs]);
|
|
2871
|
+
const banner = `${C.cyan}${C.bold} pesafy${C.reset} ${dim(`v${getPkgVersion()}`)}${C.reset}`;
|
|
605
2872
|
process.stdout.write(`\n${banner}\n`);
|
|
606
2873
|
const handler = {
|
|
607
2874
|
init: cmdInit,
|
|
608
2875
|
doctor: cmdDoctor,
|
|
609
2876
|
token: cmdToken,
|
|
610
|
-
encrypt:
|
|
611
|
-
"validate-phone":
|
|
612
|
-
"stk-push":
|
|
613
|
-
"stk-query":
|
|
614
|
-
balance:
|
|
615
|
-
"register-c2b-urls":
|
|
616
|
-
"simulate-c2b":
|
|
617
|
-
reversal:
|
|
2877
|
+
encrypt: cmdEncrypt,
|
|
2878
|
+
"validate-phone": cmdValidatePhone,
|
|
2879
|
+
"stk-push": cmdStkPush,
|
|
2880
|
+
"stk-query": cmdStkQuery,
|
|
2881
|
+
balance: cmdBalance,
|
|
2882
|
+
"register-c2b-urls": cmdRegisterC2BUrls,
|
|
2883
|
+
"simulate-c2b": cmdSimulateC2B,
|
|
2884
|
+
reversal: cmdReversal,
|
|
2885
|
+
"tx-status": cmdTxStatus,
|
|
2886
|
+
"b2c-payment": cmdB2cPayment,
|
|
2887
|
+
"b2c-disburse": cmdB2cDisburse,
|
|
2888
|
+
"b2b-checkout": cmdB2bCheckout,
|
|
2889
|
+
"tax-remit": cmdTaxRemit,
|
|
2890
|
+
"qr-generate": cmdQrGenerate,
|
|
2891
|
+
bills: cmdBills,
|
|
618
2892
|
version: cmdVersion,
|
|
619
2893
|
help: cmdHelp,
|
|
620
2894
|
"--help": cmdHelp,
|
|
@@ -628,7 +2902,7 @@ async function main() {
|
|
|
628
2902
|
process.exit(1);
|
|
629
2903
|
}
|
|
630
2904
|
try {
|
|
631
|
-
await handler();
|
|
2905
|
+
await handler(ctx);
|
|
632
2906
|
} catch (e) {
|
|
633
2907
|
console.error(r(`\n✖ Unhandled error: ${e.message}\n`));
|
|
634
2908
|
if (process.env["PESAFY_DEBUG"]) console.error(e);
|
|
@@ -638,4 +2912,5 @@ async function main() {
|
|
|
638
2912
|
main();
|
|
639
2913
|
|
|
640
2914
|
//#endregion
|
|
641
|
-
export { };
|
|
2915
|
+
export { };
|
|
2916
|
+
//# sourceMappingURL=cli.mjs.map
|