Từ số 0 đến Hệ Thống Thanh Toán (Payment System) chuẩn chỉnh: kinh nghiệm thực chiến cho Backend Dev
Chào anh em! lại là mình đây
Nếu dạo quanh các group IT hay trên Viblo, chúng ta rất dễ bắt gặp những topic về CRUD, optimize query, hay build microservices. Nhưng có một chủ đề cực kỳ "xương xẩu" mà ít thấy ai chia sẻ sâu: Xây dụng hệ thống thanh toán (Payment System).
Từng sấp mặt với đủ thể loại Gateway (từ VNPay, MoMo trong nước đến Stripe, PayPal quốc tế), đền tiền có, thức đêm fix bug do user "spam" nút thanh toán cũng có. Hôm nay, mình sẽ tổng hợp lại những kinh nghiệm "xương máu" nhất để anh em junior hay middle có cái nhìn thực tế và né được những cái bẫy chết người khi đụng đến.... tiền.
1. Tại sao làm Payment lại "khoai"?
Nhiều anh em mới nghĩ làm payment đơn giản là: User bấm mua -> gọi API của cổng thanh toán -> Cổng trả về thành công -> Update database thành PAID -> xong
Đời không như là mơ! Hệ thống thanh toán là nơi mà mọi thứ tồi tệ nhất về network đều có thể xảy ra.
- Gateway đang xử lý thì sập
- User trừ tiền rồi nhưng timeout không nhận response.
- User bực mình bấm nút "Thanh Toán" 10 lần liên tục.
- Webhook từ gateway gọi về server mình đang bị delay, hoặc gọi 3 lần cho cùng 1 giao dịch.
Làm payment không khó ở việc "làm cho nó chạy", mà khó ở việc "đảm bảo nó luôn chạy đúng trong mọi trường hợp lỗi ". Sai một ly là đi nguyên tháng lương như chơi.
2. Tổng quan Flow của một hệ thống thanh toán
Một flow chuẩn chỉnh (dạng Redirect hoặc Hosted Page) thường sẽ đi qua các bước sau:
-
Khởi tạo (init): Client gọi API Backend để tạo yêu cầu thanh toán. Backend lưu thông tin order (trạng thái PENDING) và gọi sang Payment Gateway để lấy URL thanh toán
-
Thanh toán (Pay): Backend trả URL về cho client. Client redirect user sang trang của Gateway để nhập thẻ/quét mã.
-
Xác nhận (Confirm): - Đường Webhook (Server-to-Server): Gateway báo kết quả giao dịch về backend của bạn qua một URL Webhook. Đây là đường chính xác nhất và đáng tin cậy nhất.
- Đường Redirect (client-side): Sau khi thanh toán, Gateway redirect user về lại web/app của bạn kèm theo vài param trạng thái. (Đường này chỉ để hiện thị UI, tuyệt đối không dùng để update database)
-
Đối soát (Reconciliation): Một CORN Job chạy ngầm để check lại các giao dịch PENDING quá lâu với Gateway xem thực chất nó đã thành công hay thất bại.
3. Thiết kế API cho Payment
Hãy thiết kế API theo chuẩn RESTful, rõ ràng và tách biệt logic:
- POST /api/v1/payments: Khởi tạo transaction mới. Trả về payment_url để client redirect.
- GET /api/v1/payments/{id}: Polling để client tự động check trạng thái transaction (đang pending, success hay failed) để cập nhật UI.
- POST /api/v1/webhooks/{provider}: API hứng dữ liệu từ Gateway (ví dụ: /webhooks/stripe, /webhooks/momo).
4. Thiết kế Database cho Payment Transaction
Nguyên tắc bất di bất dịch: Tách biệt Order và Payment Transaction. Một Order có thể có nhiều lần thử thanh toán (do user chọn sai thẻ, cancel rồi thanh toán lại,...).
Table payment_transactions cơ bản cần có:
- id: Primary key (Nên dùng UUID).
- order_id: Foreign key liên kết đến bảng orders.
- amount: Số tiền (khuyên chân thành: hãy lưu dưới dạng số nguyên - integer, ví dụ lưu đơn vị nhỏ nhất là VNĐ hoặc cent. Tuyệt đối không dùng float/double để lưu tiền).
- currency: Mã tiền tệ (VND, USD).
- provider: Tên cổng thanh toán (STRIPE, VNPAY, MOMO).
- provider_transaction_id: Mã giao dịch do cổng thanh toán trả về (rất quan trọng để đối soát sau này).
- status: Trạng thái (PENDING, SUCCESS, FAILED, REFUNDED, CANCELED).
- idempotency_key: Khóa để chống duplicate.
5. Xử lý các "tử huyệt" trong Payment
A. Idempotency (Tránh thanh toán trùng) Hãy tưởng tượng network lag, user spam bấm "Thanh toán". API của bạn nhận 5 request cùng lúc. Nếu không cẩn thận, bạn sẽ tạo 5 transaction và gọi gateway 5 lần. Giải pháp: Yêu cầu client sinh ra một chuỗi unique (UUID) gửi lên trong header Idempotency-Key. Ở backend, check xem key này đã tồn tại trong DB/Redis chưa. Nếu có rồi thì trả về kết quả của lần xử lý trước đó thay vì tạo mới.
B. Webhook từ Payment Gateway Webhook là mạch máu của payment system.
- Security: Gateway xịn luôn gửi kèm theo chữ ký (Signature) trong Header. Bạn bắt buộc phải dùng Secret Key để verify chữ ký này, tránh bị hacker gọi fake API nạp tiền khống.
- Fast Acknowledge: Webhook gọi đến, hãy nhanh chóng verify signature, đẩy event vào một Message Queue (RabbitMQ, Kafka) hoặc lưu DB tạm, rồi lập tức trả về 200 OK cho Gateway. Đừng xử lý logic DB nặng nề ở request này, Gateway chờ lâu sẽ báo timeout và spam gọi lại webhook.
C. Database Locks (Transaction Consistency) Khi Webhook nhận được trạng thái SUCCESS, bạn phải update payment_transactions và orders. Đôi khi Webhook gọi đúp 2 lần gần như cùng lúc (Race condition). Giải pháp: Phải dùng Lock ở Database (ví dụ: SELECT ... FOR UPDATE trong SQL) kết hợp với kiểm tra trạng thái hiện tại. Chỉ update khi trạng thái đang là PENDING. Nếu nó đã là SUCCESS rồi thì bỏ qua.
D. Retry Mechanism & Đối soát (Reconciliation) Không phải lúc nào Webhook cũng đến đích thành công. Giải pháp: Viết một worker chạy ngầm (CRON), định kỳ quét các giao dịch PENDING tạo cách đây hơn 15-30 phút. Worker này sẽ gọi API Check Transaction Status của Gateway để đồng bộ lại trạng thái cho chính xác.
6. Những cái "bẫy" mà Developer hay mắc phải
Tin tưởng Client: Lấy trạng thái thành công từ URL redirect của user để update DB. (User hoàn toàn có thể fake URL này). -> Chỉ tin Webhook hoặc API gọi trực tiếp S2S tới Gateway.
Không lưu lại Log: Khi cãi nhau với Gateway về một giao dịch lệch tiền, bạn lấy gì làm bằng chứng? -> Lưu toàn bộ Request/Response payload (kể cả Header) khi giao tiếp với Gateway vào Log hoặc một table lưu trữ log riêng biệt.
Quên mất timeout: Đang gọi sang Gateway lấy URL thanh toán mà mạng lag treo mất 30s. -> Luôn set Timeout (VD: 5s-10s) cho mọi HTTP request gọi ra bên ngoài.
7. Một số Best Practices khác
Sử dụng State Machine: Trạng thái đơn hàng/thanh toán nên được quản lý bởi State Machine (Ví dụ: PENDING chỉ được chuyển sang SUCCESS hoặc FAILED, không có chiều ngược lại từ SUCCESS về PENDING).
Alerting: Thiết lập alert bắn về Telegram/Slack ngay lập tức nếu webhook verify signature thất bại liên tục (có biến), hoặc tỷ lệ FAILED bỗng nhiên tăng vọt (Gateway có thể đang sập).
Sandboxing: Luôn test thật kỹ ở môi trường Sandbox/Test của Gateway với đủ các kịch bản: Thẻ hết tiền, thẻ bị khóa, user tắt trình duyệt giữa chừng.
8. Tổng kết
Xây dựng hệ thống payment thực chất là cuộc chiến kiểm soát các trạng thái và xử lý lỗi mạng (network failure). Đừng quá ám ảnh về việc làm sao xử lý 10.000 TPS, hãy ám ảnh về việc không bao giờ để mất, thiếu hoặc dư một đồng nào của user.
Hy vọng bài viết này giúp anh em có cái nhìn tổng quan và bớt "đau thương" khi nhận task làm payment. Anh em đã từng dính những lỗi "nhớ đời" nào khi làm thanh toán chưa? Hãy comment chia sẻ bên dưới nhé!
All rights reserved