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.
@@ -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;