multi-payment-system 0.0.0-stage → 1.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 +360 -2
- package/package.json +72 -4
- package/src/api.js +102 -0
- package/src/app.js +43 -0
- package/src/client/PaymentClient.js +173 -0
- package/src/client/http.js +39 -0
- package/src/client/index.js +35 -0
- package/src/client/react.js +107 -0
- package/src/config.js +10 -0
- package/src/gateways/BaseGateway.js +41 -0
- package/src/gateways/MidtransGateway.js +124 -0
- package/src/gateways/OmiseGateway.js +137 -0
- package/src/gateways/RazorpayGateway.js +118 -0
- package/src/gateways/StripeGateway.js +137 -0
- package/src/gateways/XenditGateway.js +95 -0
- package/src/gateways/index.js +42 -0
- package/src/index.js +48 -0
- package/src/meta.js +35 -0
- package/src/middleware/auth.js +11 -0
- package/src/routes/payments.js +156 -0
- package/src/routes/webhooks.js +91 -0
- package/src/server.js +11 -0
- package/src/services/PaymentService.js +249 -0
- package/src/store/MemoryStore.js +49 -0
- package/src/utils/base64.js +11 -0
- package/src/utils/errors.js +21 -0
- package/src/utils/money.js +75 -0
- package/src/utils/paymentMethods.js +166 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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
CHANGED
|
@@ -1,3 +1,361 @@
|
|
|
1
|
-
#
|
|
1
|
+
# multi-payment-system
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Payments for **Node** and **React** — Xendit, Midtrans, Razorpay, Stripe, Omise (+ Yokke EDC helpers).
|
|
4
|
+
|
|
5
|
+
| Import | Use in |
|
|
6
|
+
|--------|--------|
|
|
7
|
+
| `multi-payment-system` | Node / backend (`XenditGateway`, `charge`) |
|
|
8
|
+
| `multi-payment-system/client` | React / browser (`PaymentClient`) |
|
|
9
|
+
| `multi-payment-system/react` | React hook (`usePaymentClient`) |
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## React client
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install multi-payment-system
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
1. Run the payment API (`npm start` in this repo) so React has a backend.
|
|
20
|
+
2. In React:
|
|
21
|
+
|
|
22
|
+
```jsx
|
|
23
|
+
import { usePaymentClient } from "multi-payment-system/react";
|
|
24
|
+
|
|
25
|
+
export function PayButton() {
|
|
26
|
+
const { chargeAndRedirect, loading, error } = usePaymentClient({
|
|
27
|
+
baseUrl: import.meta.env.VITE_PAY_API || "http://localhost:3000",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<button
|
|
32
|
+
disabled={loading}
|
|
33
|
+
onClick={() =>
|
|
34
|
+
chargeAndRedirect({
|
|
35
|
+
gateway: "xendit",
|
|
36
|
+
amount: 30,
|
|
37
|
+
currency: "SGD",
|
|
38
|
+
paymentMethod: "card",
|
|
39
|
+
keys: { secretKey: import.meta.env.VITE_XENDIT_SECRET }, // demo only — prefer server-held keys
|
|
40
|
+
returnUrl: window.location.origin + "/success",
|
|
41
|
+
cancelUrl: window.location.origin + "/cancel",
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
>
|
|
45
|
+
{loading ? "Opening…" : "Pay with card"}
|
|
46
|
+
</button>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or without the hook:
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
import { PaymentClient, redirectToCheckout } from "multi-payment-system/client";
|
|
55
|
+
|
|
56
|
+
const client = new PaymentClient({ baseUrl: "http://localhost:3000" });
|
|
57
|
+
const { payment } = await client.charge({ /* same body as Postman */ });
|
|
58
|
+
redirectToCheckout(payment); // opens Xendit/Stripe checkout
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Security:** do not ship production secret keys in a React bundle. Put keys on the server and have the client call your API without secrets.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Node / backend SDK
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
import { XenditGateway, charge } from "multi-payment-system";
|
|
69
|
+
|
|
70
|
+
const result = await charge({
|
|
71
|
+
gateway: "xendit",
|
|
72
|
+
amount: 30,
|
|
73
|
+
currency: "SGD",
|
|
74
|
+
paymentMethod: "card",
|
|
75
|
+
keys: { secretKey: process.env.XENDIT_SECRET_KEY },
|
|
76
|
+
returnUrl: "https://example.com/success",
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Run local API server
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npm install
|
|
86
|
+
npm start
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Server: `http://localhost:3000`
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npm run test:smoke
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Postman
|
|
96
|
+
|
|
97
|
+
Import these files into Postman:
|
|
98
|
+
|
|
99
|
+
1. `postman/YOKKE_EDC_Payment_Server.postman_collection.json`
|
|
100
|
+
2. `postman/YOKKE_Local.postman_environment.json`
|
|
101
|
+
|
|
102
|
+
Select environment **YOKKE Local**, put your test keys in the environment variables, then run:
|
|
103
|
+
|
|
104
|
+
1. **Health** (no keys needed)
|
|
105
|
+
2. **Create EDC attempt** → **Report terminal result** (no gateway keys)
|
|
106
|
+
3. Or **Charge - Stripe / Xendit / …** after filling keys
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## How it fits the kiosk diagram
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
WebView → Kotlin Bridge → (A) this server OR (B) Yokke EDC via USB
|
|
114
|
+
│
|
|
115
|
+
├─ online: POST /api/payments/charge (Xendit/Stripe/…)
|
|
116
|
+
└─ EDC: POST /api/payments/attempts
|
|
117
|
+
then POST .../terminal-result after EDC responds
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Amount rules
|
|
123
|
+
|
|
124
|
+
| Field | Meaning |
|
|
125
|
+
|--------|---------|
|
|
126
|
+
| `amount` | Major units by default (`10.50` SGD = S$10.50) |
|
|
127
|
+
| `amountAlreadyMinor: true` | Treat `amount` as cents/sen/paise already (`1050`) |
|
|
128
|
+
| `currency` | ISO code: `SGD`, `MYR`, `IDR`, `THB`, `PHP`, `VND`, `USD`, `INR`, … |
|
|
129
|
+
|
|
130
|
+
Zero-decimal currencies (e.g. `IDR`, `VND`, `JPY`) use whole major units as the charge amount.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Create an online payment
|
|
135
|
+
|
|
136
|
+
`POST /api/payments/charge`
|
|
137
|
+
|
|
138
|
+
### Payment method (all gateways)
|
|
139
|
+
|
|
140
|
+
Send one or both:
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
"paymentMethod": "qr",
|
|
144
|
+
"paymentMethods": ["qr", "card"]
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Common values: `card`, `qr`, `ewallet`, `bank`, `paynow`, `promptpay`, `fpx`, `upi`, `gopay`, `grabpay`, `ovo`, `dana`, `shopeepay`.
|
|
148
|
+
|
|
149
|
+
| Value | Typical meaning |
|
|
150
|
+
|-------|-----------------|
|
|
151
|
+
| `card` | Credit/debit card |
|
|
152
|
+
| `qr` | QRIS / PayNow / PromptPay / UPI QR (mapped per gateway + currency) |
|
|
153
|
+
| `ewallet` | OVO, DANA, GrabPay, etc. |
|
|
154
|
+
| `bank` | VA / FPX / bank transfer |
|
|
155
|
+
|
|
156
|
+
### Stripe (SG / MY / global)
|
|
157
|
+
|
|
158
|
+
```json
|
|
159
|
+
{
|
|
160
|
+
"gateway": "stripe",
|
|
161
|
+
"amount": 12.5,
|
|
162
|
+
"currency": "SGD",
|
|
163
|
+
"paymentMethod": "qr",
|
|
164
|
+
"keys": { "secretKey": "sk_test_..." },
|
|
165
|
+
"orderId": "order-1001",
|
|
166
|
+
"description": "Kiosk order 1001",
|
|
167
|
+
"returnUrl": "https://your-app/success?session_id={CHECKOUT_SESSION_ID}",
|
|
168
|
+
"cancelUrl": "https://your-app/cancel",
|
|
169
|
+
"customer": { "email": "buyer@example.com" }
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Xendit (SG / MY / ID / PH / TH / VN)
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"gateway": "xendit",
|
|
178
|
+
"amount": 25,
|
|
179
|
+
"currency": "SGD",
|
|
180
|
+
"paymentMethod": "qr",
|
|
181
|
+
"paymentMethods": ["qr", "card"],
|
|
182
|
+
"keys": { "secretKey": "xnd_development_..." },
|
|
183
|
+
"orderId": "order-1002",
|
|
184
|
+
"returnUrl": "https://your-app/success",
|
|
185
|
+
"customer": { "email": "buyer@example.com" }
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Midtrans (Indonesia)
|
|
190
|
+
|
|
191
|
+
```json
|
|
192
|
+
{
|
|
193
|
+
"gateway": "midtrans",
|
|
194
|
+
"amount": 50000,
|
|
195
|
+
"currency": "IDR",
|
|
196
|
+
"keys": {
|
|
197
|
+
"serverKey": "SB-Mid-server-...",
|
|
198
|
+
"isProduction": false
|
|
199
|
+
},
|
|
200
|
+
"orderId": "order-1003",
|
|
201
|
+
"returnUrl": "https://your-app/finish"
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Razorpay
|
|
206
|
+
|
|
207
|
+
```json
|
|
208
|
+
{
|
|
209
|
+
"gateway": "razorpay",
|
|
210
|
+
"amount": 100,
|
|
211
|
+
"currency": "INR",
|
|
212
|
+
"amountAlreadyMinor": true,
|
|
213
|
+
"keys": {
|
|
214
|
+
"keyId": "rzp_test_...",
|
|
215
|
+
"keySecret": "..."
|
|
216
|
+
},
|
|
217
|
+
"orderId": "order-1004"
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Response includes `payment.checkout` for opening Razorpay Checkout in the WebView.
|
|
222
|
+
|
|
223
|
+
### Omise / Opn (SG PayNow, MY FPX, TH PromptPay)
|
|
224
|
+
|
|
225
|
+
```json
|
|
226
|
+
{
|
|
227
|
+
"gateway": "omise",
|
|
228
|
+
"amount": 15,
|
|
229
|
+
"currency": "SGD",
|
|
230
|
+
"paymentMethod": "paynow",
|
|
231
|
+
"keys": { "secretKey": "skey_test_..." },
|
|
232
|
+
"returnUrl": "https://your-app/return",
|
|
233
|
+
"orderId": "order-1005"
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
For Malaysia FPX:
|
|
238
|
+
|
|
239
|
+
```json
|
|
240
|
+
{
|
|
241
|
+
"gateway": "omise",
|
|
242
|
+
"amount": 20,
|
|
243
|
+
"currency": "MYR",
|
|
244
|
+
"paymentMethod": "fpx",
|
|
245
|
+
"keys": { "secretKey": "skey_test_...", "bank": "uob" },
|
|
246
|
+
"returnUrl": "https://your-app/return"
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Success response (shape)
|
|
251
|
+
|
|
252
|
+
```json
|
|
253
|
+
{
|
|
254
|
+
"ok": true,
|
|
255
|
+
"payment": {
|
|
256
|
+
"id": "uuid",
|
|
257
|
+
"gateway": "stripe",
|
|
258
|
+
"status": "pending",
|
|
259
|
+
"checkoutUrl": "https://checkout.stripe.com/...",
|
|
260
|
+
"amount": 12.5,
|
|
261
|
+
"amountMinor": 1250,
|
|
262
|
+
"currency": "SGD"
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Open `checkoutUrl` in the kiosk WebView (or system browser). Then poll:
|
|
268
|
+
|
|
269
|
+
- `GET /api/payments/:id`
|
|
270
|
+
- or `POST /api/payments/:id/refresh` with `{ "keys": { ... } }` if you did not keep keys server-side
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## Yokke EDC local terminal flow
|
|
275
|
+
|
|
276
|
+
### 1. Create attempt (before talking to the EDC)
|
|
277
|
+
|
|
278
|
+
```http
|
|
279
|
+
POST /api/payments/attempts
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
```json
|
|
283
|
+
{
|
|
284
|
+
"amount": 75000,
|
|
285
|
+
"currency": "IDR",
|
|
286
|
+
"orderId": "order-2001",
|
|
287
|
+
"deviceId": "kiosk-storeA-01"
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### 2. Kotlin sends sale to EDC over USB/serial
|
|
292
|
+
|
|
293
|
+
### 3. Report verified terminal result
|
|
294
|
+
|
|
295
|
+
```http
|
|
296
|
+
POST /api/payments/attempts/{id}/terminal-result
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
```json
|
|
300
|
+
{
|
|
301
|
+
"status": "approved",
|
|
302
|
+
"approvalCode": "00",
|
|
303
|
+
"referenceNo": "123456",
|
|
304
|
+
"rrn": "999888",
|
|
305
|
+
"amountMinor": 75000
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Only mark the order paid when this returns `status: "succeeded"`.
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
## Webhooks (optional)
|
|
314
|
+
|
|
315
|
+
Point each gateway dashboard to:
|
|
316
|
+
|
|
317
|
+
| Gateway | URL |
|
|
318
|
+
|-----------|-----|
|
|
319
|
+
| Xendit | `POST /webhooks/xendit` |
|
|
320
|
+
| Midtrans | `POST /webhooks/midtrans` |
|
|
321
|
+
| Razorpay | `POST /webhooks/razorpay` |
|
|
322
|
+
| Stripe | `POST /webhooks/stripe` |
|
|
323
|
+
| Omise | `POST /webhooks/omise` |
|
|
324
|
+
|
|
325
|
+
Use a public tunnel (`PUBLIC_BASE_URL`) in development.
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## Optional API key
|
|
330
|
+
|
|
331
|
+
Set in `.env`:
|
|
332
|
+
|
|
333
|
+
```
|
|
334
|
+
API_KEY=change-me-kiosk-api-key
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Then send header: `x-api-key: change-me-kiosk-api-key`
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## Project layout
|
|
342
|
+
|
|
343
|
+
```
|
|
344
|
+
src/
|
|
345
|
+
index.js # starts server
|
|
346
|
+
app.js # Express app
|
|
347
|
+
gateways/ # Xendit, Midtrans, Razorpay, Stripe, Omise
|
|
348
|
+
routes/payments.js # charge + EDC endpoints
|
|
349
|
+
routes/webhooks.js
|
|
350
|
+
services/PaymentService.js
|
|
351
|
+
store/MemoryStore.js # swap for DB in production
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## Production notes
|
|
357
|
+
|
|
358
|
+
- Replace `MemoryStore` with Postgres/Redis.
|
|
359
|
+
- Prefer storing merchant keys encrypted server-side (vault) instead of accepting raw secrets on every request long-term.
|
|
360
|
+
- Never log full card data from EDC responses.
|
|
361
|
+
- Confirm Yokke EDC model / protocol before production USB framing (see the integration guide).
|
package/package.json
CHANGED
|
@@ -1,6 +1,74 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "multi-payment-system",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Multi-gateway payments for Node and React (Xendit, Midtrans, Razorpay, Stripe, Omise)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"module": "./src/index.js",
|
|
8
|
+
"react-native": "./src/client/index.js",
|
|
9
|
+
"browser": {
|
|
10
|
+
"./src/index.js": "./src/client/index.js",
|
|
11
|
+
"./src/server.js": false,
|
|
12
|
+
"./src/app.js": false,
|
|
13
|
+
"./src/config.js": false,
|
|
14
|
+
"express": false,
|
|
15
|
+
"cors": false,
|
|
16
|
+
"dotenv": false,
|
|
17
|
+
"zod": false
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"browser": "./src/client/index.js",
|
|
22
|
+
"import": "./src/index.js",
|
|
23
|
+
"default": "./src/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./client": "./src/client/index.js",
|
|
26
|
+
"./react": "./src/client/react.js",
|
|
27
|
+
"./server": "./src/server.js",
|
|
28
|
+
"./app": "./src/app.js",
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"sideEffects": false,
|
|
32
|
+
"files": [
|
|
33
|
+
"src",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"start": "node src/server.js",
|
|
39
|
+
"dev": "node --watch src/server.js",
|
|
40
|
+
"test:smoke": "node scripts/smoke-test.js",
|
|
41
|
+
"test:package": "node scripts/package-import-test.js",
|
|
42
|
+
"prepublishOnly": "npm run test:package && npm run test:smoke"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"payment",
|
|
49
|
+
"react",
|
|
50
|
+
"xendit",
|
|
51
|
+
"midtrans",
|
|
52
|
+
"razorpay",
|
|
53
|
+
"stripe",
|
|
54
|
+
"omise",
|
|
55
|
+
"checkout"
|
|
56
|
+
],
|
|
57
|
+
"license": "MIT",
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"react": ">=17"
|
|
60
|
+
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"react": {
|
|
63
|
+
"optional": true
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"axios": "^1.7.9",
|
|
68
|
+
"cors": "^2.8.5",
|
|
69
|
+
"dotenv": "^16.4.7",
|
|
70
|
+
"express": "^4.21.2",
|
|
71
|
+
"uuid": "^11.0.5",
|
|
72
|
+
"zod": "^3.24.1"
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { getGateway, GATEWAY_NAMES, gatewayRegionsHint } from "./gateways/index.js";
|
|
2
|
+
import { toMinorUnits, normalizeCurrency, SUPPORTED_CURRENCIES } from "./utils/money.js";
|
|
3
|
+
import { PAYMENT_METHOD_HINTS, normalizeMethods } from "./utils/paymentMethods.js";
|
|
4
|
+
import { AppError } from "./utils/errors.js";
|
|
5
|
+
|
|
6
|
+
function newId() {
|
|
7
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
8
|
+
return crypto.randomUUID();
|
|
9
|
+
}
|
|
10
|
+
return `pay_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a payment on any supported gateway.
|
|
15
|
+
* Prefer calling this on a server/backend — never ship secret keys in a React bundle.
|
|
16
|
+
*/
|
|
17
|
+
export async function charge(input = {}) {
|
|
18
|
+
const gatewayName = String(input.gateway || "").toLowerCase();
|
|
19
|
+
if (!gatewayName) throw new AppError("gateway is required");
|
|
20
|
+
|
|
21
|
+
const currency = normalizeCurrency(input.currency);
|
|
22
|
+
if (!currency) throw new AppError("currency is required");
|
|
23
|
+
|
|
24
|
+
if (!input.keys || typeof input.keys !== "object") {
|
|
25
|
+
throw new AppError("keys object is required (gateway API credentials)");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const amountMinor = toMinorUnits(
|
|
29
|
+
input.amount,
|
|
30
|
+
currency,
|
|
31
|
+
Boolean(input.amountAlreadyMinor)
|
|
32
|
+
);
|
|
33
|
+
if (!amountMinor || amountMinor <= 0) {
|
|
34
|
+
throw new AppError("amount must be greater than 0");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const paymentId = input.paymentId || newId();
|
|
38
|
+
const gateway = getGateway(gatewayName);
|
|
39
|
+
|
|
40
|
+
const result = await gateway.createPayment({
|
|
41
|
+
paymentId,
|
|
42
|
+
amountMinor,
|
|
43
|
+
currency,
|
|
44
|
+
keys: input.keys,
|
|
45
|
+
orderId: input.orderId,
|
|
46
|
+
description: input.description,
|
|
47
|
+
customer: input.customer,
|
|
48
|
+
returnUrl: input.returnUrl,
|
|
49
|
+
cancelUrl: input.cancelUrl,
|
|
50
|
+
metadata: input.metadata,
|
|
51
|
+
paymentMethod: input.paymentMethod,
|
|
52
|
+
paymentMethods: input.paymentMethods,
|
|
53
|
+
cardToken: input.cardToken,
|
|
54
|
+
bank: input.bank,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
paymentId,
|
|
60
|
+
gateway: gateway.name,
|
|
61
|
+
amount: Number(input.amount),
|
|
62
|
+
amountMinor,
|
|
63
|
+
currency,
|
|
64
|
+
paymentMethod: input.paymentMethod || null,
|
|
65
|
+
paymentMethods: normalizeMethods(input.paymentMethod, input.paymentMethods),
|
|
66
|
+
...result,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Refresh / query payment status from the gateway. */
|
|
71
|
+
export async function getStatus(input = {}) {
|
|
72
|
+
const gatewayName = String(input.gateway || "").toLowerCase();
|
|
73
|
+
if (!gatewayName) throw new AppError("gateway is required");
|
|
74
|
+
if (!input.gatewayRef && !input.orderId) {
|
|
75
|
+
throw new AppError("gatewayRef (or orderId for Midtrans) is required");
|
|
76
|
+
}
|
|
77
|
+
if (!input.keys || typeof input.keys !== "object") {
|
|
78
|
+
throw new AppError("keys object is required");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const gateway = getGateway(gatewayName);
|
|
82
|
+
const result = await gateway.getStatus({
|
|
83
|
+
keys: input.keys,
|
|
84
|
+
gatewayRef: input.gatewayRef,
|
|
85
|
+
orderId: input.orderId,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
gateway: gateway.name,
|
|
91
|
+
...result,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function listGateways() {
|
|
96
|
+
return {
|
|
97
|
+
gateways: GATEWAY_NAMES,
|
|
98
|
+
regions: gatewayRegionsHint(),
|
|
99
|
+
currencies: SUPPORTED_CURRENCIES,
|
|
100
|
+
paymentMethods: PAYMENT_METHOD_HINTS,
|
|
101
|
+
};
|
|
102
|
+
}
|
package/src/app.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import cors from "cors";
|
|
3
|
+
import paymentsRouter from "./routes/payments.js";
|
|
4
|
+
import webhooksRouter from "./routes/webhooks.js";
|
|
5
|
+
import { optionalApiKey } from "./middleware/auth.js";
|
|
6
|
+
import { errorHandler } from "./utils/errors.js";
|
|
7
|
+
import { GATEWAY_NAMES, gatewayRegionsHint } from "./gateways/index.js";
|
|
8
|
+
|
|
9
|
+
export function createApp() {
|
|
10
|
+
const app = express();
|
|
11
|
+
|
|
12
|
+
app.use(cors());
|
|
13
|
+
app.use(express.json({ limit: "1mb" }));
|
|
14
|
+
app.use(express.urlencoded({ extended: true }));
|
|
15
|
+
|
|
16
|
+
app.get("/health", (_req, res) => {
|
|
17
|
+
res.json({ ok: true, service: "yokke-edc-payment-server", time: new Date().toISOString() });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
app.get("/", (_req, res) => {
|
|
21
|
+
res.json({
|
|
22
|
+
ok: true,
|
|
23
|
+
name: "YOKKE EDC + Multi-Gateway Payment Server",
|
|
24
|
+
gateways: GATEWAY_NAMES,
|
|
25
|
+
regions: gatewayRegionsHint(),
|
|
26
|
+
endpoints: {
|
|
27
|
+
meta: "GET /api/payments/meta",
|
|
28
|
+
charge: "POST /api/payments/charge",
|
|
29
|
+
get: "GET /api/payments/:id",
|
|
30
|
+
refresh: "POST /api/payments/:id/refresh",
|
|
31
|
+
edcAttempt: "POST /api/payments/attempts",
|
|
32
|
+
terminalResult: "POST /api/payments/attempts/:id/terminal-result",
|
|
33
|
+
webhooks: "POST /webhooks/{xendit|midtrans|razorpay|stripe|omise}",
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
app.use("/api/payments", optionalApiKey, paymentsRouter);
|
|
39
|
+
app.use("/webhooks", webhooksRouter);
|
|
40
|
+
|
|
41
|
+
app.use(errorHandler);
|
|
42
|
+
return app;
|
|
43
|
+
}
|