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
|
@@ -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";
|
package/src/config.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import dotenv from "dotenv";
|
|
2
|
+
|
|
3
|
+
dotenv.config();
|
|
4
|
+
|
|
5
|
+
export const config = {
|
|
6
|
+
port: Number(process.env.PORT || 3000),
|
|
7
|
+
nodeEnv: process.env.NODE_ENV || "development",
|
|
8
|
+
apiKey: process.env.API_KEY || "",
|
|
9
|
+
publicBaseUrl: process.env.PUBLIC_BASE_URL || "",
|
|
10
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export class BaseGateway {
|
|
2
|
+
constructor(name) {
|
|
3
|
+
this.name = name;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {object} ctx
|
|
8
|
+
* @param {string} ctx.paymentId
|
|
9
|
+
* @param {number} ctx.amountMinor
|
|
10
|
+
* @param {string} ctx.currency
|
|
11
|
+
* @param {object} ctx.keys
|
|
12
|
+
* @param {string} [ctx.orderId]
|
|
13
|
+
* @param {string} [ctx.description]
|
|
14
|
+
* @param {object} [ctx.customer]
|
|
15
|
+
* @param {string} [ctx.returnUrl]
|
|
16
|
+
* @param {string} [ctx.cancelUrl]
|
|
17
|
+
* @param {object} [ctx.metadata]
|
|
18
|
+
* @param {string} [ctx.paymentMethod]
|
|
19
|
+
*/
|
|
20
|
+
async createPayment(_ctx) {
|
|
21
|
+
throw new Error(`${this.name}: createPayment not implemented`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async getStatus(_ctx) {
|
|
25
|
+
throw new Error(`${this.name}: getStatus not implemented`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
normalizeStatus(raw) {
|
|
29
|
+
const s = String(raw || "").toLowerCase();
|
|
30
|
+
if (["succeeded", "success", "paid", "capture", "captured", "settlement", "completed"].includes(s)) {
|
|
31
|
+
return "succeeded";
|
|
32
|
+
}
|
|
33
|
+
if (["pending", "requires_action", "requires_payment_method", "awaiting_payment", "created", "authorized"].includes(s)) {
|
|
34
|
+
return "pending";
|
|
35
|
+
}
|
|
36
|
+
if (["failed", "failure", "cancel", "cancelled", "canceled", "expired", "deny", "denied", "void"].includes(s)) {
|
|
37
|
+
return "failed";
|
|
38
|
+
}
|
|
39
|
+
return "pending";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import { BaseGateway } from "./BaseGateway.js";
|
|
3
|
+
import { toMajorUnits, formatMajor } from "../utils/money.js";
|
|
4
|
+
import { AppError } from "../utils/errors.js";
|
|
5
|
+
import { normalizeMethods, mapMethodsForGateway } from "../utils/paymentMethods.js";
|
|
6
|
+
import { toBase64 } from "../utils/base64.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Midtrans — Indonesia-focused (Snap + Core API).
|
|
10
|
+
* paymentMethod examples: card | qr | ewallet | bank | gopay | shopeepay
|
|
11
|
+
*/
|
|
12
|
+
export class MidtransGateway extends BaseGateway {
|
|
13
|
+
constructor() {
|
|
14
|
+
super("midtrans");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
baseUrl(isProduction) {
|
|
18
|
+
return isProduction
|
|
19
|
+
? "https://app.midtrans.com"
|
|
20
|
+
: "https://app.sandbox.midtrans.com";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
apiBase(isProduction) {
|
|
24
|
+
return isProduction
|
|
25
|
+
? "https://api.midtrans.com"
|
|
26
|
+
: "https://api.sandbox.midtrans.com";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
authHeader(serverKey) {
|
|
30
|
+
const token = toBase64(`${serverKey}:`);
|
|
31
|
+
return { Authorization: `Basic ${token}`, "Content-Type": "application/json" };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async createPayment(ctx) {
|
|
35
|
+
const serverKey = ctx.keys.serverKey || ctx.keys.secretKey;
|
|
36
|
+
if (!serverKey) throw new AppError("Midtrans keys.serverKey (or secretKey) is required");
|
|
37
|
+
|
|
38
|
+
const isProduction = Boolean(ctx.keys.isProduction);
|
|
39
|
+
const orderId = ctx.orderId || ctx.paymentId;
|
|
40
|
+
const grossAmount = Math.round(toMajorUnits(ctx.amountMinor, ctx.currency));
|
|
41
|
+
const methods = normalizeMethods(ctx.paymentMethod, ctx.paymentMethods);
|
|
42
|
+
const enabled = mapMethodsForGateway("midtrans", methods, ctx.currency);
|
|
43
|
+
|
|
44
|
+
const payload = {
|
|
45
|
+
transaction_details: {
|
|
46
|
+
order_id: orderId,
|
|
47
|
+
gross_amount: grossAmount,
|
|
48
|
+
},
|
|
49
|
+
item_details: [
|
|
50
|
+
{
|
|
51
|
+
id: orderId,
|
|
52
|
+
price: grossAmount,
|
|
53
|
+
quantity: 1,
|
|
54
|
+
name: (ctx.description || "Kiosk payment").slice(0, 50),
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
customer_details: {
|
|
58
|
+
first_name: ctx.customer?.name || "Customer",
|
|
59
|
+
email: ctx.customer?.email || undefined,
|
|
60
|
+
phone: ctx.customer?.phone || undefined,
|
|
61
|
+
},
|
|
62
|
+
callbacks: {
|
|
63
|
+
finish: ctx.returnUrl || undefined,
|
|
64
|
+
error: ctx.cancelUrl || ctx.returnUrl || undefined,
|
|
65
|
+
},
|
|
66
|
+
custom_field1: ctx.paymentId,
|
|
67
|
+
metadata: ctx.metadata || {},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (enabled.length) {
|
|
71
|
+
payload.enabled_payments = enabled;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const { data } = await axios.post(
|
|
76
|
+
`${this.baseUrl(isProduction)}/snap/v1/transactions`,
|
|
77
|
+
payload,
|
|
78
|
+
{ headers: this.authHeader(serverKey), timeout: 30000 }
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
status: "pending",
|
|
83
|
+
gatewayRef: orderId,
|
|
84
|
+
checkoutUrl: data.redirect_url,
|
|
85
|
+
token: data.token,
|
|
86
|
+
paymentMethods: methods.length ? methods : ["all"],
|
|
87
|
+
amountDisplay: formatMajor(ctx.amountMinor, ctx.currency),
|
|
88
|
+
raw: data,
|
|
89
|
+
};
|
|
90
|
+
} catch (err) {
|
|
91
|
+
throw this.wrap(err);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async getStatus(ctx) {
|
|
96
|
+
const serverKey = ctx.keys.serverKey || ctx.keys.secretKey;
|
|
97
|
+
if (!serverKey) throw new AppError("Midtrans keys.serverKey (or secretKey) is required");
|
|
98
|
+
const isProduction = Boolean(ctx.keys.isProduction);
|
|
99
|
+
const orderId = ctx.gatewayRef || ctx.orderId;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const { data } = await axios.get(
|
|
103
|
+
`${this.apiBase(isProduction)}/v2/${encodeURIComponent(orderId)}/status`,
|
|
104
|
+
{ headers: this.authHeader(serverKey), timeout: 30000 }
|
|
105
|
+
);
|
|
106
|
+
return {
|
|
107
|
+
status: this.normalizeStatus(data.transaction_status || data.status_code),
|
|
108
|
+
gatewayRef: data.order_id || orderId,
|
|
109
|
+
raw: data,
|
|
110
|
+
};
|
|
111
|
+
} catch (err) {
|
|
112
|
+
throw this.wrap(err);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
wrap(err) {
|
|
117
|
+
const msg =
|
|
118
|
+
err.response?.data?.error_messages?.[0] ||
|
|
119
|
+
err.response?.data?.status_message ||
|
|
120
|
+
err.message ||
|
|
121
|
+
"Midtrans error";
|
|
122
|
+
return new AppError(String(msg), err.response?.status || 502, err.response?.data);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import { BaseGateway } from "./BaseGateway.js";
|
|
3
|
+
import { formatMajor } from "../utils/money.js";
|
|
4
|
+
import { AppError } from "../utils/errors.js";
|
|
5
|
+
import { normalizeMethods, mapMethodsForGateway } from "../utils/paymentMethods.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Omise (Opn) — Thailand, Singapore, Japan, Malaysia.
|
|
9
|
+
* paymentMethod examples: card | qr | paynow | promptpay | fpx
|
|
10
|
+
*
|
|
11
|
+
* Note: card usually needs a client-side token (tokn_...); for kiosk QR use paynow/promptpay.
|
|
12
|
+
*/
|
|
13
|
+
export class OmiseGateway extends BaseGateway {
|
|
14
|
+
constructor() {
|
|
15
|
+
super("omise");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
client(secretKey) {
|
|
19
|
+
if (!secretKey) throw new AppError("Omise keys.secretKey is required");
|
|
20
|
+
return axios.create({
|
|
21
|
+
baseURL: "https://api.omise.co",
|
|
22
|
+
auth: { username: secretKey, password: "" },
|
|
23
|
+
headers: { "Content-Type": "application/json" },
|
|
24
|
+
timeout: 30000,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
resolveSourceType(ctx, methods) {
|
|
29
|
+
const mapped = mapMethodsForGateway("omise", methods, ctx.currency);
|
|
30
|
+
if (mapped.length) return mapped[0];
|
|
31
|
+
|
|
32
|
+
if (ctx.keys.sourceType) return ctx.keys.sourceType;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
{
|
|
36
|
+
SGD: "paynow",
|
|
37
|
+
MYR: "fpx",
|
|
38
|
+
THB: "promptpay",
|
|
39
|
+
}[ctx.currency] || "promptpay"
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async createPayment(ctx) {
|
|
44
|
+
const api = this.client(ctx.keys.secretKey);
|
|
45
|
+
const returnUri = ctx.returnUrl || ctx.keys.returnUri;
|
|
46
|
+
const methods = normalizeMethods(ctx.paymentMethod, ctx.paymentMethods);
|
|
47
|
+
const sourceType = this.resolveSourceType(ctx, methods);
|
|
48
|
+
|
|
49
|
+
if (sourceType === "card" && !ctx.keys.cardToken && !ctx.cardToken) {
|
|
50
|
+
throw new AppError(
|
|
51
|
+
"Omise card payments need keys.cardToken (from Om.js). For kiosk QR use paymentMethod: \"qr\" or \"paynow\" / \"promptpay\".",
|
|
52
|
+
400
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
let sourceId = ctx.keys.cardToken || ctx.cardToken || null;
|
|
58
|
+
let source = null;
|
|
59
|
+
|
|
60
|
+
if (!sourceId) {
|
|
61
|
+
const sourcePayload = {
|
|
62
|
+
amount: ctx.amountMinor,
|
|
63
|
+
currency: ctx.currency.toLowerCase(),
|
|
64
|
+
type: sourceType,
|
|
65
|
+
};
|
|
66
|
+
if (returnUri) sourcePayload.return_uri = returnUri;
|
|
67
|
+
if (sourceType === "fpx" && (ctx.keys.bank || ctx.bank)) {
|
|
68
|
+
sourcePayload.bank = ctx.keys.bank || ctx.bank;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const created = await api.post("/sources", sourcePayload);
|
|
72
|
+
source = created.data;
|
|
73
|
+
sourceId = source.id;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const chargePayload = {
|
|
77
|
+
amount: ctx.amountMinor,
|
|
78
|
+
currency: ctx.currency.toLowerCase(),
|
|
79
|
+
source: sourceId,
|
|
80
|
+
description: ctx.description || `Payment ${ctx.paymentId}`,
|
|
81
|
+
metadata: {
|
|
82
|
+
paymentId: ctx.paymentId,
|
|
83
|
+
orderId: ctx.orderId || "",
|
|
84
|
+
paymentMethod: methods.join(",") || sourceType,
|
|
85
|
+
...(ctx.metadata || {}),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
if (returnUri) chargePayload.return_uri = returnUri;
|
|
89
|
+
|
|
90
|
+
const { data: charge } = await api.post("/charges", chargePayload);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
status: this.normalizeStatus(
|
|
94
|
+
charge.status === "successful"
|
|
95
|
+
? "succeeded"
|
|
96
|
+
: charge.status === "pending"
|
|
97
|
+
? "pending"
|
|
98
|
+
: charge.status
|
|
99
|
+
),
|
|
100
|
+
gatewayRef: charge.id,
|
|
101
|
+
checkoutUrl: charge.authorize_uri || source?.scannable_code?.image?.download_uri,
|
|
102
|
+
qrImageUrl: source?.scannable_code?.image?.download_uri,
|
|
103
|
+
paymentMethods: methods.length ? methods : [sourceType],
|
|
104
|
+
amountDisplay: formatMajor(ctx.amountMinor, ctx.currency),
|
|
105
|
+
raw: { source, charge },
|
|
106
|
+
};
|
|
107
|
+
} catch (err) {
|
|
108
|
+
throw this.wrap(err);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async getStatus(ctx) {
|
|
113
|
+
const api = this.client(ctx.keys.secretKey);
|
|
114
|
+
try {
|
|
115
|
+
const { data } = await api.get(`/charges/${ctx.gatewayRef}`);
|
|
116
|
+
return {
|
|
117
|
+
status: this.normalizeStatus(
|
|
118
|
+
data.status === "successful" ? "succeeded" : data.status
|
|
119
|
+
),
|
|
120
|
+
gatewayRef: data.id,
|
|
121
|
+
checkoutUrl: data.authorize_uri,
|
|
122
|
+
raw: data,
|
|
123
|
+
};
|
|
124
|
+
} catch (err) {
|
|
125
|
+
throw this.wrap(err);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
wrap(err) {
|
|
130
|
+
const msg =
|
|
131
|
+
err.response?.data?.message ||
|
|
132
|
+
err.response?.data?.error ||
|
|
133
|
+
err.message ||
|
|
134
|
+
"Omise error";
|
|
135
|
+
return new AppError(String(msg), err.response?.status || 502, err.response?.data);
|
|
136
|
+
}
|
|
137
|
+
}
|