Kiến Trúc Multi-Provider Payment Gateway: Xử Lý Stripe, Adyen và Các Cổng Thanh Toán Khu Vực Châu Á
Bài này có bản tiếng Anh. Read in English →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
Một marketplace du lịch bán cho khách ở nhiều thị trường thì không thể chỉ tích hợp một cổng thanh toán. Khách Nhật quen trả qua LINE Pay. Khách Hàn dùng KakaoPay hoặc Toss Payments. Khách quốc tế thì thẻ Visa/Mastercard qua Stripe hoặc Adyen.
Đến provider thứ sáu thì vấn đề không còn nằm ở việc tích hợp nữa. Nó nằm ở chỗ giữ lớp payment khỏi mục.
1. Gọi thẳng SDK từng provider trong route checkout
Cách dễ nhất, cũng là cách dễ sai nhất: gọi thẳng SDK của từng provider trong route checkout.
if (paymentMethod === "stripe") {
await stripe.paymentIntents.create({ ... });
} else if (paymentMethod === "adyen") {
await adyenClient.payments({ ... });
} else if (paymentMethod === "linepay") {
// ...
}
Mỗi provider một model dữ liệu: Stripe dùng PaymentIntent với flow confirm riêng, Adyen dùng Sessions flow, LINE Pay với KakaoPay thì bắt redirect về app di động. Webhook khác nhau. Refund với capture cũng khác.
Provider tăng từ 2 lên 6. Route checkout thành một mớ if-else không ai dám sửa.
2. Interface PaymentProvider: mọi provider đi chung một cửa
Bọn mình định nghĩa một interface chung, mọi provider phải theo. Core logic — tạo order, trừ inventory, gửi email — chỉ nói chuyện với interface đó, không cần biết Stripe khác Adyen ở điểm nào:
interface PaymentProvider {
readonly id: string; // "stripe" | "adyen" | "linepay" | ...
initiatePayment(input: InitiatePaymentInput): Promise<PaymentSession>;
capturePayment(sessionId: string): Promise<PaymentResult>;
refundPayment(paymentId: string, amount?: number): Promise<RefundResult>;
// Mỗi provider tự verify chữ ký webhook theo cách riêng,
// nhưng luôn trả về một sự kiện đã chuẩn hoá
parseWebhookEvent(rawBody: Buffer, headers: Record<string, string>): NormalizedPaymentEvent;
}
Mỗi provider implement interface này riêng biệt. Ví dụ rút gọn cho Stripe:
class StripeProvider implements PaymentProvider {
readonly id = "stripe";
async initiatePayment(input: InitiatePaymentInput): Promise<PaymentSession> {
const intent = await this.client.paymentIntents.create({
amount: input.amountMinorUnits,
currency: input.currency,
metadata: { bookingId: input.bookingId },
});
return { sessionId: intent.id, clientSecret: intent.client_secret, status: "pending" };
}
async capturePayment(sessionId: string): Promise<PaymentResult> {
const intent = await this.client.paymentIntents.capture(sessionId);
return { status: intent.status === "succeeded" ? "captured" : "failed", raw: intent };
}
parseWebhookEvent(rawBody: Buffer, headers: Record<string, string>): NormalizedPaymentEvent {
const event = this.client.webhooks.constructEvent(
rawBody,
headers["stripe-signature"],
this.webhookSecret,
);
return { type: this.mapEventType(event.type), paymentId: event.data.object.id, raw: event };
}
}
Một registry đơn giản map providerId → implementation, và route checkout/webhook chỉ cần tra registry rồi gọi qua interface chung:
const provider = paymentProviderRegistry.get(order.paymentMethod);
const result = await provider.capturePayment(order.paymentSessionId);
KakaoPay hay Toss cần thêm thì viết thêm một class implement PaymentProvider, đăng ký vào registry. Xong. Route với logic order không phải sửa dòng nào.
3. Webhook idempotency: chỗ dễ double-process nhất
Provider sẽ gửi lại cùng một event nếu không nhận được response 200 kịp — timeout, server restart, network glitch, đủ cả. Webhook handler không idempotent thì một event “payment succeeded” gửi hai lần khiến đơn hàng bị xác nhận thanh toán hai lần. Tệ hơn: email xác nhận cũng đi hai lần.
Cách xử lý:
- Mỗi
NormalizedPaymentEventmang mộteventIdduy nhất. Provider đưa thì lấy, không đưa thì tự sinh bằng hash củaprovider + paymentId + eventType + timestamp. - Trước khi xử lý, ghi
eventIdvào bảngprocessed_webhook_eventsvới unique constraint. Insert thất bại vì trùng key nghĩa là event đã xử lý rồi — trả 200 ngay, không làm gì thêm. - Việc ghi nhận event đã xử lý với việc cập nhật trạng thái order nằm trong cùng một DB transaction, không hở khoảng nào giữa hai bước.
await db.transaction(async (trx) => {
const inserted = await trx("processed_webhook_events")
.insert({ event_id: event.id, provider: provider.id })
.onConflict("event_id")
.ignore();
if (inserted.rowCount === 0) return; // đã xử lý trước đó, bỏ qua
await applyPaymentEvent(trx, event);
});
4. Reconciliation: lúc webhook không bao giờ tới
Webhook mất được. Cấu hình sai, firewall chặn, provider có sự cố — thế là hết. Chỉ dựa vào webhook thì có order kẹt mãi ở pending dù tiền đã charge thành công bên phía provider.
Thêm một job reconciliation chạy định kỳ 15 phút một lần. Nó quét order pending quá ngưỡng thời gian rồi hỏi thẳng provider qua API xem trạng thái thật là gì, không chờ webhook:
const stalePending = await orderService.findStalePendingPayments({ olderThanMinutes: 15 });
for (const order of stalePending) {
const provider = paymentProviderRegistry.get(order.paymentMethod);
const status = await provider.queryPaymentStatus(order.paymentSessionId);
if (status.isSettled) {
await applyPaymentEvent(db, status.toNormalizedEvent());
}
}
Reconciliation job dùng chung logic xử lý event với webhook handler, qua applyPaymentEvent.
Ghép hai đường lại thì luồng xử lý một payment đi như vầy.
sequenceDiagram
participant P as Payment provider
participant W as Webhook route
participant DB as Database
participant R as Reconciliation job
P->>W: gửi event payment succeeded
W->>W: parseWebhookEvent — verify chữ ký, chuẩn hoá sự kiện
W->>DB: một transaction: insert eventId và cập nhật trạng thái order
alt eventId đã có trong processed_webhook_events
DB-->>W: insert trùng key
W-->>P: trả 200, bỏ qua
else event mới
DB-->>W: ghi xong
W-->>P: trả 200
end
Note over R: chạy định kỳ mỗi 15 phút
R->>DB: tìm order pending quá ngưỡng
R->>P: queryPaymentStatus, hỏi thẳng provider
P-->>R: trạng thái thật của payment
R->>DB: applyPaymentEvent khi đã settled
5. Refund và partial refund
Refund tưởng dễ. Không hẳn. Không phải provider nào cũng hỗ trợ partial refund theo cùng một cách, mà booking du lịch thì hiếm khi refund 100% — phí huỷ tour tuỳ chính sách.
Interface refundPayment nhận amount?: number. Không truyền thì refund toàn bộ, truyền thì refund một phần. Mỗi provider tự validate amount trước khi gọi API: không vượt quá số tiền gốc, không âm.
KakaoPay với mấy ví điện tử khu vực thì refund API đôi khi không hỗ trợ partial refund trực tiếp. Provider implementation tự xử: refund toàn bộ rồi charge lại phần chênh lệch, ghi log rõ cho đội vận hành biết đây là refund tổng hợp, không phải API gốc làm được.
6. Có sự cố thì hỏi hai câu trước
Lớp abstraction PaymentProvider không xoá sự phức tạp của việc tích hợp 5-6 cổng thanh toán khác nhau. Nó khoanh sự phức tạp đó vào từng file provider riêng, để core logic đặt hàng không phải quan tâm provider nào đứng sau.
Có sự cố thanh toán thì mình hỏi hai câu trước tiên: event này đã ghi vào processed_webhook_events chưa, và job reconciliation có bắt được order này chưa. Hai câu đó khoanh được 90% nguyên nhân.
Đọc tiếp
- Debug Một Race Condition Ẩn Trong Hệ Thống Thanh Toán Real-timeĐời Sống & Sự Cố
- Thiết kế Booking System cho Travel Marketplace: Saga Pattern và Event-Driven Workflow trong Medusa v2Kỹ Thuật Chuyên Sâu
- Deep dive kiến trúc ReAct runtime trong Go: Xây dựng AI Agent tương thích chuẩn MCPKỹ Thuật Chuyên Sâu
Cần người cho dự án contract hoặc freelance?
Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.