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/dist/cli.mjs CHANGED
@@ -1,9 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
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/index.ts
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 displayDefault = defaultVal ? dim(` [${defaultVal}]`) : "";
39
- return new Promise((resolve) => {
40
- rl.question(`${question}${displayDefault}: `, (answer) => {
41
- rl.close();
42
- resolve(answer.trim() || defaultVal);
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
- 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;
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
- 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
- }
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
- 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) } : {}
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 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
- }
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
- 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;
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
- async function cmdVersion() {
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
- async function cmdHelp() {
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("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
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
- async function cmdInit() {
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://yourdomain.com/api/mpesa/callback")}
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://yourdomain.com/api/mpesa/result")}
176
- MPESA_QUEUE_TIMEOUT_URL=${await prompt("Queue Timeout URL", "https://yourdomain.com/api/mpesa/timeout")}
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
- async function cmdDoctor() {
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 = loadEnv();
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
- async function cmdToken() {
261
- const env = loadEnv();
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["MPESA_ENVIRONMENT"] === "production" ? "https://api.safaricom.co.ke" : "https://sandbox.safaricom.co.ke";
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
- async function cmdEncrypt(args) {
277
- const env = loadEnv();
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 import("./encryption.mjs");
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
- async function cmdValidatePhone(args) {
299
- let phone = args[0];
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
- async function cmdStkPush(args) {
311
- const env = loadEnv();
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 import("./utils.mjs");
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
- async function cmdStkQuery(args) {
363
- const env = loadEnv();
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 import("./utils.mjs");
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
- async function cmdBalance(args) {
410
- const env = loadEnv();
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 import("./encryption.mjs");
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
- async function cmdRegisterC2BUrls(args) {
505
- const env = loadEnv();
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
- async function cmdSimulateC2B(args) {
531
- const env = loadEnv();
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
- async function cmdReversal(args) {
564
- const env = loadEnv();
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 import("./encryption.mjs");
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 [, , command = "help", ...args] = process.argv;
604
- const banner = `${C.cyan}${C.bold} pesafy${C.reset} ${C.dim}v${getPkgVersion()}${C.reset}`;
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: () => 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),
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