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 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
- # Temporary Holding Version
1
+ # multi-payment-system
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }