[Fintech 101] Xây dựng hệ thống Thanh toán: Từ One-time, Subscription đến Hoàn tiền (Partial Refund)
Xây dựng một hệ thống thanh toán không đơn thuần là gọi một API và nhận kết quả "Success". Nó là sự kết hợp giữa logic nghiệp vụ, tính toàn vẹn dữ liệu và trải nghiệm người dùng.
Dù bạn dùng Stripe, PayPal hay VNPay, bạn đều phải đối mặt với 3 mô hình chính: Thanh toán một lần, Thuê bao định kỳ và Trả góp.
1. Các mô hình thanh toán phổ biến
A. One-time Payment (Thanh toán 1 lần)
Đây là mô hình đơn giản nhất (ví dụ: mua một ổ bánh mì, một khóa học).
Luồng đi: User chọn hàng -> Thanh toán -> Nhận hàng -> Kết thúc.
Lưu ý: Cần xử lý Idempotency (Tính không thay đổi) để đảm bảo nếu User bấm nút "Thanh toán" 2 lần do mạng lag, họ cũng không bị trừ tiền 2 lần.
B. Subscription (Thanh toán định kỳ)
Mô hình của Netflix, Spotify hay AWS.
- Cơ chế: User ủy quyền cho hệ thống tự động trừ tiền sau mỗi chu kỳ (tháng/năm).
- Kỹ thuật: Cần sử dụng Webhooks để lắng nghe sự kiện từ cổng thanh toán (Ví dụ:
invoice.paid,subscription.deleted). - Thử thách: Xử lý "Dunning" (Khi thẻ của User hết hạn hoặc hết tiền vào ngày gia hạn).
C. Installments (Trả góp)
Chia nhỏ số tiền lớn thành nhiều đợt thanh toán.
- 2 dạng chính: 1. Trả góp qua thẻ tín dụng: Ngân hàng trả toàn bộ cho bạn, User nợ ngân hàng.
- BNPL (Buy Now Pay Later): Hệ thống của bạn hoặc bên thứ 3 (như Fundiin, Kredivo) quản lý các đợt thu tiền.
2. Hoàn tiền (Refund) và Hoàn tiền một phần (Partial Refund)
Đây là nơi "cực hình" nhất của Logic thanh toán.
Refund toàn phần
Hủy bỏ toàn bộ giao dịch và trả lại 100% tiền.
- Trạng thái: Order chuyển từ
Paid->Refunded.
Partial Refund (Hoàn tiền một phần)
Ví dụ: User mua 3 món hàng nhưng muốn trả lại 1 món.
Phức tạp ở chỗ: Bạn phải tính toán lại Thuế (Tax), phí vận chuyển (Shipping fee) và các mã giảm giá (Discount) đã áp dụng.
Logic: Số tiền hoàn lại tối đa không được vượt quá số tiền đã thanh toán trừ đi các khoản đã hoàn trước đó.
Công thức cơ bản
3. Quy trình thiết kế Database chuẩn (Best Practices)
Đừng bao giờ lưu thông tin thanh toán trực tiếp vào bảng Orders. Hãy tách ra để dễ quản lý hoàn tiền và đối soát.
| Table | Chức năng |
|---|---|
| Orders | Lưu thông tin đơn hàng (Sản phẩm, tổng tiền). |
| Payments | Lưu lịch sử các lần thanh toán (Transaction ID, Gateway, Status). |
| Subscriptions | Lưu chu kỳ, ngày bắt đầu, ngày hết hạn, Plan ID. |
| Refunds | Lưu lịch sử hoàn tiền, lý do hoàn tiền, mã tham chiếu từ ngân hàng. |
4. Xử lý sự cố: Webhook là chìa khóa
Khi User thanh toán xong, họ có thể tắt trình duyệt trước khi Redirect về App của bạn.
Giải pháp: Luôn tin tưởng vào Webhook từ cổng thanh toán gửi về Server-to-Server.
Quy trình: 1. Nhận Webhook.
Kiểm tra chữ ký (Signature) để đảm bảo không bị giả mạo.
Kiểm tra trạng thái đơn hàng hiện tại.
Cập nhật Database và gửi thông báo cho User
5. Ví dụ mã giả (Pseudo-code) cho Partial Refund
public function handlePartialRefund($paymentId, $amountToRefund)
{
$payment = Payment::findOrFail($paymentId);
// 1. Kiểm tra số tiền còn lại có đủ để hoàn không
if ($amountToRefund > $payment->getRemainingAmount()) {
throw new Exception("Số tiền hoàn vượt quá số dư khả dụng.");
}
// 2. Gọi API của Cổng thanh toán (Stripe/PayPal/VNPay)
$response = $this->gateway->refund($payment->transaction_id, $amountToRefund);
if ($response->isSuccessful()) {
// 3. Ghi log vào bảng Refunds
Refund::create([
'payment_id' => $paymentId,
'amount' => $amountToRefund,
'reason' => 'User returned 1 item'
]);
// 4. Cập nhật lại số tiền đã hoàn trong bảng Payments
$payment->increment('refunded_amount', $amountToRefund);
}
}
Lời kết
Hệ thống thanh toán yêu cầu sự tỉ mỉ tuyệt đối. Một sai sót nhỏ trong logic hoàn tiền có thể khiến công ty mất tiền hoặc làm khách hàng giận dữ. Hãy luôn viết Unit Test thật kỹ cho các trường hợp biên (Edge cases).
All rights reserved