Chống trùng và đối soát hoàn tiền

Cách đặt khóa chống trùng, luồng gửi lệnh hoàn tiền an toàn khi timeout, và những trường cần lưu để đối soát với SePay về sau.

||

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ửiSePay làm gì
Khóa mớiTạo lệnh hoàn mới
Khóa cũ, nội dung y hệtTrả lại đúng bản ghi cũ, không chi tiền lần hai
Khóa cũ, đổi nội dungTừ 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_idrefund_amount. Đổi một trong số đó mà giữ nguyên khóa là 409.

Gửi lại khóa của một lệnh đã thất bại không phải là thử lại

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:

Code
1
2
3
refund-{ma_don_hang}-{so_thu_tu_lan_hoan}
refund-DH20250001-01
refund-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_error kèm lỗi trên idempotency_key.
`refund_id` là của SePay, không phải khóa của bạn

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

Luồng gửi lệnh hoàn tiền an toàn
Rendering diagram...

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ườngVì sao cần
Khóa chống trùngĐể gửi lại đúng lệnh cũ khi timeout
refund_idMã tra cứu, và là thứ SePay hỏi khi bạn cần hỗ trợ
transaction_idGiao dịch thu gốc bị hoàn, dùng để cộng dồn theo khoản thu
amountSố tiền lệnh này hoàn
statusKết quả cuối, dùng để đối soát sổ sách
created_atMốc thời gian đối chiếu với sao kê

original_referencexid 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.

Chỉ cộng sổ khi status là succeeded

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
1
2
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 đíchBộ 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ếpstatus=pending
Xem một khoản thu đã bị hoàn bao nhiêu lầntransaction={transaction_id}
Xem toàn bộ lệnh hoàn của một đơn hàngorder={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.

Xem thêm