Khi lần đầu tiếp cận bài toán multiple payment method, tôi từng nghĩ đây chủ yếu là một bài toán chia số tiền.
Ví dụ, một dịch vụ có giá 700 USD. Khách hàng có gift card trị giá 100 USD và muốn thanh toán 600 USD còn lại bằng credit card.
Về mặt số học, bài toán khá đơn giản:
700 USD
=
100 USD Gift Card
+
600 USD Credit Card
Nhưng khi bắt đầu triển khai trên môi trường thực tế, đặc biệt trong một hệ thống có POS device, saved credit card, cash, internal wallet và gift card, tôi nhận ra phần chia tiền chỉ là một phần rất nhỏ.
Những câu hỏi khó hơn bắt đầu xuất hiện:
Payment method nào nên được xử lý trước?
Nếu một phương thức thành công nhưng phương thức tiếp theo thất bại thì sao?
Database rollback có hoàn lại được giao dịch đã tạo ở third party không?
Nếu request đến third party bị timeout, làm sao biết khách đã bị charge hay chưa?
Nếu khách muốn thay đổi sản phẩm hoặc giá trong lúc POS đang chờ quét thẻ thì sao?
Nếu refund thành công ở third party nhưng backend không nhận được response thì xử lý thế nào?
Từ đó, tôi nhận ra multiple payment method không đơn thuần là bài toán cộng trừ số tiền.
Nó là bài toán điều phối nhiều payment workflow khác nhau, trong khi vẫn phải bảo đảm dữ liệu nhất quán và khách hàng không bị mất tiền.
Multiple payment method có thể xuất hiện trong rất nhiều tình huống quen thuộc.
Hai người dùng chung một hóa đơn nhưng muốn thanh toán riêng.
Người A dùng credit card.
Người C dùng tiền mặt.
Hệ thống phải ghi nhận cả hai khoản thanh toán vào cùng một order hoặc invoice, đồng thời bảo đảm tổng số tiền bằng đúng giá trị cần thanh toán.
Một dịch vụ có giá 700 USD.
Khách hàng có gift card trị giá 100 USD và muốn thanh toán phần còn lại bằng credit card.
Gift Card: 100 USD
Credit Card: 600 USD
Total: 700 USD
Trong trường hợp này, nếu credit card bị từ chối sau khi gift card đã bị trừ, hệ thống phải trả lại gift card cho khách hàng.
Một sản phẩm có giá 2.000 USD.
Khách hàng có 1.500 USD tiền mặt và hạn mức 3.000 USD trong credit card. Tuy nhiên, khách muốn giữ lại một phần hạn mức thẻ để sử dụng cho mục đích khác.
Khách có thể chọn:
Cash: 1.000 USD
Credit Card: 1.000 USD
Total: 2.000 USD
Ở đây, hệ thống không chỉ cần chia số tiền mà còn phải ghi nhận đúng ai đã nhận cash, thời điểm nào và điều gì xảy ra nếu phần credit card thất bại.
Một payment có thể bao gồm đồng thời:
POS card
saved credit card
cash
internal wallet
gift card.
Đây là lúc bài toán bắt đầu trở nên phức tạp.
Một phương thức có thể xử lý hoàn toàn trong hệ thống nội bộ. Một phương thức cần gọi đến third party. Một phương thức lại cần nhân viên xác nhận thủ công.
Không thể coi tất cả payment method là những bước giống nhau.
Nếu chỉ nhìn vào tổng tiền, multiple payment method có vẻ là một phép chia đơn giản.
Nhưng hệ thống thực tế phải quản lý một payment plan gồm nhiều payment component.
Ví dụ:
Payment Plan
├── POS Card: 500 USD
├── Saved Card: 300 USD
├── Gift Card: 100 USD
├── Internal Wallet: 50 USD
└── Cash: 50 USD
Tổng các component phải bằng đúng số tiền của order.
Tuy nhiên, việc validate tổng tiền mới chỉ là bước đầu.
Hệ thống còn phải trả lời:
Component nào đang chờ?
Component nào đã được xử lý?
Component nào đã thất bại?
Component nào cần refund?
Component nào chưa xác định được trạng thái?
Toàn bộ payment plan đã hoàn thành hay chưa?
Vì vậy, một thiết kế tốt nên xem payment plan là một workflow có state machine, không phải chỉ là một danh sách các transaction được tạo nối tiếp nhau.
Một trong những kinh nghiệm quan trọng tôi rút ra là nên gom các payment method theo đặc tính xử lý thay vì chỉ theo tên.
Nhóm này gồm:
internal wallet;
gift card;
store credit;
loyalty balance.
Đây là những phương thức hệ thống thường kiểm soát hoàn toàn.
Ta có thể thiết kế chúng theo mô hình:
AVAILABLE
↓
RESERVED
↓
CONSUMED
Nếu payment flow thất bại trước khi hoàn tất:
RESERVED
↓
RELEASED
Reservation tốt hơn việc trừ tiền ngay rồi cộng lại, bởi vì nó giảm số lượng transaction đảo ngược và giúp lịch sử tài chính rõ ràng hơn.
Nhóm này có thể gồm:
Credit card;
POS card;
Các hình thức thanh toán điện tử thông qua third party.
Những payment method này có một đặc điểm quan trọng: hệ thống nội bộ không kiểm soát hoàn toàn vòng đời giao dịch.
Một request đến third party có thể:
thành công;
thất bại;
timeout;
được xử lý thành công nhưng response không quay về;
được gửi webhook nhiều lần;
hoặc trả kết quả muộn.
Do đó, timeout không đồng nghĩa với payment failed.
Trong một số trường hợp, trạng thái chính xác phải là:
UNKNOWN
Khi đó hệ thống cần query lại third party hoặc chờ reconciliation thay vì charge lại ngay.
Cash là ví dụ rõ nhất.
Cash không thể được refund bằng API.
Nếu nhân viên đã nhận tiền nhưng các phần thanh toán khác thất bại, hệ thống phải tạo một nhiệm vụ nghiệp vụ:
CASH_RETURN_REQUIRED
Sau khi nhân viên thực sự trả lại tiền cho khách, trạng thái mới được chuyển thành:
CASH_RETURNED
Việc tự động đánh dấu cash là refunded ngay khi backend gặp exception có thể khiến dữ liệu không phản ánh đúng thực tế.
Không có một thứ tự duy nhất phù hợp với mọi hệ thống.
Thứ tự xử lý nên dựa trên một số yếu tố:
payment method có hỗ trợ reserve hay không;
có hỗ trợ authorize trước và capture sau hay không;
chi phí refund;
khả năng rollback;
xác suất thất bại;
trải nghiệm người dùng;
quy trình vận hành tại quầy;
chính sách kinh doanh.
Một nguyên tắc hữu ích là:
Reserve sớm, commit muộn.
Ví dụ:
1. Validate toàn bộ payment plan
2. Reserve internal wallet
3. Reserve gift card
4. Authorize saved card
5. Process hoặc authorize POS card
6. Xác nhận cash
7. Capture external payments
8. Commit internal balances
9. Complete order
Cách này giúp giảm nhu cầu refund.
Nếu một credit card không thể authorize, hệ thống có thể release wallet và gift card reservation mà không cần tạo transaction hoàn tiền.
Tuy nhiên, thực tế không phải payment method nào cũng hỗ trợ tách riêng authorize và capture.
POS flow cũng có thể bắt buộc phải xử lý sớm vì cần khách hàng tương tác trực tiếp với thiết bị.
Do đó, thay vì cố tìm một thứ tự cố định, hệ thống nên định nghĩa lifecycle riêng cho từng payment method:
Validate
↓
Reserve hoặc Authorize
↓
Commit hoặc Capture
↓
Compensate nếu cần
Trong một hệ thống thực tế, flow có thể diễn ra như sau:
1. Tạo POS payment và gửi đến POS device
2. Khách hàng quét hoặc chạm thẻ
3. Backend nhận webhook success từ third party
4. Tạo và lưu POS transaction
5. Xử lý saved credit card
6. Xử lý cash, wallet, gift card và các payment còn lại
7. Thực hiện các action sau thanh toán
Ở phía client, hệ thống hiển thị loading view hoặc trạng thái chờ sau những bước quan trọng:
Đang kết nối đến thiết bị thanh toán
Vui lòng quét hoặc chạm thẻ
Đang xác nhận giao dịch
Client không nên tự kết luận payment thành công chỉ dựa vào màn hình của POS device.
Trạng thái cuối cùng phải đến từ backend, sau khi backend đã nhận và xử lý kết quả từ third party.
Client có thể cập nhật trạng thái bằng:
polling;
WebSocket;
Server-Sent Events;
push notification;
hoặc một status page.
Điểm cần lưu ý là webhook xảy ra bất đồng bộ.
Request ban đầu từ client có thể đã kết thúc từ lâu. Vì vậy, không thể dựa hoàn toàn vào việc throw exception sang client.
Exception nên được chuyển thành domain state có thể truy vấn.
Ví dụ:
{
"paymentId": "payment_123",
"status": "COMPENSATING",
"failedStep": "SAVED_CARD",
"errorCode": "CARD_DECLINED",
"message": "Một phần thanh toán thất bại. Hệ thống đang hoàn lại khoản đã thu trước đó."
}
Trong phiên bản đầu, tôi từng xem các bước sau webhook như một flow cần được bọc trong một database transaction lớn.
Tư duy này có điểm hợp lý: nếu bất kỳ bước nào thất bại, toàn bộ dữ liệu nội bộ phải rollback.
Tuy nhiên, vấn đề xuất hiện khi database transaction bao phủ cả những lần gọi đến third party.
Ví dụ:
BEGIN TRANSACTION
Save POS transaction
Call third party for saved card
Update wallet
Update gift card
Update order
COMMIT
Một API call đến third party có thể mất nhiều giây hoặc timeout.
Trong thời gian đó, database transaction vẫn giữ connection và lock.
Điều này có thể gây:
transaction kéo dài;
deadlock;
giảm throughput;
các request khác bị block;
khó retry;
khó xác định trạng thái thật của external payment.
Một thiết kế tốt hơn là dùng nhiều local transaction ngắn.
Transaction 1
- ghi nhận POS success;
- cập nhật payment attempt;
- tạo nhiệm vụ xử lý bước tiếp theo;
- commit.
External processing
- gọi third party;
- nhận kết quả.
Transaction 2
- ghi nhận kết quả;
- cập nhật trạng thái;
- commit.
Database transaction vẫn rất quan trọng, nhưng chỉ nên bảo vệ dữ liệu nội bộ.
Nó không thể làm cho các hệ thống khác trở thành một phần của ACID transaction.
Đây là bài học quan trọng nhất.
Giả sử POS payment đã thành công ở third party, sau đó saved credit card bị từ chối.
Database có thể rollback các thay đổi nội bộ.
Nhưng database rollback không thể khiến giao dịch POS tự động biến mất.
Lúc này cần một compensation workflow:
Payment component failed
↓
Mark payment plan as COMPENSATION_REQUIRED
↓
Refund POS payment
↓
Release wallet reservation
↓
Restore gift card reservation
↓
Handle cash return if needed
↓
Mark payment plan as COMPENSATED
Vì vậy, một payment workflow thực tế thường là:
Local transaction
+
External side effects
+
Compensation
Đây gần với Saga orchestration hơn là một database transaction truyền thống.
Một implementation phổ biến có thể giống như sau:
try {
beginTransaction()
savePosTransaction()
chargeSavedCard()
updateWallet()
updateGiftCard()
updateOrder()
commit()
} catch (error) {
rollback()
refundPos()
refundSavedCard()
throw error
}
Cách này có thể chạy tốt trong các trường hợp đơn giản, nhưng vẫn tồn tại nhiều failure window.
Database đã rollback nhưng refund chưa được gọi.
Khách hàng vẫn bị charge, trong khi hệ thống có thể không còn đủ record để biết khoản nào cần hoàn.
Third party đã refund thành công nhưng backend bị timeout.
Nếu hệ thống retry mà không có idempotency hoặc không kiểm tra trạng thái, workflow có thể trở nên khó kiểm soát.
POS refund thành công nhưng saved card refund thất bại.
Một trạng thái chung như FAILED không đủ để mô tả trường hợp này.
Vì vậy, refund nên được lưu thành các compensation step độc lập:
POS_REFUND_REQUIRED
SAVED_CARD_REFUND_REQUIRED
WALLET_RELEASE_REQUIRED
GIFT_CARD_RELEASE_REQUIRED
CASH_RETURN_REQUIRED
Mỗi step cần có:
trạng thái riêng;
idempotency key;
retry;
audit log;
thời điểm thực hiện;
provider reference;
lý do thất bại;
cơ chế manual review.
Một worker có thể xử lý compensation bất đồng bộ và retry khi cần thiết.
Một lỗi thiết kế khác là rollback luôn transaction record của một external payment đã thành công.
Nếu POS payment đã thật sự xảy ra ở third party, local database nên giữ lại sự kiện đó.
Thay vì xóa record, ta cập nhật vòng đời:
POS CHARGE: SUCCEEDED
↓
REFUND REQUIRED
↓
REFUND PROCESSING
↓
REFUNDED
Trong payment hoặc accounting ledger, nên lưu các movement riêng:
CHARGE: +500 USD
REFUND: -500 USD
NET: 0 USD
Không nên chỉnh sửa charge record như thể giao dịch chưa từng xảy ra.
Cách làm này tốt hơn cho:
audit;
reconciliation;
báo cáo;
dispute;
partial refund;
theo dõi phí;
điều tra sự cố.
Trước khi khách đưa thẻ vào POS, payment flow có thể được cancel tương đối an toàn.
Khách hàng có thể muốn:
thay đổi sản phẩm;
thay đổi số lượng;
cập nhật giá;
đổi payment method;
thay đổi số tiền phân bổ;
hoặc hủy giao dịch.
Flow có thể là:
PAYMENT_CREATED
↓
WAITING_FOR_CARD
↓
CANCELLED
Sau đó hệ thống tạo payment attempt mới.
Không nên sửa trực tiếp payment plan cũ trong khi POS device có thể vẫn đang hiển thị số tiền trước đó.
Tốt hơn là:
Cancel old attempt
↓
Create a new attempt
↓
Send the new amount to POS
Điều này giúp tránh trường hợp POS đang charge giá cũ trong khi order đã được cập nhật sang giá mới.
Sau khi khách đã đưa thẻ vào thiết bị, tình hình phức tạp hơn.
Payment có thể đang processing.
Khi đó, yêu cầu cancel không đồng nghĩa với việc payment chắc chắn đã dừng.
Hệ thống cần chờ trạng thái cuối cùng:
CARD_PRESENTED
↓
PROCESSING
↓
SUCCEEDED hoặc FAILED
Nếu payment đã authorize nhưng chưa capture, có thể void.
Nếu đã capture, có thể cần refund.
Nếu chưa xác định được kết quả, hệ thống phải reconciliation trước khi hành động tiếp.
Idempotency cần xuất hiện ở nhiều lớp:
client gọi backend;
backend gọi third party;
webhook handler;
refund hoặc compensation worker.
Một request bị retry không được tạo thêm charge ngoài ý muốn.
Webhook có thể được gửi nhiều lần hoặc đến không đúng thứ tự.
Webhook handler phải:
verify signature;
lưu event ID;
xử lý idempotent;
kiểm tra state transition hợp lệ;
không giả định event chỉ đến một lần.
Cần chống các trường hợp:
hai webhook cùng xử lý;
client cancel đúng lúc webhook success đến;
hai worker cùng xử lý một component;
order bị sửa trong lúc payment đang chạy.
Có thể sử dụng:
optimistic locking;
version number;
short row-level lock;
unique constraint;
compare-and-set state.
Nếu database đã commit nhưng process chết trước khi publish background job, workflow có thể bị kẹt.
Outbox pattern giúp bảo đảm trạng thái và nhiệm vụ tiếp theo được ghi trong cùng local transaction.
Không nên phụ thuộc hoàn toàn vào webhook.
Cần có job định kỳ để so sánh:
Third-party transaction
↔
Local payment ledger
Nếu có chênh lệch, hệ thống tạo cảnh báo hoặc repair task.
Không phải action nào sau payment cũng nên khiến payment thất bại.
Critical action có thể gồm:
đánh dấu order đã thanh toán;
commit inventory;
cấp quyền sử dụng dịch vụ.
Non-critical action có thể gồm:
gửi email;
analytics;
notification;
cập nhật CRM.
Một email gửi thất bại không nên khiến toàn bộ payment bị refund.
Multiple payment method chưa bao giờ chỉ là bài toán chia tiền.
Phần khó nhất nằm ở việc điều phối nhiều payment method có đặc tính hoàn toàn khác nhau.
Internal wallet có thể reserve và release.
Gift card có thể giữ số dư tạm thời.
Credit card cần làm việc với third party.
POS cần tương tác trực tiếp với khách hàng và xử lý bất đồng bộ.
Cash lại phụ thuộc vào xác nhận thủ công của nhân viên.
Một thiết kế production-grade nên kết hợp:
payment plan;
state machine;
short local transaction;
reservation và authorization;
idempotency;
durable compensation;
immutable ledger;
retry;
reconciliation;
audit log;
client status tracking.
Điều quan trọng nhất tôi học được là:
Database rollback không đồng nghĩa với payment rollback.
Một khi tiền đã đi qua third party hoặc được nhân viên nhận trực tiếp, việc hoàn tác không còn là một câu lệnh rollback.
Nó trở thành một nghiệp vụ độc lập, có trạng thái, có thể thất bại, cần retry và phải được theo dõi cho đến khi hoàn tất.
Nếu chỉ nhìn multiple payment method như một phép chia số tiền, ta có thể tạo ra một flow chạy được trong happy path.
Nhưng nếu nhìn nó như một distributed payment workflow, ta mới có thể xây dựng một hệ thống đủ an toàn để vận hành trong thực tế.