pesafy 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs ADDED
@@ -0,0 +1,641 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { createInterface } from "node:readline";
4
+ import { resolve } from "node:path";
5
+
6
+ //#region src/cli/index.ts
7
+ const C = {
8
+ reset: "\x1B[0m",
9
+ bold: "\x1B[1m",
10
+ dim: "\x1B[2m",
11
+ green: "\x1B[32m",
12
+ yellow: "\x1B[33m",
13
+ red: "\x1B[31m",
14
+ cyan: "\x1B[36m",
15
+ blue: "\x1B[34m",
16
+ magenta: "\x1B[35m",
17
+ white: "\x1B[37m"
18
+ };
19
+ const g = (s) => `${C.green}${s}${C.reset}`;
20
+ const y = (s) => `${C.yellow}${s}${C.reset}`;
21
+ const r = (s) => `${C.red}${s}${C.reset}`;
22
+ const b = (s) => `${C.bold}${s}${C.reset}`;
23
+ const c = (s) => `${C.cyan}${s}${C.reset}`;
24
+ const dim = (s) => `${C.dim}${s}${C.reset}`;
25
+ function getPkgVersion() {
26
+ try {
27
+ const pkgPath = resolve(new URL("../../package.json", import.meta.url).pathname);
28
+ return JSON.parse(readFileSync(pkgPath, "utf-8")).version;
29
+ } catch {
30
+ return "unknown";
31
+ }
32
+ }
33
+ function prompt(question, defaultVal = "") {
34
+ const rl = createInterface({
35
+ input: process.stdin,
36
+ output: process.stdout
37
+ });
38
+ const displayDefault = defaultVal ? dim(` [${defaultVal}]`) : "";
39
+ return new Promise((resolve) => {
40
+ rl.question(`${question}${displayDefault}: `, (answer) => {
41
+ rl.close();
42
+ resolve(answer.trim() || defaultVal);
43
+ });
44
+ });
45
+ }
46
+ function loadEnv() {
47
+ const envPath = resolve(process.cwd(), ".env");
48
+ if (!existsSync(envPath)) return {};
49
+ const lines = readFileSync(envPath, "utf-8").split("\n");
50
+ const env = {};
51
+ for (const line of lines) {
52
+ const trimmed = line.trim();
53
+ if (!trimmed || trimmed.startsWith("#")) continue;
54
+ const eq = trimmed.indexOf("=");
55
+ if (eq === -1) continue;
56
+ const key = trimmed.slice(0, eq).trim();
57
+ env[key] = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
58
+ }
59
+ return env;
60
+ }
61
+ function requireEnv(env, ...keys) {
62
+ const missing = keys.filter((k) => !env[k]);
63
+ if (missing.length) {
64
+ console.error(r(`✖ Missing env vars: ${missing.join(", ")}`));
65
+ console.error(dim(" Run: npx pesafy init"));
66
+ process.exit(1);
67
+ }
68
+ }
69
+ async function fetchJson(url, method, headers, body) {
70
+ const res = await fetch(url, {
71
+ method,
72
+ headers: {
73
+ "Content-Type": "application/json",
74
+ Accept: "application/json",
75
+ ...headers
76
+ },
77
+ ...body ? { body: JSON.stringify(body) } : {}
78
+ });
79
+ const text = await res.text();
80
+ try {
81
+ return JSON.parse(text);
82
+ } catch {
83
+ throw new Error(`Non-JSON response (${res.status}): ${text.slice(0, 200)}`);
84
+ }
85
+ }
86
+ async function getToken(consumerKey, consumerSecret, baseUrl) {
87
+ const creds = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64");
88
+ const data = await fetchJson(`${baseUrl}/oauth/v1/generate?grant_type=client_credentials`, "GET", { Authorization: `Basic ${creds}` });
89
+ if (!data.access_token) throw new Error("No access_token in response");
90
+ return data.access_token;
91
+ }
92
+ async function cmdVersion() {
93
+ console.log(`pesafy v${getPkgVersion()}`);
94
+ }
95
+ async function cmdHelp() {
96
+ console.log(`
97
+ ${b(`pesafy`)} ${dim(`v${getPkgVersion()}`)} — M-PESA Daraja SDK CLI
98
+
99
+ ${b("COMMANDS")}
100
+ ${g("init")} Scaffold .env + config file interactively
101
+ ${g("doctor")} Validate .env config for common mistakes
102
+ ${g("token")} Print a fresh Daraja OAuth token
103
+ ${g("encrypt")} Encrypt an initiator password → SecurityCredential
104
+ ${g("validate-phone")} ${c("<phone>")} Validate and normalise a Kenyan phone number
105
+ ${g("stk-push")} Initiate an STK Push payment prompt
106
+ ${g("stk-query")} ${c("<checkoutId>")} Query status of an STK Push
107
+ ${g("balance")} Query M-PESA account balance (async — result via ResultURL)
108
+ ${g("reversal")} ${c("<txId>")} Initiate a transaction reversal
109
+ ${g("register-c2b-urls")} Register C2B Confirmation + Validation URLs
110
+ ${g("simulate-c2b")} Simulate a C2B payment (sandbox only)
111
+ ${g("version")} Print library version
112
+ ${g("help")} Show this help
113
+
114
+ ${b("ENVIRONMENT")}
115
+ All commands read from .env in the current directory.
116
+ Required keys:
117
+ MPESA_CONSUMER_KEY — Daraja consumer key
118
+ MPESA_CONSUMER_SECRET — Daraja consumer secret
119
+ MPESA_ENVIRONMENT — "sandbox" | "production"
120
+
121
+ STK Push additionally requires:
122
+ MPESA_SHORTCODE — Paybill / HO shortcode
123
+ MPESA_PASSKEY — Lipa Na M-PESA passkey
124
+ MPESA_CALLBACK_URL — Public callback URL
125
+
126
+ Initiator APIs (Account Balance / B2C / Reversal) additionally require:
127
+ MPESA_INITIATOR_NAME
128
+ MPESA_INITIATOR_PASSWORD
129
+ MPESA_CERTIFICATE_PATH — Path to .cer file
130
+ MPESA_RESULT_URL — Public URL for async balance/reversal result
131
+ MPESA_QUEUE_TIMEOUT_URL
132
+
133
+ ${b("ACCOUNT BALANCE NOTES")}
134
+ The Account Balance API is ASYNCHRONOUS.
135
+ The CLI submits the request and prints the OriginatorConversationID.
136
+ Daraja will POST the actual balance data to MPESA_RESULT_URL.
137
+
138
+ IdentifierType values:
139
+ 1 = MSISDN
140
+ 2 = Till Number
141
+ 4 = Organisation ShortCode (default, most common)
142
+
143
+ ${b("EXAMPLES")}
144
+ ${dim("$ npx pesafy init")}
145
+ ${dim("$ npx pesafy stk-push --amount 100 --phone 254712345678")}
146
+ ${dim("$ npx pesafy stk-query ws_CO_1234567890")}
147
+ ${dim("$ npx pesafy balance --shortcode 600000 --identifier-type 4")}
148
+ ${dim("$ npx pesafy token")}
149
+ ${dim("$ npx pesafy doctor")}
150
+ ${dim("$ npx pesafy validate-phone 0712345678")}
151
+ `);
152
+ }
153
+ async function cmdInit() {
154
+ console.log(`\n${b("🚀 pesafy — Interactive Setup")}\n`);
155
+ console.log(dim("This will create a .env file in the current directory.\n"));
156
+ const content = `# pesafy — M-PESA Daraja configuration
157
+ # Generated by: npx pesafy init
158
+
159
+ MPESA_ENVIRONMENT=${await prompt("Environment (sandbox/production)", "sandbox")}
160
+ MPESA_CONSUMER_KEY=${await prompt("Consumer Key")}
161
+ MPESA_CONSUMER_SECRET=${await prompt("Consumer Secret")}
162
+
163
+ # STK Push (M-PESA Express)
164
+ MPESA_SHORTCODE=${await prompt("Lipa Na M-PESA Shortcode (for STK Push)", "174379")}
165
+ MPESA_PASSKEY=${await prompt("Lipa Na M-PESA Passkey")}
166
+ MPESA_CALLBACK_URL=${await prompt("STK Push Callback URL", "https://yourdomain.com/api/mpesa/callback")}
167
+
168
+ # Initiator (Account Balance / B2C / Reversal / Transaction Status / Tax Remittance)
169
+ # Required org portal role for Account Balance: "Balance Query ORG API"
170
+ MPESA_INITIATOR_NAME=${await prompt("Initiator Name (leave blank if not using B2C/Reversal/Balance)")}
171
+ MPESA_INITIATOR_PASSWORD=${await prompt("Initiator Password (leave blank if not using B2C/Reversal/Balance)")}
172
+ MPESA_CERTIFICATE_PATH=${await prompt("Certificate path (leave blank to skip)", "./SandboxCertificate.cer")}
173
+
174
+ # Async API result endpoints (Account Balance, Reversal, B2C)
175
+ MPESA_RESULT_URL=${await prompt("Result URL (for async APIs — Account Balance, Reversal, B2C)", "https://yourdomain.com/api/mpesa/result")}
176
+ MPESA_QUEUE_TIMEOUT_URL=${await prompt("Queue Timeout URL", "https://yourdomain.com/api/mpesa/timeout")}
177
+ `;
178
+ const envPath = resolve(process.cwd(), ".env");
179
+ if (existsSync(envPath)) {
180
+ if ((await prompt(".env already exists — overwrite? (y/N)", "N")).toLowerCase() !== "y") {
181
+ console.log(y("⚠ Skipped — existing .env preserved."));
182
+ return;
183
+ }
184
+ }
185
+ writeFileSync(envPath, content);
186
+ console.log(`\n${g("✔ .env created")} — run ${c("npx pesafy doctor")} to validate.\n`);
187
+ }
188
+ async function cmdDoctor() {
189
+ console.log(`\n${b("🩺 pesafy doctor")}\n`);
190
+ const env = loadEnv();
191
+ let ok = true;
192
+ function check(key, hint = "") {
193
+ if (env[key]) console.log(`${g("✔")} ${key}`);
194
+ else {
195
+ console.log(`${r("✖")} ${key}${hint ? dim(` — ${hint}`) : ""}`);
196
+ ok = false;
197
+ }
198
+ }
199
+ console.log(b("Core:"));
200
+ check("MPESA_CONSUMER_KEY", "Get from https://developer.safaricom.co.ke");
201
+ check("MPESA_CONSUMER_SECRET");
202
+ check("MPESA_ENVIRONMENT", "'sandbox' or 'production'");
203
+ const envVal = env["MPESA_ENVIRONMENT"] ?? "";
204
+ if (envVal && envVal !== "sandbox" && envVal !== "production") {
205
+ console.log(r(` ✖ MPESA_ENVIRONMENT must be "sandbox" or "production", got "${envVal}"`));
206
+ ok = false;
207
+ }
208
+ console.log(`\n${b("STK Push:")}`);
209
+ check("MPESA_SHORTCODE");
210
+ check("MPESA_PASSKEY");
211
+ check("MPESA_CALLBACK_URL", "Must be a publicly accessible HTTPS URL in production");
212
+ const cb = env["MPESA_CALLBACK_URL"] ?? "";
213
+ if (cb && envVal === "production" && !cb.startsWith("https://")) {
214
+ console.log(r(" ✖ MPESA_CALLBACK_URL must be HTTPS in production"));
215
+ ok = false;
216
+ }
217
+ if (cb && [
218
+ "mpesa",
219
+ "safaricom",
220
+ ".exe",
221
+ "cmd",
222
+ "sql"
223
+ ].some((kw) => cb.toLowerCase().includes(kw))) {
224
+ console.log(r(" ✖ MPESA_CALLBACK_URL contains a forbidden keyword (mpesa/safaricom/etc.)"));
225
+ ok = false;
226
+ }
227
+ console.log(`\n${b("Initiator (Account Balance / B2C / Reversal / Balance — optional):")}`);
228
+ if (!!!(env["MPESA_INITIATOR_NAME"] && env["MPESA_INITIATOR_PASSWORD"])) console.log(dim(" — Not configured (required for Account Balance, B2C, Reversal, Tax)"));
229
+ else {
230
+ check("MPESA_INITIATOR_NAME");
231
+ check("MPESA_INITIATOR_PASSWORD");
232
+ const certPath = env["MPESA_CERTIFICATE_PATH"];
233
+ if (certPath) {
234
+ const fullPath = resolve(process.cwd(), certPath);
235
+ if (existsSync(fullPath)) console.log(`${g("✔")} MPESA_CERTIFICATE_PATH ${dim(`(${fullPath})`)}`);
236
+ else {
237
+ console.log(`${r("✖")} MPESA_CERTIFICATE_PATH — file not found: ${fullPath}`);
238
+ ok = false;
239
+ }
240
+ } else console.log(y("⚠ MPESA_CERTIFICATE_PATH not set — download from Safaricom Daraja portal"));
241
+ }
242
+ console.log(`\n${b("Async result URLs (required for Account Balance, Reversal, B2C):")}`);
243
+ if (env["MPESA_RESULT_URL"]) {
244
+ console.log(`${g("✔")} MPESA_RESULT_URL`);
245
+ const resultUrl = env["MPESA_RESULT_URL"];
246
+ if (envVal === "production" && !resultUrl.startsWith("https://")) {
247
+ console.log(r(" ✖ MPESA_RESULT_URL must be HTTPS in production"));
248
+ ok = false;
249
+ }
250
+ } else console.log(y("⚠ MPESA_RESULT_URL not set — required for Account Balance, Reversal, B2C"));
251
+ if (env["MPESA_QUEUE_TIMEOUT_URL"]) console.log(`${g("✔")} MPESA_QUEUE_TIMEOUT_URL`);
252
+ else console.log(y("⚠ MPESA_QUEUE_TIMEOUT_URL not set — required for Account Balance, Reversal, B2C"));
253
+ console.log("");
254
+ if (ok) console.log(`${g("✔ All checks passed!")} Your config looks good.\n`);
255
+ else {
256
+ console.log(`${r("✖ Some checks failed.")} Fix the issues above, then re-run ${c("npx pesafy doctor")}.\n`);
257
+ process.exit(1);
258
+ }
259
+ }
260
+ async function cmdToken() {
261
+ const env = loadEnv();
262
+ requireEnv(env, "MPESA_CONSUMER_KEY", "MPESA_CONSUMER_SECRET", "MPESA_ENVIRONMENT");
263
+ const baseUrl = env["MPESA_ENVIRONMENT"] === "production" ? "https://api.safaricom.co.ke" : "https://sandbox.safaricom.co.ke";
264
+ process.stdout.write(dim("Fetching token…"));
265
+ try {
266
+ const token = await getToken(env["MPESA_CONSUMER_KEY"], env["MPESA_CONSUMER_SECRET"], baseUrl);
267
+ process.stdout.write("\r");
268
+ console.log(`\n${b("Access Token:")}\n\n${c(token)}\n`);
269
+ console.log(dim("Token is valid for 3600 seconds (1 hour)."));
270
+ } catch (e) {
271
+ process.stdout.write("\r");
272
+ console.error(r(`✖ ${e.message}`));
273
+ process.exit(1);
274
+ }
275
+ }
276
+ async function cmdEncrypt(args) {
277
+ const env = loadEnv();
278
+ let password = args[0];
279
+ let certPath = args[1] ?? env["MPESA_CERTIFICATE_PATH"];
280
+ if (!password) password = await prompt("Initiator password to encrypt");
281
+ if (!certPath) certPath = await prompt("Certificate path", "./SandboxCertificate.cer");
282
+ if (!existsSync(resolve(process.cwd(), certPath))) {
283
+ console.error(r(`✖ Certificate not found: ${certPath}`));
284
+ process.exit(1);
285
+ }
286
+ try {
287
+ const { encryptSecurityCredential } = await import("./encryption.mjs");
288
+ const { readFile } = await import("node:fs/promises");
289
+ const pem = await readFile(resolve(process.cwd(), certPath), "utf-8");
290
+ const credential = encryptSecurityCredential(password, pem);
291
+ console.log(`\n${b("SecurityCredential (base64):")}\n\n${c(credential)}\n`);
292
+ console.log(dim("Copy this value into MPESA_SECURITY_CREDENTIAL in your .env or config."));
293
+ } catch (e) {
294
+ console.error(r(`✖ Encryption failed: ${e.message}`));
295
+ process.exit(1);
296
+ }
297
+ }
298
+ async function cmdValidatePhone(args) {
299
+ let phone = args[0];
300
+ if (!phone) phone = await prompt("Phone number to validate");
301
+ try {
302
+ const { formatSafaricomPhone } = await import("./phone.mjs");
303
+ const normalised = formatSafaricomPhone(phone);
304
+ console.log(`\n${g("✔")} ${b(phone)} → ${c(normalised)}\n`);
305
+ } catch (e) {
306
+ console.error(`\n${r("✖")} Invalid: ${e.message}\n`);
307
+ process.exit(1);
308
+ }
309
+ }
310
+ async function cmdStkPush(args) {
311
+ const env = loadEnv();
312
+ requireEnv(env, "MPESA_CONSUMER_KEY", "MPESA_CONSUMER_SECRET", "MPESA_ENVIRONMENT", "MPESA_SHORTCODE", "MPESA_PASSKEY", "MPESA_CALLBACK_URL");
313
+ const getArg = (flag) => {
314
+ const idx = args.indexOf(flag);
315
+ return idx !== -1 ? args[idx + 1] : void 0;
316
+ };
317
+ let amount = Number(getArg("--amount") ?? 0);
318
+ let phone = getArg("--phone") ?? "";
319
+ let accountRef = getArg("--ref") ?? "";
320
+ let desc = getArg("--desc") ?? "Payment";
321
+ if (!amount) amount = Number(await prompt("Amount (KES)"));
322
+ if (!phone) phone = await prompt("Phone number (e.g. 0712345678)");
323
+ if (!accountRef) accountRef = await prompt("Account reference", `CLI-${Date.now().toString(36).toUpperCase()}`);
324
+ const baseUrl = env["MPESA_ENVIRONMENT"] === "production" ? "https://api.safaricom.co.ke" : "https://sandbox.safaricom.co.ke";
325
+ console.log(dim("\nFetching token…"));
326
+ const token = await getToken(env["MPESA_CONSUMER_KEY"], env["MPESA_CONSUMER_SECRET"], baseUrl);
327
+ const { formatSafaricomPhone } = await import("./phone.mjs");
328
+ const msisdn = formatSafaricomPhone(phone);
329
+ const { getStkPushPassword, getTimestamp } = await import("./utils.mjs");
330
+ const timestamp = getTimestamp();
331
+ const password = getStkPushPassword(env["MPESA_SHORTCODE"], env["MPESA_PASSKEY"], timestamp);
332
+ console.log(dim("Sending STK Push…\n"));
333
+ try {
334
+ const result = await fetchJson(`${baseUrl}/mpesa/stkpush/v1/processrequest`, "POST", { Authorization: `Bearer ${token}` }, {
335
+ BusinessShortCode: env["MPESA_SHORTCODE"],
336
+ Password: password,
337
+ Timestamp: timestamp,
338
+ TransactionType: "CustomerPayBillOnline",
339
+ Amount: Math.round(amount),
340
+ PartyA: msisdn,
341
+ PartyB: env["MPESA_SHORTCODE"],
342
+ PhoneNumber: msisdn,
343
+ CallBackURL: env["MPESA_CALLBACK_URL"],
344
+ AccountReference: accountRef.slice(0, 12),
345
+ TransactionDesc: desc.slice(0, 13)
346
+ });
347
+ if (result["ResponseCode"] === "0") {
348
+ console.log(`${g("✔ STK Push sent successfully!")}\n`);
349
+ console.log(` ${b("CheckoutRequestID:")} ${c(result["CheckoutRequestID"] ?? "")}`);
350
+ console.log(` ${b("MerchantRequestID:")} ${result["MerchantRequestID"] ?? ""}`);
351
+ console.log(` ${b("CustomerMessage:")} ${result["CustomerMessage"] ?? ""}`);
352
+ console.log(`\n${dim(" Use `npx pesafy stk-query <CheckoutRequestID>` to check status.")}\n`);
353
+ } else {
354
+ console.log(`${r("✖ STK Push failed:")}\n`);
355
+ console.log(JSON.stringify(result, null, 2));
356
+ }
357
+ } catch (e) {
358
+ console.error(r(`✖ ${e.message}`));
359
+ process.exit(1);
360
+ }
361
+ }
362
+ async function cmdStkQuery(args) {
363
+ const env = loadEnv();
364
+ requireEnv(env, "MPESA_CONSUMER_KEY", "MPESA_CONSUMER_SECRET", "MPESA_ENVIRONMENT", "MPESA_SHORTCODE", "MPESA_PASSKEY");
365
+ let checkoutId = args[0];
366
+ if (!checkoutId) checkoutId = await prompt("CheckoutRequestID");
367
+ const baseUrl = env["MPESA_ENVIRONMENT"] === "production" ? "https://api.safaricom.co.ke" : "https://sandbox.safaricom.co.ke";
368
+ const token = await getToken(env["MPESA_CONSUMER_KEY"], env["MPESA_CONSUMER_SECRET"], baseUrl);
369
+ const { getStkPushPassword, getTimestamp } = await import("./utils.mjs");
370
+ const timestamp = getTimestamp();
371
+ const password = getStkPushPassword(env["MPESA_SHORTCODE"], env["MPESA_PASSKEY"], timestamp);
372
+ try {
373
+ const result = await fetchJson(`${baseUrl}/mpesa/stkpushquery/v1/query`, "POST", { Authorization: `Bearer ${token}` }, {
374
+ BusinessShortCode: env["MPESA_SHORTCODE"],
375
+ Password: password,
376
+ Timestamp: timestamp,
377
+ CheckoutRequestID: checkoutId
378
+ });
379
+ const code = result["ResultCode"];
380
+ const desc = result["ResultDesc"];
381
+ if (code === 0) console.log(`\n${g("✔ Payment confirmed!")} — ${desc}\n`);
382
+ else console.log(`\n${y("⚠ Payment not complete")} (code ${code}) — ${desc}\n`);
383
+ console.log(JSON.stringify(result, null, 2));
384
+ } catch (e) {
385
+ console.error(r(`✖ ${e.message}`));
386
+ process.exit(1);
387
+ }
388
+ }
389
+ /**
390
+ * Account Balance CLI command
391
+ *
392
+ * Daraja API: POST /mpesa/accountbalance/v1/query
393
+ *
394
+ * ASYNCHRONOUS — submits the request and returns OriginatorConversationID.
395
+ * Actual balance data arrives via POST to MPESA_RESULT_URL.
396
+ *
397
+ * Required env:
398
+ * MPESA_CONSUMER_KEY, MPESA_CONSUMER_SECRET, MPESA_ENVIRONMENT
399
+ * MPESA_INITIATOR_NAME, MPESA_INITIATOR_PASSWORD, MPESA_CERTIFICATE_PATH
400
+ * MPESA_RESULT_URL, MPESA_QUEUE_TIMEOUT_URL
401
+ *
402
+ * Required org portal role: "Balance Query ORG API"
403
+ *
404
+ * Flags:
405
+ * --shortcode <code> — PartyA shortcode to query (default: MPESA_SHORTCODE)
406
+ * --identifier-type <type> — "1" MSISDN | "2" Till | "4" ShortCode (default: "4")
407
+ * --remarks <text> — Optional remarks (default: "Balance query via pesafy CLI")
408
+ */
409
+ async function cmdBalance(args) {
410
+ const env = loadEnv();
411
+ requireEnv(env, "MPESA_CONSUMER_KEY", "MPESA_CONSUMER_SECRET", "MPESA_ENVIRONMENT", "MPESA_INITIATOR_NAME", "MPESA_INITIATOR_PASSWORD");
412
+ const getArg = (flag) => {
413
+ const idx = args.indexOf(flag);
414
+ return idx !== -1 ? args[idx + 1] : void 0;
415
+ };
416
+ const partyA = getArg("--shortcode") ?? args[0] ?? env["MPESA_SHORTCODE"] ?? await prompt("Shortcode to query (PartyA)");
417
+ const identifierTypeRaw = getArg("--identifier-type") ?? env["MPESA_IDENTIFIER_TYPE"] ?? "4";
418
+ if (![
419
+ "1",
420
+ "2",
421
+ "4"
422
+ ].includes(identifierTypeRaw)) {
423
+ console.error(r(`✖ Invalid --identifier-type "${identifierTypeRaw}". Must be "1" (MSISDN), "2" (Till), or "4" (ShortCode).`));
424
+ process.exit(1);
425
+ }
426
+ const identifierType = identifierTypeRaw;
427
+ const remarks = getArg("--remarks") ?? "Balance query via pesafy CLI";
428
+ const resultUrl = env["MPESA_RESULT_URL"] ?? await prompt("Result URL");
429
+ const queueTimeoutUrl = env["MPESA_QUEUE_TIMEOUT_URL"] ?? await prompt("Queue Timeout URL");
430
+ if (!resultUrl.trim()) {
431
+ console.error(r("✖ ResultURL is required. Set MPESA_RESULT_URL in your .env"));
432
+ process.exit(1);
433
+ }
434
+ if (!queueTimeoutUrl.trim()) {
435
+ console.error(r("✖ QueueTimeOutURL is required. Set MPESA_QUEUE_TIMEOUT_URL in your .env"));
436
+ process.exit(1);
437
+ }
438
+ const baseUrl = env["MPESA_ENVIRONMENT"] === "production" ? "https://api.safaricom.co.ke" : "https://sandbox.safaricom.co.ke";
439
+ console.log(dim("\nFetching token…"));
440
+ const token = await getToken(env["MPESA_CONSUMER_KEY"], env["MPESA_CONSUMER_SECRET"], baseUrl);
441
+ const { encryptSecurityCredential } = await import("./encryption.mjs");
442
+ const { readFile } = await import("node:fs/promises");
443
+ const certPath = env["MPESA_CERTIFICATE_PATH"] ?? "./SandboxCertificate.cer";
444
+ if (!existsSync(resolve(process.cwd(), certPath))) {
445
+ console.error(r(`✖ Certificate not found: ${certPath}`));
446
+ console.error(dim(" Download from Safaricom Daraja portal and set MPESA_CERTIFICATE_PATH"));
447
+ process.exit(1);
448
+ }
449
+ const pem = await readFile(resolve(process.cwd(), certPath), "utf-8");
450
+ const securityCredential = encryptSecurityCredential(env["MPESA_INITIATOR_PASSWORD"], pem);
451
+ console.log(dim("Sending Account Balance query…\n"));
452
+ const identifierTypeLabel = {
453
+ "1": "MSISDN",
454
+ "2": "Till Number",
455
+ "4": "Organisation ShortCode"
456
+ };
457
+ try {
458
+ const result = await fetchJson(`${baseUrl}/mpesa/accountbalance/v1/query`, "POST", { Authorization: `Bearer ${token}` }, {
459
+ Initiator: env["MPESA_INITIATOR_NAME"],
460
+ SecurityCredential: securityCredential,
461
+ CommandID: "AccountBalance",
462
+ PartyA: String(partyA),
463
+ IdentifierType: identifierType,
464
+ ResultURL: resultUrl,
465
+ QueueTimeOutURL: queueTimeoutUrl,
466
+ Remarks: remarks
467
+ });
468
+ if (result["ResponseCode"] === "0") {
469
+ console.log(`${g("✔ Account Balance query submitted!")}\n`);
470
+ console.log(` ${b("OriginatorConversationID:")} ${c(result["OriginatorConversationID"] ?? "")}`);
471
+ console.log(` ${b("ConversationID:")} ${result["ConversationID"] ?? ""}`);
472
+ console.log(` ${b("ResponseDescription:")} ${result["ResponseDescription"] ?? ""}`);
473
+ console.log(` ${b("PartyA (shortcode):")} ${partyA}`);
474
+ console.log(` ${b("IdentifierType:")} ${identifierType} (${identifierTypeLabel[identifierType]})`);
475
+ console.log();
476
+ console.log(dim(` ⚠ This API is ASYNCHRONOUS. The balance data will be POSTed to:\n ${resultUrl}\n`));
477
+ console.log(dim(" Save the OriginatorConversationID to correlate the async callback.\n If the callback fails, check the M-PESA org portal manually.\n"));
478
+ } else {
479
+ console.log(`${r("✖ Account Balance query failed:")}\n`);
480
+ console.log(JSON.stringify(result, null, 2));
481
+ const errorCode = result["errorCode"] ?? "";
482
+ if (errorCode === "18" || result["ResponseDescription"]?.includes("initiator")) {
483
+ console.log(y("\n Hint: Error 18 = Initiator Credential Check Failure"));
484
+ console.log(dim(" — Verify MPESA_INITIATOR_NAME is the correct API operator username"));
485
+ console.log(dim(" — Confirm the API user is active (not dormant) on the M-PESA org portal"));
486
+ console.log(dim(" — Ensure the certificate matches the environment (sandbox/production)"));
487
+ } else if (errorCode === "20") {
488
+ console.log(y("\n Hint: Error 20 = Unresolved Initiator"));
489
+ console.log(dim(" — The initiator username was not found. Log in to the M-PESA portal to verify."));
490
+ } else if (errorCode === "21") {
491
+ console.log(y("\n Hint: Error 21 = Initiator to Primary Party Permission Failure"));
492
+ console.log(dim(" — The API user does not have the \"Balance Query ORG API\" role."));
493
+ console.log(dim(" — Ask your Business Administrator to assign the correct role."));
494
+ } else if (errorCode === "15") {
495
+ console.log(y("\n Hint: Error 15 = Duplicate Detected"));
496
+ console.log(dim(" — A request with the same OriginatorConversationID was already submitted."));
497
+ }
498
+ }
499
+ } catch (e) {
500
+ console.error(r(`✖ ${e.message}`));
501
+ process.exit(1);
502
+ }
503
+ }
504
+ async function cmdRegisterC2BUrls(args) {
505
+ const env = loadEnv();
506
+ requireEnv(env, "MPESA_CONSUMER_KEY", "MPESA_CONSUMER_SECRET", "MPESA_ENVIRONMENT");
507
+ const shortCode = args[0] ?? env["MPESA_SHORTCODE"] ?? await prompt("Shortcode");
508
+ const confirmationUrl = args[1] ?? await prompt("Confirmation URL");
509
+ const validationUrl = args[2] ?? await prompt("Validation URL");
510
+ const responseType = args[3] ?? await prompt("Response type (Completed/Cancelled)", "Completed");
511
+ const baseUrl = env["MPESA_ENVIRONMENT"] === "production" ? "https://api.safaricom.co.ke" : "https://sandbox.safaricom.co.ke";
512
+ const token = await getToken(env["MPESA_CONSUMER_KEY"], env["MPESA_CONSUMER_SECRET"], baseUrl);
513
+ try {
514
+ const result = await fetchJson(`${baseUrl}/mpesa/c2b/v2/registerurl`, "POST", { Authorization: `Bearer ${token}` }, {
515
+ ShortCode: shortCode,
516
+ ResponseType: responseType,
517
+ ConfirmationURL: confirmationUrl,
518
+ ValidationURL: validationUrl
519
+ });
520
+ if (result["ResponseCode"] === "0") console.log(`\n${g("✔ C2B URLs registered successfully!")}\n`);
521
+ else {
522
+ console.log(r("✖ Registration failed:"));
523
+ console.log(JSON.stringify(result, null, 2));
524
+ }
525
+ } catch (e) {
526
+ console.error(r(`✖ ${e.message}`));
527
+ process.exit(1);
528
+ }
529
+ }
530
+ async function cmdSimulateC2B(args) {
531
+ const env = loadEnv();
532
+ requireEnv(env, "MPESA_CONSUMER_KEY", "MPESA_CONSUMER_SECRET");
533
+ if (env["MPESA_ENVIRONMENT"] === "production") {
534
+ console.error(r("✖ C2B simulate is only available in sandbox."));
535
+ process.exit(1);
536
+ }
537
+ const shortCode = args[0] ?? env["MPESA_SHORTCODE"] ?? await prompt("Shortcode");
538
+ const amount = Number(args[1] ?? await prompt("Amount (KES)"));
539
+ const msisdn = args[2] ?? await prompt("MSISDN", "254708374149");
540
+ const commandId = args[3] ?? await prompt("CommandID (CustomerPayBillOnline/CustomerBuyGoodsOnline)", "CustomerPayBillOnline");
541
+ const billRef = commandId === "CustomerBuyGoodsOnline" ? void 0 : args[4] ?? await prompt("BillRefNumber (account ref)", "");
542
+ const baseUrl = "https://sandbox.safaricom.co.ke";
543
+ const token = await getToken(env["MPESA_CONSUMER_KEY"], env["MPESA_CONSUMER_SECRET"], baseUrl);
544
+ const payload = {
545
+ ShortCode: Number(shortCode),
546
+ CommandID: commandId,
547
+ Amount: Math.round(amount),
548
+ Msisdn: Number(msisdn)
549
+ };
550
+ if (commandId !== "CustomerBuyGoodsOnline") payload["BillRefNumber"] = billRef ?? "";
551
+ try {
552
+ const result = await fetchJson(`${baseUrl}/mpesa/c2b/v2/simulate`, "POST", { Authorization: `Bearer ${token}` }, payload);
553
+ if (result["ResponseCode"] === "0") console.log(`\n${g("✔ C2B simulation submitted!")}\n`);
554
+ else {
555
+ console.log(r("✖ Simulation failed:"));
556
+ console.log(JSON.stringify(result, null, 2));
557
+ }
558
+ } catch (e) {
559
+ console.error(r(`✖ ${e.message}`));
560
+ process.exit(1);
561
+ }
562
+ }
563
+ async function cmdReversal(args) {
564
+ const env = loadEnv();
565
+ requireEnv(env, "MPESA_CONSUMER_KEY", "MPESA_CONSUMER_SECRET", "MPESA_ENVIRONMENT", "MPESA_INITIATOR_NAME", "MPESA_INITIATOR_PASSWORD");
566
+ const transactionId = args[0] ?? await prompt("Transaction ID to reverse");
567
+ const receiverParty = args[1] ?? env["MPESA_SHORTCODE"] ?? await prompt("Receiver Party (shortcode)");
568
+ const amount = Number(args[2] ?? await prompt("Amount to reverse"));
569
+ const resultUrl = env["MPESA_RESULT_URL"] ?? await prompt("Result URL");
570
+ const queueTimeoutUrl = env["MPESA_QUEUE_TIMEOUT_URL"] ?? await prompt("Queue Timeout URL");
571
+ const baseUrl = env["MPESA_ENVIRONMENT"] === "production" ? "https://api.safaricom.co.ke" : "https://sandbox.safaricom.co.ke";
572
+ const token = await getToken(env["MPESA_CONSUMER_KEY"], env["MPESA_CONSUMER_SECRET"], baseUrl);
573
+ const { encryptSecurityCredential } = await import("./encryption.mjs");
574
+ const { readFile } = await import("node:fs/promises");
575
+ const certPath = env["MPESA_CERTIFICATE_PATH"] ?? "./SandboxCertificate.cer";
576
+ const pem = await readFile(resolve(process.cwd(), certPath), "utf-8");
577
+ const cred = encryptSecurityCredential(env["MPESA_INITIATOR_PASSWORD"], pem);
578
+ try {
579
+ const result = await fetchJson(`${baseUrl}/mpesa/reversal/v1/request`, "POST", { Authorization: `Bearer ${token}` }, {
580
+ Initiator: env["MPESA_INITIATOR_NAME"],
581
+ SecurityCredential: cred,
582
+ CommandID: "TransactionReversal",
583
+ TransactionID: transactionId,
584
+ Amount: String(Math.round(amount)),
585
+ ReceiverParty: receiverParty,
586
+ RecieverIdentifierType: "4",
587
+ ResultURL: resultUrl,
588
+ QueueTimeOutURL: queueTimeoutUrl,
589
+ Remarks: "Reversal via pesafy CLI",
590
+ Occasion: ""
591
+ });
592
+ if (result["ResponseCode"] === "0") console.log(`\n${g("✔ Reversal submitted!")} Result will be POSTed to:\n ${c(resultUrl)}\n`);
593
+ else {
594
+ console.log(r("✖ Reversal failed:"));
595
+ console.log(JSON.stringify(result, null, 2));
596
+ }
597
+ } catch (e) {
598
+ console.error(r(`✖ ${e.message}`));
599
+ process.exit(1);
600
+ }
601
+ }
602
+ async function main() {
603
+ const [, , command = "help", ...args] = process.argv;
604
+ const banner = `${C.cyan}${C.bold} pesafy${C.reset} ${C.dim}v${getPkgVersion()}${C.reset}`;
605
+ process.stdout.write(`\n${banner}\n`);
606
+ const handler = {
607
+ init: cmdInit,
608
+ doctor: cmdDoctor,
609
+ token: cmdToken,
610
+ encrypt: () => cmdEncrypt(args),
611
+ "validate-phone": () => cmdValidatePhone(args),
612
+ "stk-push": () => cmdStkPush(args),
613
+ "stk-query": () => cmdStkQuery(args),
614
+ balance: () => cmdBalance(args),
615
+ "register-c2b-urls": () => cmdRegisterC2BUrls(args),
616
+ "simulate-c2b": () => cmdSimulateC2B(args),
617
+ reversal: () => cmdReversal(args),
618
+ version: cmdVersion,
619
+ help: cmdHelp,
620
+ "--help": cmdHelp,
621
+ "-h": cmdHelp,
622
+ "--version": cmdVersion,
623
+ "-v": cmdVersion
624
+ }[command];
625
+ if (!handler) {
626
+ console.error(r(`✖ Unknown command: "${command}"`));
627
+ console.error(dim(" Run: npx pesafy help"));
628
+ process.exit(1);
629
+ }
630
+ try {
631
+ await handler();
632
+ } catch (e) {
633
+ console.error(r(`\n✖ Unhandled error: ${e.message}\n`));
634
+ if (process.env["PESAFY_DEBUG"]) console.error(e);
635
+ process.exit(1);
636
+ }
637
+ }
638
+ main();
639
+
640
+ //#endregion
641
+ export { };
@@ -0,0 +1,22 @@
1
+ import { t as PesafyError } from "./errors.mjs";
2
+ import { constants, publicEncrypt } from "node:crypto";
3
+
4
+ //#region src/core/encryption/security-credentials.ts
5
+ function encryptSecurityCredential(initiatorPassword, certificatePem) {
6
+ try {
7
+ const passwordBuffer = Buffer.from(initiatorPassword, "utf-8");
8
+ return publicEncrypt({
9
+ key: certificatePem,
10
+ padding: constants.RSA_PKCS1_PADDING
11
+ }, passwordBuffer).toString("base64");
12
+ } catch (error) {
13
+ throw new PesafyError({
14
+ code: "ENCRYPTION_FAILED",
15
+ message: "Failed to encrypt security credential. Ensure the certificate PEM is valid and matches the environment (sandbox/production).",
16
+ cause: error
17
+ });
18
+ }
19
+ }
20
+
21
+ //#endregion
22
+ export { encryptSecurityCredential };