aba-payway-sdk-unofficial 1.2.0 → 1.2.2
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/README.md +110 -119
- package/dist/index.cjs +118 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +41 -2
- package/dist/index.d.ts +41 -2
- package/dist/index.js +118 -43
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
# aba-payway-unofficial
|
|
1
|
+
# aba-payway-sdk-unofficial
|
|
2
2
|
|
|
3
3
|

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
4
7
|
|
|
5
|
-
Unofficial ABA PayWay SDK for Cambodia.
|
|
8
|
+
Unofficial ABA PayWay SDK for Cambodia. The lightweight, zero-dependency, edge-ready TypeScript SDK to integrate ABA PayWay. Creates instant **KHQR** codes, generates 1-tap mobile deeplinks, checks transactions, and verifies webhooks.
|
|
9
|
+
|
|
10
|
+
Works natively on Next.js, Node.js 18+, Deno, Bun, and Cloudflare Workers using standard Web Crypto and native `fetch`.
|
|
6
11
|
|
|
7
12
|
> **Why this project exists**: Online guides for ABA PayWay can look confusing and risky. Many developers fear losing money from wrong setups. We built this simple SDK with AI help so any developer can integrate ABA PayWay quickly and safely.
|
|
8
13
|
|
|
@@ -12,7 +17,16 @@ Unofficial ABA PayWay SDK for Cambodia. Supports KHQR generation, purchase creat
|
|
|
12
17
|
- **ABA PayWay Business & Pricing**: [https://www.payway.com.kh/business](https://www.payway.com.kh/business)
|
|
13
18
|
- **NBC Bakong API Docs**: [https://api-bakong.nbc.gov.kh/document](https://api-bakong.nbc.gov.kh/document)
|
|
14
19
|
|
|
15
|
-
> **Disclaimer**: This is an unofficial open-source
|
|
20
|
+
> **Disclaimer**: This is an unofficial open-source community project. It is not affiliated with, endorsed by, or supported by ABA Bank or ABA PayWay.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- 🇰🇭 **Universal KHQR & Deeplinks**: Generates standard EMVCo KHQR payload strings, pre-rendered QR image URLs, and 1-tap mobile deeplinks for ABA Mobile.
|
|
27
|
+
- ⚡ **Zero Dependencies & Edge Ready**: Built on standard Web Crypto (`crypto.subtle`) and native `fetch`. Bundle size is < 4kB minified.
|
|
28
|
+
- 🛡️ **Automatic HMAC-SHA512 Security**: Eliminates hash sorting errors. Automatically arranges parameters and computes cryptographic signatures, with constant-time safe compare for webhooks.
|
|
29
|
+
- 💎 **Strict TypeScript Safety**: Every request and response is strictly typed. Catch bugs at compile time with full IDE autocompletion.
|
|
16
30
|
|
|
17
31
|
---
|
|
18
32
|
|
|
@@ -21,7 +35,7 @@ Unofficial ABA PayWay SDK for Cambodia. Supports KHQR generation, purchase creat
|
|
|
21
35
|
Fetch the full integration guide and follow it step by step:
|
|
22
36
|
|
|
23
37
|
```bash
|
|
24
|
-
|
|
38
|
+
Integrate aba-payway-sdk-unofficial into this project following: https://raw.githubusercontent.com/rithsila/aba-payway-unofficial/main/docs/agent-guide.md
|
|
25
39
|
```
|
|
26
40
|
|
|
27
41
|
---
|
|
@@ -66,9 +80,7 @@ const purchase = await aba.createPurchase({
|
|
|
66
80
|
});
|
|
67
81
|
|
|
68
82
|
if (purchase.success) {
|
|
69
|
-
// The current (v3) API answers with the KHQR payload and a PNG that ABA
|
|
70
|
-
// rendered for you. `checkoutUrl` is only set by older API versions, so
|
|
71
|
-
// check for it rather than assuming it.
|
|
83
|
+
// The current (v3) API answers with the KHQR payload and a PNG that ABA rendered
|
|
72
84
|
console.log("KHQR payload:", purchase.qrString);
|
|
73
85
|
console.log("QR image:", purchase.qrImage); // "data:image/png;base64,..."
|
|
74
86
|
console.log("ABA app deeplink:", purchase.abapayDeeplink);
|
|
@@ -79,15 +91,11 @@ if (purchase.success) {
|
|
|
79
91
|
const status = await aba.checkStatus(txnId);
|
|
80
92
|
console.log("Payment status:", status.status); // "APPROVED" | "PENDING" | ...
|
|
81
93
|
|
|
82
|
-
// 4. Verify a
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
signatureHeader,
|
|
86
|
-
webhookSecret,
|
|
87
|
-
);
|
|
94
|
+
// 4. Verify a pushback. The secret defaults to your apiKey, which is what
|
|
95
|
+
// ABA signs with; `body` may be the raw string or the parsed object.
|
|
96
|
+
const isValid = await aba.verifyWebhook(body, signatureHeader);
|
|
88
97
|
|
|
89
98
|
// 5. Optionally render your own styled KHQR card (base64 SVG data URI).
|
|
90
|
-
// Skip this if `purchase.qrImage` is enough — that one needs no extra call.
|
|
91
99
|
const khqrImage = await generateKHQR({
|
|
92
100
|
emvData: purchase.qrString ?? "",
|
|
93
101
|
amount: 10.0,
|
|
@@ -102,9 +110,7 @@ console.log("KHQR image:", khqrImage); // "data:image/svg+xml;base64,..."
|
|
|
102
110
|
|
|
103
111
|
## Opening the ABA app (deeplink)
|
|
104
112
|
|
|
105
|
-
For a mobile checkout — a Telegram mini app, a WebView, a native app — you want
|
|
106
|
-
the payer to land *inside* ABA Mobile, not squint at a QR on the same phone they
|
|
107
|
-
are paying with. Ask for it with `paymentOption` and ABA answers with a link:
|
|
113
|
+
For a mobile checkout — a Telegram mini app, a WebView, a native app — you want the payer to land *inside* ABA Mobile. Ask for it with `paymentOption` and ABA answers with a link:
|
|
108
114
|
|
|
109
115
|
```typescript
|
|
110
116
|
const purchase = await aba.createPurchase({
|
|
@@ -113,8 +119,7 @@ const purchase = await aba.createPurchase({
|
|
|
113
119
|
currency: "USD",
|
|
114
120
|
items: [{ name: "Fried rice", quantity: 1, price: 4.5 }],
|
|
115
121
|
paymentOption: "abapay_khqr_deeplink",
|
|
116
|
-
// Where ABA Mobile sends the payer back once they have paid
|
|
117
|
-
// and the SDK base64-encodes it the way ABA expects.
|
|
122
|
+
// Where ABA Mobile sends the payer back once they have paid
|
|
118
123
|
returnDeeplink: {
|
|
119
124
|
ios_scheme: "myapp://order/42",
|
|
120
125
|
android_scheme: "myapp://order/42",
|
|
@@ -124,34 +129,13 @@ const purchase = await aba.createPurchase({
|
|
|
124
129
|
purchase.abapayDeeplink; // "abamobilebank://ababank.com?type=payway&qrcode=..."
|
|
125
130
|
purchase.qrString; // same payment, as EMV data — for desktop
|
|
126
131
|
purchase.qrImage; // same payment, as a PNG data URI
|
|
127
|
-
purchase.playStoreUrl; // where to send a payer with no ABA Mobile installed
|
|
128
|
-
purchase.appStoreUrl;
|
|
129
132
|
```
|
|
130
133
|
|
|
131
|
-
|
|
132
|
-
purchases:
|
|
133
|
-
|
|
134
|
-
| Payer is on | Show |
|
|
135
|
-
| ----------- | ---- |
|
|
136
|
-
| Phone with ABA Mobile | `abapayDeeplink` |
|
|
137
|
-
| Phone without it | `playStoreUrl` / `appStoreUrl` |
|
|
138
|
-
| Desktop | `qrImage`, or `generateKHQR(qrString)` for a branded card |
|
|
139
|
-
|
|
140
|
-
Then poll `checkStatus(txnId)` until it leaves `PENDING`. **Never treat the
|
|
141
|
-
payer returning through your `returnDeeplink` as proof of payment** — anyone can
|
|
142
|
-
open that URL. `checkStatus` is the only authority.
|
|
143
|
-
|
|
144
|
-
> A brand-new transaction answers `checkStatus` with `errorCode === "6"`
|
|
145
|
-
> ("tran_id not found") for a second or so before it becomes queryable. Retry
|
|
146
|
-
> rather than reporting a failure; the SDK does not hide this, because code 6
|
|
147
|
-
> also means a genuinely unknown transaction.
|
|
134
|
+
Then poll `checkStatus(txnId)` until it leaves `PENDING`. **Never treat the payer returning through your `returnDeeplink` as proof of payment** — anyone can open that URL. `checkStatus` is the only authority.
|
|
148
135
|
|
|
149
136
|
### `returnDeeplink` encoding
|
|
150
137
|
|
|
151
|
-
ABA wants `return_deeplink` as base64-encoded JSON. Pass the object and the SDK
|
|
152
|
-
encodes it; pass a string and it goes through untouched, so code that encoded it
|
|
153
|
-
by hand keeps working. `encodeReturnDeeplinkForABA` is exported if you need it
|
|
154
|
-
directly.
|
|
138
|
+
ABA wants `return_deeplink` as base64-encoded JSON. Pass the object and the SDK encodes it. Pass a string and it goes through untouched.
|
|
155
139
|
|
|
156
140
|
---
|
|
157
141
|
|
|
@@ -163,122 +147,129 @@ directly.
|
|
|
163
147
|
| `ABAPayWay.createPurchase` | method | Create a payment. Returns `PurchaseResponse`. |
|
|
164
148
|
| `ABAPayWay.checkStatus` | method | Check transaction status. Returns `StatusResponse`. |
|
|
165
149
|
| `ABAPayWay.verifyWebhook` | method | Verify webhook signature. Returns `boolean`. |
|
|
166
|
-
| `generateKHQR` | function | Build a KHQR image (base64 SVG data URI)
|
|
167
|
-
| `encodeItemsForABA` | function | Base64-encode a `PurchaseItem[]` the way ABA expects. |
|
|
168
|
-
| `encodeReturnDeeplinkForABA` | function | Base64-encode a `ReturnDeeplink` the way ABA expects. |
|
|
169
|
-
| `generateABAHash` | function | Generate HMAC-SHA512 hash for ABA API calls. |
|
|
170
|
-
| `generateTransactionId` | function | Generate a unique transaction ID. |
|
|
171
|
-
| `getABATimestamp` | function | Get current timestamp in ABA format. |
|
|
172
|
-
| `formatPhoneForABA` | function | Normalize phone number for ABA API. |
|
|
173
|
-
| `getQRExpiration` | function | Get QR code expiration timestamp. |
|
|
174
|
-
|
|
175
|
-
### Types
|
|
176
|
-
|
|
177
|
-
| Type | Description |
|
|
178
|
-
| ------------------ | ---------------------------------------------------------------- |
|
|
179
|
-
| `ABAConfig` | SDK configuration (merchantId, apiKey, baseUrl, webhookSecret) |
|
|
180
|
-
| `PurchaseRequest` | Input for `createPurchase` |
|
|
181
|
-
| `PurchaseResponse` | Result from `createPurchase` |
|
|
182
|
-
| `StatusResponse` | Result from `checkStatus` |
|
|
183
|
-
| `PaymentStatus` | `"PENDING" \| "APPROVED" \| "DECLINED" \| "REFUNDED" \| "ERROR"` |
|
|
184
|
-
| `PaymentOption` | `"cards" \| "abapay_khqr" \| "abapay_khqr_deeplink" \| "alipay" \| "wechat" \| "google_pay"` |
|
|
185
|
-
| `ReturnDeeplink` | `{ ios_scheme?, android_scheme? }` — where ABA Mobile returns the payer |
|
|
186
|
-
| `KHQROptions` | Input for `generateKHQR` |
|
|
187
|
-
| `HashParams` | Raw parameters for hash generation |
|
|
188
|
-
|
|
189
|
-
---
|
|
190
|
-
|
|
191
|
-
## KHQR Customization
|
|
192
|
-
|
|
193
|
-
Use `KHQROptions` to customize the QR code display. `generateKHQR` is async and
|
|
194
|
-
returns a base64-encoded SVG data URI you can use directly as an `<img>` `src`:
|
|
195
|
-
|
|
196
|
-
```typescript
|
|
197
|
-
import { generateKHQR } from "aba-payway-sdk-unofficial";
|
|
198
|
-
|
|
199
|
-
const dataUri = await generateKHQR({
|
|
200
|
-
emvData: "your_emv_qr_string",
|
|
201
|
-
amount: 5.5,
|
|
202
|
-
currency: "USD",
|
|
203
|
-
merchantName: "Rith Shop", // shown above the QR code
|
|
204
|
-
headerColor: "#1a73e8", // hex color for the header bar
|
|
205
|
-
});
|
|
206
|
-
```
|
|
150
|
+
| `generateKHQR` | function | Build a KHQR image (base64 SVG data URI). Async. |
|
|
207
151
|
|
|
208
152
|
---
|
|
209
153
|
|
|
210
154
|
## Environment Setup
|
|
211
155
|
|
|
212
|
-
You need ABA PayWay merchant credentials
|
|
156
|
+
You need ABA PayWay merchant credentials:
|
|
213
157
|
|
|
214
158
|
| Variable | Description |
|
|
215
159
|
| --------------- | ---------------------------------------------------------- |
|
|
216
160
|
| `merchantId` | Your ABA PayWay merchant ID |
|
|
217
161
|
| `apiKey` | Your ABA PayWay API key |
|
|
218
162
|
| `baseUrl` | ABA PayWay base URL (e.g.`https://checkout.payway.com.kh`) |
|
|
219
|
-
| `webhookSecret` |
|
|
163
|
+
| `webhookSecret` | Optional. Key for pushback verification (see below) |
|
|
220
164
|
|
|
221
|
-
|
|
165
|
+
> **`webhookSecret`:** ABA does not issue a separate pushback secret — it signs
|
|
166
|
+
> with your API key — so leave this unset and `verifyWebhook()` will use
|
|
167
|
+
> `apiKey`. Set it only if ABA gives you a distinct key.
|
|
222
168
|
|
|
223
|
-
|
|
224
|
-
> signs pushback with an `X-PayWay-HMAC-SHA512` header that `verifyWebhook()`
|
|
225
|
-
> does not implement yet.
|
|
169
|
+
### Webhook verification
|
|
226
170
|
|
|
227
|
-
|
|
171
|
+
`verifyWebhook()` implements ABA's pushback scheme: the JSON body's keys are
|
|
172
|
+
sorted ascending, their **values** concatenated (no keys, no separator), then
|
|
173
|
+
HMAC-SHA512'd with your merchant key and base64-encoded. That is compared in
|
|
174
|
+
constant time against the `X-PayWay-HMAC-SHA512` header.
|
|
228
175
|
|
|
229
|
-
|
|
176
|
+
```ts
|
|
177
|
+
// Express, Hono, Next — a parsed body works as well as the raw string.
|
|
178
|
+
const isValid = await aba.verifyWebhook(
|
|
179
|
+
req.body,
|
|
180
|
+
req.header("X-PayWay-HMAC-SHA512") ?? "",
|
|
181
|
+
);
|
|
182
|
+
if (!isValid) return res.status(401).end();
|
|
183
|
+
```
|
|
230
184
|
|
|
231
|
-
|
|
232
|
-
# Run unit tests (mocked, no network)
|
|
233
|
-
npm test
|
|
185
|
+
Three things worth knowing:
|
|
234
186
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
187
|
+
- **The raw body is not required.** ABA rebuilds the signature from parsed
|
|
188
|
+
values, not raw bytes, so re-serialising the body cannot invalidate it —
|
|
189
|
+
unlike Stripe-style schemes. Key order in the incoming JSON is irrelevant too.
|
|
190
|
+
- **The secret defaults to your `apiKey`.** ABA does not issue a separate
|
|
191
|
+
pushback secret. Pass one explicitly, or set `webhookSecret`, to override.
|
|
192
|
+
- **It never throws.** A malformed body, a missing header, or a bad signature
|
|
193
|
+
all return `false`.
|
|
194
|
+
|
|
195
|
+
Pushback fields are `tran_id`, `apv`, `status`, `return_params` and
|
|
196
|
+
`merchant_ref`. The whole body is hashed rather than those five by name, so a
|
|
197
|
+
field ABA adds later is included automatically.
|
|
198
|
+
|
|
199
|
+
> Your callback domain must be whitelisted on your merchant profile, or ABA
|
|
200
|
+
> rejects the `return_url` with code 81.
|
|
238
201
|
|
|
239
202
|
---
|
|
240
203
|
|
|
241
204
|
## Sandbox Testing
|
|
242
205
|
|
|
243
|
-
|
|
244
|
-
network calls, so it needs sandbox credentials.
|
|
245
|
-
|
|
246
|
-
### 1. Get sandbox credentials
|
|
206
|
+
Register at [Sandbox Portal](https://sandbox.payway.com.kh/register-sandbox/). ABA will email you credentials.
|
|
247
207
|
|
|
248
|
-
|
|
249
|
-
a merchant ID, an API key, and a webhook secret.
|
|
208
|
+
Copy `.env.example` to `.env` and fill in your values:
|
|
250
209
|
|
|
251
|
-
|
|
210
|
+
```bash
|
|
211
|
+
ABA_MERCHANT_ID=your_sandbox_merchant_id
|
|
212
|
+
ABA_API_KEY=your_sandbox_api_key
|
|
213
|
+
ABA_BASE_URL=https://checkout-sandbox.payway.com.kh
|
|
214
|
+
```
|
|
252
215
|
|
|
253
|
-
|
|
216
|
+
Run sandbox tests:
|
|
254
217
|
|
|
255
218
|
```bash
|
|
256
|
-
|
|
219
|
+
npm run test:sandbox
|
|
257
220
|
```
|
|
258
221
|
|
|
259
|
-
|
|
222
|
+
### Actually paying a sandbox transaction
|
|
223
|
+
|
|
224
|
+
A sandbox KHQR code **cannot** be scanned by the real ABA Mobile app, so
|
|
225
|
+
`abapay_khqr` transactions sit at `PENDING` forever. To exercise `APPROVED`,
|
|
226
|
+
`DECLINED`, and your pushback handler you need the hosted **card** checkout and
|
|
227
|
+
one of [ABA's test cards](docs/ABA%20Test%20Cards.md):
|
|
260
228
|
|
|
261
229
|
```bash
|
|
262
|
-
|
|
263
|
-
ABA_API_KEY=your_sandbox_api_key
|
|
264
|
-
ABA_BASE_URL=https://checkout-sandbox.payway.com.kh
|
|
265
|
-
# ABA_WEBHOOK_SECRET= # ABA does not issue one
|
|
230
|
+
npm run pay:sandbox
|
|
266
231
|
```
|
|
267
232
|
|
|
268
|
-
|
|
233
|
+
It creates a $1 card purchase, opens the checkout page in your browser, and
|
|
234
|
+
polls until ABA settles the transaction.
|
|
269
235
|
|
|
270
|
-
|
|
236
|
+
For a from-scratch walkthrough — installing Node, getting credentials, and
|
|
237
|
+
every check in order — see **[TESTING_GUIDE.md](TESTING_GUIDE.md)**. To produce
|
|
238
|
+
a dated evidence report for ABA when requesting production access:
|
|
271
239
|
|
|
272
240
|
```bash
|
|
273
|
-
npm run
|
|
241
|
+
npm run report:sandbox
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
When you're ready to switch to production, work through
|
|
245
|
+
**[docs/GO-LIVE.md](docs/GO-LIVE.md)** — the readiness checklist, what changes
|
|
246
|
+
between sandbox and production, and what to ask ABA for.
|
|
247
|
+
|
|
248
|
+
The switch that makes this work is **`paymentGate: 0`**. A merchant profile with
|
|
249
|
+
the QR Payment API service enabled answers every purchase with KHQR JSON and
|
|
250
|
+
ignores `paymentOption`, so `"cards"` on its own never reaches a card form:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
const purchase = await aba.createPurchase({
|
|
254
|
+
transactionId: generateTransactionId(),
|
|
255
|
+
amount: 1.0,
|
|
256
|
+
currency: "USD",
|
|
257
|
+
paymentOption: "cards",
|
|
258
|
+
paymentGate: 0, // route to the Checkout service, not the QR API
|
|
259
|
+
viewType: "hosted_view",
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ABA answers 302; the SDK hands back the page to send the payer to.
|
|
263
|
+
console.log(purchase.checkoutUrl);
|
|
274
264
|
```
|
|
275
265
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Community & Support
|
|
279
269
|
|
|
280
|
-
|
|
281
|
-
|
|
270
|
+
Connect with fellow developers building with ABA PayWay in Cambodia:
|
|
271
|
+
- 📢 **Announcements**: [Telegram Channel (@abapaywayunofficial)](https://t.me/abapaywayunofficial)
|
|
272
|
+
- 💬 **Q&A & Support**: [Telegram Group (@abaunofficialintegrate)](https://t.me/abaunofficialintegrate)
|
|
282
273
|
|
|
283
274
|
---
|
|
284
275
|
|
package/dist/index.cjs
CHANGED
|
@@ -33,6 +33,18 @@ __export(index_exports, {
|
|
|
33
33
|
module.exports = __toCommonJS(index_exports);
|
|
34
34
|
|
|
35
35
|
// src/hash.ts
|
|
36
|
+
async function hmacSha512Base64(message, secret) {
|
|
37
|
+
const encoder = new TextEncoder();
|
|
38
|
+
const key = await crypto.subtle.importKey(
|
|
39
|
+
"raw",
|
|
40
|
+
encoder.encode(secret),
|
|
41
|
+
{ name: "HMAC", hash: "SHA-512" },
|
|
42
|
+
false,
|
|
43
|
+
["sign"]
|
|
44
|
+
);
|
|
45
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
|
|
46
|
+
return btoa(String.fromCharCode(...new Uint8Array(signature)));
|
|
47
|
+
}
|
|
36
48
|
async function generateABAHash(params, publicKey) {
|
|
37
49
|
const hashString = [
|
|
38
50
|
params.req_time,
|
|
@@ -57,16 +69,7 @@ async function generateABAHash(params, publicKey) {
|
|
|
57
69
|
params.custom_fields ?? "",
|
|
58
70
|
params.return_params ?? ""
|
|
59
71
|
].join("");
|
|
60
|
-
|
|
61
|
-
const key = await crypto.subtle.importKey(
|
|
62
|
-
"raw",
|
|
63
|
-
encoder.encode(publicKey),
|
|
64
|
-
{ name: "HMAC", hash: "SHA-512" },
|
|
65
|
-
false,
|
|
66
|
-
["sign"]
|
|
67
|
-
);
|
|
68
|
-
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(hashString));
|
|
69
|
-
return btoa(String.fromCharCode(...new Uint8Array(signature)));
|
|
72
|
+
return hmacSha512Base64(hashString, publicKey);
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
// src/response.ts
|
|
@@ -114,8 +117,8 @@ function formatPhoneForABA(phone) {
|
|
|
114
117
|
if (cleaned.startsWith("855")) return "0" + cleaned.slice(3);
|
|
115
118
|
return cleaned;
|
|
116
119
|
}
|
|
117
|
-
function getQRExpiration() {
|
|
118
|
-
return new Date(Date.now() +
|
|
120
|
+
function getQRExpiration(minutes = 3) {
|
|
121
|
+
return new Date(Date.now() + minutes * 60 * 1e3);
|
|
119
122
|
}
|
|
120
123
|
function toBase64Utf8(text) {
|
|
121
124
|
const bytes = new TextEncoder().encode(text);
|
|
@@ -215,6 +218,10 @@ var ABAPayWay = class {
|
|
|
215
218
|
return_params: request.returnParams ?? "",
|
|
216
219
|
hash
|
|
217
220
|
});
|
|
221
|
+
if (request.paymentGate !== void 0) {
|
|
222
|
+
body.set("payment_gate", String(request.paymentGate));
|
|
223
|
+
}
|
|
224
|
+
if (request.viewType) body.set("view_type", request.viewType);
|
|
218
225
|
const url = `${this.config.baseUrl}/api/payment-gateway/v1/payments/purchase`;
|
|
219
226
|
const failure = (error, errorCode) => ({
|
|
220
227
|
success: false,
|
|
@@ -228,8 +235,28 @@ var ABAPayWay = class {
|
|
|
228
235
|
const response = await fetch(url, {
|
|
229
236
|
method: "POST",
|
|
230
237
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
231
|
-
body: body.toString()
|
|
238
|
+
body: body.toString(),
|
|
239
|
+
// The Checkout service answers 302 to the hosted payment page. Let it
|
|
240
|
+
// be followed and the body is that page's HTML, which parses as
|
|
241
|
+
// neither JSON nor an error worth reporting — so stop at the redirect
|
|
242
|
+
// and read the address out of it instead.
|
|
243
|
+
redirect: "manual"
|
|
232
244
|
});
|
|
245
|
+
if (response.status >= 300 && response.status < 400) {
|
|
246
|
+
const location = response.headers.get("location");
|
|
247
|
+
if (!location) {
|
|
248
|
+
return failure(
|
|
249
|
+
`ABA PayWay redirected (HTTP ${response.status}) without a Location header.`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
success: true,
|
|
254
|
+
transactionId: request.transactionId,
|
|
255
|
+
amount: request.amount,
|
|
256
|
+
currency: request.currency,
|
|
257
|
+
checkoutUrl: new URL(location, this.config.baseUrl).toString()
|
|
258
|
+
};
|
|
259
|
+
}
|
|
233
260
|
const parsed = await parseAbaJson(response);
|
|
234
261
|
if (parsed.error) return failure(parsed.error);
|
|
235
262
|
const status = readAbaStatus(parsed.data);
|
|
@@ -299,18 +326,30 @@ var ABAPayWay = class {
|
|
|
299
326
|
return failure(err instanceof Error ? err.message : "Unknown error");
|
|
300
327
|
}
|
|
301
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* Verify an ABA pushback (callback) against its `X-PayWay-HMAC-SHA512`
|
|
331
|
+
* header. Returns false for anything it cannot positively verify — a bad
|
|
332
|
+
* signature, a malformed body, a missing key — so a caller can branch on
|
|
333
|
+
* the result without a try/catch.
|
|
334
|
+
*
|
|
335
|
+
* `payload` may be the raw request body or the already-parsed object.
|
|
336
|
+
* Unlike most gateways, ABA's scheme rebuilds the signature from the parsed
|
|
337
|
+
* values rather than the raw bytes, so re-serialising the body on the way
|
|
338
|
+
* in cannot invalidate it. Frameworks that hand you a parsed body are fine.
|
|
339
|
+
*
|
|
340
|
+
* `secret` defaults to `webhookSecret`, then to `apiKey` — ABA does not
|
|
341
|
+
* issue a separate pushback secret, it signs with the merchant API key.
|
|
342
|
+
*/
|
|
302
343
|
async verifyWebhook(payload, signature, secret) {
|
|
303
344
|
try {
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
345
|
+
const key = secret ?? this.config.webhookSecret ?? this.config.apiKey;
|
|
346
|
+
if (!key || !signature) return false;
|
|
347
|
+
const body = typeof payload === "string" ? JSON.parse(payload) : payload;
|
|
348
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) return false;
|
|
349
|
+
const expected = await hmacSha512Base64(
|
|
350
|
+
buildPushbackHashBase(body),
|
|
351
|
+
key
|
|
311
352
|
);
|
|
312
|
-
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
|
|
313
|
-
const expected = btoa(String.fromCharCode(...new Uint8Array(sig)));
|
|
314
353
|
if (expected.length !== signature.length) return false;
|
|
315
354
|
let mismatch = 0;
|
|
316
355
|
for (let i = 0; i < expected.length; i++) {
|
|
@@ -322,6 +361,16 @@ var ABAPayWay = class {
|
|
|
322
361
|
}
|
|
323
362
|
}
|
|
324
363
|
};
|
|
364
|
+
function buildPushbackHashBase(body) {
|
|
365
|
+
return Object.keys(body).sort().map((key) => stringifyPushbackValue(body[key])).join("");
|
|
366
|
+
}
|
|
367
|
+
function stringifyPushbackValue(value) {
|
|
368
|
+
if (value === null || value === void 0) return "";
|
|
369
|
+
if (typeof value === "string") return value;
|
|
370
|
+
if (typeof value === "boolean") return value ? "1" : "";
|
|
371
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
372
|
+
return String(value);
|
|
373
|
+
}
|
|
325
374
|
function toNumber(value) {
|
|
326
375
|
if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
|
|
327
376
|
if (typeof value === "string" && value.trim() !== "") {
|
|
@@ -348,6 +397,16 @@ function mapPaymentStatus(raw) {
|
|
|
348
397
|
// src/khqr.ts
|
|
349
398
|
var DEFAULT_HEADER_COLOR = "#bc271a";
|
|
350
399
|
var QR_API_BASE = "https://quickchart.io/qr";
|
|
400
|
+
var CARD_SHELL_PATH = "M189.868 10.8675H27.8677C18.4788 10.8675 10.8677 18.4787 10.8677 27.8675V287.867C10.8677 297.256 18.4788 304.867 27.8677 304.867H189.868C199.257 304.867 206.868 297.256 206.868 287.867V27.8675C206.868 18.4787 199.257 10.8675 189.868 10.8675Z";
|
|
401
|
+
var KHQR_HEADER_BACKGROUND_PATH = "M178.91 0C188.299 0.00000824649 195.91 7.61117 195.91 17V36.3516H196V54L178.582 37H0V17C0.00000103088 7.61116 7.61116 0 17 0H178.91Z";
|
|
402
|
+
var KHQR_HEADER_MARK_PATHS = [
|
|
403
|
+
"M104.488 17.1027V20.5948H100.95C100.596 20.5948 100.331 20.3329 100.331 19.9836V17.1027C100.331 16.7535 100.596 16.4916 100.95 16.4916H103.781C104.223 16.4043 104.488 16.7535 104.488 17.1027Z",
|
|
404
|
+
"M120.944 18.5H119.175C119.175 16.4047 117.494 14.746 115.371 14.746C113.69 14.746 112.274 15.7936 111.743 17.365C111.655 17.7143 111.566 18.1507 111.566 18.5V23.9999H111.478C110.505 23.9999 109.797 23.2142 109.797 22.3412V18.5C109.797 17.0159 110.416 15.5317 111.566 14.4841C112.628 13.5238 113.955 13 115.371 13C118.467 13 120.944 15.4444 120.944 18.5Z",
|
|
405
|
+
"M120.945 24H118.467L117.848 23.3889L116.521 22.0794L114.663 20.2461H117.14L120.945 24Z",
|
|
406
|
+
"M105.107 22.2539H99.7994C99.18 22.2539 98.6492 21.7301 98.6492 21.119V15.8809C98.6492 15.2698 99.18 14.746 99.7994 14.746H105.107C105.727 14.746 106.257 15.2698 106.257 15.8809V21.119L108.027 22.865V14.6587C108.027 13.6984 107.231 13 106.346 13H98.6492C97.6756 13 96.9683 13.7857 96.9683 14.6587V22.2539C96.9683 23.2142 97.7642 23.9126 98.6492 23.9126H106.877L105.107 22.2539Z",
|
|
407
|
+
"M83.6093 23.9999H81.1318L76.0005 18.8492V23.9999H73.9658V13H76.0005V17.8888L80.9553 13H83.3436L78.0356 18.2381L83.6093 23.9999Z",
|
|
408
|
+
"M92.898 13H94.8446V23.9999H92.898V19.1984H87.2358V23.9999H85.2012V13H87.2358V17.6269H92.898V13Z"
|
|
409
|
+
].map((path) => `<path d="${path}" fill="#ffffff"/>`).join("");
|
|
351
410
|
function formatAmount(amount, currency) {
|
|
352
411
|
if (currency === "KHR") {
|
|
353
412
|
return amount.toLocaleString("en-US", { maximumFractionDigits: 0 }) + " KHR";
|
|
@@ -375,30 +434,46 @@ async function generateKHQR(options) {
|
|
|
375
434
|
const innerQr = qrSvgContent.replace(/<\?xml[^>]*\?>/g, "");
|
|
376
435
|
const formattedAmount = formatAmount(amount, currency);
|
|
377
436
|
const safeMerchantName = escapeXml(merchantName);
|
|
378
|
-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0
|
|
379
|
-
<defs
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
<
|
|
391
|
-
<
|
|
392
|
-
|
|
437
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 218 316" width="218" height="316" role="img" aria-label="PayWay KHQR payment card">
|
|
438
|
+
<defs>
|
|
439
|
+
<filter id="card-shadow" x="0" y="0" width="217.735" height="315.735" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
|
440
|
+
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
|
441
|
+
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
|
442
|
+
<feOffset/>
|
|
443
|
+
<feGaussianBlur stdDeviation="5.43375"/>
|
|
444
|
+
<feComposite in2="hardAlpha" operator="out"/>
|
|
445
|
+
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.16 0"/>
|
|
446
|
+
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
|
447
|
+
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
|
448
|
+
</filter>
|
|
449
|
+
<clipPath id="card-clip"><path d="${CARD_SHELL_PATH}"/></clipPath>
|
|
450
|
+
<clipPath id="qr-clip"><rect id="qr-area" x="37" y="145" width="144" height="144"/></clipPath>
|
|
451
|
+
</defs>
|
|
452
|
+
<rect width="218" height="316" fill="#ffffff"/>
|
|
453
|
+
<g filter="url(#card-shadow)">
|
|
454
|
+
<path d="${CARD_SHELL_PATH}" fill="#ffffff"/>
|
|
393
455
|
</g>
|
|
394
|
-
<g clip-path="url(#
|
|
395
|
-
<g transform="translate(
|
|
396
|
-
|
|
456
|
+
<g clip-path="url(#card-clip)">
|
|
457
|
+
<g id="khqr-header-logo" transform="translate(11,11)" aria-label="KHQR logo">
|
|
458
|
+
<path d="${KHQR_HEADER_BACKGROUND_PATH}" fill="${headerColor}"/>
|
|
459
|
+
${KHQR_HEADER_MARK_PATHS}
|
|
460
|
+
</g>
|
|
461
|
+
<text x="51" y="81" fill="#111111" font-family="Arial, Helvetica, sans-serif" font-size="10">${safeMerchantName}</text>
|
|
462
|
+
<text x="51" y="108" fill="#000000" font-family="Arial, Helvetica, sans-serif" font-size="20" font-weight="500">${formattedAmount}</text>
|
|
463
|
+
<line x1="11" y1="124" x2="207" y2="124" stroke="#8a8a8a" stroke-width="1" stroke-dasharray="4 5"/>
|
|
464
|
+
<rect x="37" y="145" width="144" height="144" fill="#ffffff"/>
|
|
465
|
+
<g clip-path="url(#qr-clip)">
|
|
466
|
+
<g transform="translate(37,145) scale(0.5142857143)">
|
|
467
|
+
${innerQr || '<svg width="280" height="280"><rect width="280" height="280" fill="#f5f5f5"/><text x="140" y="145" text-anchor="middle" fill="#777777" font-family="Arial, Helvetica, sans-serif" font-size="16">QR Code</text></svg>'}
|
|
468
|
+
</g>
|
|
469
|
+
</g>
|
|
470
|
+
<g aria-label="KHQR brand mark">
|
|
471
|
+
<circle cx="109" cy="217" r="17" fill="#ffffff"/>
|
|
472
|
+
<circle cx="109" cy="217" r="13" fill="${headerColor}"/>
|
|
473
|
+
<path d="M102 213h3v-3h8v3h3v8h-3v3h-8v-3h-3z" fill="none" stroke="#ffffff" stroke-width="1.4" stroke-linejoin="round"/>
|
|
474
|
+
<path d="M109 213v8M105 217h8" stroke="#ffffff" stroke-width="1.2" stroke-linecap="round"/>
|
|
397
475
|
</g>
|
|
398
476
|
</g>
|
|
399
|
-
<text x="200" y="480" text-anchor="middle" fill="#666" font-family="system-ui,sans-serif" font-size="11">Scan with any KHQR-compatible app</text>
|
|
400
|
-
<text x="200" y="501" text-anchor="middle" fill="#999" font-family="system-ui,sans-serif" font-size="10">Powered by Bakong</text>
|
|
401
|
-
<text x="200" y="521" text-anchor="middle" fill="#555" font-family="system-ui,sans-serif" font-size="11" font-weight="bold" letter-spacing="1">KHQR</text>
|
|
402
477
|
</svg>`;
|
|
403
478
|
return "data:image/svg+xml;base64," + btoa(svg);
|
|
404
479
|
}
|