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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 joarder97
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# paykit-bd
|
|
2
|
+
|
|
3
|
+
Typed, zero-dependency payment clients for Bangladeshi gateways. bKash tokenized
|
|
4
|
+
checkout today; the provider seam is there so Nagad and SSLCommerz slot in
|
|
5
|
+
without rewriting order handling.
|
|
6
|
+
|
|
7
|
+
Checked against the live bKash sandbox, not only against the docs — which turned
|
|
8
|
+
out to be wrong or silent in seven places, listed in
|
|
9
|
+
[docs/bkash.md](docs/bkash.md).
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install paykit-bd
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Node 20+. No runtime dependencies — `fetch` and `node:crypto` are enough.
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { BkashClient, configFromEnv } from "paykit-bd/bkash";
|
|
21
|
+
|
|
22
|
+
const bkash = new BkashClient(configFromEnv());
|
|
23
|
+
|
|
24
|
+
// 1. Start the payment and send the customer to bKash.
|
|
25
|
+
const payment = await bkash.createPayment({ amount: "500", reference: "ORD-1042" });
|
|
26
|
+
redirect(payment.redirectUrl!);
|
|
27
|
+
|
|
28
|
+
// 2. When they come back, ask bKash what actually happened.
|
|
29
|
+
const settled = await bkash.executePayment(payment.paymentId);
|
|
30
|
+
if (settled.status === "completed") {
|
|
31
|
+
await fulfilOrder("ORD-1042", settled.transactionId!);
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The redirect back from bKash carries `status=success`, and it is worth nothing on
|
|
36
|
+
its own — the customer's browser followed that URL and could have edited it.
|
|
37
|
+
`executePayment` is what decides.
|
|
38
|
+
|
|
39
|
+
## Webhooks (IPN)
|
|
40
|
+
|
|
41
|
+
bKash delivers payment notifications through Amazon SNS, so what has to be
|
|
42
|
+
verified is an **SNS message signature** — not an HMAC of the body, which is what
|
|
43
|
+
most bKash integrations assume, and then skip.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// app/api/bkash/webhook/route.ts
|
|
47
|
+
import { createBkashWebhookHandler } from "paykit-bd/bkash/next";
|
|
48
|
+
|
|
49
|
+
export const POST = createBkashWebhookHandler(bkash, {
|
|
50
|
+
onPaymentCompleted: async (event) => {
|
|
51
|
+
await markPaid(event.reference!, event.transactionId!, event.amount!);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Express needs the raw body, and bKash posts as `text/plain`, so `express.json()`
|
|
57
|
+
sees nothing:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { bkashWebhookMiddleware, rawBodyParser } from "paykit-bd/bkash/express";
|
|
61
|
+
|
|
62
|
+
app.post("/api/bkash/webhook", rawBodyParser(), bkashWebhookMiddleware(bkash, {
|
|
63
|
+
onPaymentCompleted: fulfilOrder,
|
|
64
|
+
}));
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Set `BKASH_WEBHOOK_TOPIC_ARN` to your own SNS topic. Without it, any
|
|
68
|
+
Amazon-signed topic passes — including someone else's merchant account.
|
|
69
|
+
|
|
70
|
+
## What this handles that a hand-rolled client usually does not
|
|
71
|
+
|
|
72
|
+
**The refresh-token trap.** bKash blocks your merchant account for a full hour if
|
|
73
|
+
the Refresh Token API is called more than twice in an hour. The budget belongs to
|
|
74
|
+
the account, not to your process, so this counts refreshes in a rolling window,
|
|
75
|
+
falls back to a fresh Grant when the budget is spent, de-duplicates concurrent
|
|
76
|
+
callers into one acquisition, and opens a local circuit breaker at ten
|
|
77
|
+
acquisitions per hour rather than letting a retry loop get you blocked. Put the
|
|
78
|
+
token in a shared store and it stays correct across instances:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
new BkashClient(config, { tokenStore: createKvTokenStore(redis) })
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Forged webhooks.** The SNS signature is verified against a certificate fetched
|
|
85
|
+
from a URL *inside the message*. A verifier that does not pin that URL to an
|
|
86
|
+
Amazon host will fetch an attacker's certificate and confirm the attacker's own
|
|
87
|
+
signature over a forged "payment completed" — free orders with a clean audit
|
|
88
|
+
trail. `SigningCertURL` is checked against `sns.<region>.amazonaws.com` before
|
|
89
|
+
anything is fetched.
|
|
90
|
+
|
|
91
|
+
**`sku` and `reason` are mandatory on refunds.** The docs read as though they were
|
|
92
|
+
optional. Omit either and the v2 refund API answers
|
|
93
|
+
`{"message": "Invalid request body"}` — no code, no field name. Defaults are
|
|
94
|
+
always sent.
|
|
95
|
+
|
|
96
|
+
**Timestamps that `Date` cannot parse.** bKash sends
|
|
97
|
+
`2026-09-18T06:00:19:952 GMT+0600` — a colon before the milliseconds. `new Date()`
|
|
98
|
+
returns `Invalid Date`. The refund API drops the offset entirely and means
|
|
99
|
+
Bangladesh time.
|
|
100
|
+
|
|
101
|
+
**Four different error envelopes**, depending on endpoint and version:
|
|
102
|
+
`{statusCode}` with HTTP 200 (a 200 is not success), `{errorCode}`,
|
|
103
|
+
`{internalCode, externalCode, errorMessageEn}` on the v2 refund API, and
|
|
104
|
+
`{message}` from the API Gateway in front of bKash. All four normalise to one
|
|
105
|
+
`BkashError` with the code, whether the customer caused it, and whether the
|
|
106
|
+
payment was already settled.
|
|
107
|
+
|
|
108
|
+
**Money as integers.** Amounts are handled in poisha, never floats, so partial
|
|
109
|
+
refunds still sum to the original.
|
|
110
|
+
|
|
111
|
+
**Field names that change between endpoints.** Create and execute return
|
|
112
|
+
`merchantInvoiceNumber`; query returns `merchantInvoice`. Every endpoint takes
|
|
113
|
+
`paymentID` except the v2 refund API, which takes `paymentId`.
|
|
114
|
+
|
|
115
|
+
## Try it without credentials
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pnpm smoke
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Runs against the real bKash sandbox using bKash's published demo credentials. It
|
|
122
|
+
grants a token, refreshes it and checks the budget moved, creates an agreement
|
|
123
|
+
and a payment, queries the payment, confirms a premature execute is refused,
|
|
124
|
+
exercises the error envelopes, and prints a URL you can open to finish the
|
|
125
|
+
payment by hand. It also runs in CI on every push.
|
|
126
|
+
|
|
127
|
+
## API
|
|
128
|
+
|
|
129
|
+
| | |
|
|
130
|
+
| --- | --- |
|
|
131
|
+
| `createPayment(input)` | Mode 0011, or 0001 with `extra.agreementID`. |
|
|
132
|
+
| `executePayment(paymentId)` | Finalise. Re-queries instead of throwing if bKash says it already ran. |
|
|
133
|
+
| `getPayment(paymentId)` | Current state. Safe to repeat — this is the recovery path. |
|
|
134
|
+
| `refund(input)` | Full or partial. Reads `maxRefundableAmount` when no amount is given. |
|
|
135
|
+
| `getRefunds({paymentId, transactionId})` | Every refund taken against a transaction. |
|
|
136
|
+
| `verifyWebhook(request)` | Verify an IPN message and normalise it. |
|
|
137
|
+
| `createAgreement` / `executeAgreement` | Two-step setup for PIN-only repeat payments. |
|
|
138
|
+
| `getAgreement` / `cancelAgreement` | Undocumented by bKash, live in sandbox. |
|
|
139
|
+
| `tokenBudget()` | Refreshes used this hour. Worth putting on a health endpoint. |
|
|
140
|
+
|
|
141
|
+
Details and the full endpoint map: [docs/bkash.md](docs/bkash.md).
|
|
142
|
+
Adding a gateway: [docs/adding-a-provider.md](docs/adding-a-provider.md).
|
|
143
|
+
|
|
144
|
+
## Status
|
|
145
|
+
|
|
146
|
+
bKash tokenized checkout is complete. Nagad and SSLCommerz are not written yet —
|
|
147
|
+
the `PaymentProvider` interface is the seam they plug into.
|
|
148
|
+
|
|
149
|
+
**Verified against the live sandbox:** grant token, refresh token, create
|
|
150
|
+
agreement (0000), create payment (0011, both `sale` and `authorization`), execute,
|
|
151
|
+
query payment, agreement status and cancel, refund and refund status on v2, and
|
|
152
|
+
all four error envelopes.
|
|
153
|
+
|
|
154
|
+
**Not verified end to end, because it needs a human with a test wallet:** a
|
|
155
|
+
completed payment, and therefore executing an agreement (0001), and a refund of
|
|
156
|
+
real money. Those paths are covered by unit tests against recorded response
|
|
157
|
+
shapes, which is weaker evidence — `pnpm smoke` prints a URL if you want to
|
|
158
|
+
finish a payment by hand and check.
|
|
159
|
+
|
|
160
|
+
**Not verified at all:** a real inbound IPN message, which needs bKash support to
|
|
161
|
+
register a listener URL against a live merchant account. The SNS verification is
|
|
162
|
+
tested against signatures generated with a real RSA key the same way Amazon
|
|
163
|
+
generates them, but no message from bKash itself has passed through it. If you
|
|
164
|
+
wire one up, an issue saying whether it verified would be useful.
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/core/errors.ts
|
|
4
|
+
var BRANDS = /* @__PURE__ */ Symbol.for("paykit-bd.error.brands");
|
|
5
|
+
function brandedInstanceOf(tag) {
|
|
6
|
+
return (value) => {
|
|
7
|
+
if (typeof value !== "object" || value === null) return false;
|
|
8
|
+
const brands = value[BRANDS];
|
|
9
|
+
return Array.isArray(brands) && brands.includes(tag);
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
var PaykitError = class extends Error {
|
|
13
|
+
static [Symbol.hasInstance] = brandedInstanceOf("PaykitError");
|
|
14
|
+
/** Class lineage, innermost last. Read `instanceof` instead of this. */
|
|
15
|
+
[BRANDS];
|
|
16
|
+
/** Gateway this came from — `"bkash"`, or `"paykit"` for local failures. */
|
|
17
|
+
provider;
|
|
18
|
+
/** Stable machine-readable code. Gateway codes are passed through verbatim. */
|
|
19
|
+
code;
|
|
20
|
+
/** True when retrying the identical request could plausibly succeed. */
|
|
21
|
+
retryable;
|
|
22
|
+
/** Untouched gateway response body, for logging. */
|
|
23
|
+
raw;
|
|
24
|
+
constructor(message, opts, brands = []) {
|
|
25
|
+
super(message, { cause: opts.cause });
|
|
26
|
+
this.name = new.target.name;
|
|
27
|
+
this[BRANDS] = ["PaykitError", ...brands];
|
|
28
|
+
this.provider = opts.provider;
|
|
29
|
+
this.code = opts.code;
|
|
30
|
+
this.retryable = opts.retryable ?? false;
|
|
31
|
+
this.raw = opts.raw;
|
|
32
|
+
}
|
|
33
|
+
toJSON() {
|
|
34
|
+
return {
|
|
35
|
+
name: this.name,
|
|
36
|
+
provider: this.provider,
|
|
37
|
+
code: this.code,
|
|
38
|
+
message: this.message,
|
|
39
|
+
retryable: this.retryable
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
var WebhookVerificationError = class extends PaykitError {
|
|
44
|
+
static [Symbol.hasInstance] = brandedInstanceOf("WebhookVerificationError");
|
|
45
|
+
constructor(message, opts) {
|
|
46
|
+
super(
|
|
47
|
+
message,
|
|
48
|
+
{
|
|
49
|
+
provider: opts.provider,
|
|
50
|
+
code: opts.code ?? "webhook_verification_failed",
|
|
51
|
+
retryable: false,
|
|
52
|
+
raw: opts.raw,
|
|
53
|
+
cause: opts.cause
|
|
54
|
+
},
|
|
55
|
+
["WebhookVerificationError"]
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// src/bkash/adapters/express.ts
|
|
61
|
+
function rawBodyParser(limitBytes = 1e6) {
|
|
62
|
+
return function parse(req, res, next) {
|
|
63
|
+
if (typeof req.body === "string") {
|
|
64
|
+
next();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
let data = "";
|
|
68
|
+
let size = 0;
|
|
69
|
+
req.setEncoding?.("utf8");
|
|
70
|
+
req.on("data", (chunk) => {
|
|
71
|
+
const text = String(chunk);
|
|
72
|
+
size += Buffer.byteLength(text, "utf8");
|
|
73
|
+
if (size > limitBytes) {
|
|
74
|
+
next(new Error(`paykit/bkash: webhook body exceeded ${limitBytes} bytes`));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
data += text;
|
|
78
|
+
});
|
|
79
|
+
req.on("end", () => {
|
|
80
|
+
req.body = data;
|
|
81
|
+
next();
|
|
82
|
+
});
|
|
83
|
+
req.on("error", (error) => next(error));
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function bkashWebhookMiddleware(client, options = {}) {
|
|
87
|
+
return async function handle(req, res) {
|
|
88
|
+
const rawBody = typeof req.body === "string" ? req.body : Buffer.isBuffer(req.body) ? req.body.toString("utf8") : "";
|
|
89
|
+
if (!rawBody) {
|
|
90
|
+
res.status(400).json({
|
|
91
|
+
ok: false,
|
|
92
|
+
error: "paykit/bkash: empty raw body. bKash posts its IPN as text/plain, so mount rawBodyParser() (or express.text({ type: '*/*' })) on this route before this middleware."
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let event;
|
|
97
|
+
try {
|
|
98
|
+
event = await client.verifyWebhook({ body: rawBody, headers: req.headers });
|
|
99
|
+
} catch (error) {
|
|
100
|
+
await options.onVerificationFailure?.(error, rawBody);
|
|
101
|
+
res.status(400).json({
|
|
102
|
+
ok: false,
|
|
103
|
+
error: error instanceof WebhookVerificationError ? error.message : "webhook verification failed"
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
if (event.type === "subscription.confirmation") {
|
|
109
|
+
const envelope = event.raw;
|
|
110
|
+
const shouldConfirm = await options.onSubscriptionConfirmation?.(envelope) ?? false;
|
|
111
|
+
if (shouldConfirm) await client.webhooks.confirmSubscription(envelope);
|
|
112
|
+
res.status(200).json({ ok: true, confirmed: shouldConfirm });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (event.type === "payment.completed") {
|
|
116
|
+
await options.onPaymentCompleted?.(event);
|
|
117
|
+
} else {
|
|
118
|
+
await options.onOtherEvent?.(event);
|
|
119
|
+
}
|
|
120
|
+
res.status(200).json({ ok: true });
|
|
121
|
+
} catch (error) {
|
|
122
|
+
res.status(500).json({ ok: false, error: error instanceof Error ? error.message : "handler failed" });
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
exports.bkashWebhookMiddleware = bkashWebhookMiddleware;
|
|
128
|
+
exports.rawBodyParser = rawBodyParser;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { W as WebhookEvent } from '../../token-store-C8IMMLPJ.cjs';
|
|
2
|
+
import { S as SnsEnvelope, B as BkashClient } from '../../client-UNDtdWhu.cjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Express middleware.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import express from "express";
|
|
9
|
+
* import { bkashWebhookMiddleware, rawBodyParser } from "paykit-bd/bkash/express";
|
|
10
|
+
*
|
|
11
|
+
* app.post("/api/bkash/webhook",
|
|
12
|
+
* rawBodyParser(),
|
|
13
|
+
* bkashWebhookMiddleware(bkash, { onPaymentCompleted: fulfilOrder }));
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* **The body parser matters.** bKash sends its IPN with
|
|
17
|
+
* `Content-Type: text/plain; charset=UTF-8`, so `express.json()` ignores it and
|
|
18
|
+
* `req.body` arrives empty. Even with the right content type, a parsed and
|
|
19
|
+
* re-serialised body no longer matches the signature. Use {@link rawBodyParser}
|
|
20
|
+
* or `express.text({ type: "*/*" })` on this route only.
|
|
21
|
+
*
|
|
22
|
+
* Typed structurally so Express is not a dependency of this package.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
interface ExpressRequestLike {
|
|
26
|
+
body?: unknown;
|
|
27
|
+
headers: Record<string, string | string[] | undefined>;
|
|
28
|
+
setEncoding?(encoding: string): void;
|
|
29
|
+
on(event: string, listener: (chunk?: unknown) => void): unknown;
|
|
30
|
+
}
|
|
31
|
+
interface ExpressResponseLike {
|
|
32
|
+
status(code: number): ExpressResponseLike;
|
|
33
|
+
json(body: unknown): unknown;
|
|
34
|
+
}
|
|
35
|
+
type NextFn = (error?: unknown) => void;
|
|
36
|
+
interface ExpressWebhookOptions {
|
|
37
|
+
onPaymentCompleted?: (event: WebhookEvent) => Promise<void> | void;
|
|
38
|
+
onOtherEvent?: (event: WebhookEvent) => Promise<void> | void;
|
|
39
|
+
/** Return true to confirm the SNS subscription. Default false — see the Next adapter. */
|
|
40
|
+
onSubscriptionConfirmation?: (envelope: SnsEnvelope) => Promise<boolean> | boolean;
|
|
41
|
+
onVerificationFailure?: (error: unknown, rawBody: string) => Promise<void> | void;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Collect the raw request body into `req.body` as a string, whatever the
|
|
45
|
+
* content type. Mount it on the webhook route only.
|
|
46
|
+
*/
|
|
47
|
+
declare function rawBodyParser(limitBytes?: number): (req: ExpressRequestLike, res: ExpressResponseLike, next: NextFn) => void;
|
|
48
|
+
declare function bkashWebhookMiddleware(client: BkashClient, options?: ExpressWebhookOptions): (req: ExpressRequestLike, res: ExpressResponseLike) => Promise<void>;
|
|
49
|
+
|
|
50
|
+
export { type ExpressWebhookOptions, bkashWebhookMiddleware, rawBodyParser };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { W as WebhookEvent } from '../../token-store-C8IMMLPJ.js';
|
|
2
|
+
import { S as SnsEnvelope, B as BkashClient } from '../../client-Y9603twG.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Express middleware.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import express from "express";
|
|
9
|
+
* import { bkashWebhookMiddleware, rawBodyParser } from "paykit-bd/bkash/express";
|
|
10
|
+
*
|
|
11
|
+
* app.post("/api/bkash/webhook",
|
|
12
|
+
* rawBodyParser(),
|
|
13
|
+
* bkashWebhookMiddleware(bkash, { onPaymentCompleted: fulfilOrder }));
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* **The body parser matters.** bKash sends its IPN with
|
|
17
|
+
* `Content-Type: text/plain; charset=UTF-8`, so `express.json()` ignores it and
|
|
18
|
+
* `req.body` arrives empty. Even with the right content type, a parsed and
|
|
19
|
+
* re-serialised body no longer matches the signature. Use {@link rawBodyParser}
|
|
20
|
+
* or `express.text({ type: "*/*" })` on this route only.
|
|
21
|
+
*
|
|
22
|
+
* Typed structurally so Express is not a dependency of this package.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
interface ExpressRequestLike {
|
|
26
|
+
body?: unknown;
|
|
27
|
+
headers: Record<string, string | string[] | undefined>;
|
|
28
|
+
setEncoding?(encoding: string): void;
|
|
29
|
+
on(event: string, listener: (chunk?: unknown) => void): unknown;
|
|
30
|
+
}
|
|
31
|
+
interface ExpressResponseLike {
|
|
32
|
+
status(code: number): ExpressResponseLike;
|
|
33
|
+
json(body: unknown): unknown;
|
|
34
|
+
}
|
|
35
|
+
type NextFn = (error?: unknown) => void;
|
|
36
|
+
interface ExpressWebhookOptions {
|
|
37
|
+
onPaymentCompleted?: (event: WebhookEvent) => Promise<void> | void;
|
|
38
|
+
onOtherEvent?: (event: WebhookEvent) => Promise<void> | void;
|
|
39
|
+
/** Return true to confirm the SNS subscription. Default false — see the Next adapter. */
|
|
40
|
+
onSubscriptionConfirmation?: (envelope: SnsEnvelope) => Promise<boolean> | boolean;
|
|
41
|
+
onVerificationFailure?: (error: unknown, rawBody: string) => Promise<void> | void;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Collect the raw request body into `req.body` as a string, whatever the
|
|
45
|
+
* content type. Mount it on the webhook route only.
|
|
46
|
+
*/
|
|
47
|
+
declare function rawBodyParser(limitBytes?: number): (req: ExpressRequestLike, res: ExpressResponseLike, next: NextFn) => void;
|
|
48
|
+
declare function bkashWebhookMiddleware(client: BkashClient, options?: ExpressWebhookOptions): (req: ExpressRequestLike, res: ExpressResponseLike) => Promise<void>;
|
|
49
|
+
|
|
50
|
+
export { type ExpressWebhookOptions, bkashWebhookMiddleware, rawBodyParser };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// src/core/errors.ts
|
|
2
|
+
var BRANDS = /* @__PURE__ */ Symbol.for("paykit-bd.error.brands");
|
|
3
|
+
function brandedInstanceOf(tag) {
|
|
4
|
+
return (value) => {
|
|
5
|
+
if (typeof value !== "object" || value === null) return false;
|
|
6
|
+
const brands = value[BRANDS];
|
|
7
|
+
return Array.isArray(brands) && brands.includes(tag);
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
var PaykitError = class extends Error {
|
|
11
|
+
static [Symbol.hasInstance] = brandedInstanceOf("PaykitError");
|
|
12
|
+
/** Class lineage, innermost last. Read `instanceof` instead of this. */
|
|
13
|
+
[BRANDS];
|
|
14
|
+
/** Gateway this came from — `"bkash"`, or `"paykit"` for local failures. */
|
|
15
|
+
provider;
|
|
16
|
+
/** Stable machine-readable code. Gateway codes are passed through verbatim. */
|
|
17
|
+
code;
|
|
18
|
+
/** True when retrying the identical request could plausibly succeed. */
|
|
19
|
+
retryable;
|
|
20
|
+
/** Untouched gateway response body, for logging. */
|
|
21
|
+
raw;
|
|
22
|
+
constructor(message, opts, brands = []) {
|
|
23
|
+
super(message, { cause: opts.cause });
|
|
24
|
+
this.name = new.target.name;
|
|
25
|
+
this[BRANDS] = ["PaykitError", ...brands];
|
|
26
|
+
this.provider = opts.provider;
|
|
27
|
+
this.code = opts.code;
|
|
28
|
+
this.retryable = opts.retryable ?? false;
|
|
29
|
+
this.raw = opts.raw;
|
|
30
|
+
}
|
|
31
|
+
toJSON() {
|
|
32
|
+
return {
|
|
33
|
+
name: this.name,
|
|
34
|
+
provider: this.provider,
|
|
35
|
+
code: this.code,
|
|
36
|
+
message: this.message,
|
|
37
|
+
retryable: this.retryable
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var WebhookVerificationError = class extends PaykitError {
|
|
42
|
+
static [Symbol.hasInstance] = brandedInstanceOf("WebhookVerificationError");
|
|
43
|
+
constructor(message, opts) {
|
|
44
|
+
super(
|
|
45
|
+
message,
|
|
46
|
+
{
|
|
47
|
+
provider: opts.provider,
|
|
48
|
+
code: opts.code ?? "webhook_verification_failed",
|
|
49
|
+
retryable: false,
|
|
50
|
+
raw: opts.raw,
|
|
51
|
+
cause: opts.cause
|
|
52
|
+
},
|
|
53
|
+
["WebhookVerificationError"]
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/bkash/adapters/express.ts
|
|
59
|
+
function rawBodyParser(limitBytes = 1e6) {
|
|
60
|
+
return function parse(req, res, next) {
|
|
61
|
+
if (typeof req.body === "string") {
|
|
62
|
+
next();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
let data = "";
|
|
66
|
+
let size = 0;
|
|
67
|
+
req.setEncoding?.("utf8");
|
|
68
|
+
req.on("data", (chunk) => {
|
|
69
|
+
const text = String(chunk);
|
|
70
|
+
size += Buffer.byteLength(text, "utf8");
|
|
71
|
+
if (size > limitBytes) {
|
|
72
|
+
next(new Error(`paykit/bkash: webhook body exceeded ${limitBytes} bytes`));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
data += text;
|
|
76
|
+
});
|
|
77
|
+
req.on("end", () => {
|
|
78
|
+
req.body = data;
|
|
79
|
+
next();
|
|
80
|
+
});
|
|
81
|
+
req.on("error", (error) => next(error));
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function bkashWebhookMiddleware(client, options = {}) {
|
|
85
|
+
return async function handle(req, res) {
|
|
86
|
+
const rawBody = typeof req.body === "string" ? req.body : Buffer.isBuffer(req.body) ? req.body.toString("utf8") : "";
|
|
87
|
+
if (!rawBody) {
|
|
88
|
+
res.status(400).json({
|
|
89
|
+
ok: false,
|
|
90
|
+
error: "paykit/bkash: empty raw body. bKash posts its IPN as text/plain, so mount rawBodyParser() (or express.text({ type: '*/*' })) on this route before this middleware."
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
let event;
|
|
95
|
+
try {
|
|
96
|
+
event = await client.verifyWebhook({ body: rawBody, headers: req.headers });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
await options.onVerificationFailure?.(error, rawBody);
|
|
99
|
+
res.status(400).json({
|
|
100
|
+
ok: false,
|
|
101
|
+
error: error instanceof WebhookVerificationError ? error.message : "webhook verification failed"
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
if (event.type === "subscription.confirmation") {
|
|
107
|
+
const envelope = event.raw;
|
|
108
|
+
const shouldConfirm = await options.onSubscriptionConfirmation?.(envelope) ?? false;
|
|
109
|
+
if (shouldConfirm) await client.webhooks.confirmSubscription(envelope);
|
|
110
|
+
res.status(200).json({ ok: true, confirmed: shouldConfirm });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (event.type === "payment.completed") {
|
|
114
|
+
await options.onPaymentCompleted?.(event);
|
|
115
|
+
} else {
|
|
116
|
+
await options.onOtherEvent?.(event);
|
|
117
|
+
}
|
|
118
|
+
res.status(200).json({ ok: true });
|
|
119
|
+
} catch (error) {
|
|
120
|
+
res.status(500).json({ ok: false, error: error instanceof Error ? error.message : "handler failed" });
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export { bkashWebhookMiddleware, rawBodyParser };
|