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,383 @@
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 WebhookVerificationError = class extends PaykitError {
46
+ static [Symbol.hasInstance] = brandedInstanceOf("WebhookVerificationError");
47
+ constructor(message, opts) {
48
+ super(
49
+ message,
50
+ {
51
+ provider: opts.provider,
52
+ code: opts.code ?? "webhook_verification_failed",
53
+ retryable: false,
54
+ raw: opts.raw,
55
+ cause: opts.cause
56
+ },
57
+ ["WebhookVerificationError"]
58
+ );
59
+ }
60
+ };
61
+
62
+ // src/core/http.ts
63
+ var noopLogger = {
64
+ debug() {
65
+ },
66
+ warn() {
67
+ },
68
+ error() {
69
+ }
70
+ };
71
+ function parseBkashCompactTime(value) {
72
+ if (!value) return void 0;
73
+ const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/.exec(value.trim());
74
+ if (!match) return void 0;
75
+ const [, y, mo, d, h, mi, s] = match;
76
+ const date = new Date(
77
+ Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)) - 6 * 60 * 6e4
78
+ );
79
+ return Number.isNaN(date.getTime()) ? void 0 : date;
80
+ }
81
+
82
+ // src/bkash/webhook.ts
83
+ var SNS_CERT_HOST = /^sns\.[a-z0-9-]+\.amazonaws\.com(\.cn)?$/i;
84
+ var BkashWebhookVerifier = class _BkashWebhookVerifier {
85
+ #options;
86
+ #logger;
87
+ #fetch;
88
+ #certCache = /* @__PURE__ */ new Map();
89
+ constructor(options = {}) {
90
+ this.#options = options;
91
+ this.#logger = options.logger ?? noopLogger;
92
+ this.#fetch = options.fetchImpl ?? fetch;
93
+ }
94
+ /**
95
+ * Verify an inbound request and normalise it.
96
+ *
97
+ * `request.body` must be the raw bytes as received. A body that has been
98
+ * parsed and re-serialised will not match the signature.
99
+ */
100
+ async verify(request) {
101
+ const envelope = this.#parseEnvelope(request.body);
102
+ await this.#verifySignature(envelope);
103
+ this.#checkTopic(envelope);
104
+ this.#checkAge(envelope);
105
+ return this.#normalise(envelope);
106
+ }
107
+ /**
108
+ * Confirm an SNS subscription by visiting its SubscribeURL.
109
+ *
110
+ * Deliberately not automatic: it is an outbound call that switches on real
111
+ * payment traffic, so your handler decides when to make it. Verify the
112
+ * message first — this method re-checks the URL host but assumes the
113
+ * signature was already proven.
114
+ */
115
+ async confirmSubscription(envelope) {
116
+ const url = envelope.SubscribeURL;
117
+ if (!url) {
118
+ throw new WebhookVerificationError("bKash: subscription confirmation has no SubscribeURL", {
119
+ provider: "bkash",
120
+ code: "missing_subscribe_url",
121
+ raw: envelope
122
+ });
123
+ }
124
+ assertSnsUrl(url, "SubscribeURL");
125
+ const response = await this.#fetch(url, { method: "GET" });
126
+ if (!response.ok) {
127
+ throw new WebhookVerificationError(
128
+ `bKash: SNS subscription confirmation failed with HTTP ${response.status}`,
129
+ { provider: "bkash", code: "subscribe_failed", raw: await response.text().catch(() => "") }
130
+ );
131
+ }
132
+ this.#logger.debug("paykit/bkash: SNS subscription confirmed", { topicArn: envelope.TopicArn });
133
+ }
134
+ /** Parse an already-verified envelope's inner payment message. */
135
+ static parseMessage(envelope) {
136
+ if (!envelope.Message) return null;
137
+ try {
138
+ return JSON.parse(envelope.Message);
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+ #parseEnvelope(body) {
144
+ if (!body || body.trim() === "") {
145
+ throw new WebhookVerificationError("bKash: empty webhook body", {
146
+ provider: "bkash",
147
+ code: "empty_body"
148
+ });
149
+ }
150
+ try {
151
+ return JSON.parse(body);
152
+ } catch (cause) {
153
+ throw new WebhookVerificationError("bKash: webhook body is not JSON", {
154
+ provider: "bkash",
155
+ code: "invalid_body",
156
+ raw: body.slice(0, 500),
157
+ cause
158
+ });
159
+ }
160
+ }
161
+ async #verifySignature(envelope) {
162
+ const { Signature, SignatureVersion, SigningCertURL, Type } = envelope;
163
+ if (!Signature || !SigningCertURL || !Type) {
164
+ throw new WebhookVerificationError(
165
+ "bKash: webhook is missing Type, Signature or SigningCertURL \u2014 it is not an SNS message",
166
+ { provider: "bkash", code: "not_sns", raw: envelope }
167
+ );
168
+ }
169
+ const algorithm = SignatureVersion === "2" ? "RSA-SHA256" : "RSA-SHA1";
170
+ if (SignatureVersion !== "1" && SignatureVersion !== "2") {
171
+ throw new WebhookVerificationError(
172
+ `bKash: unsupported SNS SignatureVersion ${JSON.stringify(SignatureVersion)}`,
173
+ { provider: "bkash", code: "unsupported_signature_version", raw: envelope }
174
+ );
175
+ }
176
+ assertSnsUrl(SigningCertURL, "SigningCertURL");
177
+ const pem = await this.#loadCertificate(SigningCertURL);
178
+ const canonical = canonicalString(envelope);
179
+ let valid;
180
+ try {
181
+ valid = crypto.createVerify(algorithm).update(canonical, "utf8").verify(pem, Signature, "base64");
182
+ } catch (cause) {
183
+ throw new WebhookVerificationError("bKash: SNS signature could not be checked", {
184
+ provider: "bkash",
185
+ code: "signature_check_failed",
186
+ cause
187
+ });
188
+ }
189
+ if (!valid) {
190
+ throw new WebhookVerificationError(
191
+ "bKash: SNS signature does not match the message. Treat the payload as forged, and check that the raw request body reached the verifier unmodified.",
192
+ { provider: "bkash", code: "signature_mismatch" }
193
+ );
194
+ }
195
+ }
196
+ #checkTopic(envelope) {
197
+ const expected = this.#options.topicArn;
198
+ if (!expected) {
199
+ this.#logger.warn(
200
+ "paykit/bkash: webhook accepted without a pinned topicArn \u2014 any Amazon-signed SNS topic will pass"
201
+ );
202
+ return;
203
+ }
204
+ const allowed = Array.isArray(expected) ? expected : [expected];
205
+ if (!envelope.TopicArn || !allowed.includes(envelope.TopicArn)) {
206
+ throw new WebhookVerificationError(
207
+ `bKash: message came from SNS topic ${JSON.stringify(envelope.TopicArn)}, which is not one of yours`,
208
+ { provider: "bkash", code: "topic_mismatch", raw: envelope.TopicArn }
209
+ );
210
+ }
211
+ }
212
+ #checkAge(envelope) {
213
+ const maxAgeMs = this.#options.maxAgeMs;
214
+ if (!maxAgeMs || !envelope.Timestamp) return;
215
+ const sent = new Date(envelope.Timestamp).getTime();
216
+ if (Number.isNaN(sent)) return;
217
+ if (Date.now() - sent > maxAgeMs) {
218
+ throw new WebhookVerificationError(
219
+ `bKash: message is older than the configured maxAgeMs (${maxAgeMs}ms)`,
220
+ { provider: "bkash", code: "message_too_old", raw: envelope.Timestamp }
221
+ );
222
+ }
223
+ }
224
+ #normalise(envelope) {
225
+ const base = {
226
+ provider: "bkash",
227
+ eventId: envelope.MessageId,
228
+ raw: envelope
229
+ };
230
+ if (envelope.Type === "SubscriptionConfirmation" || envelope.Type === "UnsubscribeConfirmation") {
231
+ return { ...base, type: "subscription.confirmation" };
232
+ }
233
+ const message = _BkashWebhookVerifier.parseMessage(envelope);
234
+ if (!message) return { ...base, type: "unknown" };
235
+ const completed = (message.transactionStatus ?? "").toLowerCase() === "completed";
236
+ return {
237
+ ...base,
238
+ type: completed ? "payment.completed" : "payment.failed",
239
+ transactionId: message.trxID,
240
+ reference: message.merchantInvoiceNumber ?? message.transactionReference,
241
+ amount: message.amount,
242
+ currency: message.currency ?? "BDT",
243
+ payerAccount: message.debitMSISDN,
244
+ occurredAt: parseBkashCompactTime(message.dateTime)
245
+ };
246
+ }
247
+ async #loadCertificate(url) {
248
+ const ttl = this.#options.certCacheMs ?? 24 * 60 * 60 * 1e3;
249
+ const cached = this.#certCache.get(url);
250
+ if (cached && Date.now() - cached.fetchedAt < ttl) return cached.pem;
251
+ const response = await this.#fetch(url, { method: "GET", signal: AbortSignal.timeout(1e4) });
252
+ if (!response.ok) {
253
+ throw new WebhookVerificationError(
254
+ `bKash: could not fetch the SNS signing certificate (HTTP ${response.status})`,
255
+ { provider: "bkash", code: "cert_fetch_failed", raw: url }
256
+ );
257
+ }
258
+ const pem = await response.text();
259
+ if (!pem.includes("BEGIN CERTIFICATE") && !pem.includes("BEGIN PUBLIC KEY")) {
260
+ throw new WebhookVerificationError("bKash: SigningCertURL did not return a PEM certificate", {
261
+ provider: "bkash",
262
+ code: "cert_invalid",
263
+ raw: pem.slice(0, 200)
264
+ });
265
+ }
266
+ this.#certCache.set(url, { pem, fetchedAt: Date.now() });
267
+ return pem;
268
+ }
269
+ };
270
+ function canonicalString(envelope) {
271
+ const fields = envelope.Type === "SubscriptionConfirmation" || envelope.Type === "UnsubscribeConfirmation" ? ["Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type"] : ["Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"];
272
+ let canonical = "";
273
+ for (const field of fields) {
274
+ const value = envelope[field];
275
+ if (value === void 0 || value === null) continue;
276
+ canonical += `${field}
277
+ ${value}
278
+ `;
279
+ }
280
+ return canonical;
281
+ }
282
+ function assertSnsUrl(rawUrl, field) {
283
+ let url;
284
+ try {
285
+ url = new URL(rawUrl);
286
+ } catch {
287
+ throw new WebhookVerificationError(`bKash: ${field} is not a URL`, {
288
+ provider: "bkash",
289
+ code: "cert_url_invalid",
290
+ raw: rawUrl
291
+ });
292
+ }
293
+ if (url.protocol !== "https:") {
294
+ throw new WebhookVerificationError(`bKash: ${field} must be https`, {
295
+ provider: "bkash",
296
+ code: "cert_url_insecure",
297
+ raw: rawUrl
298
+ });
299
+ }
300
+ if (!SNS_CERT_HOST.test(url.hostname)) {
301
+ throw new WebhookVerificationError(
302
+ `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.`,
303
+ { provider: "bkash", code: "cert_url_untrusted", raw: rawUrl }
304
+ );
305
+ }
306
+ if (field === "SigningCertURL" && !url.pathname.endsWith(".pem")) {
307
+ throw new WebhookVerificationError(`bKash: ${field} does not point at a .pem file`, {
308
+ provider: "bkash",
309
+ code: "cert_url_invalid",
310
+ raw: rawUrl
311
+ });
312
+ }
313
+ return url;
314
+ }
315
+
316
+ // src/bkash/adapters/next.ts
317
+ function createBkashWebhookHandler(client, options = {}) {
318
+ return async function POST(request) {
319
+ const rawBody = await request.text();
320
+ let event;
321
+ try {
322
+ event = await client.verifyWebhook({ body: rawBody, headers: headersToObject(request.headers) });
323
+ } catch (error) {
324
+ await options.onVerificationFailure?.(error, rawBody);
325
+ const message = error instanceof WebhookVerificationError ? error.message : "webhook verification failed";
326
+ return Response.json({ ok: false, error: message }, { status: 400 });
327
+ }
328
+ try {
329
+ if (event.type === "subscription.confirmation") {
330
+ const envelope = event.raw;
331
+ const shouldConfirm = await options.onSubscriptionConfirmation?.(envelope) ?? false;
332
+ if (shouldConfirm) await client.webhooks.confirmSubscription(envelope);
333
+ return Response.json({ ok: true, confirmed: shouldConfirm });
334
+ }
335
+ if (event.type === "payment.completed") {
336
+ await options.onPaymentCompleted?.(event);
337
+ } else {
338
+ await options.onOtherEvent?.(event);
339
+ }
340
+ return Response.json({ ok: true });
341
+ } catch (error) {
342
+ return Response.json(
343
+ { ok: false, error: error instanceof PaykitError ? error.message : "handler failed" },
344
+ { status: 500 }
345
+ );
346
+ }
347
+ };
348
+ }
349
+ function createBkashCallbackHandler(client, options = {}) {
350
+ return async function GET(request) {
351
+ const url = new URL(request.url);
352
+ const query = client.constructor.parseCallback(url.searchParams);
353
+ const paymentId = query.paymentID;
354
+ if (!paymentId) {
355
+ const handled = await options.onFailure?.(new Error("callback has no paymentID"), void 0);
356
+ return handled ?? redirect(options.failureUrl ?? "/", url);
357
+ }
358
+ try {
359
+ const payment = query.status === "success" ? await client.executePayment(paymentId) : await client.getPayment(paymentId);
360
+ const handled = await options.onSettled?.(payment);
361
+ if (handled) return handled;
362
+ const destination = payment.status === "completed" ? options.successUrl ?? "/" : options.failureUrl ?? "/";
363
+ return redirect(destination, url);
364
+ } catch (error) {
365
+ const handled = await options.onFailure?.(error, paymentId);
366
+ return handled ?? redirect(options.failureUrl ?? "/", url);
367
+ }
368
+ };
369
+ }
370
+ function redirect(destination, base) {
371
+ return Response.redirect(new URL(destination, base).toString(), 303);
372
+ }
373
+ function headersToObject(headers) {
374
+ const out = {};
375
+ headers.forEach((value, key) => {
376
+ out[key] = value;
377
+ });
378
+ return out;
379
+ }
380
+
381
+ exports.BkashWebhookVerifier = BkashWebhookVerifier;
382
+ exports.createBkashCallbackHandler = createBkashCallbackHandler;
383
+ exports.createBkashWebhookHandler = createBkashWebhookHandler;
@@ -0,0 +1,64 @@
1
+ import { W as WebhookEvent } from '../../token-store-C8IMMLPJ.cjs';
2
+ import { B as BkashClient, S as SnsEnvelope } from '../../client-UNDtdWhu.cjs';
3
+ export { a as BkashWebhookVerifier } from '../../client-UNDtdWhu.cjs';
4
+
5
+ /**
6
+ * Next.js App Router route handlers.
7
+ *
8
+ * ```ts
9
+ * // app/api/bkash/webhook/route.ts
10
+ * import { createBkashWebhookHandler } from "paykit-bd/bkash/next";
11
+ * export const POST = createBkashWebhookHandler(bkash, {
12
+ * onPaymentCompleted: async (event) => { await fulfilOrder(event.reference!); },
13
+ * });
14
+ * ```
15
+ *
16
+ * No Next.js import is needed — these are plain `Request` → `Response`
17
+ * functions, so they also work in any fetch-based runtime.
18
+ */
19
+
20
+ interface WebhookHandlerOptions {
21
+ /** A genuine, completed payment. Do your fulfilment here. */
22
+ onPaymentCompleted?: (event: WebhookEvent) => Promise<void> | void;
23
+ /** A genuine notification that is not a completed payment. */
24
+ onOtherEvent?: (event: WebhookEvent) => Promise<void> | void;
25
+ /**
26
+ * Called for an SNS SubscriptionConfirmation. Return true to confirm the
27
+ * subscription, which turns on live notification delivery.
28
+ *
29
+ * Default is false — confirming is an outbound action that starts real
30
+ * traffic, so it is yours to trigger, usually once during onboarding.
31
+ */
32
+ onSubscriptionConfirmation?: (envelope: SnsEnvelope) => Promise<boolean> | boolean;
33
+ /** Called when verification fails. The request is already being rejected. */
34
+ onVerificationFailure?: (error: unknown, rawBody: string) => Promise<void> | void;
35
+ }
36
+ /**
37
+ * Build a POST handler for the bKash IPN endpoint.
38
+ *
39
+ * Status codes are chosen for how SNS behaves: 200 means delivered and SNS
40
+ * stops; a 5xx makes SNS retry, which is what you want when your own
41
+ * fulfilment threw. A forged message gets 400 and no retry.
42
+ */
43
+ declare function createBkashWebhookHandler(client: BkashClient, options?: WebhookHandlerOptions): (request: Request) => Promise<Response>;
44
+ interface CallbackHandlerOptions {
45
+ /**
46
+ * Called after the payment has been executed and its real outcome is known.
47
+ * Return a Response to control the redirect; returning nothing sends the
48
+ * customer to `successUrl` or `failureUrl`.
49
+ */
50
+ onSettled?: (result: Awaited<ReturnType<BkashClient["executePayment"]>>) => Promise<Response | void> | Response | void;
51
+ onFailure?: (error: unknown, paymentId: string | undefined) => Promise<Response | void> | Response | void;
52
+ successUrl?: string;
53
+ failureUrl?: string;
54
+ }
55
+ /**
56
+ * Build a GET handler for the URL bKash redirects the customer back to.
57
+ *
58
+ * It executes the payment, because the redirect itself proves nothing: the
59
+ * customer's browser followed that URL and could have edited it. `status=success`
60
+ * in the query string is a hint, never the decision.
61
+ */
62
+ declare function createBkashCallbackHandler(client: BkashClient, options?: CallbackHandlerOptions): (request: Request) => Promise<Response>;
63
+
64
+ export { type CallbackHandlerOptions, type WebhookHandlerOptions, createBkashCallbackHandler, createBkashWebhookHandler };
@@ -0,0 +1,64 @@
1
+ import { W as WebhookEvent } from '../../token-store-C8IMMLPJ.js';
2
+ import { B as BkashClient, S as SnsEnvelope } from '../../client-Y9603twG.js';
3
+ export { a as BkashWebhookVerifier } from '../../client-Y9603twG.js';
4
+
5
+ /**
6
+ * Next.js App Router route handlers.
7
+ *
8
+ * ```ts
9
+ * // app/api/bkash/webhook/route.ts
10
+ * import { createBkashWebhookHandler } from "paykit-bd/bkash/next";
11
+ * export const POST = createBkashWebhookHandler(bkash, {
12
+ * onPaymentCompleted: async (event) => { await fulfilOrder(event.reference!); },
13
+ * });
14
+ * ```
15
+ *
16
+ * No Next.js import is needed — these are plain `Request` → `Response`
17
+ * functions, so they also work in any fetch-based runtime.
18
+ */
19
+
20
+ interface WebhookHandlerOptions {
21
+ /** A genuine, completed payment. Do your fulfilment here. */
22
+ onPaymentCompleted?: (event: WebhookEvent) => Promise<void> | void;
23
+ /** A genuine notification that is not a completed payment. */
24
+ onOtherEvent?: (event: WebhookEvent) => Promise<void> | void;
25
+ /**
26
+ * Called for an SNS SubscriptionConfirmation. Return true to confirm the
27
+ * subscription, which turns on live notification delivery.
28
+ *
29
+ * Default is false — confirming is an outbound action that starts real
30
+ * traffic, so it is yours to trigger, usually once during onboarding.
31
+ */
32
+ onSubscriptionConfirmation?: (envelope: SnsEnvelope) => Promise<boolean> | boolean;
33
+ /** Called when verification fails. The request is already being rejected. */
34
+ onVerificationFailure?: (error: unknown, rawBody: string) => Promise<void> | void;
35
+ }
36
+ /**
37
+ * Build a POST handler for the bKash IPN endpoint.
38
+ *
39
+ * Status codes are chosen for how SNS behaves: 200 means delivered and SNS
40
+ * stops; a 5xx makes SNS retry, which is what you want when your own
41
+ * fulfilment threw. A forged message gets 400 and no retry.
42
+ */
43
+ declare function createBkashWebhookHandler(client: BkashClient, options?: WebhookHandlerOptions): (request: Request) => Promise<Response>;
44
+ interface CallbackHandlerOptions {
45
+ /**
46
+ * Called after the payment has been executed and its real outcome is known.
47
+ * Return a Response to control the redirect; returning nothing sends the
48
+ * customer to `successUrl` or `failureUrl`.
49
+ */
50
+ onSettled?: (result: Awaited<ReturnType<BkashClient["executePayment"]>>) => Promise<Response | void> | Response | void;
51
+ onFailure?: (error: unknown, paymentId: string | undefined) => Promise<Response | void> | Response | void;
52
+ successUrl?: string;
53
+ failureUrl?: string;
54
+ }
55
+ /**
56
+ * Build a GET handler for the URL bKash redirects the customer back to.
57
+ *
58
+ * It executes the payment, because the redirect itself proves nothing: the
59
+ * customer's browser followed that URL and could have edited it. `status=success`
60
+ * in the query string is a hint, never the decision.
61
+ */
62
+ declare function createBkashCallbackHandler(client: BkashClient, options?: CallbackHandlerOptions): (request: Request) => Promise<Response>;
63
+
64
+ export { type CallbackHandlerOptions, type WebhookHandlerOptions, createBkashCallbackHandler, createBkashWebhookHandler };