multi-payment-system 0.0.0-stage → 1.0.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 +350 -2
- package/package.json +51 -4
- package/src/api.js +117 -0
- package/src/app.js +43 -0
- package/src/config.js +10 -0
- package/src/gateways/BaseGateway.js +41 -0
- package/src/gateways/MidtransGateway.js +123 -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 +119 -0
- package/src/gateways/index.js +42 -0
- package/src/index.js +47 -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 +251 -0
- package/src/store/MemoryStore.js +49 -0
- package/src/utils/errors.js +21 -0
- package/src/utils/money.js +75 -0
- package/src/utils/paymentMethods.js +168 -0
|
@@ -0,0 +1,123 @@
|
|
|
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
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Midtrans — Indonesia-focused (Snap + Core API).
|
|
9
|
+
* paymentMethod examples: card | qr | ewallet | bank | gopay | shopeepay
|
|
10
|
+
*/
|
|
11
|
+
export class MidtransGateway extends BaseGateway {
|
|
12
|
+
constructor() {
|
|
13
|
+
super("midtrans");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
baseUrl(isProduction) {
|
|
17
|
+
return isProduction
|
|
18
|
+
? "https://app.midtrans.com"
|
|
19
|
+
: "https://app.sandbox.midtrans.com";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
apiBase(isProduction) {
|
|
23
|
+
return isProduction
|
|
24
|
+
? "https://api.midtrans.com"
|
|
25
|
+
: "https://api.sandbox.midtrans.com";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
authHeader(serverKey) {
|
|
29
|
+
const token = Buffer.from(`${serverKey}:`).toString("base64");
|
|
30
|
+
return { Authorization: `Basic ${token}`, "Content-Type": "application/json" };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async createPayment(ctx) {
|
|
34
|
+
const serverKey = ctx.keys.serverKey || ctx.keys.secretKey;
|
|
35
|
+
if (!serverKey) throw new AppError("Midtrans keys.serverKey (or secretKey) is required");
|
|
36
|
+
|
|
37
|
+
const isProduction = Boolean(ctx.keys.isProduction);
|
|
38
|
+
const orderId = ctx.orderId || ctx.paymentId;
|
|
39
|
+
const grossAmount = Math.round(toMajorUnits(ctx.amountMinor, ctx.currency));
|
|
40
|
+
const methods = normalizeMethods(ctx.paymentMethod, ctx.paymentMethods);
|
|
41
|
+
const enabled = mapMethodsForGateway("midtrans", methods, ctx.currency);
|
|
42
|
+
|
|
43
|
+
const payload = {
|
|
44
|
+
transaction_details: {
|
|
45
|
+
order_id: orderId,
|
|
46
|
+
gross_amount: grossAmount,
|
|
47
|
+
},
|
|
48
|
+
item_details: [
|
|
49
|
+
{
|
|
50
|
+
id: orderId,
|
|
51
|
+
price: grossAmount,
|
|
52
|
+
quantity: 1,
|
|
53
|
+
name: (ctx.description || "Kiosk payment").slice(0, 50),
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
customer_details: {
|
|
57
|
+
first_name: ctx.customer?.name || "Customer",
|
|
58
|
+
email: ctx.customer?.email || undefined,
|
|
59
|
+
phone: ctx.customer?.phone || undefined,
|
|
60
|
+
},
|
|
61
|
+
callbacks: {
|
|
62
|
+
finish: ctx.returnUrl || undefined,
|
|
63
|
+
error: ctx.cancelUrl || ctx.returnUrl || undefined,
|
|
64
|
+
},
|
|
65
|
+
custom_field1: ctx.paymentId,
|
|
66
|
+
metadata: ctx.metadata || {},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (enabled.length) {
|
|
70
|
+
payload.enabled_payments = enabled;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const { data } = await axios.post(
|
|
75
|
+
`${this.baseUrl(isProduction)}/snap/v1/transactions`,
|
|
76
|
+
payload,
|
|
77
|
+
{ headers: this.authHeader(serverKey), timeout: 30000 }
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
status: "pending",
|
|
82
|
+
gatewayRef: orderId,
|
|
83
|
+
checkoutUrl: data.redirect_url,
|
|
84
|
+
token: data.token,
|
|
85
|
+
paymentMethods: methods.length ? methods : ["all"],
|
|
86
|
+
amountDisplay: formatMajor(ctx.amountMinor, ctx.currency),
|
|
87
|
+
raw: data,
|
|
88
|
+
};
|
|
89
|
+
} catch (err) {
|
|
90
|
+
throw this.wrap(err);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async getStatus(ctx) {
|
|
95
|
+
const serverKey = ctx.keys.serverKey || ctx.keys.secretKey;
|
|
96
|
+
if (!serverKey) throw new AppError("Midtrans keys.serverKey (or secretKey) is required");
|
|
97
|
+
const isProduction = Boolean(ctx.keys.isProduction);
|
|
98
|
+
const orderId = ctx.gatewayRef || ctx.orderId;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const { data } = await axios.get(
|
|
102
|
+
`${this.apiBase(isProduction)}/v2/${encodeURIComponent(orderId)}/status`,
|
|
103
|
+
{ headers: this.authHeader(serverKey), timeout: 30000 }
|
|
104
|
+
);
|
|
105
|
+
return {
|
|
106
|
+
status: this.normalizeStatus(data.transaction_status || data.status_code),
|
|
107
|
+
gatewayRef: data.order_id || orderId,
|
|
108
|
+
raw: data,
|
|
109
|
+
};
|
|
110
|
+
} catch (err) {
|
|
111
|
+
throw this.wrap(err);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
wrap(err) {
|
|
116
|
+
const msg =
|
|
117
|
+
err.response?.data?.error_messages?.[0] ||
|
|
118
|
+
err.response?.data?.status_message ||
|
|
119
|
+
err.message ||
|
|
120
|
+
"Midtrans error";
|
|
121
|
+
return new AppError(String(msg), err.response?.status || 502, err.response?.data);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
* Razorpay — India + international.
|
|
9
|
+
* paymentMethod examples: card | qr | upi | ewallet | bank
|
|
10
|
+
*/
|
|
11
|
+
export class RazorpayGateway extends BaseGateway {
|
|
12
|
+
constructor() {
|
|
13
|
+
super("razorpay");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
client(keys) {
|
|
17
|
+
const keyId = keys.keyId || keys.publicKey;
|
|
18
|
+
const keySecret = keys.keySecret || keys.secretKey;
|
|
19
|
+
if (!keyId || !keySecret) {
|
|
20
|
+
throw new AppError("Razorpay keys.keyId and keys.keySecret are required");
|
|
21
|
+
}
|
|
22
|
+
return axios.create({
|
|
23
|
+
baseURL: "https://api.razorpay.com/v1",
|
|
24
|
+
auth: { username: keyId, password: keySecret },
|
|
25
|
+
headers: { "Content-Type": "application/json" },
|
|
26
|
+
timeout: 30000,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async createPayment(ctx) {
|
|
31
|
+
const api = this.client(ctx.keys);
|
|
32
|
+
const methods = normalizeMethods(ctx.paymentMethod, ctx.paymentMethods);
|
|
33
|
+
const rzpMethods = mapMethodsForGateway("razorpay", methods, ctx.currency);
|
|
34
|
+
|
|
35
|
+
const payload = {
|
|
36
|
+
amount: ctx.amountMinor,
|
|
37
|
+
currency: ctx.currency,
|
|
38
|
+
receipt: (ctx.orderId || ctx.paymentId).slice(0, 40),
|
|
39
|
+
notes: {
|
|
40
|
+
paymentId: ctx.paymentId,
|
|
41
|
+
paymentMethod: methods.join(",") || undefined,
|
|
42
|
+
...(ctx.metadata || {}),
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Prefer method for single channel (upi/card/netbanking/wallet)
|
|
47
|
+
if (rzpMethods.length === 1) {
|
|
48
|
+
payload.method = rzpMethods[0];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const { data } = await api.post("/orders", payload);
|
|
53
|
+
const checkout = {
|
|
54
|
+
keyId: ctx.keys.keyId || ctx.keys.publicKey,
|
|
55
|
+
orderId: data.id,
|
|
56
|
+
amount: data.amount,
|
|
57
|
+
currency: data.currency,
|
|
58
|
+
name: ctx.description || "Kiosk Payment",
|
|
59
|
+
prefill: {
|
|
60
|
+
name: ctx.customer?.name,
|
|
61
|
+
email: ctx.customer?.email,
|
|
62
|
+
contact: ctx.customer?.phone,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (rzpMethods.length === 1) {
|
|
67
|
+
checkout.method = rzpMethods[0];
|
|
68
|
+
} else if (rzpMethods.length > 1) {
|
|
69
|
+
checkout.config = {
|
|
70
|
+
display: {
|
|
71
|
+
blocks: {
|
|
72
|
+
banks: {
|
|
73
|
+
name: "Pay using",
|
|
74
|
+
instruments: rzpMethods.map((method) => ({ method })),
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
sequence: ["block.banks"],
|
|
78
|
+
preferences: { show_default_blocks: false },
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
status: this.normalizeStatus(data.status === "created" ? "pending" : data.status),
|
|
85
|
+
gatewayRef: data.id,
|
|
86
|
+
checkout,
|
|
87
|
+
paymentMethods: methods.length ? methods : ["all"],
|
|
88
|
+
amountDisplay: formatMajor(ctx.amountMinor, ctx.currency),
|
|
89
|
+
raw: data,
|
|
90
|
+
};
|
|
91
|
+
} catch (err) {
|
|
92
|
+
throw this.wrap(err);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async getStatus(ctx) {
|
|
97
|
+
const api = this.client(ctx.keys);
|
|
98
|
+
try {
|
|
99
|
+
const { data } = await api.get(`/orders/${ctx.gatewayRef}`);
|
|
100
|
+
return {
|
|
101
|
+
status: this.normalizeStatus(data.status === "paid" ? "succeeded" : data.status),
|
|
102
|
+
gatewayRef: data.id,
|
|
103
|
+
raw: data,
|
|
104
|
+
};
|
|
105
|
+
} catch (err) {
|
|
106
|
+
throw this.wrap(err);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
wrap(err) {
|
|
111
|
+
const msg =
|
|
112
|
+
err.response?.data?.error?.description ||
|
|
113
|
+
err.response?.data?.error?.code ||
|
|
114
|
+
err.message ||
|
|
115
|
+
"Razorpay error";
|
|
116
|
+
return new AppError(String(msg), err.response?.status || 502, err.response?.data);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -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
|
+
* Stripe — global (SG, MY, etc.).
|
|
9
|
+
* paymentMethod examples: card | qr | paynow | fpx | grabpay | promptpay
|
|
10
|
+
*/
|
|
11
|
+
export class StripeGateway extends BaseGateway {
|
|
12
|
+
constructor() {
|
|
13
|
+
super("stripe");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
client(secretKey) {
|
|
17
|
+
if (!secretKey) throw new AppError("Stripe keys.secretKey is required");
|
|
18
|
+
return axios.create({
|
|
19
|
+
baseURL: "https://api.stripe.com/v1",
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${secretKey}`,
|
|
22
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
23
|
+
},
|
|
24
|
+
timeout: 30000,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
toForm(obj, prefix = "") {
|
|
29
|
+
const params = new URLSearchParams();
|
|
30
|
+
const walk = (value, path) => {
|
|
31
|
+
if (value === undefined || value === null) return;
|
|
32
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
33
|
+
for (const [k, v] of Object.entries(value)) {
|
|
34
|
+
walk(v, path ? `${path}[${k}]` : k);
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
value.forEach((v, i) => walk(v, `${path}[${i}]`));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
params.append(path, String(value));
|
|
43
|
+
};
|
|
44
|
+
walk(obj, prefix);
|
|
45
|
+
return params;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async createPayment(ctx) {
|
|
49
|
+
const api = this.client(ctx.keys.secretKey);
|
|
50
|
+
const successUrl =
|
|
51
|
+
ctx.keys.successUrl ||
|
|
52
|
+
ctx.returnUrl ||
|
|
53
|
+
"https://example.com/success?session_id={CHECKOUT_SESSION_ID}";
|
|
54
|
+
const cancelUrl = ctx.keys.cancelUrl || ctx.cancelUrl || ctx.returnUrl || "https://example.com/cancel";
|
|
55
|
+
|
|
56
|
+
const methods = normalizeMethods(ctx.paymentMethod, ctx.paymentMethods);
|
|
57
|
+
let stripeMethods = mapMethodsForGateway("stripe", methods, ctx.currency);
|
|
58
|
+
|
|
59
|
+
// Sensible defaults by currency when only "qr" was requested
|
|
60
|
+
if (methods.includes("qr") && ctx.currency === "SGD") stripeMethods = ["paynow"];
|
|
61
|
+
if (methods.includes("qr") && ctx.currency === "THB") stripeMethods = ["promptpay"];
|
|
62
|
+
|
|
63
|
+
const session = {
|
|
64
|
+
mode: "payment",
|
|
65
|
+
success_url: successUrl,
|
|
66
|
+
cancel_url: cancelUrl,
|
|
67
|
+
client_reference_id: ctx.orderId || ctx.paymentId,
|
|
68
|
+
line_items: [
|
|
69
|
+
{
|
|
70
|
+
quantity: 1,
|
|
71
|
+
price_data: {
|
|
72
|
+
currency: ctx.currency.toLowerCase(),
|
|
73
|
+
unit_amount: ctx.amountMinor,
|
|
74
|
+
product_data: {
|
|
75
|
+
name: (ctx.description || "Kiosk payment").slice(0, 120),
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
metadata: {
|
|
81
|
+
paymentId: ctx.paymentId,
|
|
82
|
+
orderId: ctx.orderId || "",
|
|
83
|
+
paymentMethod: methods.join(",") || "",
|
|
84
|
+
...(ctx.metadata || {}),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (stripeMethods.length) {
|
|
89
|
+
session.payment_method_types = stripeMethods;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const body = this.toForm(session);
|
|
93
|
+
|
|
94
|
+
if (ctx.customer?.email) {
|
|
95
|
+
body.append("customer_email", ctx.customer.email);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const { data } = await api.post("/checkout/sessions", body);
|
|
100
|
+
return {
|
|
101
|
+
status: this.normalizeStatus(data.payment_status === "paid" ? "succeeded" : "pending"),
|
|
102
|
+
gatewayRef: data.id,
|
|
103
|
+
checkoutUrl: data.url,
|
|
104
|
+
clientSecret: data.client_secret || undefined,
|
|
105
|
+
paymentMethods: methods.length ? methods : ["all"],
|
|
106
|
+
amountDisplay: formatMajor(ctx.amountMinor, ctx.currency),
|
|
107
|
+
raw: data,
|
|
108
|
+
};
|
|
109
|
+
} catch (err) {
|
|
110
|
+
throw this.wrap(err);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async getStatus(ctx) {
|
|
115
|
+
const api = this.client(ctx.keys.secretKey);
|
|
116
|
+
try {
|
|
117
|
+
const { data } = await api.get(`/checkout/sessions/${ctx.gatewayRef}`);
|
|
118
|
+
const paid = data.payment_status === "paid" || data.status === "complete";
|
|
119
|
+
return {
|
|
120
|
+
status: paid ? "succeeded" : this.normalizeStatus(data.status),
|
|
121
|
+
gatewayRef: data.id,
|
|
122
|
+
checkoutUrl: data.url,
|
|
123
|
+
raw: data,
|
|
124
|
+
};
|
|
125
|
+
} catch (err) {
|
|
126
|
+
throw this.wrap(err);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
wrap(err) {
|
|
131
|
+
const msg =
|
|
132
|
+
err.response?.data?.error?.message ||
|
|
133
|
+
err.message ||
|
|
134
|
+
"Stripe error";
|
|
135
|
+
return new AppError(String(msg), err.response?.status || 502, err.response?.data);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Xendit — strong in ID, PH, MY, TH, VN, SG.
|
|
9
|
+
* paymentMethod examples: card | qr | ewallet | bank | paynow | ovo | dana
|
|
10
|
+
*/
|
|
11
|
+
export class XenditGateway extends BaseGateway {
|
|
12
|
+
constructor() {
|
|
13
|
+
super("xendit");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
client(secretKey) {
|
|
17
|
+
if (!secretKey) throw new AppError("Xendit keys.secretKey is required");
|
|
18
|
+
return axios.create({
|
|
19
|
+
baseURL: "https://api.xendit.co",
|
|
20
|
+
auth: { username: secretKey, password: "" },
|
|
21
|
+
headers: { "Content-Type": "application/json" },
|
|
22
|
+
timeout: 30000,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async createPayment(ctx) {
|
|
27
|
+
const api = this.client(ctx.keys.secretKey);
|
|
28
|
+
const amount = toMajorUnits(ctx.amountMinor, ctx.currency);
|
|
29
|
+
const externalId = ctx.orderId || ctx.paymentId;
|
|
30
|
+
const methods = normalizeMethods(ctx.paymentMethod, ctx.paymentMethods);
|
|
31
|
+
const xenditMethods = mapMethodsForGateway("xendit", methods, ctx.currency);
|
|
32
|
+
|
|
33
|
+
const payload = {
|
|
34
|
+
external_id: externalId,
|
|
35
|
+
amount,
|
|
36
|
+
currency: ctx.currency,
|
|
37
|
+
description: ctx.description || `Payment ${externalId}`,
|
|
38
|
+
invoice_duration: 3600,
|
|
39
|
+
success_redirect_url: ctx.returnUrl || undefined,
|
|
40
|
+
failure_redirect_url: ctx.cancelUrl || ctx.returnUrl || undefined,
|
|
41
|
+
metadata: {
|
|
42
|
+
paymentId: ctx.paymentId,
|
|
43
|
+
paymentMethod: methods.join(",") || undefined,
|
|
44
|
+
...(ctx.metadata || {}),
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Restrict invoice payment channels when client asks for card/qr/etc.
|
|
49
|
+
if (xenditMethods.length) {
|
|
50
|
+
payload.payment_methods = xenditMethods;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (ctx.customer?.email) {
|
|
54
|
+
payload.payer_email = ctx.customer.email;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const { data } = await api.post("/v2/invoices", payload);
|
|
59
|
+
return {
|
|
60
|
+
status: this.normalizeStatus(data.status),
|
|
61
|
+
gatewayRef: data.id,
|
|
62
|
+
checkoutUrl: data.invoice_url,
|
|
63
|
+
paymentMethods: methods.length ? methods : ["all"],
|
|
64
|
+
xenditPaymentMethods: xenditMethods,
|
|
65
|
+
amountDisplay: formatMajor(ctx.amountMinor, ctx.currency),
|
|
66
|
+
raw: data,
|
|
67
|
+
};
|
|
68
|
+
} 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
|
+
throw this.wrap(err);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async getStatus(ctx) {
|
|
97
|
+
const api = this.client(ctx.keys.secretKey);
|
|
98
|
+
try {
|
|
99
|
+
const { data } = await api.get(`/v2/invoices/${ctx.gatewayRef}`);
|
|
100
|
+
return {
|
|
101
|
+
status: this.normalizeStatus(data.status),
|
|
102
|
+
gatewayRef: data.id,
|
|
103
|
+
checkoutUrl: data.invoice_url,
|
|
104
|
+
raw: data,
|
|
105
|
+
};
|
|
106
|
+
} catch (err) {
|
|
107
|
+
throw this.wrap(err);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
wrap(err) {
|
|
112
|
+
const msg =
|
|
113
|
+
err.response?.data?.message ||
|
|
114
|
+
err.response?.data?.error_code ||
|
|
115
|
+
err.message ||
|
|
116
|
+
"Xendit error";
|
|
117
|
+
return new AppError(String(msg), err.response?.status || 502, err.response?.data);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { XenditGateway } from "./XenditGateway.js";
|
|
2
|
+
import { MidtransGateway } from "./MidtransGateway.js";
|
|
3
|
+
import { RazorpayGateway } from "./RazorpayGateway.js";
|
|
4
|
+
import { StripeGateway } from "./StripeGateway.js";
|
|
5
|
+
import { OmiseGateway } from "./OmiseGateway.js";
|
|
6
|
+
import { AppError } from "../utils/errors.js";
|
|
7
|
+
|
|
8
|
+
const gateways = {
|
|
9
|
+
xendit: new XenditGateway(),
|
|
10
|
+
midtrans: new MidtransGateway(),
|
|
11
|
+
razorpay: new RazorpayGateway(),
|
|
12
|
+
// common typos
|
|
13
|
+
razerpay: new RazorpayGateway(),
|
|
14
|
+
stripe: new StripeGateway(),
|
|
15
|
+
// common typo from request
|
|
16
|
+
stipt: new StripeGateway(),
|
|
17
|
+
omise: new OmiseGateway(),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const GATEWAY_NAMES = ["xendit", "midtrans", "razorpay", "stripe", "omise"];
|
|
21
|
+
|
|
22
|
+
export function getGateway(name) {
|
|
23
|
+
const key = String(name || "").trim().toLowerCase();
|
|
24
|
+
const gw = gateways[key];
|
|
25
|
+
if (!gw) {
|
|
26
|
+
throw new AppError(
|
|
27
|
+
`Unsupported gateway "${name}". Use one of: ${GATEWAY_NAMES.join(", ")}`,
|
|
28
|
+
400
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return gw;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function gatewayRegionsHint() {
|
|
35
|
+
return {
|
|
36
|
+
xendit: ["SG", "MY", "ID", "PH", "TH", "VN"],
|
|
37
|
+
midtrans: ["ID"],
|
|
38
|
+
razorpay: ["IN", "international"],
|
|
39
|
+
stripe: ["SG", "MY", "global"],
|
|
40
|
+
omise: ["SG", "MY", "TH", "JP"],
|
|
41
|
+
};
|
|
42
|
+
}
|