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 +156 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +233 -26
- package/dist/index.d.ts +233 -26
- package/dist/index.js +154 -23
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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" : "
|
|
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.
|
|
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.
|
|
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
|
|
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;
|