Điểm mấu chốt: refund là một giao dịch riêng, không phải xoá hay sửa giao dịch cũ. Bản ghi thanh toán gốc phải giữ nguyên để đối soát và kế toán.
Mô hình dữ liệu: bảng refunds tham chiếu payment_id, gồm amount, reason, status (requested → processing → succeeded/failed), gateway_refund_id, requested_by. Cho phép nhiều refund một phần trên cùng payment, nhưng ràng buộc tổng refund không vượt số tiền đã thu — kiểm tra trong cùng transaction với SELECT ... FOR UPDATE trên payment.
Luồng:
1. Kiểm tra điều kiện nghiệp vụ trước khi gọi cổng: đơn ở trạng thái cho phép huỷ, còn trong thời hạn hoàn, chưa giao hàng.
2. Tạo bản ghi refund requested kèm khoá idempotent (thường chính refund.id) rồi mới gọi API cổng. Có bản ghi trước khi gọi thì khi timeout bạn vẫn biết đã gửi lệnh gì.
3. Gọi cổng. Timeout không đồng nghĩa thất bại — retry phải gửi lại đúng khoá idempotent để cổng không hoàn tiền hai lần.
4. Cổng xử lý bất đồng bộ; chờ webhook kết quả để chuyển succeeded, đồng thời hoàn tồn kho, thu hồi quyền lợi (điểm thưởng, gói Pro), gửi email báo khách.
Refund qua cổng VN thường mất từ vài giờ tới vài ngày làm việc để tiền về tài khoản khách, nên giao diện phải hiển thị trạng thái đang xử lý thay vì báo hoàn tất ngay. Với đơn đã quá hạn refund của cổng, luồng thay thế là chuyển khoản tay — vẫn ghi vào bảng refunds với method = manual để số liệu đối soát không bị hụt.