pesafy 0.3.12 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -68,13 +68,14 @@ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
68
68
  function sleep(ms) {
69
69
  return new Promise((resolve) => setTimeout(resolve, ms));
70
70
  }
71
- function jitter(baseMs) {
71
+ function withJitter(baseMs) {
72
72
  const spread = baseMs * 0.25;
73
73
  return baseMs + (Math.random() * spread * 2 - spread);
74
74
  }
75
75
  async function httpRequest(url, options) {
76
76
  const maxRetries = options.retries ?? 4;
77
77
  const baseDelay = options.retryDelay ?? 2e3;
78
+ const timeout = options.timeout ?? 3e4;
78
79
  const headers = {
79
80
  "Content-Type": "application/json",
80
81
  Accept: "application/json",
@@ -88,27 +89,50 @@ async function httpRequest(url, options) {
88
89
  let lastError = null;
89
90
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
90
91
  if (attempt > 0) {
91
- const delay = jitter(baseDelay * Math.pow(2, attempt - 1));
92
+ const delay = withJitter(baseDelay * Math.pow(2, attempt - 1));
93
+ console.warn(
94
+ `[pesafy/http] Retry ${attempt}/${maxRetries} for ${options.method} ${url} in ${Math.round(delay)}ms (last error: ${lastError?.message ?? "unknown"})`
95
+ );
92
96
  await sleep(delay);
93
97
  }
98
+ const controller = new AbortController();
99
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
94
100
  let response;
95
101
  try {
96
- response = await fetch(url, init);
102
+ response = await fetch(url, { ...init, signal: controller.signal });
97
103
  } catch (err) {
104
+ clearTimeout(timeoutId);
105
+ if (err instanceof Error && err.name === "AbortError") {
106
+ lastError = new PesafyError({
107
+ code: "TIMEOUT",
108
+ message: `Request to ${url} timed out after ${timeout}ms`,
109
+ cause: err
110
+ });
111
+ if (attempt < maxRetries) continue;
112
+ throw lastError;
113
+ }
98
114
  lastError = new PesafyError({
99
115
  code: "NETWORK_ERROR",
100
- message: `Network error calling ${url}: ${String(err)}`,
116
+ message: `Network error calling ${url}: ${err instanceof Error ? err.message : String(err)}`,
101
117
  cause: err
102
118
  });
103
119
  if (attempt < maxRetries) continue;
104
120
  throw lastError;
121
+ } finally {
122
+ clearTimeout(timeoutId);
105
123
  }
124
+ let rawText = "";
106
125
  let data;
107
126
  const contentType = response.headers.get("content-type") ?? "";
108
127
  try {
109
- data = contentType.includes("application/json") ? await response.json() : await response.text();
128
+ rawText = await response.text();
129
+ if (rawText) {
130
+ data = contentType.includes("application/json") ? JSON.parse(rawText) : rawText;
131
+ } else {
132
+ data = null;
133
+ }
110
134
  } catch {
111
- data = null;
135
+ data = rawText || null;
112
136
  }
113
137
  const responseHeaders = {};
114
138
  response.headers.forEach((value, key) => {
@@ -122,11 +146,11 @@ async function httpRequest(url, options) {
122
146
  };
123
147
  }
124
148
  const isTransient = RETRYABLE_STATUSES.has(response.status);
125
- const daraja = data ?? {};
126
- const message = daraja.errorMessage ?? daraja.ResponseDescription ?? `HTTP ${response.status}`;
149
+ const daraja = typeof data === "object" && data !== null ? data : {};
150
+ const errorMessage = daraja.errorMessage ?? daraja.ResponseDescription ?? rawText ?? `HTTP ${response.status}`;
127
151
  lastError = new PesafyError({
128
- code: isTransient ? "REQUEST_FAILED" : "HTTP_ERROR",
129
- message,
152
+ code: isTransient ? "REQUEST_FAILED" : "API_ERROR",
153
+ message: errorMessage,
130
154
  statusCode: response.status,
131
155
  response: data,
132
156
  requestId: daraja.requestId
@@ -264,19 +288,13 @@ async function simulateC2B(baseUrl, accessToken, request) {
264
288
  if (!baseUrl.includes("sandbox")) {
265
289
  throw createError({
266
290
  code: "VALIDATION_ERROR",
267
- message: "C2B simulate is only available in the Sandbox environment. Production M-PESA payments must be initiated by the customer via M-PESA App, USSD, or SIM Toolkit."
291
+ message: "C2B simulate is only available in the Sandbox environment. In production, customers initiate payments via M-PESA App, USSD, or SIM Toolkit."
268
292
  });
269
293
  }
270
294
  if (!request.shortCode) {
271
295
  throw createError({
272
296
  code: "VALIDATION_ERROR",
273
- message: "shortCode is required"
274
- });
275
- }
276
- if (!request.commandId) {
277
- throw createError({
278
- code: "VALIDATION_ERROR",
279
- message: 'commandId is required: "CustomerPayBillOnline" | "CustomerBuyGoodsOnline"'
297
+ message: "shortCode is required."
280
298
  });
281
299
  }
282
300
  if (request.commandId !== "CustomerPayBillOnline" && request.commandId !== "CustomerBuyGoodsOnline") {
@@ -289,25 +307,32 @@ async function simulateC2B(baseUrl, accessToken, request) {
289
307
  if (!Number.isFinite(amount) || amount < 1) {
290
308
  throw createError({
291
309
  code: "VALIDATION_ERROR",
292
- message: `amount must be a whole number \u2265 1 (got ${request.amount})`
310
+ message: `amount must be a whole number \u2265 1 (got ${request.amount}).`
293
311
  });
294
312
  }
295
313
  if (!request.msisdn) {
296
314
  throw createError({
297
315
  code: "VALIDATION_ERROR",
298
- message: "msisdn is required. Use the test phone number from the Daraja simulator."
316
+ message: "msisdn is required. Sandbox test MSISDN: 254708374149."
299
317
  });
300
318
  }
301
- const version = request.apiVersion ?? "v2";
302
319
  const isBuyGoods = request.commandId === "CustomerBuyGoodsOnline";
320
+ const version = request.apiVersion ?? "v2";
303
321
  const payload = {
304
322
  ShortCode: Number(request.shortCode),
305
323
  CommandID: request.commandId,
306
324
  Amount: amount,
307
325
  Msisdn: Number(request.msisdn)
326
+ // BillRefNumber is NOT here — added conditionally below for Paybill only
308
327
  };
309
328
  if (!isBuyGoods) {
310
- payload.BillRefNumber = request.billRefNumber ?? "";
329
+ payload["BillRefNumber"] = request.billRefNumber ?? "";
330
+ }
331
+ if (isBuyGoods && Object.prototype.hasOwnProperty.call(payload, "BillRefNumber")) {
332
+ delete payload["BillRefNumber"];
333
+ console.warn(
334
+ "[pesafy/simulateC2B] BillRefNumber leaked into Buy Goods payload \u2014 removed. This is a library bug; please report it."
335
+ );
311
336
  }
312
337
  const { data } = await httpRequest(
313
338
  `${baseUrl}/mpesa/c2b/${version}/simulate`,
@@ -536,6 +561,66 @@ function getCallbackValue(callback, name) {
536
561
  return inner.CallbackMetadata.Item.find((i) => i.Name === name)?.Value;
537
562
  }
538
563
 
564
+ // src/mpesa/tax-remittance/remit-tax.ts
565
+ var KRA_SHORTCODE = "572572";
566
+ var TAX_COMMAND_ID = "PayTaxToKRA";
567
+ async function remitTax(baseUrl, accessToken, securityCredential, initiatorName, request) {
568
+ const amount = Math.round(request.amount);
569
+ if (!Number.isFinite(amount) || amount < 1) {
570
+ throw createError({
571
+ code: "VALIDATION_ERROR",
572
+ message: `amount must be a whole number \u2265 1 (got ${request.amount} which rounds to ${amount}).`
573
+ });
574
+ }
575
+ if (!request.partyA) {
576
+ throw createError({
577
+ code: "VALIDATION_ERROR",
578
+ message: "partyA is required \u2014 your M-PESA business shortcode from which tax is deducted."
579
+ });
580
+ }
581
+ if (!request.accountReference?.trim()) {
582
+ throw createError({
583
+ code: "VALIDATION_ERROR",
584
+ message: "accountReference is required \u2014 the Payment Registration Number (PRN) issued by KRA."
585
+ });
586
+ }
587
+ if (!request.resultUrl?.trim()) {
588
+ throw createError({
589
+ code: "VALIDATION_ERROR",
590
+ message: "resultUrl is required \u2014 Safaricom POSTs the tax remittance result here."
591
+ });
592
+ }
593
+ if (!request.queueTimeOutUrl?.trim()) {
594
+ throw createError({
595
+ code: "VALIDATION_ERROR",
596
+ message: "queueTimeOutUrl is required \u2014 Safaricom calls this on request timeout."
597
+ });
598
+ }
599
+ const payload = {
600
+ Initiator: initiatorName,
601
+ SecurityCredential: securityCredential,
602
+ CommandID: TAX_COMMAND_ID,
603
+ SenderIdentifierType: "4",
604
+ RecieverIdentifierType: "4",
605
+ Amount: String(amount),
606
+ PartyA: String(request.partyA),
607
+ PartyB: request.partyB ?? KRA_SHORTCODE,
608
+ AccountReference: request.accountReference,
609
+ Remarks: request.remarks ?? "Tax Remittance",
610
+ QueueTimeOutURL: request.queueTimeOutUrl,
611
+ ResultURL: request.resultUrl
612
+ };
613
+ const { data } = await httpRequest(
614
+ `${baseUrl}/mpesa/b2b/v1/remittax`,
615
+ {
616
+ method: "POST",
617
+ headers: { Authorization: `Bearer ${accessToken}` },
618
+ body: payload
619
+ }
620
+ );
621
+ return data;
622
+ }
623
+
539
624
  // src/mpesa/transaction-status/query.ts
540
625
  async function queryTransactionStatus(baseUrl, token, securityCredential, initiator, request) {
541
626
  if (!request.transactionId) {
@@ -825,6 +910,52 @@ var Mpesa = class {
825
910
  const token = await this.getToken();
826
911
  return simulateC2B(this.baseUrl, token, request);
827
912
  }
913
+ // ── Tax Remittance ────────────────────────────────────────────────────────
914
+ /**
915
+ * Tax Remittance — remits tax to Kenya Revenue Authority (KRA) via M-PESA.
916
+ *
917
+ * Requires:
918
+ * - initiatorName in config
919
+ * - initiatorPassword + certificate (or pre-computed securityCredential)
920
+ *
921
+ * This is ASYNCHRONOUS. The synchronous response only confirms receipt.
922
+ * Final details are POSTed to your resultUrl.
923
+ *
924
+ * Prerequisites (from Daraja docs):
925
+ * - Prior integration with KRA for tax declaration.
926
+ * - A Payment Registration Number (PRN) from KRA.
927
+ * - Initiator with "Tax Remittance ORG API" role on M-PESA org portal.
928
+ *
929
+ * Fixed values (set automatically — do NOT override unless Safaricom changes them):
930
+ * CommandID: "PayTaxToKRA"
931
+ * SenderIdentifierType: "4"
932
+ * RecieverIdentifierType: "4"
933
+ * PartyB: "572572" (KRA shortcode)
934
+ *
935
+ * @example
936
+ * await mpesa.remitTax({
937
+ * amount: 5000,
938
+ * partyA: "888880",
939
+ * accountReference: "PRN1234XN", // PRN from KRA
940
+ * resultUrl: "https://yourdomain.com/mpesa/tax/result",
941
+ * queueTimeOutUrl: "https://yourdomain.com/mpesa/tax/timeout",
942
+ * remarks: "Monthly PAYE remittance",
943
+ * });
944
+ */
945
+ async remitTax(request) {
946
+ const initiator = this.config.initiatorName ?? "";
947
+ if (!initiator) {
948
+ throw new PesafyError({
949
+ code: "VALIDATION_ERROR",
950
+ message: "initiatorName is required for Tax Remittance"
951
+ });
952
+ }
953
+ const [token, securityCred] = await Promise.all([
954
+ this.getToken(),
955
+ this.buildSecurityCredential()
956
+ ]);
957
+ return remitTax(this.baseUrl, token, securityCred, initiator, request);
958
+ }
828
959
  /** Force the cached OAuth token to be refreshed on the next API call */
829
960
  clearTokenCache() {
830
961
  this.tokenManager.clearCache();
@@ -950,9 +1081,11 @@ function isSuccessfulCallback(webhook) {
950
1081
  }
951
1082
 
952
1083
  exports.DARAJA_BASE_URLS = DARAJA_BASE_URLS;
1084
+ exports.KRA_SHORTCODE = KRA_SHORTCODE;
953
1085
  exports.Mpesa = Mpesa;
954
1086
  exports.PesafyError = PesafyError;
955
1087
  exports.SAFARICOM_IPS = SAFARICOM_IPS;
1088
+ exports.TAX_COMMAND_ID = TAX_COMMAND_ID;
956
1089
  exports.acceptC2BValidation = acceptC2BValidation;
957
1090
  exports.acknowledgeC2BConfirmation = acknowledgeC2BConfirmation;
958
1091
  exports.createError = createError;
@@ -976,6 +1109,7 @@ exports.isSuccessfulCallback = isSuccessfulCallback;
976
1109
  exports.parseStkPushWebhook = parseStkPushWebhook;
977
1110
  exports.registerC2BUrls = registerC2BUrls;
978
1111
  exports.rejectC2BValidation = rejectC2BValidation;
1112
+ exports.remitTax = remitTax;
979
1113
  exports.retryWithBackoff = retryWithBackoff;
980
1114
  exports.simulateC2B = simulateC2B;
981
1115
  exports.verifyWebhookIP = verifyWebhookIP;