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/src/index.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * multi-payment-system
3
+ *
4
+ * @example
5
+ * import { XenditGateway, charge, getStatus } from "multi-payment-system";
6
+ *
7
+ * const xendit = new XenditGateway();
8
+ * const pay = await xendit.createPayment({ ... });
9
+ *
10
+ * // or one-liner helpers:
11
+ * const result = await charge({ gateway: "xendit", amount: 30, currency: "SGD", keys: {...}, paymentMethod: "card" });
12
+ */
13
+
14
+ export { BaseGateway } from "./gateways/BaseGateway.js";
15
+ export { XenditGateway } from "./gateways/XenditGateway.js";
16
+ export { MidtransGateway } from "./gateways/MidtransGateway.js";
17
+ export { RazorpayGateway } from "./gateways/RazorpayGateway.js";
18
+ export { StripeGateway } from "./gateways/StripeGateway.js";
19
+ export { OmiseGateway } from "./gateways/OmiseGateway.js";
20
+
21
+ export {
22
+ getGateway,
23
+ GATEWAY_NAMES,
24
+ gatewayRegionsHint,
25
+ } from "./gateways/index.js";
26
+
27
+ export { charge, getStatus, listGateways } from "./api.js";
28
+
29
+ export { PaymentService, paymentService } from "./services/PaymentService.js";
30
+
31
+ export {
32
+ toMinorUnits,
33
+ toMajorUnits,
34
+ formatMajor,
35
+ normalizeCurrency,
36
+ SUPPORTED_CURRENCIES,
37
+ } from "./utils/money.js";
38
+
39
+ export {
40
+ normalizeMethods,
41
+ mapMethodsForGateway,
42
+ PAYMENT_METHOD_HINTS,
43
+ } from "./utils/paymentMethods.js";
44
+
45
+ export { AppError } from "./utils/errors.js";
46
+
47
+ export { createApp } from "./app.js";
@@ -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;
@@ -0,0 +1,91 @@
1
+ import { Router } from "express";
2
+ import { paymentService } from "../services/PaymentService.js";
3
+
4
+ const router = Router();
5
+
6
+ function mapXendit(body) {
7
+ const statusMap = {
8
+ PAID: "succeeded",
9
+ SETTLED: "succeeded",
10
+ EXPIRED: "failed",
11
+ PENDING: "pending",
12
+ };
13
+ return {
14
+ gatewayRef: body.id || body.invoice_id,
15
+ status: statusMap[String(body.status || "").toUpperCase()] || "pending",
16
+ };
17
+ }
18
+
19
+ function mapMidtrans(body) {
20
+ const s = String(body.transaction_status || "").toLowerCase();
21
+ let status = "pending";
22
+ if (["capture", "settlement"].includes(s)) status = "succeeded";
23
+ if (["deny", "cancel", "expire", "failure"].includes(s)) status = "failed";
24
+ return {
25
+ gatewayRef: body.order_id,
26
+ status,
27
+ };
28
+ }
29
+
30
+ function mapRazorpay(body) {
31
+ const event = body.event || "";
32
+ const entity = body.payload?.payment?.entity || body.payload?.order?.entity || {};
33
+ let status = "pending";
34
+ if (event.includes("captured") || entity.status === "captured" || entity.status === "paid") {
35
+ status = "succeeded";
36
+ }
37
+ if (event.includes("failed") || entity.status === "failed") status = "failed";
38
+ return {
39
+ gatewayRef: entity.order_id || entity.id,
40
+ status,
41
+ };
42
+ }
43
+
44
+ function mapStripe(body) {
45
+ const type = body.type || "";
46
+ const obj = body.data?.object || {};
47
+ let status = "pending";
48
+ if (type === "checkout.session.completed" && obj.payment_status === "paid") {
49
+ status = "succeeded";
50
+ }
51
+ if (type.includes("payment_intent.payment_failed")) status = "failed";
52
+ return {
53
+ gatewayRef: obj.id,
54
+ status,
55
+ };
56
+ }
57
+
58
+ function mapOmise(body) {
59
+ const data = body.data || body;
60
+ let status = "pending";
61
+ if (data.status === "successful") status = "succeeded";
62
+ if (data.status === "failed" || data.status === "expired") status = "failed";
63
+ return {
64
+ gatewayRef: data.id,
65
+ status,
66
+ };
67
+ }
68
+
69
+ async function handle(gateway, mapper, req, res) {
70
+ const mapped = mapper(req.body || {});
71
+ const updated = paymentService.applyWebhookUpdate({
72
+ gateway,
73
+ gatewayRef: mapped.gatewayRef,
74
+ status: mapped.status,
75
+ raw: req.body,
76
+ });
77
+ res.json({
78
+ ok: true,
79
+ matched: Boolean(updated),
80
+ paymentId: updated?.id || null,
81
+ status: updated?.status || mapped.status,
82
+ });
83
+ }
84
+
85
+ router.post("/xendit", (req, res) => handle("xendit", mapXendit, req, res));
86
+ router.post("/midtrans", (req, res) => handle("midtrans", mapMidtrans, req, res));
87
+ router.post("/razorpay", (req, res) => handle("razorpay", mapRazorpay, req, res));
88
+ router.post("/stripe", (req, res) => handle("stripe", mapStripe, req, res));
89
+ router.post("/omise", (req, res) => handle("omise", mapOmise, req, res));
90
+
91
+ export default router;
package/src/server.js ADDED
@@ -0,0 +1,11 @@
1
+ import { createApp } from "./app.js";
2
+ import { config } from "./config.js";
3
+
4
+ const app = createApp();
5
+
6
+ app.listen(config.port, () => {
7
+ console.log(`Payment server listening on http://localhost:${config.port}`);
8
+ console.log(`Health: GET http://localhost:${config.port}/health`);
9
+ console.log(`Charge: POST http://localhost:${config.port}/api/payments/charge`);
10
+ console.log(`Meta: GET http://localhost:${config.port}/api/payments/meta`);
11
+ });
@@ -0,0 +1,251 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+ import { store } from "../store/MemoryStore.js";
3
+ import { getGateway } from "../gateways/index.js";
4
+ import { toMinorUnits, normalizeCurrency } from "../utils/money.js";
5
+ import { AppError } from "../utils/errors.js";
6
+
7
+ function scrubKeys(keys = {}) {
8
+ // Never persist full secrets in responses; keep only for outbound calls in memory
9
+ const copy = { ...keys };
10
+ return copy;
11
+ }
12
+
13
+ function publicPayment(payment) {
14
+ const { keys, ...rest } = payment;
15
+ return {
16
+ ...rest,
17
+ keysPresent: Boolean(keys && Object.keys(keys).length),
18
+ };
19
+ }
20
+
21
+ export class PaymentService {
22
+ async createOnlinePayment(input) {
23
+ const gatewayName = String(input.gateway || "").toLowerCase();
24
+ const currency = normalizeCurrency(input.currency);
25
+ const amountMinor = toMinorUnits(
26
+ input.amount,
27
+ currency,
28
+ Boolean(input.amountAlreadyMinor)
29
+ );
30
+
31
+ if (!input.keys || typeof input.keys !== "object") {
32
+ throw new AppError("keys object is required (gateway API credentials)");
33
+ }
34
+ if (!amountMinor || amountMinor <= 0) {
35
+ throw new AppError("amount must be greater than 0");
36
+ }
37
+
38
+ const id = uuidv4();
39
+ const now = new Date().toISOString();
40
+ const gateway = getGateway(gatewayName);
41
+
42
+ const draft = {
43
+ id,
44
+ type: "online",
45
+ gateway: gateway.name,
46
+ orderId: input.orderId || null,
47
+ amount: Number(input.amount),
48
+ amountMinor,
49
+ currency,
50
+ description: input.description || null,
51
+ customer: input.customer || null,
52
+ paymentMethod: input.paymentMethod || null,
53
+ paymentMethods: input.paymentMethods || null,
54
+ status: "created",
55
+ gatewayRef: null,
56
+ checkoutUrl: null,
57
+ keys: scrubKeys(input.keys),
58
+ metadata: input.metadata || {},
59
+ createdAt: now,
60
+ updatedAt: now,
61
+ history: [{ at: now, status: "created", note: "Payment created" }],
62
+ };
63
+
64
+ store.savePayment(draft);
65
+
66
+ const result = await gateway.createPayment({
67
+ paymentId: id,
68
+ amountMinor,
69
+ currency,
70
+ keys: input.keys,
71
+ orderId: input.orderId,
72
+ description: input.description,
73
+ customer: input.customer,
74
+ returnUrl: input.returnUrl,
75
+ cancelUrl: input.cancelUrl,
76
+ metadata: input.metadata,
77
+ paymentMethod: input.paymentMethod,
78
+ paymentMethods: input.paymentMethods,
79
+ cardToken: input.cardToken,
80
+ bank: input.bank,
81
+ });
82
+
83
+ draft.status = result.status || "pending";
84
+ draft.gatewayRef = result.gatewayRef || null;
85
+ draft.checkoutUrl = result.checkoutUrl || null;
86
+ draft.token = result.token || null;
87
+ draft.clientSecret = result.clientSecret || null;
88
+ draft.checkout = result.checkout || null;
89
+ draft.qrImageUrl = result.qrImageUrl || null;
90
+ draft.resolvedPaymentMethods = result.paymentMethods || null;
91
+ draft.xenditPaymentMethods = result.xenditPaymentMethods || null;
92
+ draft.warning = result.warning || null;
93
+ draft.amountDisplay = result.amountDisplay || null;
94
+ draft.gatewayRaw = result.raw || null;
95
+ draft.updatedAt = new Date().toISOString();
96
+ draft.history.push({
97
+ at: draft.updatedAt,
98
+ status: draft.status,
99
+ note: "Gateway charge/session created",
100
+ });
101
+
102
+ store.savePayment(draft);
103
+ return publicPayment(draft);
104
+ }
105
+
106
+ async refreshStatus(paymentId, keysOverride) {
107
+ const payment = store.getPayment(paymentId);
108
+ if (!payment) throw new AppError("Payment not found", 404);
109
+ if (payment.type === "edc") {
110
+ return publicPayment(payment);
111
+ }
112
+
113
+ const keys = keysOverride || payment.keys;
114
+ if (!keys) throw new AppError("keys required to refresh status", 400);
115
+ if (!payment.gatewayRef) throw new AppError("No gateway reference to query", 400);
116
+
117
+ const gateway = getGateway(payment.gateway);
118
+ const result = await gateway.getStatus({
119
+ keys,
120
+ gatewayRef: payment.gatewayRef,
121
+ orderId: payment.orderId,
122
+ });
123
+
124
+ payment.status = result.status;
125
+ payment.gatewayRaw = result.raw;
126
+ payment.updatedAt = new Date().toISOString();
127
+ payment.history.push({
128
+ at: payment.updatedAt,
129
+ status: payment.status,
130
+ note: "Status refreshed from gateway",
131
+ });
132
+ // refresh stored keys if override provided
133
+ if (keysOverride) payment.keys = scrubKeys(keysOverride);
134
+ store.savePayment(payment);
135
+ return publicPayment(payment);
136
+ }
137
+
138
+ getPayment(id) {
139
+ const payment = store.getPayment(id);
140
+ if (!payment) throw new AppError("Payment not found", 404);
141
+ return publicPayment(payment);
142
+ }
143
+
144
+ listPayments() {
145
+ return store.listPayments().map(publicPayment);
146
+ }
147
+
148
+ applyWebhookUpdate({ gateway, gatewayRef, status, raw }) {
149
+ const payment =
150
+ store.findPaymentByGatewayRef(String(gateway).toLowerCase(), gatewayRef) ||
151
+ null;
152
+ if (!payment) return null;
153
+
154
+ payment.status = status;
155
+ payment.gatewayRaw = raw;
156
+ payment.updatedAt = new Date().toISOString();
157
+ payment.history.push({
158
+ at: payment.updatedAt,
159
+ status,
160
+ note: "Updated via webhook",
161
+ });
162
+ store.savePayment(payment);
163
+ return publicPayment(payment);
164
+ }
165
+
166
+ // --- Yokke EDC local-terminal flow (from integration guide) ---
167
+
168
+ createEdcAttempt(input) {
169
+ const currency = normalizeCurrency(input.currency || "IDR");
170
+ const amountMinor = toMinorUnits(
171
+ input.amount,
172
+ currency,
173
+ Boolean(input.amountAlreadyMinor)
174
+ );
175
+ if (!amountMinor || amountMinor <= 0) {
176
+ throw new AppError("amount must be greater than 0");
177
+ }
178
+
179
+ const id = uuidv4();
180
+ const now = new Date().toISOString();
181
+ const attempt = {
182
+ id,
183
+ type: "edc",
184
+ gateway: "yokke_edc",
185
+ orderId: input.orderId || null,
186
+ deviceId: input.deviceId || null,
187
+ storeId: input.storeId || null,
188
+ amount: Number(input.amount),
189
+ amountMinor,
190
+ currency,
191
+ status: "awaiting_terminal",
192
+ terminalResult: null,
193
+ metadata: input.metadata || {},
194
+ createdAt: now,
195
+ updatedAt: now,
196
+ history: [{ at: now, status: "awaiting_terminal", note: "EDC attempt created" }],
197
+ };
198
+
199
+ // Also mirror into payments map for unified GET
200
+ store.saveAttempt(attempt);
201
+ store.savePayment({ ...attempt, keys: null });
202
+ return publicPayment(attempt);
203
+ }
204
+
205
+ recordTerminalResult(attemptId, result) {
206
+ const attempt = store.getAttempt(attemptId) || store.getPayment(attemptId);
207
+ if (!attempt || attempt.type !== "edc") {
208
+ throw new AppError("EDC payment attempt not found", 404);
209
+ }
210
+
211
+ const statusRaw = String(result.status || "").toLowerCase();
212
+ let status = "pending";
213
+ if (["approved", "success", "succeeded", "paid"].includes(statusRaw)) status = "succeeded";
214
+ else if (["declined", "failed", "cancelled", "canceled", "error"].includes(statusRaw)) {
215
+ status = "failed";
216
+ } else if (["unknown", "timeout", "pending"].includes(statusRaw)) {
217
+ status = "pending";
218
+ }
219
+
220
+ // Basic amount check when terminal echoes amount
221
+ if (result.amountMinor != null && Number(result.amountMinor) !== attempt.amountMinor) {
222
+ throw new AppError("Terminal amount does not match attempt amount", 409, {
223
+ expected: attempt.amountMinor,
224
+ received: result.amountMinor,
225
+ });
226
+ }
227
+
228
+ attempt.status = status;
229
+ attempt.terminalResult = {
230
+ approvalCode: result.approvalCode || null,
231
+ referenceNo: result.referenceNo || null,
232
+ rrn: result.rrn || null,
233
+ cardBrand: result.cardBrand || null,
234
+ maskedPan: result.maskedPan || null,
235
+ raw: result.raw || result,
236
+ verifiedAt: new Date().toISOString(),
237
+ };
238
+ attempt.updatedAt = new Date().toISOString();
239
+ attempt.history.push({
240
+ at: attempt.updatedAt,
241
+ status,
242
+ note: "Terminal result received from kiosk",
243
+ });
244
+
245
+ store.saveAttempt(attempt);
246
+ store.savePayment({ ...attempt, keys: null });
247
+ return publicPayment(attempt);
248
+ }
249
+ }
250
+
251
+ export const paymentService = new PaymentService();
@@ -0,0 +1,49 @@
1
+ /**
2
+ * In-memory payment store for demos / local kiosk PoC.
3
+ * Replace with Postgres/Redis for production.
4
+ */
5
+ class MemoryStore {
6
+ constructor() {
7
+ this.payments = new Map();
8
+ this.attempts = new Map();
9
+ }
10
+
11
+ savePayment(payment) {
12
+ this.payments.set(payment.id, payment);
13
+ return payment;
14
+ }
15
+
16
+ getPayment(id) {
17
+ return this.payments.get(id) || null;
18
+ }
19
+
20
+ findPaymentByGatewayRef(gateway, gatewayRef) {
21
+ for (const p of this.payments.values()) {
22
+ if (p.gateway === gateway && p.gatewayRef === gatewayRef) return p;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ listPayments(limit = 50) {
28
+ return [...this.payments.values()]
29
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
30
+ .slice(0, limit);
31
+ }
32
+
33
+ saveAttempt(attempt) {
34
+ this.attempts.set(attempt.id, attempt);
35
+ return attempt;
36
+ }
37
+
38
+ getAttempt(id) {
39
+ return this.attempts.get(id) || null;
40
+ }
41
+
42
+ listAttempts(limit = 50) {
43
+ return [...this.attempts.values()]
44
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
45
+ .slice(0, limit);
46
+ }
47
+ }
48
+
49
+ export const store = new MemoryStore();
@@ -0,0 +1,21 @@
1
+ export class AppError extends Error {
2
+ constructor(message, statusCode = 400, details = undefined) {
3
+ super(message);
4
+ this.name = "AppError";
5
+ this.statusCode = statusCode;
6
+ this.details = details;
7
+ }
8
+ }
9
+
10
+ export function errorHandler(err, _req, res, _next) {
11
+ const status = err.statusCode || 500;
12
+ const body = {
13
+ ok: false,
14
+ error: err.message || "Internal server error",
15
+ };
16
+ if (err.details) body.details = err.details;
17
+ if (status >= 500) {
18
+ console.error("[error]", err);
19
+ }
20
+ res.status(status).json(body);
21
+ }