Hoàn tiền là lệnh chi tiền, gửi nhầm hai lần là mất tiền thật. Trang này nói cách đặt khóa chống trùng, xử lý từng mã trả về, và lưu gì để đối soát về sau.
Khóa chống trùng hoạt động thế nào
Mỗi lệnh hoàn phải kèm header X-Idempotency-Key do bạn tự sinh. SePay so khóa đó với nội dung request để quyết định:
| Bạn gửi | SePay làm gì |
|---|---|
| Khóa mới | Tạo lệnh hoàn mới |
| Khóa cũ, nội dung y hệt | Trả lại đúng bản ghi cũ, không chi tiền lần hai |
| Khóa cũ, đổi nội dung | Từ chối với 409 idempotency_key_reused |
"Nội dung" ở đây là những gì quyết định tiền đi đâu và bao nhiêu: đối tượng hoàn, transaction_id và refund_amount. Đổi một trong số đó mà giữ nguyên khóa là 409.
Lệnh đã failed hoặc rejected là kết quả cuối. Gửi lại cùng khóa chỉ nhận lại đúng lỗi cũ, SePay không gửi lệnh mới sang ngân hàng. Muốn thử lại thì sinh khóa mới.
Đặt cấu trúc mã
Khóa là chuỗi của riêng bạn, SePay chỉ dùng để so trùng và không bao giờ gửi ra ngoài. Ghép từ những thứ bạn đã có sẵn và duy nhất trong hệ thống của mình:
refund-{ma_don_hang}-{so_thu_tu_lan_hoan}refund-DH20250001-01refund-DH20250001-02
Nguyên tắc đặt khóa:
- Một lệnh hoàn, một khóa. Hoàn hai đợt cho cùng đơn hàng là hai khóa khác nhau.
- Sinh khóa trước khi gọi API và lưu lại ngay. Nếu sinh khóa trong bộ nhớ rồi mới gọi, request timeout là bạn mất khóa và không gửi lại được đúng lệnh đó.
- Đừng dùng số ngẫu nhiên thuần. Lần gửi lại phải tái tạo được đúng khóa cũ, nên khóa cần suy ra từ dữ liệu nghiệp vụ.
- Tối đa 100 ký tự. Khóa dài hơn nhận
422 validation_errorkèm lỗi trênidempotency_key.
SePay cấp refund_id dạng RF-7QK3-M8PZ-4VNW, sinh ngẫu nhiên và không suy ra được từ khóa bạn gửi. Đây là mã dùng để tra cứu và để trao đổi khi cần hỗ trợ, nên hãy lưu ánh xạ giữa khóa của bạn và refund_id ngay khi nhận response.
Luồng gửi lệnh an toàn
Ba tình huống đáng gửi lại tự động: 503 refund_busy, 503 vietinbank_map_connection_error, và mất kết nối phía bạn. Cả ba đều gửi lại nguyên văn cùng khóa, vì khóa là thứ bảo đảm lần gửi lại không thành lệnh chi tiền thứ hai.
Hai mã 503 khác nhau ở chỗ đã có bản ghi hay chưa. refund_busy là chưa tạo gì, có lệnh khác đang chạy cho cùng giao dịch gốc. vietinbank_map_connection_error là bản ghi đã tạo và đang pending, chỉ không rõ ngân hàng đã nhận chưa, nên SePay giữ bản ghi lại để tự đối soát. Cả hai gửi lại cùng khóa đều an toàn: trường hợp sau bạn nhận lại đúng bản ghi đang chờ đó.
500 unexpected_error cũng vậy: lệnh có thể đã tới ngân hàng, nên gửi lại cùng khóa hoặc tra bằng Danh sách hoàn tiền. Đừng sinh khóa mới.
422 còn lại là lỗi nghiệp vụ, gửi lại y nguyên sẽ ra đúng kết quả đó. Đọc error_code rồi xử lý theo từng trường hợp, xem bảng lỗi ở Hoàn tiền theo đơn hàng.
Lưu gì để đối soát
Lưu tối thiểu các trường sau vào bản ghi hoàn tiền phía bạn:
| Trường | Vì sao cần |
|---|---|
| Khóa chống trùng | Để gửi lại đúng lệnh cũ khi timeout |
refund_id | Mã tra cứu, và là thứ SePay hỏi khi bạn cần hỗ trợ |
transaction_id | Giao dịch thu gốc bị hoàn, dùng để cộng dồn theo khoản thu |
amount | Số tiền lệnh này hoàn |
status | Kết quả cuối, dùng để đối soát sổ sách |
created_at | Mốc thời gian đối chiếu với sao kê |
original_reference và xid không bắt buộc, nhưng lưu lại thì tiện khi cần đối chiếu chi tiết với ngân hàng qua SePay.
Lệnh pending vẫn chiếm hạn mức hoàn của giao dịch gốc nhưng chưa chắc tiền đã đi. Cộng dồn số tiền đã hoàn dựa trên pending sẽ lệch sổ nếu lệnh đó về sau chuyển failed.
Đối soát định kỳ
Gọi Danh sách hoàn tiền và lọc theo trạng thái:
curl -X GET "https://userapi.sepay.vn/v2/bank-accounts/{ba_uuid}/refunds?status=succeeded&per_page=100" \-H "Authorization: Bearer YOUR_API_TOKEN"
| Mục đích | Bộ lọc |
|---|---|
| Đối soát số tiền đã hoàn thực tế | status=succeeded |
| Tìm lệnh treo cần theo dõi tiếp | status=pending |
| Xem một khoản thu đã bị hoàn bao nhiêu lần | transaction={transaction_id} |
| Xem toàn bộ lệnh hoàn của một đơn hàng | order={order_xid} |
So status bạn đang lưu với status trả về. Lệnh nào phía bạn còn pending mà SePay đã trả kết quả cuối thì cập nhật lại; ngược lại, lệnh nào bạn không có bản ghi mà SePay có thì đó là lệnh gửi đi rồi mất response, tra bằng transaction_id để nối lại.