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,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,95 @@
|
|
|
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
|
+
amountDisplay: formatMajor(ctx.amountMinor, ctx.currency),
|
|
65
|
+
raw: data,
|
|
66
|
+
};
|
|
67
|
+
} catch (err) {
|
|
68
|
+
throw this.wrap(err);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async getStatus(ctx) {
|
|
73
|
+
const api = this.client(ctx.keys.secretKey);
|
|
74
|
+
try {
|
|
75
|
+
const { data } = await api.get(`/v2/invoices/${ctx.gatewayRef}`);
|
|
76
|
+
return {
|
|
77
|
+
status: this.normalizeStatus(data.status),
|
|
78
|
+
gatewayRef: data.id,
|
|
79
|
+
checkoutUrl: data.invoice_url,
|
|
80
|
+
raw: data,
|
|
81
|
+
};
|
|
82
|
+
} catch (err) {
|
|
83
|
+
throw this.wrap(err);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
wrap(err) {
|
|
88
|
+
const msg =
|
|
89
|
+
err.response?.data?.message ||
|
|
90
|
+
err.response?.data?.error_code ||
|
|
91
|
+
err.message ||
|
|
92
|
+
"Xendit error";
|
|
93
|
+
return new AppError(String(msg), err.response?.status || 502, err.response?.data);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -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
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* multi-payment-system — Node / server SDK
|
|
3
|
+
*
|
|
4
|
+
* For React / browser use:
|
|
5
|
+
* import { PaymentClient } from "multi-payment-system/client";
|
|
6
|
+
* import { usePaymentClient } from "multi-payment-system/react";
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* import { XenditGateway, charge } from "multi-payment-system";
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export { BaseGateway } from "./gateways/BaseGateway.js";
|
|
13
|
+
export { XenditGateway } from "./gateways/XenditGateway.js";
|
|
14
|
+
export { MidtransGateway } from "./gateways/MidtransGateway.js";
|
|
15
|
+
export { RazorpayGateway } from "./gateways/RazorpayGateway.js";
|
|
16
|
+
export { StripeGateway } from "./gateways/StripeGateway.js";
|
|
17
|
+
export { OmiseGateway } from "./gateways/OmiseGateway.js";
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
getGateway,
|
|
21
|
+
GATEWAY_NAMES,
|
|
22
|
+
gatewayRegionsHint,
|
|
23
|
+
} from "./gateways/index.js";
|
|
24
|
+
|
|
25
|
+
export { charge, getStatus, listGateways } from "./api.js";
|
|
26
|
+
|
|
27
|
+
export { PaymentService, paymentService } from "./services/PaymentService.js";
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
toMinorUnits,
|
|
31
|
+
toMajorUnits,
|
|
32
|
+
formatMajor,
|
|
33
|
+
normalizeCurrency,
|
|
34
|
+
SUPPORTED_CURRENCIES,
|
|
35
|
+
} from "./utils/money.js";
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
normalizeMethods,
|
|
39
|
+
mapMethodsForGateway,
|
|
40
|
+
PAYMENT_METHOD_HINTS,
|
|
41
|
+
} from "./utils/paymentMethods.js";
|
|
42
|
+
|
|
43
|
+
export { AppError } from "./utils/errors.js";
|
|
44
|
+
|
|
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
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
import { AppError } from "../utils/errors.js";
|
|
3
|
+
|
|
4
|
+
export function optionalApiKey(req, _res, next) {
|
|
5
|
+
if (!config.apiKey) return next();
|
|
6
|
+
const header = req.header("x-api-key") || req.header("authorization")?.replace(/^Bearer\s+/i, "");
|
|
7
|
+
if (header !== config.apiKey) {
|
|
8
|
+
return next(new AppError("Unauthorized", 401));
|
|
9
|
+
}
|
|
10
|
+
return next();
|
|
11
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { paymentService } from "../services/PaymentService.js";
|
|
4
|
+
import { GATEWAY_NAMES, gatewayRegionsHint } from "../gateways/index.js";
|
|
5
|
+
import { SUPPORTED_CURRENCIES } from "../utils/money.js";
|
|
6
|
+
import { PAYMENT_METHOD_HINTS } from "../utils/paymentMethods.js";
|
|
7
|
+
import { AppError } from "../utils/errors.js";
|
|
8
|
+
|
|
9
|
+
const router = Router();
|
|
10
|
+
|
|
11
|
+
const chargeSchema = z.object({
|
|
12
|
+
gateway: z.string().min(1),
|
|
13
|
+
amount: z.number().positive(),
|
|
14
|
+
currency: z.string().min(3).max(3),
|
|
15
|
+
keys: z.record(z.any()),
|
|
16
|
+
orderId: z.string().optional(),
|
|
17
|
+
description: z.string().optional(),
|
|
18
|
+
customer: z
|
|
19
|
+
.object({
|
|
20
|
+
name: z.string().optional(),
|
|
21
|
+
email: z.string().email().optional(),
|
|
22
|
+
phone: z.string().optional(),
|
|
23
|
+
})
|
|
24
|
+
.optional(),
|
|
25
|
+
returnUrl: z.string().url().optional(),
|
|
26
|
+
cancelUrl: z.string().url().optional(),
|
|
27
|
+
// card | qr | ewallet | bank | paynow | promptpay | fpx | upi | gopay | ...
|
|
28
|
+
paymentMethod: z.string().optional(),
|
|
29
|
+
// e.g. ["card", "qr"] to allow multiple channels
|
|
30
|
+
paymentMethods: z.union([z.array(z.string()), z.string()]).optional(),
|
|
31
|
+
cardToken: z.string().optional(),
|
|
32
|
+
bank: z.string().optional(),
|
|
33
|
+
amountAlreadyMinor: z.boolean().optional(),
|
|
34
|
+
metadata: z.record(z.any()).optional(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const edcSchema = z.object({
|
|
38
|
+
amount: z.number().positive(),
|
|
39
|
+
currency: z.string().min(3).max(3).default("IDR"),
|
|
40
|
+
orderId: z.string().optional(),
|
|
41
|
+
deviceId: z.string().optional(),
|
|
42
|
+
storeId: z.string().optional(),
|
|
43
|
+
amountAlreadyMinor: z.boolean().optional(),
|
|
44
|
+
metadata: z.record(z.any()).optional(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const terminalResultSchema = z.object({
|
|
48
|
+
status: z.string().min(1),
|
|
49
|
+
approvalCode: z.string().optional(),
|
|
50
|
+
referenceNo: z.string().optional(),
|
|
51
|
+
rrn: z.string().optional(),
|
|
52
|
+
cardBrand: z.string().optional(),
|
|
53
|
+
maskedPan: z.string().optional(),
|
|
54
|
+
amountMinor: z.number().optional(),
|
|
55
|
+
raw: z.any().optional(),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
router.get("/meta", (_req, res) => {
|
|
59
|
+
res.json({
|
|
60
|
+
ok: true,
|
|
61
|
+
gateways: GATEWAY_NAMES,
|
|
62
|
+
regions: gatewayRegionsHint(),
|
|
63
|
+
currencies: SUPPORTED_CURRENCIES,
|
|
64
|
+
paymentMethods: PAYMENT_METHOD_HINTS,
|
|
65
|
+
note: "Pass gateway, keys, amount, currency, and optional paymentMethod/paymentMethods (card, qr, ewallet, bank, ...).",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** Create online payment via selected gateway */
|
|
70
|
+
router.post("/charge", async (req, res, next) => {
|
|
71
|
+
try {
|
|
72
|
+
const parsed = chargeSchema.safeParse(req.body);
|
|
73
|
+
if (!parsed.success) {
|
|
74
|
+
throw new AppError("Invalid request body", 400, parsed.error.flatten());
|
|
75
|
+
}
|
|
76
|
+
const payment = await paymentService.createOnlinePayment(parsed.data);
|
|
77
|
+
res.status(201).json({ ok: true, payment });
|
|
78
|
+
} catch (err) {
|
|
79
|
+
next(err);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/** Alias matching the integration guide naming */
|
|
84
|
+
router.post("/attempts", async (req, res, next) => {
|
|
85
|
+
try {
|
|
86
|
+
// If gateway is provided → online; otherwise treat as EDC attempt
|
|
87
|
+
if (req.body?.gateway) {
|
|
88
|
+
const parsed = chargeSchema.safeParse(req.body);
|
|
89
|
+
if (!parsed.success) {
|
|
90
|
+
throw new AppError("Invalid request body", 400, parsed.error.flatten());
|
|
91
|
+
}
|
|
92
|
+
const payment = await paymentService.createOnlinePayment(parsed.data);
|
|
93
|
+
return res.status(201).json({ ok: true, payment });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const parsed = edcSchema.safeParse(req.body);
|
|
97
|
+
if (!parsed.success) {
|
|
98
|
+
throw new AppError("Invalid request body", 400, parsed.error.flatten());
|
|
99
|
+
}
|
|
100
|
+
const payment = paymentService.createEdcAttempt(parsed.data);
|
|
101
|
+
return res.status(201).json({ ok: true, payment });
|
|
102
|
+
} catch (err) {
|
|
103
|
+
next(err);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
router.post("/attempts/:id/terminal-result", (req, res, next) => {
|
|
108
|
+
try {
|
|
109
|
+
const parsed = terminalResultSchema.safeParse(req.body);
|
|
110
|
+
if (!parsed.success) {
|
|
111
|
+
throw new AppError("Invalid request body", 400, parsed.error.flatten());
|
|
112
|
+
}
|
|
113
|
+
const payment = paymentService.recordTerminalResult(req.params.id, parsed.data);
|
|
114
|
+
res.json({ ok: true, payment });
|
|
115
|
+
} catch (err) {
|
|
116
|
+
next(err);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
router.get("/", (_req, res) => {
|
|
121
|
+
res.json({ ok: true, payments: paymentService.listPayments() });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
router.get("/:id", (req, res, next) => {
|
|
125
|
+
try {
|
|
126
|
+
const payment = paymentService.getPayment(req.params.id);
|
|
127
|
+
res.json({ ok: true, payment });
|
|
128
|
+
} catch (err) {
|
|
129
|
+
next(err);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
router.post("/:id/refresh", async (req, res, next) => {
|
|
134
|
+
try {
|
|
135
|
+
const payment = await paymentService.refreshStatus(req.params.id, req.body?.keys);
|
|
136
|
+
res.json({ ok: true, payment });
|
|
137
|
+
} catch (err) {
|
|
138
|
+
next(err);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
router.post("/:id/reconcile", async (req, res, next) => {
|
|
143
|
+
try {
|
|
144
|
+
// Same as refresh for online; for EDC just returns current state
|
|
145
|
+
const existing = paymentService.getPayment(req.params.id);
|
|
146
|
+
if (existing.type === "edc") {
|
|
147
|
+
return res.json({ ok: true, payment: existing, note: "EDC reconcile is manual via terminal-result / last-txn inquiry on device" });
|
|
148
|
+
}
|
|
149
|
+
const payment = await paymentService.refreshStatus(req.params.id, req.body?.keys);
|
|
150
|
+
return res.json({ ok: true, payment });
|
|
151
|
+
} catch (err) {
|
|
152
|
+
next(err);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
export default router;
|