multi-payment-system 1.0.0 → 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/README.md +53 -43
- package/package.json +33 -12
- package/src/api.js +10 -25
- 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/gateways/MidtransGateway.js +2 -1
- package/src/gateways/XenditGateway.js +0 -24
- package/src/index.js +9 -8
- package/src/meta.js +35 -0
- package/src/services/PaymentService.js +0 -2
- package/src/utils/base64.js +11 -0
- package/src/utils/paymentMethods.js +3 -5
package/README.md
CHANGED
|
@@ -1,38 +1,71 @@
|
|
|
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`) |
|
|
4
10
|
|
|
5
11
|
---
|
|
6
12
|
|
|
7
|
-
##
|
|
13
|
+
## React client
|
|
8
14
|
|
|
9
15
|
```bash
|
|
10
16
|
npm install multi-payment-system
|
|
11
17
|
```
|
|
12
18
|
|
|
13
|
-
|
|
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
|
+
```
|
|
14
50
|
|
|
15
|
-
|
|
16
|
-
import { XenditGateway, StripeGateway, charge, getStatus, listGateways } from "multi-payment-system";
|
|
51
|
+
Or without the hook:
|
|
17
52
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
paymentId: "pay_1",
|
|
21
|
-
amountMinor: 3000, // SGD 30.00
|
|
22
|
-
currency: "SGD",
|
|
23
|
-
paymentMethod: "card",
|
|
24
|
-
keys: { secretKey: process.env.XENDIT_SECRET_KEY },
|
|
25
|
-
returnUrl: "https://example.com/success",
|
|
26
|
-
cancelUrl: "https://example.com/cancel",
|
|
27
|
-
});
|
|
53
|
+
```js
|
|
54
|
+
import { PaymentClient, redirectToCheckout } from "multi-payment-system/client";
|
|
28
55
|
|
|
29
|
-
|
|
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
|
|
30
59
|
```
|
|
31
60
|
|
|
32
|
-
|
|
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
|
|
33
66
|
|
|
34
67
|
```js
|
|
35
|
-
import {
|
|
68
|
+
import { XenditGateway, charge } from "multi-payment-system";
|
|
36
69
|
|
|
37
70
|
const result = await charge({
|
|
38
71
|
gateway: "xendit",
|
|
@@ -42,34 +75,11 @@ const result = await charge({
|
|
|
42
75
|
keys: { secretKey: process.env.XENDIT_SECRET_KEY },
|
|
43
76
|
returnUrl: "https://example.com/success",
|
|
44
77
|
});
|
|
45
|
-
|
|
46
|
-
const status = await getStatus({
|
|
47
|
-
gateway: "xendit",
|
|
48
|
-
gatewayRef: result.gatewayRef,
|
|
49
|
-
keys: { secretKey: process.env.XENDIT_SECRET_KEY },
|
|
50
|
-
});
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
### Exports
|
|
54
|
-
|
|
55
|
-
| Export | What |
|
|
56
|
-
|--------|------|
|
|
57
|
-
| `XenditGateway`, `MidtransGateway`, `RazorpayGateway`, `StripeGateway`, `OmiseGateway` | Gateway classes |
|
|
58
|
-
| `charge`, `getStatus`, `listGateways` | Helper functions |
|
|
59
|
-
| `getGateway("xendit")` | Resolve by name |
|
|
60
|
-
| `createApp()` | Express app (HTTP API) |
|
|
61
|
-
| `PaymentService` | In-memory payment tracker used by the server |
|
|
62
|
-
|
|
63
|
-
### Publish to npm
|
|
64
|
-
|
|
65
|
-
```bash
|
|
66
|
-
npm login
|
|
67
|
-
npm publish --access public
|
|
68
78
|
```
|
|
69
79
|
|
|
70
80
|
---
|
|
71
81
|
|
|
72
|
-
## Run
|
|
82
|
+
## Run local API server
|
|
73
83
|
|
|
74
84
|
```bash
|
|
75
85
|
npm install
|
|
@@ -79,7 +89,7 @@ npm start
|
|
|
79
89
|
Server: `http://localhost:3000`
|
|
80
90
|
|
|
81
91
|
```bash
|
|
82
|
-
npm run test:smoke
|
|
92
|
+
npm run test:smoke
|
|
83
93
|
```
|
|
84
94
|
|
|
85
95
|
### Postman
|
package/package.json
CHANGED
|
@@ -1,15 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "multi-payment-system",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Multi-gateway
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Multi-gateway payments for Node and React (Xendit, Midtrans, Razorpay, Stripe, Omise)",
|
|
5
5
|
"type": "module",
|
|
6
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
|
+
},
|
|
7
19
|
"exports": {
|
|
8
|
-
".":
|
|
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",
|
|
9
27
|
"./server": "./src/server.js",
|
|
10
28
|
"./app": "./src/app.js",
|
|
11
29
|
"./package.json": "./package.json"
|
|
12
30
|
},
|
|
31
|
+
"sideEffects": false,
|
|
13
32
|
"files": [
|
|
14
33
|
"src",
|
|
15
34
|
"README.md",
|
|
@@ -19,28 +38,30 @@
|
|
|
19
38
|
"start": "node src/server.js",
|
|
20
39
|
"dev": "node --watch src/server.js",
|
|
21
40
|
"test:smoke": "node scripts/smoke-test.js",
|
|
22
|
-
"
|
|
41
|
+
"test:package": "node scripts/package-import-test.js",
|
|
42
|
+
"prepublishOnly": "npm run test:package && npm run test:smoke"
|
|
23
43
|
},
|
|
24
44
|
"engines": {
|
|
25
45
|
"node": ">=18"
|
|
26
46
|
},
|
|
27
47
|
"keywords": [
|
|
28
48
|
"payment",
|
|
49
|
+
"react",
|
|
29
50
|
"xendit",
|
|
30
51
|
"midtrans",
|
|
31
52
|
"razorpay",
|
|
32
53
|
"stripe",
|
|
33
54
|
"omise",
|
|
34
|
-
"
|
|
35
|
-
"myr",
|
|
36
|
-
"kiosk",
|
|
37
|
-
"gateway"
|
|
55
|
+
"checkout"
|
|
38
56
|
],
|
|
39
|
-
"author": "",
|
|
40
57
|
"license": "MIT",
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"react": ">=17"
|
|
60
|
+
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"react": {
|
|
63
|
+
"optional": true
|
|
64
|
+
}
|
|
44
65
|
},
|
|
45
66
|
"dependencies": {
|
|
46
67
|
"axios": "^1.7.9",
|
package/src/api.js
CHANGED
|
@@ -1,22 +1,18 @@
|
|
|
1
|
-
import { v4 as uuidv4 } from "uuid";
|
|
2
1
|
import { getGateway, GATEWAY_NAMES, gatewayRegionsHint } from "./gateways/index.js";
|
|
3
2
|
import { toMinorUnits, normalizeCurrency, SUPPORTED_CURRENCIES } from "./utils/money.js";
|
|
4
3
|
import { PAYMENT_METHOD_HINTS, normalizeMethods } from "./utils/paymentMethods.js";
|
|
5
4
|
import { AppError } from "./utils/errors.js";
|
|
6
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
|
+
|
|
7
13
|
/**
|
|
8
14
|
* Create a payment on any supported gateway.
|
|
9
|
-
*
|
|
10
|
-
* @example
|
|
11
|
-
* import { charge } from "multi-payment-system";
|
|
12
|
-
* const result = await charge({
|
|
13
|
-
* gateway: "xendit",
|
|
14
|
-
* amount: 30,
|
|
15
|
-
* currency: "SGD",
|
|
16
|
-
* paymentMethod: "card",
|
|
17
|
-
* keys: { secretKey: "xnd_development_..." },
|
|
18
|
-
* returnUrl: "https://example.com/success",
|
|
19
|
-
* });
|
|
15
|
+
* Prefer calling this on a server/backend — never ship secret keys in a React bundle.
|
|
20
16
|
*/
|
|
21
17
|
export async function charge(input = {}) {
|
|
22
18
|
const gatewayName = String(input.gateway || "").toLowerCase();
|
|
@@ -38,7 +34,7 @@ export async function charge(input = {}) {
|
|
|
38
34
|
throw new AppError("amount must be greater than 0");
|
|
39
35
|
}
|
|
40
36
|
|
|
41
|
-
const paymentId = input.paymentId ||
|
|
37
|
+
const paymentId = input.paymentId || newId();
|
|
42
38
|
const gateway = getGateway(gatewayName);
|
|
43
39
|
|
|
44
40
|
const result = await gateway.createPayment({
|
|
@@ -71,17 +67,7 @@ export async function charge(input = {}) {
|
|
|
71
67
|
};
|
|
72
68
|
}
|
|
73
69
|
|
|
74
|
-
/**
|
|
75
|
-
* Refresh / query payment status from the gateway.
|
|
76
|
-
*
|
|
77
|
-
* @example
|
|
78
|
-
* import { getStatus } from "multi-payment-system";
|
|
79
|
-
* const status = await getStatus({
|
|
80
|
-
* gateway: "xendit",
|
|
81
|
-
* gatewayRef: "inv_...",
|
|
82
|
-
* keys: { secretKey: "xnd_development_..." },
|
|
83
|
-
* });
|
|
84
|
-
*/
|
|
70
|
+
/** Refresh / query payment status from the gateway. */
|
|
85
71
|
export async function getStatus(input = {}) {
|
|
86
72
|
const gatewayName = String(input.gateway || "").toLowerCase();
|
|
87
73
|
if (!gatewayName) throw new AppError("gateway is required");
|
|
@@ -106,7 +92,6 @@ export async function getStatus(input = {}) {
|
|
|
106
92
|
};
|
|
107
93
|
}
|
|
108
94
|
|
|
109
|
-
/** List supported gateways, regions, currencies, payment method hints */
|
|
110
95
|
export function listGateways() {
|
|
111
96
|
return {
|
|
112
97
|
gateways: GATEWAY_NAMES,
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { requestJson, ClientHttpError } from "./http.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Browser / React-safe client.
|
|
5
|
+
* Talks to YOUR payment backend (this package's Express server or any compatible API).
|
|
6
|
+
* Secret gateway keys stay on the server — do not put them in React.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* import { PaymentClient } from "multi-payment-system/client";
|
|
10
|
+
*
|
|
11
|
+
* const client = new PaymentClient({
|
|
12
|
+
* baseUrl: "http://localhost:3000",
|
|
13
|
+
* // apiKey: "optional-if-server-has-API_KEY",
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* const { payment } = await client.charge({
|
|
17
|
+
* gateway: "xendit",
|
|
18
|
+
* amount: 30,
|
|
19
|
+
* currency: "SGD",
|
|
20
|
+
* paymentMethod: "card",
|
|
21
|
+
* keys: { secretKey: "..." }, // only if your backend accepts keys per request
|
|
22
|
+
* returnUrl: window.location.origin + "/success",
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* client.openCheckout(payment);
|
|
26
|
+
*/
|
|
27
|
+
export class PaymentClient {
|
|
28
|
+
constructor({ baseUrl, apiKey, headers } = {}) {
|
|
29
|
+
if (!baseUrl) throw new Error("PaymentClient: baseUrl is required");
|
|
30
|
+
this.baseUrl = baseUrl;
|
|
31
|
+
this.apiKey = apiKey || "";
|
|
32
|
+
this.headers = headers || {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#opts(extra = {}) {
|
|
36
|
+
return {
|
|
37
|
+
apiKey: this.apiKey,
|
|
38
|
+
headers: this.headers,
|
|
39
|
+
...extra,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** GET /api/payments/meta */
|
|
44
|
+
async meta() {
|
|
45
|
+
return requestJson(this.baseUrl, "/api/payments/meta", this.#opts());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* POST /api/payments/charge
|
|
50
|
+
* Body can include gateway, amount, currency, paymentMethod, keys, returnUrl, ...
|
|
51
|
+
*/
|
|
52
|
+
async charge(body) {
|
|
53
|
+
return requestJson(this.baseUrl, "/api/payments/charge", {
|
|
54
|
+
...this.#opts(),
|
|
55
|
+
method: "POST",
|
|
56
|
+
body,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** GET /api/payments/:id */
|
|
61
|
+
async getPayment(id) {
|
|
62
|
+
return requestJson(this.baseUrl, `/api/payments/${encodeURIComponent(id)}`, this.#opts());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** POST /api/payments/:id/refresh */
|
|
66
|
+
async refresh(id, keys) {
|
|
67
|
+
return requestJson(this.baseUrl, `/api/payments/${encodeURIComponent(id)}/refresh`, {
|
|
68
|
+
...this.#opts(),
|
|
69
|
+
method: "POST",
|
|
70
|
+
body: keys ? { keys } : {},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Yokke EDC: create attempt */
|
|
75
|
+
async createEdcAttempt(body) {
|
|
76
|
+
return requestJson(this.baseUrl, "/api/payments/attempts", {
|
|
77
|
+
...this.#opts(),
|
|
78
|
+
method: "POST",
|
|
79
|
+
body,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Yokke EDC: report terminal result */
|
|
84
|
+
async reportTerminalResult(id, body) {
|
|
85
|
+
return requestJson(
|
|
86
|
+
this.baseUrl,
|
|
87
|
+
`/api/payments/attempts/${encodeURIComponent(id)}/terminal-result`,
|
|
88
|
+
{
|
|
89
|
+
...this.#opts(),
|
|
90
|
+
method: "POST",
|
|
91
|
+
body,
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Open hosted checkout in the current window, new tab, or return the URL.
|
|
98
|
+
* Supports Xendit/Stripe/Omise checkoutUrl and Midtrans redirect_url.
|
|
99
|
+
*/
|
|
100
|
+
openCheckout(paymentOrUrl, { target = "_self" } = {}) {
|
|
101
|
+
const url =
|
|
102
|
+
typeof paymentOrUrl === "string"
|
|
103
|
+
? paymentOrUrl
|
|
104
|
+
: paymentOrUrl?.checkoutUrl ||
|
|
105
|
+
paymentOrUrl?.payment?.checkoutUrl ||
|
|
106
|
+
paymentOrUrl?.qrImageUrl ||
|
|
107
|
+
paymentOrUrl?.payment?.qrImageUrl;
|
|
108
|
+
|
|
109
|
+
if (!url) {
|
|
110
|
+
throw new Error("No checkoutUrl on payment result");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (typeof window === "undefined") {
|
|
114
|
+
return url;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (target === "_blank") {
|
|
118
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
119
|
+
} else {
|
|
120
|
+
window.location.assign(url);
|
|
121
|
+
}
|
|
122
|
+
return url;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Razorpay Checkout helper — loads Razorpay.js and opens modal (browser only). */
|
|
126
|
+
async openRazorpayCheckout(payment, options = {}) {
|
|
127
|
+
const checkout = payment?.checkout || payment?.payment?.checkout;
|
|
128
|
+
if (!checkout?.keyId || !checkout?.orderId) {
|
|
129
|
+
throw new Error("Razorpay checkout payload missing (keyId / orderId)");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await loadScript("https://checkout.razorpay.com/v1/checkout.js");
|
|
133
|
+
if (typeof window === "undefined" || !window.Razorpay) {
|
|
134
|
+
throw new Error("Razorpay SDK failed to load");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const rzp = new window.Razorpay({
|
|
139
|
+
key: checkout.keyId,
|
|
140
|
+
amount: checkout.amount,
|
|
141
|
+
currency: checkout.currency,
|
|
142
|
+
name: checkout.name || "Payment",
|
|
143
|
+
order_id: checkout.orderId,
|
|
144
|
+
prefill: checkout.prefill || {},
|
|
145
|
+
handler: (response) => resolve(response),
|
|
146
|
+
modal: {
|
|
147
|
+
ondismiss: () => reject(new Error("Razorpay checkout dismissed")),
|
|
148
|
+
},
|
|
149
|
+
...options,
|
|
150
|
+
});
|
|
151
|
+
rzp.open();
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function loadScript(src) {
|
|
157
|
+
if (typeof document === "undefined") {
|
|
158
|
+
return Promise.reject(new Error("document not available"));
|
|
159
|
+
}
|
|
160
|
+
const existing = document.querySelector(`script[src="${src}"]`);
|
|
161
|
+
if (existing) return Promise.resolve();
|
|
162
|
+
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
const s = document.createElement("script");
|
|
165
|
+
s.src = src;
|
|
166
|
+
s.async = true;
|
|
167
|
+
s.onload = () => resolve();
|
|
168
|
+
s.onerror = () => reject(new Error(`Failed to load ${src}`));
|
|
169
|
+
document.head.appendChild(s);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export { ClientHttpError };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export class ClientHttpError extends Error {
|
|
2
|
+
constructor(message, status, details) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "ClientHttpError";
|
|
5
|
+
this.status = status;
|
|
6
|
+
this.details = details;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function requestJson(baseUrl, path, { method = "GET", body, apiKey, headers } = {}) {
|
|
11
|
+
const url = `${String(baseUrl).replace(/\/$/, "")}${path}`;
|
|
12
|
+
const res = await fetch(url, {
|
|
13
|
+
method,
|
|
14
|
+
headers: {
|
|
15
|
+
Accept: "application/json",
|
|
16
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
17
|
+
...(apiKey ? { "x-api-key": apiKey } : {}),
|
|
18
|
+
...(headers || {}),
|
|
19
|
+
},
|
|
20
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
let json = null;
|
|
24
|
+
try {
|
|
25
|
+
json = await res.json();
|
|
26
|
+
} catch {
|
|
27
|
+
json = null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
throw new ClientHttpError(
|
|
32
|
+
json?.error || `Request failed (${res.status})`,
|
|
33
|
+
res.status,
|
|
34
|
+
json?.details || json
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return json;
|
|
39
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser / React entry — no Express.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* import { PaymentClient, redirectToCheckout } from "multi-payment-system/client";
|
|
6
|
+
*/
|
|
7
|
+
export { PaymentClient, ClientHttpError } from "./PaymentClient.js";
|
|
8
|
+
export { requestJson } from "./http.js";
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
listGateways,
|
|
12
|
+
GATEWAY_NAMES,
|
|
13
|
+
gatewayRegionsHint,
|
|
14
|
+
SUPPORTED_CURRENCIES,
|
|
15
|
+
} from "../meta.js";
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
toMinorUnits,
|
|
19
|
+
toMajorUnits,
|
|
20
|
+
formatMajor,
|
|
21
|
+
normalizeCurrency,
|
|
22
|
+
} from "../utils/money.js";
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
normalizeMethods,
|
|
26
|
+
mapMethodsForGateway,
|
|
27
|
+
PAYMENT_METHOD_HINTS,
|
|
28
|
+
} from "../utils/paymentMethods.js";
|
|
29
|
+
|
|
30
|
+
import { PaymentClient } from "./PaymentClient.js";
|
|
31
|
+
|
|
32
|
+
/** Open hosted checkout URL (Xendit / Stripe / Omise / Midtrans). */
|
|
33
|
+
export function redirectToCheckout(paymentOrUrl, options) {
|
|
34
|
+
return new PaymentClient({ baseUrl: "https://local" }).openCheckout(paymentOrUrl, options);
|
|
35
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React helpers. Peer-depends on react.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* import { usePaymentClient } from "multi-payment-system/react";
|
|
6
|
+
*
|
|
7
|
+
* function PayButton() {
|
|
8
|
+
* const { chargeAndRedirect, loading, error } = usePaymentClient({
|
|
9
|
+
* baseUrl: import.meta.env.VITE_PAY_API,
|
|
10
|
+
* });
|
|
11
|
+
*
|
|
12
|
+
* return (
|
|
13
|
+
* <button
|
|
14
|
+
* disabled={loading}
|
|
15
|
+
* onClick={() =>
|
|
16
|
+
* chargeAndRedirect({
|
|
17
|
+
* gateway: "xendit",
|
|
18
|
+
* amount: 30,
|
|
19
|
+
* currency: "SGD",
|
|
20
|
+
* paymentMethod: "card",
|
|
21
|
+
* keys: { secretKey: "..." }, // better: store keys only on server
|
|
22
|
+
* returnUrl: window.location.origin + "/success",
|
|
23
|
+
* })
|
|
24
|
+
* }
|
|
25
|
+
* >
|
|
26
|
+
* {loading ? "Opening…" : "Pay"}
|
|
27
|
+
* </button>
|
|
28
|
+
* );
|
|
29
|
+
* }
|
|
30
|
+
*/
|
|
31
|
+
import { useCallback, useMemo, useState } from "react";
|
|
32
|
+
import { PaymentClient } from "./PaymentClient.js";
|
|
33
|
+
|
|
34
|
+
export function usePaymentClient({ baseUrl, apiKey, headers } = {}) {
|
|
35
|
+
const client = useMemo(
|
|
36
|
+
() => new PaymentClient({ baseUrl, apiKey, headers }),
|
|
37
|
+
[baseUrl, apiKey, headers]
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const [loading, setLoading] = useState(false);
|
|
41
|
+
const [error, setError] = useState(null);
|
|
42
|
+
const [payment, setPayment] = useState(null);
|
|
43
|
+
|
|
44
|
+
const charge = useCallback(
|
|
45
|
+
async (body) => {
|
|
46
|
+
setLoading(true);
|
|
47
|
+
setError(null);
|
|
48
|
+
try {
|
|
49
|
+
const res = await client.charge(body);
|
|
50
|
+
setPayment(res.payment || res);
|
|
51
|
+
return res;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
setError(err);
|
|
54
|
+
throw err;
|
|
55
|
+
} finally {
|
|
56
|
+
setLoading(false);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
[client]
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const chargeAndRedirect = useCallback(
|
|
63
|
+
async (body, openOptions) => {
|
|
64
|
+
const res = await charge(body);
|
|
65
|
+
const pay = res.payment || res;
|
|
66
|
+
if (pay?.checkout) {
|
|
67
|
+
await client.openRazorpayCheckout(pay, openOptions);
|
|
68
|
+
} else {
|
|
69
|
+
client.openCheckout(pay, openOptions);
|
|
70
|
+
}
|
|
71
|
+
return res;
|
|
72
|
+
},
|
|
73
|
+
[charge, client]
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const refresh = useCallback(
|
|
77
|
+
async (id, keys) => {
|
|
78
|
+
setLoading(true);
|
|
79
|
+
setError(null);
|
|
80
|
+
try {
|
|
81
|
+
const res = await client.refresh(id, keys);
|
|
82
|
+
setPayment(res.payment || res);
|
|
83
|
+
return res;
|
|
84
|
+
} catch (err) {
|
|
85
|
+
setError(err);
|
|
86
|
+
throw err;
|
|
87
|
+
} finally {
|
|
88
|
+
setLoading(false);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
[client]
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
client,
|
|
96
|
+
loading,
|
|
97
|
+
error,
|
|
98
|
+
payment,
|
|
99
|
+
charge,
|
|
100
|
+
chargeAndRedirect,
|
|
101
|
+
refresh,
|
|
102
|
+
openCheckout: (p, opts) => client.openCheckout(p, opts),
|
|
103
|
+
openRazorpayCheckout: (p, opts) => client.openRazorpayCheckout(p, opts),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export { PaymentClient } from "./PaymentClient.js";
|
|
@@ -3,6 +3,7 @@ import { BaseGateway } from "./BaseGateway.js";
|
|
|
3
3
|
import { toMajorUnits, formatMajor } from "../utils/money.js";
|
|
4
4
|
import { AppError } from "../utils/errors.js";
|
|
5
5
|
import { normalizeMethods, mapMethodsForGateway } from "../utils/paymentMethods.js";
|
|
6
|
+
import { toBase64 } from "../utils/base64.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Midtrans — Indonesia-focused (Snap + Core API).
|
|
@@ -26,7 +27,7 @@ export class MidtransGateway extends BaseGateway {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
authHeader(serverKey) {
|
|
29
|
-
const token =
|
|
30
|
+
const token = toBase64(`${serverKey}:`);
|
|
30
31
|
return { Authorization: `Basic ${token}`, "Content-Type": "application/json" };
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -61,34 +61,10 @@ export class XenditGateway extends BaseGateway {
|
|
|
61
61
|
gatewayRef: data.id,
|
|
62
62
|
checkoutUrl: data.invoice_url,
|
|
63
63
|
paymentMethods: methods.length ? methods : ["all"],
|
|
64
|
-
xenditPaymentMethods: xenditMethods,
|
|
65
64
|
amountDisplay: formatMajor(ctx.amountMinor, ctx.currency),
|
|
66
65
|
raw: data,
|
|
67
66
|
};
|
|
68
67
|
} catch (err) {
|
|
69
|
-
// If filtered methods are wrong for this business, retry with all activated channels
|
|
70
|
-
if (
|
|
71
|
-
xenditMethods.length &&
|
|
72
|
-
err.response?.data?.error_code === "UNAVAILABLE_PAYMENT_METHOD_ERROR"
|
|
73
|
-
) {
|
|
74
|
-
delete payload.payment_methods;
|
|
75
|
-
try {
|
|
76
|
-
const { data } = await api.post("/v2/invoices", payload);
|
|
77
|
-
return {
|
|
78
|
-
status: this.normalizeStatus(data.status),
|
|
79
|
-
gatewayRef: data.id,
|
|
80
|
-
checkoutUrl: data.invoice_url,
|
|
81
|
-
paymentMethods: methods.length ? methods : ["all"],
|
|
82
|
-
xenditPaymentMethods: [],
|
|
83
|
-
warning:
|
|
84
|
-
`Requested channels ${xenditMethods.join(",")} unavailable on this Xendit business; invoice opened with all activated methods.`,
|
|
85
|
-
amountDisplay: formatMajor(ctx.amountMinor, ctx.currency),
|
|
86
|
-
raw: data,
|
|
87
|
-
};
|
|
88
|
-
} catch (retryErr) {
|
|
89
|
-
throw this.wrap(retryErr);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
68
|
throw this.wrap(err);
|
|
93
69
|
}
|
|
94
70
|
}
|
package/src/index.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* multi-payment-system
|
|
2
|
+
* multi-payment-system — Node / server SDK
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* const xendit = new XenditGateway();
|
|
8
|
-
* const pay = await xendit.createPayment({ ... });
|
|
4
|
+
* For React / browser use:
|
|
5
|
+
* import { PaymentClient } from "multi-payment-system/client";
|
|
6
|
+
* import { usePaymentClient } from "multi-payment-system/react";
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* @example
|
|
9
|
+
* import { XenditGateway, charge } from "multi-payment-system";
|
|
12
10
|
*/
|
|
13
11
|
|
|
14
12
|
export { BaseGateway } from "./gateways/BaseGateway.js";
|
|
@@ -45,3 +43,6 @@ export {
|
|
|
45
43
|
export { AppError } from "./utils/errors.js";
|
|
46
44
|
|
|
47
45
|
export { createApp } from "./app.js";
|
|
46
|
+
|
|
47
|
+
// Re-export browser client for convenience (still prefer /client in React apps)
|
|
48
|
+
export { PaymentClient, redirectToCheckout } from "./client/index.js";
|
package/src/meta.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Lightweight meta — safe for browser bundles (no gateway class imports). */
|
|
2
|
+
|
|
3
|
+
export const GATEWAY_NAMES = ["xendit", "midtrans", "razorpay", "stripe", "omise"];
|
|
4
|
+
|
|
5
|
+
export function gatewayRegionsHint() {
|
|
6
|
+
return {
|
|
7
|
+
xendit: ["SG", "MY", "ID", "PH", "TH", "VN"],
|
|
8
|
+
midtrans: ["ID"],
|
|
9
|
+
razorpay: ["IN", "international"],
|
|
10
|
+
stripe: ["SG", "MY", "global"],
|
|
11
|
+
omise: ["SG", "MY", "TH", "JP"],
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const SUPPORTED_CURRENCIES = [
|
|
16
|
+
"SGD",
|
|
17
|
+
"MYR",
|
|
18
|
+
"IDR",
|
|
19
|
+
"THB",
|
|
20
|
+
"PHP",
|
|
21
|
+
"VND",
|
|
22
|
+
"USD",
|
|
23
|
+
"INR",
|
|
24
|
+
"JPY",
|
|
25
|
+
"AUD",
|
|
26
|
+
"HKD",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export function listGateways() {
|
|
30
|
+
return {
|
|
31
|
+
gateways: GATEWAY_NAMES,
|
|
32
|
+
regions: gatewayRegionsHint(),
|
|
33
|
+
currencies: SUPPORTED_CURRENCIES,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -88,8 +88,6 @@ export class PaymentService {
|
|
|
88
88
|
draft.checkout = result.checkout || null;
|
|
89
89
|
draft.qrImageUrl = result.qrImageUrl || null;
|
|
90
90
|
draft.resolvedPaymentMethods = result.paymentMethods || null;
|
|
91
|
-
draft.xenditPaymentMethods = result.xenditPaymentMethods || null;
|
|
92
|
-
draft.warning = result.warning || null;
|
|
93
91
|
draft.amountDisplay = result.amountDisplay || null;
|
|
94
92
|
draft.gatewayRaw = result.raw || null;
|
|
95
93
|
draft.updatedAt = new Date().toISOString();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Base64 encode that works in Node and browsers. */
|
|
2
|
+
export function toBase64(value) {
|
|
3
|
+
const str = String(value);
|
|
4
|
+
if (typeof Buffer !== "undefined") {
|
|
5
|
+
return Buffer.from(str, "utf8").toString("base64");
|
|
6
|
+
}
|
|
7
|
+
if (typeof btoa === "function") {
|
|
8
|
+
return btoa(unescape(encodeURIComponent(str)));
|
|
9
|
+
}
|
|
10
|
+
throw new Error("No Base64 encoder available in this environment");
|
|
11
|
+
}
|
|
@@ -44,9 +44,8 @@ export const PAYMENT_METHOD_HINTS = {
|
|
|
44
44
|
qr: ["QRIS"],
|
|
45
45
|
ewallet: ["GRABPAY", "OVO", "DANA", "LINKAJA", "SHOPEEPAY", "GCASH", "PAYMAYA"],
|
|
46
46
|
bank: ["VIRTUAL_ACCOUNT", "DIRECT_DEBIT"],
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
sgqr: ["SGQR"],
|
|
47
|
+
paynow: ["PAYNOW"],
|
|
48
|
+
sgqr: ["PAYNOW"],
|
|
50
49
|
promptpay: ["PROMPTPAY"],
|
|
51
50
|
grabpay: ["GRABPAY"],
|
|
52
51
|
ovo: ["OVO"],
|
|
@@ -134,9 +133,8 @@ export function mapMethodsForGateway(gateway, methods, currency) {
|
|
|
134
133
|
const out = [];
|
|
135
134
|
for (const m of methods) {
|
|
136
135
|
if (m === "qr" || m === "paynow" || m === "sgqr") {
|
|
137
|
-
if (cur === "SGD") out.push("
|
|
136
|
+
if (cur === "SGD") out.push("PAYNOW");
|
|
138
137
|
else if (cur === "THB") out.push("PROMPTPAY");
|
|
139
|
-
else if (cur === "PHP") out.push("QRPH");
|
|
140
138
|
else out.push("QRIS");
|
|
141
139
|
continue;
|
|
142
140
|
}
|