paykit-bd 0.1.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/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/bkash/adapters/express.cjs +128 -0
- package/dist/bkash/adapters/express.d.cts +50 -0
- package/dist/bkash/adapters/express.d.ts +50 -0
- package/dist/bkash/adapters/express.js +125 -0
- package/dist/bkash/adapters/next.cjs +383 -0
- package/dist/bkash/adapters/next.d.cts +64 -0
- package/dist/bkash/adapters/next.d.ts +64 -0
- package/dist/bkash/adapters/next.js +379 -0
- package/dist/bkash/index.cjs +1335 -0
- package/dist/bkash/index.d.cts +104 -0
- package/dist/bkash/index.d.ts +104 -0
- package/dist/bkash/index.js +1315 -0
- package/dist/client-UNDtdWhu.d.cts +475 -0
- package/dist/client-Y9603twG.d.ts +475 -0
- package/dist/errors-CleuZfqT.d.cts +103 -0
- package/dist/errors-CleuZfqT.d.ts +103 -0
- package/dist/index.cjs +369 -0
- package/dist/index.d.cts +20 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +348 -0
- package/dist/token-store-C8IMMLPJ.d.cts +240 -0
- package/dist/token-store-C8IMMLPJ.d.ts +240 -0
- package/package.json +78 -0
|
@@ -0,0 +1,1335 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
// src/core/errors.ts
|
|
6
|
+
var BRANDS = /* @__PURE__ */ Symbol.for("paykit-bd.error.brands");
|
|
7
|
+
function brandedInstanceOf(tag) {
|
|
8
|
+
return (value) => {
|
|
9
|
+
if (typeof value !== "object" || value === null) return false;
|
|
10
|
+
const brands = value[BRANDS];
|
|
11
|
+
return Array.isArray(brands) && brands.includes(tag);
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
var PaykitError = class extends Error {
|
|
15
|
+
static [Symbol.hasInstance] = brandedInstanceOf("PaykitError");
|
|
16
|
+
/** Class lineage, innermost last. Read `instanceof` instead of this. */
|
|
17
|
+
[BRANDS];
|
|
18
|
+
/** Gateway this came from — `"bkash"`, or `"paykit"` for local failures. */
|
|
19
|
+
provider;
|
|
20
|
+
/** Stable machine-readable code. Gateway codes are passed through verbatim. */
|
|
21
|
+
code;
|
|
22
|
+
/** True when retrying the identical request could plausibly succeed. */
|
|
23
|
+
retryable;
|
|
24
|
+
/** Untouched gateway response body, for logging. */
|
|
25
|
+
raw;
|
|
26
|
+
constructor(message, opts, brands = []) {
|
|
27
|
+
super(message, { cause: opts.cause });
|
|
28
|
+
this.name = new.target.name;
|
|
29
|
+
this[BRANDS] = ["PaykitError", ...brands];
|
|
30
|
+
this.provider = opts.provider;
|
|
31
|
+
this.code = opts.code;
|
|
32
|
+
this.retryable = opts.retryable ?? false;
|
|
33
|
+
this.raw = opts.raw;
|
|
34
|
+
}
|
|
35
|
+
toJSON() {
|
|
36
|
+
return {
|
|
37
|
+
name: this.name,
|
|
38
|
+
provider: this.provider,
|
|
39
|
+
code: this.code,
|
|
40
|
+
message: this.message,
|
|
41
|
+
retryable: this.retryable
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
var ProviderError = class extends PaykitError {
|
|
46
|
+
static [Symbol.hasInstance] = brandedInstanceOf("ProviderError");
|
|
47
|
+
constructor(message, opts, brands = []) {
|
|
48
|
+
super(message, opts, ["ProviderError", ...brands]);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
var NetworkError = class extends PaykitError {
|
|
52
|
+
static [Symbol.hasInstance] = brandedInstanceOf("NetworkError");
|
|
53
|
+
/** HTTP status, when there was one. */
|
|
54
|
+
status;
|
|
55
|
+
constructor(message, opts) {
|
|
56
|
+
super(
|
|
57
|
+
message,
|
|
58
|
+
{
|
|
59
|
+
provider: opts.provider,
|
|
60
|
+
code: opts.code ?? "network_error",
|
|
61
|
+
retryable: true,
|
|
62
|
+
raw: opts.raw,
|
|
63
|
+
cause: opts.cause
|
|
64
|
+
},
|
|
65
|
+
["NetworkError"]
|
|
66
|
+
);
|
|
67
|
+
this.status = opts.status;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
var ConfigError = class extends PaykitError {
|
|
71
|
+
static [Symbol.hasInstance] = brandedInstanceOf("ConfigError");
|
|
72
|
+
constructor(message, opts = {}) {
|
|
73
|
+
super(
|
|
74
|
+
message,
|
|
75
|
+
{
|
|
76
|
+
provider: opts.provider ?? "paykit",
|
|
77
|
+
code: opts.code ?? "config_error",
|
|
78
|
+
retryable: false
|
|
79
|
+
},
|
|
80
|
+
["ConfigError"]
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
var WebhookVerificationError = class extends PaykitError {
|
|
85
|
+
static [Symbol.hasInstance] = brandedInstanceOf("WebhookVerificationError");
|
|
86
|
+
constructor(message, opts) {
|
|
87
|
+
super(
|
|
88
|
+
message,
|
|
89
|
+
{
|
|
90
|
+
provider: opts.provider,
|
|
91
|
+
code: opts.code ?? "webhook_verification_failed",
|
|
92
|
+
retryable: false,
|
|
93
|
+
raw: opts.raw,
|
|
94
|
+
cause: opts.cause
|
|
95
|
+
},
|
|
96
|
+
["WebhookVerificationError"]
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
var RateLimitError = class extends PaykitError {
|
|
101
|
+
static [Symbol.hasInstance] = brandedInstanceOf("RateLimitError");
|
|
102
|
+
/** Epoch ms when the guard will let the call through. */
|
|
103
|
+
retryAt;
|
|
104
|
+
constructor(message, opts) {
|
|
105
|
+
super(
|
|
106
|
+
message,
|
|
107
|
+
{
|
|
108
|
+
provider: opts.provider,
|
|
109
|
+
code: opts.code ?? "rate_limited",
|
|
110
|
+
retryable: true
|
|
111
|
+
},
|
|
112
|
+
["RateLimitError"]
|
|
113
|
+
);
|
|
114
|
+
this.retryAt = opts.retryAt;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
function brandCheckFor(tag) {
|
|
118
|
+
return brandedInstanceOf(tag);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/core/http.ts
|
|
122
|
+
var noopLogger = {
|
|
123
|
+
debug() {
|
|
124
|
+
},
|
|
125
|
+
warn() {
|
|
126
|
+
},
|
|
127
|
+
error() {
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
131
|
+
var SECRET_HEADERS = /* @__PURE__ */ new Set(["authorization", "password", "username", "x-app-key", "x-app-secret"]);
|
|
132
|
+
function redactHeaders(headers) {
|
|
133
|
+
const out = {};
|
|
134
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
135
|
+
out[key] = SECRET_HEADERS.has(key.toLowerCase()) ? "[redacted]" : value;
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
async function requestJson(url, options = {}, ctx = { provider: "paykit" }) {
|
|
140
|
+
const {
|
|
141
|
+
method = "POST",
|
|
142
|
+
headers = {},
|
|
143
|
+
json,
|
|
144
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
145
|
+
retries = 0,
|
|
146
|
+
retryBaseMs = 300,
|
|
147
|
+
signal
|
|
148
|
+
} = options;
|
|
149
|
+
const logger = ctx.logger ?? noopLogger;
|
|
150
|
+
const requestHeaders = { Accept: "application/json", ...headers };
|
|
151
|
+
let payload;
|
|
152
|
+
if (json !== void 0) {
|
|
153
|
+
payload = JSON.stringify(json);
|
|
154
|
+
requestHeaders["Content-Type"] ??= "application/json";
|
|
155
|
+
}
|
|
156
|
+
let lastError;
|
|
157
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
158
|
+
if (attempt > 0) {
|
|
159
|
+
const delay = retryBaseMs * 2 ** (attempt - 1) + Math.floor(Math.random() * retryBaseMs);
|
|
160
|
+
logger.warn("paykit: retrying request", { url, attempt, delay });
|
|
161
|
+
await sleep(delay, signal);
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const response = await fetch(url, {
|
|
165
|
+
method,
|
|
166
|
+
headers: requestHeaders,
|
|
167
|
+
body: payload,
|
|
168
|
+
signal: mergeSignals(signal, AbortSignal.timeout(timeoutMs))
|
|
169
|
+
});
|
|
170
|
+
const text = await response.text();
|
|
171
|
+
if (response.status >= 500) {
|
|
172
|
+
lastError = new NetworkError(`${ctx.provider}: gateway returned HTTP ${response.status}`, {
|
|
173
|
+
provider: ctx.provider,
|
|
174
|
+
code: "upstream_error",
|
|
175
|
+
status: response.status,
|
|
176
|
+
raw: text.slice(0, 2e3)
|
|
177
|
+
});
|
|
178
|
+
if (attempt < retries) continue;
|
|
179
|
+
throw lastError;
|
|
180
|
+
}
|
|
181
|
+
let body;
|
|
182
|
+
try {
|
|
183
|
+
body = text ? JSON.parse(text) : {};
|
|
184
|
+
} catch (cause) {
|
|
185
|
+
throw new NetworkError(
|
|
186
|
+
`${ctx.provider}: expected JSON but got ${describeBody(text)} (HTTP ${response.status})`,
|
|
187
|
+
{ provider: ctx.provider, code: "invalid_json", status: response.status, raw: text.slice(0, 2e3), cause }
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
logger.debug("paykit: request complete", {
|
|
191
|
+
url,
|
|
192
|
+
method,
|
|
193
|
+
status: response.status,
|
|
194
|
+
headers: redactHeaders(requestHeaders)
|
|
195
|
+
});
|
|
196
|
+
return { status: response.status, headers: response.headers, body, text };
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error instanceof NetworkError && error.code === "invalid_json") throw error;
|
|
199
|
+
lastError = error;
|
|
200
|
+
const aborted = signal?.aborted === true;
|
|
201
|
+
if (aborted || attempt >= retries) {
|
|
202
|
+
if (error instanceof NetworkError) throw error;
|
|
203
|
+
throw new NetworkError(`${ctx.provider}: request to ${url} failed`, {
|
|
204
|
+
provider: ctx.provider,
|
|
205
|
+
code: isTimeout(error) ? "timeout" : "network_error",
|
|
206
|
+
cause: error
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
throw lastError instanceof Error ? lastError : new NetworkError(`${ctx.provider}: request to ${url} failed`, { provider: ctx.provider });
|
|
212
|
+
}
|
|
213
|
+
function describeBody(text) {
|
|
214
|
+
const trimmed = text.trim();
|
|
215
|
+
if (!trimmed) return "an empty body";
|
|
216
|
+
if (trimmed.startsWith("<")) return "HTML (usually a proxy or WAF page)";
|
|
217
|
+
return `${JSON.stringify(trimmed.slice(0, 80))}\u2026`;
|
|
218
|
+
}
|
|
219
|
+
function isTimeout(error) {
|
|
220
|
+
return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
221
|
+
}
|
|
222
|
+
function mergeSignals(...signals) {
|
|
223
|
+
const present = signals.filter((s) => s !== void 0);
|
|
224
|
+
return present.length === 1 ? present[0] : AbortSignal.any(present);
|
|
225
|
+
}
|
|
226
|
+
function sleep(ms, signal) {
|
|
227
|
+
return new Promise((resolve, reject) => {
|
|
228
|
+
if (signal?.aborted) {
|
|
229
|
+
reject(signal.reason);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const timer = setTimeout(() => {
|
|
233
|
+
signal?.removeEventListener("abort", onAbort);
|
|
234
|
+
resolve();
|
|
235
|
+
}, ms);
|
|
236
|
+
const onAbort = () => {
|
|
237
|
+
clearTimeout(timer);
|
|
238
|
+
reject(signal?.reason);
|
|
239
|
+
};
|
|
240
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/core/money.ts
|
|
245
|
+
var AMOUNT_RE = /^-?\d+(\.\d{1,2})?$/;
|
|
246
|
+
function toPoisha(amount) {
|
|
247
|
+
const text = typeof amount === "number" ? formatNumber(amount) : amount.trim();
|
|
248
|
+
if (!AMOUNT_RE.test(text)) {
|
|
249
|
+
throw new ConfigError(
|
|
250
|
+
`Invalid BDT amount ${JSON.stringify(amount)}: expected a decimal with at most 2 places, e.g. "12.50".`,
|
|
251
|
+
{ code: "invalid_amount" }
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
const negative = text.startsWith("-");
|
|
255
|
+
const [whole = "0", fraction = ""] = (negative ? text.slice(1) : text).split(".");
|
|
256
|
+
const poisha = BigInt(whole) * 100n + BigInt(fraction.padEnd(2, "0"));
|
|
257
|
+
return negative ? -poisha : poisha;
|
|
258
|
+
}
|
|
259
|
+
function fromPoisha(poisha) {
|
|
260
|
+
const negative = poisha < 0n;
|
|
261
|
+
const abs = negative ? -poisha : poisha;
|
|
262
|
+
const whole = abs / 100n;
|
|
263
|
+
const fraction = (abs % 100n).toString().padStart(2, "0");
|
|
264
|
+
return `${negative ? "-" : ""}${whole}.${fraction}`;
|
|
265
|
+
}
|
|
266
|
+
function toAmountString(amount) {
|
|
267
|
+
return fromPoisha(toPoisha(amount));
|
|
268
|
+
}
|
|
269
|
+
function formatNumber(value) {
|
|
270
|
+
if (!Number.isFinite(value)) {
|
|
271
|
+
throw new ConfigError(`Invalid BDT amount ${value}: not a finite number.`, { code: "invalid_amount" });
|
|
272
|
+
}
|
|
273
|
+
return value.toFixed(2);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/bkash/config.ts
|
|
277
|
+
var BKASH_HOSTS = {
|
|
278
|
+
sandbox: "https://tokenized.sandbox.bka.sh",
|
|
279
|
+
live: "https://tokenized.pay.bka.sh"
|
|
280
|
+
};
|
|
281
|
+
var BKASH_API_VERSION = "v1.2.0-beta";
|
|
282
|
+
var BKASH_MODE = {
|
|
283
|
+
/** Create an agreement — no money moves, you get an agreementID back. */
|
|
284
|
+
CREATE_AGREEMENT: "0000",
|
|
285
|
+
/** Charge an existing agreement. Customer confirms with a PIN only. */
|
|
286
|
+
AGREEMENT_PAYMENT: "0001",
|
|
287
|
+
/** One-off payment, no agreement. Customer is redirected to bKash. */
|
|
288
|
+
ONE_OFF_PAYMENT: "0011"
|
|
289
|
+
};
|
|
290
|
+
function endpoints(origin) {
|
|
291
|
+
const versioned = `${origin}/${BKASH_API_VERSION}`;
|
|
292
|
+
return {
|
|
293
|
+
grantToken: `${versioned}/tokenized/checkout/token/grant`,
|
|
294
|
+
refreshToken: `${versioned}/tokenized/checkout/token/refresh`,
|
|
295
|
+
/** Serves agreement creation and both payment modes; `mode` decides which. */
|
|
296
|
+
create: `${versioned}/tokenized/checkout/create`,
|
|
297
|
+
/** Finalises whatever `create` started, agreement or payment. */
|
|
298
|
+
execute: `${versioned}/tokenized/checkout/execute`,
|
|
299
|
+
queryPayment: `${versioned}/tokenized/checkout/payment/status`,
|
|
300
|
+
// Undocumented in the public index but live in sandbox: both answer
|
|
301
|
+
// "2051 Invalid Agreement ID" for a bad id rather than a routing error.
|
|
302
|
+
queryAgreement: `${versioned}/tokenized/checkout/agreement/status`,
|
|
303
|
+
cancelAgreement: `${versioned}/tokenized/checkout/agreement/cancel`,
|
|
304
|
+
// The v2 refund API hangs off the host root, not off the version segment.
|
|
305
|
+
// Probing the sandbox confirms it: the versioned path answers "Missing
|
|
306
|
+
// Authentication Token", which is API Gateway for "no such route".
|
|
307
|
+
refund: `${origin}/v2/tokenized-checkout/refund/payment/transaction`,
|
|
308
|
+
refundStatus: `${origin}/v2/tokenized-checkout/refund/payment/status`,
|
|
309
|
+
/** Pre-v2 refund, still provisioned for some merchants. */
|
|
310
|
+
legacyRefund: `${versioned}/tokenized/checkout/payment/refund`
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function resolveConfig(config) {
|
|
314
|
+
const missing = ["username", "password", "appKey", "appSecret"].filter(
|
|
315
|
+
(key) => !config[key] || String(config[key]).trim() === ""
|
|
316
|
+
);
|
|
317
|
+
if (missing.length > 0) {
|
|
318
|
+
throw new ConfigError(
|
|
319
|
+
`bKash: missing required credential${missing.length > 1 ? "s" : ""} ${missing.join(", ")}. These are issued by bKash during merchant onboarding.`,
|
|
320
|
+
{ provider: "bkash", code: "missing_credentials" }
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
const environment = config.environment ?? "sandbox";
|
|
324
|
+
if (!config.baseUrl && !(environment in BKASH_HOSTS)) {
|
|
325
|
+
throw new ConfigError(`bKash: unknown environment ${JSON.stringify(environment)}; expected "sandbox" or "live".`, {
|
|
326
|
+
provider: "bkash",
|
|
327
|
+
code: "invalid_environment"
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
const origin = stripTrailingSlash(config.baseUrl ?? BKASH_HOSTS[environment]);
|
|
331
|
+
return {
|
|
332
|
+
environment,
|
|
333
|
+
origin,
|
|
334
|
+
username: config.username,
|
|
335
|
+
password: config.password,
|
|
336
|
+
appKey: config.appKey,
|
|
337
|
+
appSecret: config.appSecret,
|
|
338
|
+
callbackUrl: config.callbackUrl,
|
|
339
|
+
tokenKey: config.tokenKey ?? `bkash:${environment}:${config.appKey.slice(0, 12)}`,
|
|
340
|
+
refreshSkewMs: config.refreshSkewMs ?? 10 * 60 * 1e3,
|
|
341
|
+
maxRefreshesPerHour: config.maxRefreshesPerHour ?? 2,
|
|
342
|
+
timeoutMs: config.timeoutMs ?? 3e4,
|
|
343
|
+
webhookTopicArn: config.webhookTopicArn
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
function configFromEnv(env = process.env) {
|
|
347
|
+
const environment = env["BKASH_ENV"] ?? "sandbox";
|
|
348
|
+
return {
|
|
349
|
+
environment,
|
|
350
|
+
baseUrl: env["BKASH_BASE_URL"],
|
|
351
|
+
username: env["BKASH_USERNAME"] ?? "",
|
|
352
|
+
password: env["BKASH_PASSWORD"] ?? "",
|
|
353
|
+
appKey: env["BKASH_APP_KEY"] ?? "",
|
|
354
|
+
appSecret: env["BKASH_APP_SECRET"] ?? "",
|
|
355
|
+
callbackUrl: env["BKASH_CALLBACK_URL"],
|
|
356
|
+
webhookTopicArn: env["BKASH_WEBHOOK_TOPIC_ARN"]
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
function stripTrailingSlash(url) {
|
|
360
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/bkash/errors.ts
|
|
364
|
+
var BKASH_ERROR_CODES = {
|
|
365
|
+
"0000": "Successful",
|
|
366
|
+
"2001": "Invalid App Key",
|
|
367
|
+
"2002": "Invalid Payment ID",
|
|
368
|
+
"2003": "Process failed",
|
|
369
|
+
"2004": "Invalid firstPaymentDate",
|
|
370
|
+
"2005": "Invalid frequency",
|
|
371
|
+
"2006": "Invalid amount",
|
|
372
|
+
"2007": "Invalid currency",
|
|
373
|
+
"2008": "Invalid intent",
|
|
374
|
+
"2009": "Invalid Wallet",
|
|
375
|
+
"2010": "Invalid OTP",
|
|
376
|
+
"2011": "Invalid PIN",
|
|
377
|
+
"2012": "Invalid Receiver MSISDN",
|
|
378
|
+
"2013": "Resend Limit Exceeded",
|
|
379
|
+
"2014": "Wrong PIN",
|
|
380
|
+
"2015": "Wrong PIN count exceeded",
|
|
381
|
+
"2016": "Wrong verification code",
|
|
382
|
+
"2017": "Wrong verification limit exceeded",
|
|
383
|
+
"2018": "OTP verification time expired",
|
|
384
|
+
"2019": "PIN verification time expired",
|
|
385
|
+
"2020": "Exception Occurred",
|
|
386
|
+
"2021": "Invalid Mandate ID",
|
|
387
|
+
"2022": "The mandate does not exist",
|
|
388
|
+
"2023": "Insufficient Balance",
|
|
389
|
+
"2024": "Exception occurred",
|
|
390
|
+
"2025": "Invalid request body",
|
|
391
|
+
"2026": "The reversal amount cannot be greater than the original transaction amount",
|
|
392
|
+
"2027": "The mandate corresponding to the payer reference number already exists and cannot be created again",
|
|
393
|
+
"2028": "Reverse failed because the transaction serial number does not exist",
|
|
394
|
+
"2029": "Duplicate for all transactions",
|
|
395
|
+
"2030": "Invalid mandate request type",
|
|
396
|
+
"2031": "Invalid merchant invoice number",
|
|
397
|
+
"2032": "Invalid transfer type",
|
|
398
|
+
"2033": "Transaction not found",
|
|
399
|
+
"2034": "The transaction cannot be reversed because the original transaction has been reversed",
|
|
400
|
+
"2035": "Reverse failed because the initiator has no permission to reverse the transaction",
|
|
401
|
+
"2036": "The direct debit mandate is not in Active state",
|
|
402
|
+
"2037": "The account of the debit party is in a state which prohibits execution of this transaction",
|
|
403
|
+
"2038": "Debit party identity tag prohibits execution of this transaction",
|
|
404
|
+
"2039": "The account of the credit party is in a state which prohibits execution of this transaction",
|
|
405
|
+
"2040": "Credit party identity tag prohibits execution of this transaction",
|
|
406
|
+
"2041": "Credit party identity is in a state which does not support the current service",
|
|
407
|
+
"2042": "Reverse failed because the initiator has no permission to reverse the transaction",
|
|
408
|
+
"2043": "The security credential of the subscriber is incorrect",
|
|
409
|
+
"2044": "Identity has not subscribed to a product that contains the expected service, or the identity is not in Active status",
|
|
410
|
+
"2045": "The MSISDN of the customer does not exist",
|
|
411
|
+
"2046": "Identity has not subscribed to a product that contains requested service",
|
|
412
|
+
"2047": "TLV Data Format Error",
|
|
413
|
+
"2048": "Invalid Payer Reference",
|
|
414
|
+
"2049": "Invalid Merchant Callback URL",
|
|
415
|
+
"2050": "Agreement already exists between payer and merchant",
|
|
416
|
+
"2051": "Invalid Agreement ID",
|
|
417
|
+
"2052": "Agreement is in incomplete state",
|
|
418
|
+
"2053": "Agreement has already been cancelled",
|
|
419
|
+
"2054": "Agreement execution pre-requisite hasn't been met",
|
|
420
|
+
"2055": "Invalid Agreement State",
|
|
421
|
+
"2056": "Invalid Payment State",
|
|
422
|
+
"2057": "Not a bKash Account",
|
|
423
|
+
"2058": "Not a Customer Wallet",
|
|
424
|
+
"2059": "Multiple OTP request for a single session denied",
|
|
425
|
+
"2060": "Payment execution pre-requisite hasn't been met",
|
|
426
|
+
"2061": "This action can only be performed by the agreement or payment initiator party",
|
|
427
|
+
"2062": "The payment has already been completed",
|
|
428
|
+
"2063": "Mode is not valid as per request data",
|
|
429
|
+
"2064": "This product mode currently unavailable",
|
|
430
|
+
"2065": "Mandatory field missing",
|
|
431
|
+
"2066": "Agreement is not shared with other merchant",
|
|
432
|
+
"2067": "Invalid permission",
|
|
433
|
+
"2068": "Transaction has already been completed",
|
|
434
|
+
"2069": "Transaction has already been cancelled",
|
|
435
|
+
"2116": "The agreement execution has already been completed",
|
|
436
|
+
"2117": "The payment execution has already been completed",
|
|
437
|
+
"2118": "The Platform value is invalid",
|
|
438
|
+
"2119": "The authorized payment has already been processed"
|
|
439
|
+
};
|
|
440
|
+
var RETRYABLE_CODES = /* @__PURE__ */ new Set(["2003", "2020", "2024"]);
|
|
441
|
+
var ALREADY_SETTLED_CODES = /* @__PURE__ */ new Set(["2062", "2068", "2116", "2117", "2119"]);
|
|
442
|
+
var CUSTOMER_FAULT_CODES = /* @__PURE__ */ new Set([
|
|
443
|
+
"2010",
|
|
444
|
+
"2011",
|
|
445
|
+
"2013",
|
|
446
|
+
"2014",
|
|
447
|
+
"2015",
|
|
448
|
+
"2016",
|
|
449
|
+
"2017",
|
|
450
|
+
"2018",
|
|
451
|
+
"2019",
|
|
452
|
+
"2023",
|
|
453
|
+
"2057",
|
|
454
|
+
"2058",
|
|
455
|
+
"2059"
|
|
456
|
+
]);
|
|
457
|
+
var BkashError = class extends ProviderError {
|
|
458
|
+
// Branded like the core errors so `instanceof` holds across this package's
|
|
459
|
+
// separate entry-point bundles. See the note in src/core/errors.ts.
|
|
460
|
+
static [Symbol.hasInstance] = brandCheckFor("BkashError");
|
|
461
|
+
/** True when bKash considers the payment already settled — re-query, don't retry. */
|
|
462
|
+
alreadySettled;
|
|
463
|
+
/** True when the customer caused it (wrong PIN, no balance). */
|
|
464
|
+
customerFault;
|
|
465
|
+
/** Bangla message, on the v2 refund API only. */
|
|
466
|
+
messageBn;
|
|
467
|
+
constructor(opts) {
|
|
468
|
+
super(
|
|
469
|
+
opts.message,
|
|
470
|
+
{
|
|
471
|
+
provider: "bkash",
|
|
472
|
+
code: opts.code,
|
|
473
|
+
retryable: opts.retryable ?? RETRYABLE_CODES.has(opts.code),
|
|
474
|
+
raw: opts.raw
|
|
475
|
+
},
|
|
476
|
+
["BkashError"]
|
|
477
|
+
);
|
|
478
|
+
this.alreadySettled = ALREADY_SETTLED_CODES.has(opts.code);
|
|
479
|
+
this.customerFault = CUSTOMER_FAULT_CODES.has(opts.code);
|
|
480
|
+
this.messageBn = opts.messageBn;
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
function toBkashError(body, status) {
|
|
484
|
+
if (typeof body !== "object" || body === null) {
|
|
485
|
+
return new NetworkError(`bKash: unreadable response body (HTTP ${status})`, {
|
|
486
|
+
provider: "bkash",
|
|
487
|
+
raw: body,
|
|
488
|
+
status
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
const b = body;
|
|
492
|
+
if (typeof b["externalCode"] === "string" || typeof b["internalCode"] === "string") {
|
|
493
|
+
const code = b["externalCode"] ?? b["internalCode"];
|
|
494
|
+
return new BkashError({
|
|
495
|
+
code,
|
|
496
|
+
message: describe(code, b["errorMessageEn"] ?? b["internalCode"]),
|
|
497
|
+
messageBn: b["errorMessageBn"] ?? void 0,
|
|
498
|
+
raw: body
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
if (typeof b["errorCode"] === "string" && b["errorCode"] !== "") {
|
|
502
|
+
const code = b["errorCode"];
|
|
503
|
+
return new BkashError({ code, message: describe(code, b["errorMessage"]), raw: body });
|
|
504
|
+
}
|
|
505
|
+
if (typeof b["statusCode"] === "string") {
|
|
506
|
+
const code = b["statusCode"];
|
|
507
|
+
if (code === "0000") return null;
|
|
508
|
+
return new BkashError({ code, message: describe(code, b["statusMessage"]), raw: body });
|
|
509
|
+
}
|
|
510
|
+
if (status >= 400) {
|
|
511
|
+
const message = typeof b["message"] === "string" ? b["message"] : `HTTP ${status}`;
|
|
512
|
+
if (status === 401 || status === 403) {
|
|
513
|
+
return new BkashError({
|
|
514
|
+
code: "unauthorized",
|
|
515
|
+
message: `bKash rejected the request before reaching the payment API: ${message}. Usually an expired or malformed id_token, or an x-app-key that does not match the token.`,
|
|
516
|
+
raw: body
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
return new BkashError({ code: `http_${status}`, message: `bKash: ${message}`, raw: body });
|
|
520
|
+
}
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
function describe(code, message) {
|
|
524
|
+
const known = BKASH_ERROR_CODES[code];
|
|
525
|
+
const text = message?.trim() || known || "Unknown error";
|
|
526
|
+
return known && message && known.toLowerCase() !== message.trim().toLowerCase() ? `bKash ${code}: ${text} (${known})` : `bKash ${code}: ${text}`;
|
|
527
|
+
}
|
|
528
|
+
function parseBkashTime(value) {
|
|
529
|
+
if (!value) return void 0;
|
|
530
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?::(\d{1,3}))?(?:\s*(?:GMT|UTC)?\s*([+-])(\d{2}):?(\d{2}))?\s*$/.exec(
|
|
531
|
+
value.trim()
|
|
532
|
+
);
|
|
533
|
+
if (!match) {
|
|
534
|
+
const fallback = new Date(value);
|
|
535
|
+
return Number.isNaN(fallback.getTime()) ? void 0 : fallback;
|
|
536
|
+
}
|
|
537
|
+
const [, y, mo, d, h, mi, s, ms, sign, offH, offM] = match;
|
|
538
|
+
const offsetMinutes = sign && offH && offM ? (sign === "-" ? -1 : 1) * (Number(offH) * 60 + Number(offM)) : 6 * 60;
|
|
539
|
+
const utcMs = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s), Number((ms ?? "0").padEnd(3, "0"))) - offsetMinutes * 6e4;
|
|
540
|
+
const date = new Date(utcMs);
|
|
541
|
+
return Number.isNaN(date.getTime()) ? void 0 : date;
|
|
542
|
+
}
|
|
543
|
+
function parseBkashCompactTime(value) {
|
|
544
|
+
if (!value) return void 0;
|
|
545
|
+
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/.exec(value.trim());
|
|
546
|
+
if (!match) return void 0;
|
|
547
|
+
const [, y, mo, d, h, mi, s] = match;
|
|
548
|
+
const date = new Date(
|
|
549
|
+
Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)) - 6 * 60 * 6e4
|
|
550
|
+
);
|
|
551
|
+
return Number.isNaN(date.getTime()) ? void 0 : date;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// src/core/token-store.ts
|
|
555
|
+
var MemoryTokenStore = class {
|
|
556
|
+
#records = /* @__PURE__ */ new Map();
|
|
557
|
+
async get(key) {
|
|
558
|
+
return this.#records.get(key) ?? null;
|
|
559
|
+
}
|
|
560
|
+
async set(key, record) {
|
|
561
|
+
this.#records.set(key, record);
|
|
562
|
+
}
|
|
563
|
+
async delete(key) {
|
|
564
|
+
this.#records.delete(key);
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
// src/bkash/token-manager.ts
|
|
569
|
+
var HOUR_MS = 60 * 60 * 1e3;
|
|
570
|
+
var ACQUISITION_CIRCUIT_BREAKER = 10;
|
|
571
|
+
var BkashTokenManager = class {
|
|
572
|
+
#config;
|
|
573
|
+
#store;
|
|
574
|
+
#logger;
|
|
575
|
+
#urls;
|
|
576
|
+
#inFlight = null;
|
|
577
|
+
constructor(config, options = {}) {
|
|
578
|
+
this.#config = config;
|
|
579
|
+
this.#store = options.store ?? new MemoryTokenStore();
|
|
580
|
+
this.#logger = options.logger ?? noopLogger;
|
|
581
|
+
this.#urls = endpoints(config.origin);
|
|
582
|
+
}
|
|
583
|
+
/** A token that is valid now, acquiring one only if the cached one will not do. */
|
|
584
|
+
async getToken() {
|
|
585
|
+
const record = await this.#store.get(this.#config.tokenKey);
|
|
586
|
+
if (record && !this.#needsRefresh(record)) return record.idToken;
|
|
587
|
+
this.#inFlight ??= this.#acquire(record).finally(() => {
|
|
588
|
+
this.#inFlight = null;
|
|
589
|
+
});
|
|
590
|
+
return this.#inFlight;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Drop the cached token so the next call acquires a new one. Use this when
|
|
594
|
+
* bKash answers 401 — it means the token died earlier than advertised.
|
|
595
|
+
*/
|
|
596
|
+
async invalidate() {
|
|
597
|
+
const record = await this.#store.get(this.#config.tokenKey);
|
|
598
|
+
if (!record) return;
|
|
599
|
+
await this.#store.set(this.#config.tokenKey, { ...record, expiresAt: 0 });
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Force one refresh, spending a unit of the hourly budget. Only useful for
|
|
603
|
+
* proving the refresh path works — normal use should let {@link getToken}
|
|
604
|
+
* decide. Throws if the budget is already spent.
|
|
605
|
+
*/
|
|
606
|
+
async refreshNow() {
|
|
607
|
+
const record = await this.#store.get(this.#config.tokenKey);
|
|
608
|
+
if (!record?.refreshToken) {
|
|
609
|
+
throw new ConfigError("bKash: no refresh_token stored yet \u2014 grant a token first.", {
|
|
610
|
+
provider: "bkash",
|
|
611
|
+
code: "no_refresh_token"
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
const now = Date.now();
|
|
615
|
+
const refreshes = withinWindow(record.refreshes, now);
|
|
616
|
+
if (refreshes.length >= this.#config.maxRefreshesPerHour) {
|
|
617
|
+
throw new RateLimitError(
|
|
618
|
+
`bKash: refresh budget for this hour is already spent (${refreshes.length}/${this.#config.maxRefreshesPerHour}).`,
|
|
619
|
+
{ provider: "bkash", code: "refresh_budget_spent", retryAt: (refreshes[0] ?? now) + HOUR_MS }
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
const response = await this.#call(this.#urls.refreshToken, {
|
|
623
|
+
app_key: this.#config.appKey,
|
|
624
|
+
app_secret: this.#config.appSecret,
|
|
625
|
+
refresh_token: record.refreshToken
|
|
626
|
+
});
|
|
627
|
+
if (!response.id_token) {
|
|
628
|
+
throw new BkashError({
|
|
629
|
+
code: response.statusCode ?? "no_token",
|
|
630
|
+
message: `bKash refresh returned no id_token: ${response.statusMessage ?? "no status message"}`,
|
|
631
|
+
raw: response
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
await this.#store.set(this.#config.tokenKey, {
|
|
635
|
+
idToken: response.id_token,
|
|
636
|
+
refreshToken: response.refresh_token ?? record.refreshToken,
|
|
637
|
+
expiresAt: Date.now() + (Number(response.expires_in) || 3600) * 1e3,
|
|
638
|
+
acquisitions: [...withinWindow(record.acquisitions, now), Date.now()],
|
|
639
|
+
refreshes: [...refreshes, Date.now()]
|
|
640
|
+
});
|
|
641
|
+
return response.id_token;
|
|
642
|
+
}
|
|
643
|
+
/** What the budget looks like right now. Useful for a health endpoint. */
|
|
644
|
+
async budget() {
|
|
645
|
+
const record = await this.#store.get(this.#config.tokenKey);
|
|
646
|
+
const now = Date.now();
|
|
647
|
+
return {
|
|
648
|
+
refreshesUsed: withinWindow(record?.refreshes, now).length,
|
|
649
|
+
refreshesAllowed: this.#config.maxRefreshesPerHour,
|
|
650
|
+
acquisitionsUsed: withinWindow(record?.acquisitions, now).length
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
#needsRefresh(record) {
|
|
654
|
+
return Date.now() >= record.expiresAt - this.#config.refreshSkewMs;
|
|
655
|
+
}
|
|
656
|
+
async #acquire(cached) {
|
|
657
|
+
const run = async () => {
|
|
658
|
+
const current = await this.#store.get(this.#config.tokenKey);
|
|
659
|
+
if (current && !this.#needsRefresh(current)) return current.idToken;
|
|
660
|
+
const record = current ?? cached;
|
|
661
|
+
const now = Date.now();
|
|
662
|
+
const acquisitions = withinWindow(record?.acquisitions, now);
|
|
663
|
+
const refreshes = withinWindow(record?.refreshes, now);
|
|
664
|
+
if (acquisitions.length >= ACQUISITION_CIRCUIT_BREAKER) {
|
|
665
|
+
const retryAt = (acquisitions[0] ?? now) + HOUR_MS;
|
|
666
|
+
throw new RateLimitError(
|
|
667
|
+
`bKash: ${acquisitions.length} token acquisitions in the last hour, which is past the safety ceiling. Refusing to call bKash again until ${new Date(retryAt).toISOString()} \u2014 going further risks an hour-long block on the merchant account. This usually means a retry loop, or a per-request client instance with no shared token store.`,
|
|
668
|
+
{ provider: "bkash", code: "token_circuit_open", retryAt }
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
const canRefresh = record?.refreshToken && refreshes.length < this.#config.maxRefreshesPerHour;
|
|
672
|
+
if (record?.refreshToken && !canRefresh) {
|
|
673
|
+
this.#logger.warn("paykit/bkash: refresh budget spent this hour, granting a new token instead", {
|
|
674
|
+
refreshesUsed: refreshes.length,
|
|
675
|
+
allowed: this.#config.maxRefreshesPerHour
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
const response = canRefresh ? await this.#call(this.#urls.refreshToken, {
|
|
679
|
+
app_key: this.#config.appKey,
|
|
680
|
+
app_secret: this.#config.appSecret,
|
|
681
|
+
refresh_token: record.refreshToken
|
|
682
|
+
}) : await this.#call(this.#urls.grantToken, {
|
|
683
|
+
app_key: this.#config.appKey,
|
|
684
|
+
app_secret: this.#config.appSecret
|
|
685
|
+
});
|
|
686
|
+
if (!response.id_token) {
|
|
687
|
+
throw new BkashError({
|
|
688
|
+
code: response.statusCode ?? "no_token",
|
|
689
|
+
message: `bKash returned no id_token: ${response.statusMessage ?? "no status message"}`,
|
|
690
|
+
raw: response
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
const lifetimeMs = (Number(response.expires_in) || 3600) * 1e3;
|
|
694
|
+
const next = {
|
|
695
|
+
idToken: response.id_token,
|
|
696
|
+
refreshToken: response.refresh_token ?? record?.refreshToken ?? "",
|
|
697
|
+
expiresAt: Date.now() + lifetimeMs,
|
|
698
|
+
acquisitions: [...acquisitions, Date.now()],
|
|
699
|
+
refreshes: canRefresh ? [...refreshes, Date.now()] : refreshes
|
|
700
|
+
};
|
|
701
|
+
await this.#store.set(this.#config.tokenKey, next);
|
|
702
|
+
this.#logger.debug("paykit/bkash: token acquired", {
|
|
703
|
+
method: canRefresh ? "refresh" : "grant",
|
|
704
|
+
expiresInSeconds: Math.round(lifetimeMs / 1e3),
|
|
705
|
+
refreshesUsedThisHour: next.refreshes.length
|
|
706
|
+
});
|
|
707
|
+
return next.idToken;
|
|
708
|
+
};
|
|
709
|
+
return this.#store.withLock ? this.#store.withLock(this.#config.tokenKey, 15e3, run) : run();
|
|
710
|
+
}
|
|
711
|
+
async #call(url, body) {
|
|
712
|
+
const { body: parsed, status } = await requestJson(
|
|
713
|
+
url,
|
|
714
|
+
{
|
|
715
|
+
method: "POST",
|
|
716
|
+
headers: {
|
|
717
|
+
"Content-Type": "application/json",
|
|
718
|
+
username: this.#config.username,
|
|
719
|
+
password: this.#config.password
|
|
720
|
+
},
|
|
721
|
+
json: body,
|
|
722
|
+
timeoutMs: this.#config.timeoutMs,
|
|
723
|
+
// Token acquisition moves no money, but it does spend budget, so retry
|
|
724
|
+
// transport failures only, and only once.
|
|
725
|
+
retries: 1
|
|
726
|
+
},
|
|
727
|
+
{ provider: "bkash", logger: this.#logger }
|
|
728
|
+
);
|
|
729
|
+
const error = toBkashError(parsed, status);
|
|
730
|
+
if (error) {
|
|
731
|
+
if (error instanceof NetworkError) throw error;
|
|
732
|
+
throw error;
|
|
733
|
+
}
|
|
734
|
+
return parsed;
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
function withinWindow(timestamps, now) {
|
|
738
|
+
return (timestamps ?? []).filter((t) => now - t < HOUR_MS).sort((a, b) => a - b);
|
|
739
|
+
}
|
|
740
|
+
var SNS_CERT_HOST = /^sns\.[a-z0-9-]+\.amazonaws\.com(\.cn)?$/i;
|
|
741
|
+
var BKASH_TRANSACTION_TYPES = {
|
|
742
|
+
"10002294": "Payment via API",
|
|
743
|
+
"10003126": "Payment via QR",
|
|
744
|
+
"10002175": "Payment via USSD",
|
|
745
|
+
"10002809": "Voucher Redeem",
|
|
746
|
+
"10002264": "M2M Payment via API",
|
|
747
|
+
"10003209": "M2M Payment via QR",
|
|
748
|
+
"10002177": "M2M Payment via USSD",
|
|
749
|
+
"10003476": "Bank Payment",
|
|
750
|
+
"10003237": "B2B Collection"
|
|
751
|
+
};
|
|
752
|
+
var BkashWebhookVerifier = class _BkashWebhookVerifier {
|
|
753
|
+
#options;
|
|
754
|
+
#logger;
|
|
755
|
+
#fetch;
|
|
756
|
+
#certCache = /* @__PURE__ */ new Map();
|
|
757
|
+
constructor(options = {}) {
|
|
758
|
+
this.#options = options;
|
|
759
|
+
this.#logger = options.logger ?? noopLogger;
|
|
760
|
+
this.#fetch = options.fetchImpl ?? fetch;
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Verify an inbound request and normalise it.
|
|
764
|
+
*
|
|
765
|
+
* `request.body` must be the raw bytes as received. A body that has been
|
|
766
|
+
* parsed and re-serialised will not match the signature.
|
|
767
|
+
*/
|
|
768
|
+
async verify(request) {
|
|
769
|
+
const envelope = this.#parseEnvelope(request.body);
|
|
770
|
+
await this.#verifySignature(envelope);
|
|
771
|
+
this.#checkTopic(envelope);
|
|
772
|
+
this.#checkAge(envelope);
|
|
773
|
+
return this.#normalise(envelope);
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Confirm an SNS subscription by visiting its SubscribeURL.
|
|
777
|
+
*
|
|
778
|
+
* Deliberately not automatic: it is an outbound call that switches on real
|
|
779
|
+
* payment traffic, so your handler decides when to make it. Verify the
|
|
780
|
+
* message first — this method re-checks the URL host but assumes the
|
|
781
|
+
* signature was already proven.
|
|
782
|
+
*/
|
|
783
|
+
async confirmSubscription(envelope) {
|
|
784
|
+
const url = envelope.SubscribeURL;
|
|
785
|
+
if (!url) {
|
|
786
|
+
throw new WebhookVerificationError("bKash: subscription confirmation has no SubscribeURL", {
|
|
787
|
+
provider: "bkash",
|
|
788
|
+
code: "missing_subscribe_url",
|
|
789
|
+
raw: envelope
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
assertSnsUrl(url, "SubscribeURL");
|
|
793
|
+
const response = await this.#fetch(url, { method: "GET" });
|
|
794
|
+
if (!response.ok) {
|
|
795
|
+
throw new WebhookVerificationError(
|
|
796
|
+
`bKash: SNS subscription confirmation failed with HTTP ${response.status}`,
|
|
797
|
+
{ provider: "bkash", code: "subscribe_failed", raw: await response.text().catch(() => "") }
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
this.#logger.debug("paykit/bkash: SNS subscription confirmed", { topicArn: envelope.TopicArn });
|
|
801
|
+
}
|
|
802
|
+
/** Parse an already-verified envelope's inner payment message. */
|
|
803
|
+
static parseMessage(envelope) {
|
|
804
|
+
if (!envelope.Message) return null;
|
|
805
|
+
try {
|
|
806
|
+
return JSON.parse(envelope.Message);
|
|
807
|
+
} catch {
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
#parseEnvelope(body) {
|
|
812
|
+
if (!body || body.trim() === "") {
|
|
813
|
+
throw new WebhookVerificationError("bKash: empty webhook body", {
|
|
814
|
+
provider: "bkash",
|
|
815
|
+
code: "empty_body"
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
try {
|
|
819
|
+
return JSON.parse(body);
|
|
820
|
+
} catch (cause) {
|
|
821
|
+
throw new WebhookVerificationError("bKash: webhook body is not JSON", {
|
|
822
|
+
provider: "bkash",
|
|
823
|
+
code: "invalid_body",
|
|
824
|
+
raw: body.slice(0, 500),
|
|
825
|
+
cause
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
async #verifySignature(envelope) {
|
|
830
|
+
const { Signature, SignatureVersion, SigningCertURL, Type } = envelope;
|
|
831
|
+
if (!Signature || !SigningCertURL || !Type) {
|
|
832
|
+
throw new WebhookVerificationError(
|
|
833
|
+
"bKash: webhook is missing Type, Signature or SigningCertURL \u2014 it is not an SNS message",
|
|
834
|
+
{ provider: "bkash", code: "not_sns", raw: envelope }
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
const algorithm = SignatureVersion === "2" ? "RSA-SHA256" : "RSA-SHA1";
|
|
838
|
+
if (SignatureVersion !== "1" && SignatureVersion !== "2") {
|
|
839
|
+
throw new WebhookVerificationError(
|
|
840
|
+
`bKash: unsupported SNS SignatureVersion ${JSON.stringify(SignatureVersion)}`,
|
|
841
|
+
{ provider: "bkash", code: "unsupported_signature_version", raw: envelope }
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
assertSnsUrl(SigningCertURL, "SigningCertURL");
|
|
845
|
+
const pem = await this.#loadCertificate(SigningCertURL);
|
|
846
|
+
const canonical = canonicalString(envelope);
|
|
847
|
+
let valid;
|
|
848
|
+
try {
|
|
849
|
+
valid = crypto.createVerify(algorithm).update(canonical, "utf8").verify(pem, Signature, "base64");
|
|
850
|
+
} catch (cause) {
|
|
851
|
+
throw new WebhookVerificationError("bKash: SNS signature could not be checked", {
|
|
852
|
+
provider: "bkash",
|
|
853
|
+
code: "signature_check_failed",
|
|
854
|
+
cause
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
if (!valid) {
|
|
858
|
+
throw new WebhookVerificationError(
|
|
859
|
+
"bKash: SNS signature does not match the message. Treat the payload as forged, and check that the raw request body reached the verifier unmodified.",
|
|
860
|
+
{ provider: "bkash", code: "signature_mismatch" }
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
#checkTopic(envelope) {
|
|
865
|
+
const expected = this.#options.topicArn;
|
|
866
|
+
if (!expected) {
|
|
867
|
+
this.#logger.warn(
|
|
868
|
+
"paykit/bkash: webhook accepted without a pinned topicArn \u2014 any Amazon-signed SNS topic will pass"
|
|
869
|
+
);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
const allowed = Array.isArray(expected) ? expected : [expected];
|
|
873
|
+
if (!envelope.TopicArn || !allowed.includes(envelope.TopicArn)) {
|
|
874
|
+
throw new WebhookVerificationError(
|
|
875
|
+
`bKash: message came from SNS topic ${JSON.stringify(envelope.TopicArn)}, which is not one of yours`,
|
|
876
|
+
{ provider: "bkash", code: "topic_mismatch", raw: envelope.TopicArn }
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
#checkAge(envelope) {
|
|
881
|
+
const maxAgeMs = this.#options.maxAgeMs;
|
|
882
|
+
if (!maxAgeMs || !envelope.Timestamp) return;
|
|
883
|
+
const sent = new Date(envelope.Timestamp).getTime();
|
|
884
|
+
if (Number.isNaN(sent)) return;
|
|
885
|
+
if (Date.now() - sent > maxAgeMs) {
|
|
886
|
+
throw new WebhookVerificationError(
|
|
887
|
+
`bKash: message is older than the configured maxAgeMs (${maxAgeMs}ms)`,
|
|
888
|
+
{ provider: "bkash", code: "message_too_old", raw: envelope.Timestamp }
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
#normalise(envelope) {
|
|
893
|
+
const base = {
|
|
894
|
+
provider: "bkash",
|
|
895
|
+
eventId: envelope.MessageId,
|
|
896
|
+
raw: envelope
|
|
897
|
+
};
|
|
898
|
+
if (envelope.Type === "SubscriptionConfirmation" || envelope.Type === "UnsubscribeConfirmation") {
|
|
899
|
+
return { ...base, type: "subscription.confirmation" };
|
|
900
|
+
}
|
|
901
|
+
const message = _BkashWebhookVerifier.parseMessage(envelope);
|
|
902
|
+
if (!message) return { ...base, type: "unknown" };
|
|
903
|
+
const completed = (message.transactionStatus ?? "").toLowerCase() === "completed";
|
|
904
|
+
return {
|
|
905
|
+
...base,
|
|
906
|
+
type: completed ? "payment.completed" : "payment.failed",
|
|
907
|
+
transactionId: message.trxID,
|
|
908
|
+
reference: message.merchantInvoiceNumber ?? message.transactionReference,
|
|
909
|
+
amount: message.amount,
|
|
910
|
+
currency: message.currency ?? "BDT",
|
|
911
|
+
payerAccount: message.debitMSISDN,
|
|
912
|
+
occurredAt: parseBkashCompactTime(message.dateTime)
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
async #loadCertificate(url) {
|
|
916
|
+
const ttl = this.#options.certCacheMs ?? 24 * 60 * 60 * 1e3;
|
|
917
|
+
const cached = this.#certCache.get(url);
|
|
918
|
+
if (cached && Date.now() - cached.fetchedAt < ttl) return cached.pem;
|
|
919
|
+
const response = await this.#fetch(url, { method: "GET", signal: AbortSignal.timeout(1e4) });
|
|
920
|
+
if (!response.ok) {
|
|
921
|
+
throw new WebhookVerificationError(
|
|
922
|
+
`bKash: could not fetch the SNS signing certificate (HTTP ${response.status})`,
|
|
923
|
+
{ provider: "bkash", code: "cert_fetch_failed", raw: url }
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
const pem = await response.text();
|
|
927
|
+
if (!pem.includes("BEGIN CERTIFICATE") && !pem.includes("BEGIN PUBLIC KEY")) {
|
|
928
|
+
throw new WebhookVerificationError("bKash: SigningCertURL did not return a PEM certificate", {
|
|
929
|
+
provider: "bkash",
|
|
930
|
+
code: "cert_invalid",
|
|
931
|
+
raw: pem.slice(0, 200)
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
this.#certCache.set(url, { pem, fetchedAt: Date.now() });
|
|
935
|
+
return pem;
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
function canonicalString(envelope) {
|
|
939
|
+
const fields = envelope.Type === "SubscriptionConfirmation" || envelope.Type === "UnsubscribeConfirmation" ? ["Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type"] : ["Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"];
|
|
940
|
+
let canonical = "";
|
|
941
|
+
for (const field of fields) {
|
|
942
|
+
const value = envelope[field];
|
|
943
|
+
if (value === void 0 || value === null) continue;
|
|
944
|
+
canonical += `${field}
|
|
945
|
+
${value}
|
|
946
|
+
`;
|
|
947
|
+
}
|
|
948
|
+
return canonical;
|
|
949
|
+
}
|
|
950
|
+
function assertSnsUrl(rawUrl, field) {
|
|
951
|
+
let url;
|
|
952
|
+
try {
|
|
953
|
+
url = new URL(rawUrl);
|
|
954
|
+
} catch {
|
|
955
|
+
throw new WebhookVerificationError(`bKash: ${field} is not a URL`, {
|
|
956
|
+
provider: "bkash",
|
|
957
|
+
code: "cert_url_invalid",
|
|
958
|
+
raw: rawUrl
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
if (url.protocol !== "https:") {
|
|
962
|
+
throw new WebhookVerificationError(`bKash: ${field} must be https`, {
|
|
963
|
+
provider: "bkash",
|
|
964
|
+
code: "cert_url_insecure",
|
|
965
|
+
raw: rawUrl
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
if (!SNS_CERT_HOST.test(url.hostname)) {
|
|
969
|
+
throw new WebhookVerificationError(
|
|
970
|
+
`bKash: ${field} points at ${url.hostname}, which is not an Amazon SNS host. This is what a forged notification looks like \u2014 the payload is not from bKash.`,
|
|
971
|
+
{ provider: "bkash", code: "cert_url_untrusted", raw: rawUrl }
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
if (field === "SigningCertURL" && !url.pathname.endsWith(".pem")) {
|
|
975
|
+
throw new WebhookVerificationError(`bKash: ${field} does not point at a .pem file`, {
|
|
976
|
+
provider: "bkash",
|
|
977
|
+
code: "cert_url_invalid",
|
|
978
|
+
raw: rawUrl
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
return url;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// src/bkash/client.ts
|
|
985
|
+
var BkashClient = class {
|
|
986
|
+
id = "bkash";
|
|
987
|
+
#config;
|
|
988
|
+
#urls;
|
|
989
|
+
#tokens;
|
|
990
|
+
#logger;
|
|
991
|
+
#verifier;
|
|
992
|
+
constructor(config, options = {}) {
|
|
993
|
+
this.#config = resolveConfig(config);
|
|
994
|
+
this.#urls = endpoints(this.#config.origin);
|
|
995
|
+
this.#logger = options.logger ?? config.logger ?? noopLogger;
|
|
996
|
+
this.#tokens = new BkashTokenManager(this.#config, {
|
|
997
|
+
store: options.tokenStore ?? config.tokenStore,
|
|
998
|
+
logger: this.#logger
|
|
999
|
+
});
|
|
1000
|
+
this.#verifier = new BkashWebhookVerifier({
|
|
1001
|
+
topicArn: this.#config.webhookTopicArn,
|
|
1002
|
+
...options.webhook,
|
|
1003
|
+
logger: this.#logger
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
/** Which environment and host this client is pointed at. */
|
|
1007
|
+
get environment() {
|
|
1008
|
+
return { environment: this.#config.environment, origin: this.#config.origin };
|
|
1009
|
+
}
|
|
1010
|
+
/** Token budget for the current rolling hour. */
|
|
1011
|
+
tokenBudget() {
|
|
1012
|
+
return this.#tokens.budget();
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Force a token refresh, spending one unit of the hourly budget. Normal use
|
|
1016
|
+
* should leave this alone and let the client renew when it needs to; it is
|
|
1017
|
+
* here so the refresh path can be exercised deliberately.
|
|
1018
|
+
*/
|
|
1019
|
+
refreshToken() {
|
|
1020
|
+
return this.#tokens.refreshNow();
|
|
1021
|
+
}
|
|
1022
|
+
// ---------------------------------------------------------------- agreements
|
|
1023
|
+
/**
|
|
1024
|
+
* Step 1 of 2. Start an agreement so this customer can later pay with a PIN
|
|
1025
|
+
* alone. Send them to `redirectUrl`, then call {@link executeAgreement}.
|
|
1026
|
+
*/
|
|
1027
|
+
async createAgreement(input) {
|
|
1028
|
+
const callbackURL = input.callbackUrl ?? this.#config.callbackUrl;
|
|
1029
|
+
if (!callbackURL) throw missingCallback();
|
|
1030
|
+
const raw = await this.#post(this.#urls.create, {
|
|
1031
|
+
mode: BKASH_MODE.CREATE_AGREEMENT,
|
|
1032
|
+
payerReference: input.payerReference,
|
|
1033
|
+
callbackURL
|
|
1034
|
+
});
|
|
1035
|
+
return {
|
|
1036
|
+
paymentId: raw.paymentID ?? "",
|
|
1037
|
+
redirectUrl: raw.bkashURL ?? null,
|
|
1038
|
+
status: raw.agreementStatus ?? "Initiated",
|
|
1039
|
+
raw
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Step 2 of 2. Call once the customer returns to your callback URL. The
|
|
1044
|
+
* `agreementID` it returns is what you store against the customer — it is the
|
|
1045
|
+
* whole point of the flow and bKash will not hand it to you again.
|
|
1046
|
+
*/
|
|
1047
|
+
async executeAgreement(paymentId) {
|
|
1048
|
+
const raw = await this.#post(this.#urls.execute, { paymentID: paymentId });
|
|
1049
|
+
return this.#toAgreement(raw);
|
|
1050
|
+
}
|
|
1051
|
+
async getAgreement(agreementId) {
|
|
1052
|
+
const raw = await this.#post(this.#urls.queryAgreement, { agreementID: agreementId });
|
|
1053
|
+
return this.#toAgreement(raw);
|
|
1054
|
+
}
|
|
1055
|
+
/** Ends the agreement. The customer must go through the OTP flow again after this. */
|
|
1056
|
+
async cancelAgreement(agreementId) {
|
|
1057
|
+
const raw = await this.#post(this.#urls.cancelAgreement, { agreementID: agreementId });
|
|
1058
|
+
return this.#toAgreement(raw);
|
|
1059
|
+
}
|
|
1060
|
+
// ------------------------------------------------------------------ payments
|
|
1061
|
+
/**
|
|
1062
|
+
* Start a payment.
|
|
1063
|
+
*
|
|
1064
|
+
* Passing `extra.agreementID` charges an existing agreement (mode 0001) and
|
|
1065
|
+
* returns no `redirectUrl` — the customer confirms with a PIN in the bKash
|
|
1066
|
+
* app. Without it this is a one-off payment (mode 0011) and the customer must
|
|
1067
|
+
* be sent to `redirectUrl`.
|
|
1068
|
+
*/
|
|
1069
|
+
async createPayment(input) {
|
|
1070
|
+
const callbackURL = input.callbackUrl ?? this.#config.callbackUrl;
|
|
1071
|
+
if (!callbackURL) throw missingCallback();
|
|
1072
|
+
const agreementID = input.extra?.["agreementID"];
|
|
1073
|
+
const amount = toAmountString(input.amount);
|
|
1074
|
+
const raw = await this.#post(this.#urls.create, {
|
|
1075
|
+
mode: agreementID ? BKASH_MODE.AGREEMENT_PAYMENT : BKASH_MODE.ONE_OFF_PAYMENT,
|
|
1076
|
+
payerReference: input.payerReference ?? input.reference,
|
|
1077
|
+
callbackURL,
|
|
1078
|
+
amount,
|
|
1079
|
+
currency: input.currency ?? "BDT",
|
|
1080
|
+
intent: input.intent ?? "sale",
|
|
1081
|
+
merchantInvoiceNumber: input.reference,
|
|
1082
|
+
...agreementID ? { agreementID } : {},
|
|
1083
|
+
...input.extra?.["merchantAssociationInfo"] ? { merchantAssociationInfo: input.extra["merchantAssociationInfo"] } : {}
|
|
1084
|
+
});
|
|
1085
|
+
const createdAt = parseBkashTime(raw.paymentCreateTime);
|
|
1086
|
+
return {
|
|
1087
|
+
provider: this.id,
|
|
1088
|
+
paymentId: raw.paymentID ?? "",
|
|
1089
|
+
redirectUrl: raw.bkashURL ?? null,
|
|
1090
|
+
status: toStatus(raw.transactionStatus),
|
|
1091
|
+
amount: raw.amount ?? amount,
|
|
1092
|
+
currency: raw.currency ?? "BDT",
|
|
1093
|
+
reference: raw.merchantInvoiceNumber ?? input.reference,
|
|
1094
|
+
// A payment id is good for 24 hours and one execution.
|
|
1095
|
+
expiresAt: createdAt ? new Date(createdAt.getTime() + 24 * 60 * 60 * 1e3) : void 0,
|
|
1096
|
+
raw
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Finalise a payment after the customer approves it. Valid exactly once per
|
|
1101
|
+
* payment id.
|
|
1102
|
+
*
|
|
1103
|
+
* If bKash answers that the payment was already executed, this reads the real
|
|
1104
|
+
* outcome with {@link getPayment} instead of throwing — that response means
|
|
1105
|
+
* the money moved, and the caller wants the result, not an error.
|
|
1106
|
+
*/
|
|
1107
|
+
async executePayment(paymentId) {
|
|
1108
|
+
try {
|
|
1109
|
+
const raw = await this.#post(this.#urls.execute, { paymentID: paymentId });
|
|
1110
|
+
return {
|
|
1111
|
+
provider: this.id,
|
|
1112
|
+
paymentId: raw.paymentID ?? paymentId,
|
|
1113
|
+
transactionId: raw.trxID ?? null,
|
|
1114
|
+
status: toStatus(raw.transactionStatus),
|
|
1115
|
+
amount: raw.amount ?? "",
|
|
1116
|
+
currency: raw.currency ?? "BDT",
|
|
1117
|
+
reference: raw.merchantInvoiceNumber,
|
|
1118
|
+
payerAccount: raw.customerMsisdn ?? raw.payerReference,
|
|
1119
|
+
completedAt: parseBkashTime(raw.paymentExecuteTime),
|
|
1120
|
+
raw
|
|
1121
|
+
};
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
if (error instanceof BkashError && error.alreadySettled) {
|
|
1124
|
+
this.#logger.warn("paykit/bkash: payment already executed, reading current state", {
|
|
1125
|
+
paymentId,
|
|
1126
|
+
code: error.code
|
|
1127
|
+
});
|
|
1128
|
+
return this.getPayment(paymentId);
|
|
1129
|
+
}
|
|
1130
|
+
throw error;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
/** Read current state. Safe to call as often as you like — this is the recovery path. */
|
|
1134
|
+
async getPayment(paymentId) {
|
|
1135
|
+
const raw = await this.#post(this.#urls.queryPayment, { paymentID: paymentId });
|
|
1136
|
+
return {
|
|
1137
|
+
provider: this.id,
|
|
1138
|
+
paymentId: raw.paymentID ?? paymentId,
|
|
1139
|
+
transactionId: raw.trxID ?? null,
|
|
1140
|
+
status: toStatus(raw.transactionStatus),
|
|
1141
|
+
amount: raw.amount ?? "",
|
|
1142
|
+
currency: raw.currency ?? "BDT",
|
|
1143
|
+
// The query endpoint calls it merchantInvoice; create and execute call it
|
|
1144
|
+
// merchantInvoiceNumber. Same field.
|
|
1145
|
+
reference: raw.merchantInvoice ?? raw.merchantInvoiceNumber,
|
|
1146
|
+
payerAccount: raw.customerMsisdn ?? raw.payerReference,
|
|
1147
|
+
completedAt: parseBkashTime(raw.paymentExecuteTime),
|
|
1148
|
+
raw
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
/** How much of this payment can still be refunded, per bKash. */
|
|
1152
|
+
async getRefundableAmount(paymentId) {
|
|
1153
|
+
const raw = await this.#post(this.#urls.queryPayment, { paymentID: paymentId });
|
|
1154
|
+
return raw.maxRefundableAmount ?? null;
|
|
1155
|
+
}
|
|
1156
|
+
// ------------------------------------------------------------------- refunds
|
|
1157
|
+
/**
|
|
1158
|
+
* Refund all or part of a completed payment.
|
|
1159
|
+
*
|
|
1160
|
+
* The v2 API allows up to ten partial refunds per transaction, within 60 days.
|
|
1161
|
+
* Omit `amount` for a full refund, which is read from bKash's own
|
|
1162
|
+
* `maxRefundableAmount` rather than assumed.
|
|
1163
|
+
*/
|
|
1164
|
+
async refund(input) {
|
|
1165
|
+
const refundAmount = input.amount ? toAmountString(input.amount) : await this.getRefundableAmount(input.paymentId) ?? void 0;
|
|
1166
|
+
if (!refundAmount) {
|
|
1167
|
+
throw new ConfigError(
|
|
1168
|
+
`bKash: no refund amount given and bKash reported no refundable balance for payment ${input.paymentId}.`,
|
|
1169
|
+
{ provider: "bkash", code: "refund_amount_unknown" }
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
const raw = await this.#post(this.#urls.refund, {
|
|
1173
|
+
// Note the lower-case d: the v2 refund API alone spells it paymentId.
|
|
1174
|
+
paymentId: input.paymentId,
|
|
1175
|
+
trxId: input.transactionId,
|
|
1176
|
+
refundAmount,
|
|
1177
|
+
// Both are always sent. They are mandatory at bKash's schema layer even
|
|
1178
|
+
// though the docs present them as optional, and leaving either out gets
|
|
1179
|
+
// you "Invalid request body" with no indication of which field is wrong.
|
|
1180
|
+
sku: input.sku?.trim() || "refund",
|
|
1181
|
+
reason: input.reason?.trim() || "Merchant refund"
|
|
1182
|
+
});
|
|
1183
|
+
return {
|
|
1184
|
+
provider: this.id,
|
|
1185
|
+
refundTransactionId: raw.refundTrxId ?? "",
|
|
1186
|
+
originalTransactionId: raw.originalTrxId ?? input.transactionId,
|
|
1187
|
+
status: toStatus(raw.refundTransactionStatus),
|
|
1188
|
+
amount: raw.refundAmount ?? refundAmount,
|
|
1189
|
+
currency: raw.currency ?? "BDT",
|
|
1190
|
+
completedAt: parseBkashTime(raw.completedTime),
|
|
1191
|
+
raw
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
/** Every refund recorded against one transaction. */
|
|
1195
|
+
async getRefunds(input) {
|
|
1196
|
+
const raw = await this.#post(this.#urls.refundStatus, {
|
|
1197
|
+
paymentId: input.paymentId,
|
|
1198
|
+
trxId: input.transactionId
|
|
1199
|
+
});
|
|
1200
|
+
return {
|
|
1201
|
+
originalTransactionId: raw.originalTrxId ?? input.transactionId,
|
|
1202
|
+
originalAmount: raw.originalTrxAmount ?? "",
|
|
1203
|
+
refunds: (raw.refundTransactions ?? []).map((entry) => ({
|
|
1204
|
+
provider: this.id,
|
|
1205
|
+
refundTransactionId: entry.refundTrxId ?? "",
|
|
1206
|
+
originalTransactionId: raw.originalTrxId ?? input.transactionId,
|
|
1207
|
+
status: toStatus(entry.refundTransactionStatus),
|
|
1208
|
+
amount: entry.refundAmount ?? "",
|
|
1209
|
+
currency: "BDT",
|
|
1210
|
+
completedAt: parseBkashTime(entry.completedTime),
|
|
1211
|
+
raw: entry
|
|
1212
|
+
})),
|
|
1213
|
+
raw
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
// ------------------------------------------------------------------ webhooks
|
|
1217
|
+
/** Verify an inbound IPN message and normalise it. Throws if it is not genuine. */
|
|
1218
|
+
verifyWebhook(request) {
|
|
1219
|
+
return this.#verifier.verify(request);
|
|
1220
|
+
}
|
|
1221
|
+
get webhooks() {
|
|
1222
|
+
return this.#verifier;
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Read the query string bKash appends when it redirects a customer back.
|
|
1226
|
+
*
|
|
1227
|
+
* Nothing here is proof of payment — it is a URL the customer's own browser
|
|
1228
|
+
* followed and could have edited. Always confirm with {@link executePayment}
|
|
1229
|
+
* or {@link getPayment} before releasing an order.
|
|
1230
|
+
*/
|
|
1231
|
+
static parseCallback(input) {
|
|
1232
|
+
const params = input instanceof URLSearchParams ? input : typeof input === "string" ? new URL(input, "https://placeholder.invalid").searchParams : input instanceof URL ? input.searchParams : new URLSearchParams(input);
|
|
1233
|
+
return {
|
|
1234
|
+
paymentID: params.get("paymentID") ?? params.get("paymentId") ?? void 0,
|
|
1235
|
+
status: params.get("status") ?? void 0,
|
|
1236
|
+
signature: params.get("signature") ?? void 0,
|
|
1237
|
+
apiVersion: params.get("apiVersion") ?? params.get("version") ?? void 0,
|
|
1238
|
+
product: params.get("product") ?? void 0
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
// ------------------------------------------------------------------ internal
|
|
1242
|
+
#toAgreement(raw) {
|
|
1243
|
+
return {
|
|
1244
|
+
agreementId: raw.agreementID ?? "",
|
|
1245
|
+
paymentId: raw.paymentID,
|
|
1246
|
+
customerMsisdn: raw.customerMsisdn,
|
|
1247
|
+
payerReference: raw.payerReference,
|
|
1248
|
+
status: raw.agreementStatus ?? "Unknown",
|
|
1249
|
+
createdAt: parseBkashTime(raw.agreementCreateTime),
|
|
1250
|
+
executedAt: parseBkashTime(raw.agreementExecuteTime),
|
|
1251
|
+
raw
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* One POST, with the token attached and the four bKash error envelopes turned
|
|
1256
|
+
* into a thrown BkashError.
|
|
1257
|
+
*
|
|
1258
|
+
* `retries` stays 0: a create or execute that times out may well have
|
|
1259
|
+
* succeeded at bKash, so the safe recovery is getPayment, never a second
|
|
1260
|
+
* attempt. The single exception is a 401, which means the token died early —
|
|
1261
|
+
* that is retried once with a fresh token.
|
|
1262
|
+
*/
|
|
1263
|
+
async #post(url, body, isRetry = false) {
|
|
1264
|
+
const token = await this.#tokens.getToken();
|
|
1265
|
+
const { body: parsed, status } = await requestJson(
|
|
1266
|
+
url,
|
|
1267
|
+
{
|
|
1268
|
+
method: "POST",
|
|
1269
|
+
headers: {
|
|
1270
|
+
"Content-Type": "application/json",
|
|
1271
|
+
authorization: token,
|
|
1272
|
+
"x-app-key": this.#config.appKey
|
|
1273
|
+
},
|
|
1274
|
+
json: body,
|
|
1275
|
+
timeoutMs: this.#config.timeoutMs,
|
|
1276
|
+
retries: 0
|
|
1277
|
+
},
|
|
1278
|
+
{ provider: "bkash", logger: this.#logger }
|
|
1279
|
+
);
|
|
1280
|
+
const error = toBkashError(parsed, status);
|
|
1281
|
+
if (error) {
|
|
1282
|
+
if (!isRetry && error instanceof BkashError && error.code === "unauthorized") {
|
|
1283
|
+
this.#logger.warn("paykit/bkash: token rejected, acquiring a new one and retrying once", { url });
|
|
1284
|
+
await this.#tokens.invalidate();
|
|
1285
|
+
return this.#post(url, body, true);
|
|
1286
|
+
}
|
|
1287
|
+
throw error;
|
|
1288
|
+
}
|
|
1289
|
+
return parsed;
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1292
|
+
function toStatus(value) {
|
|
1293
|
+
switch ((value ?? "").toLowerCase()) {
|
|
1294
|
+
case "completed":
|
|
1295
|
+
return "completed";
|
|
1296
|
+
case "initiated":
|
|
1297
|
+
return "initiated";
|
|
1298
|
+
case "pending":
|
|
1299
|
+
case "processing":
|
|
1300
|
+
return "pending";
|
|
1301
|
+
case "cancelled":
|
|
1302
|
+
case "canceled":
|
|
1303
|
+
return "cancelled";
|
|
1304
|
+
case "failed":
|
|
1305
|
+
return "failed";
|
|
1306
|
+
default:
|
|
1307
|
+
return value ? "pending" : "initiated";
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
function missingCallback() {
|
|
1311
|
+
return new ConfigError(
|
|
1312
|
+
"bKash: no callback URL. Set `callbackUrl` on the client config (or BKASH_CALLBACK_URL), or pass `callbackUrl` on the call.",
|
|
1313
|
+
{ provider: "bkash", code: "missing_callback_url" }
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
exports.ALREADY_SETTLED_CODES = ALREADY_SETTLED_CODES;
|
|
1318
|
+
exports.BKASH_API_VERSION = BKASH_API_VERSION;
|
|
1319
|
+
exports.BKASH_ERROR_CODES = BKASH_ERROR_CODES;
|
|
1320
|
+
exports.BKASH_HOSTS = BKASH_HOSTS;
|
|
1321
|
+
exports.BKASH_MODE = BKASH_MODE;
|
|
1322
|
+
exports.BKASH_TRANSACTION_TYPES = BKASH_TRANSACTION_TYPES;
|
|
1323
|
+
exports.BkashClient = BkashClient;
|
|
1324
|
+
exports.BkashError = BkashError;
|
|
1325
|
+
exports.BkashTokenManager = BkashTokenManager;
|
|
1326
|
+
exports.BkashWebhookVerifier = BkashWebhookVerifier;
|
|
1327
|
+
exports.CUSTOMER_FAULT_CODES = CUSTOMER_FAULT_CODES;
|
|
1328
|
+
exports.assertSnsUrl = assertSnsUrl;
|
|
1329
|
+
exports.canonicalString = canonicalString;
|
|
1330
|
+
exports.configFromEnv = configFromEnv;
|
|
1331
|
+
exports.endpoints = endpoints;
|
|
1332
|
+
exports.parseBkashCompactTime = parseBkashCompactTime;
|
|
1333
|
+
exports.parseBkashTime = parseBkashTime;
|
|
1334
|
+
exports.resolveConfig = resolveConfig;
|
|
1335
|
+
exports.toBkashError = toBkashError;
|