← Blog

Nhận thanh toán chuyển khoản VietQR và xác nhận qua webhook

Phát triển web

Thiết kế luồng thanh toán VietQR cho Dailerc Tools Store: mã đơn trong nội dung chuyển khoản, webhook được xác thực, kiểm tra số tiền chính xác, chống xử lý trùng và phương án xác nhận thủ công.

Ở Việt Nam, chuyển khoản bằng mã QR là cách thanh toán quen thuộc nhất: khách mở app ngân hàng, quét mã, xác nhận. Với một sản phẩm nhỏ, VietQR rẻ và nhanh hơn nhiều so với cổng thẻ. Phần khó là biết chắc tiền đã về và thuộc về đơn nào. Bài này trình bày thiết kế mà chúng tôi đang xây cho Dailerc Tools Store. Tính năng còn đang phát triển, nên đây là thiết kế và lý do đằng sau nó, chưa phải kinh nghiệm vận hành.

VietQR mang những gì

VietQR là chuẩn QR của NAPAS, dựa trên định dạng EMVCo. Mã chứa ngân hàng nhận, số tài khoản, và có thể kèm số tiền cùng nội dung chuyển khoản. Khi khách quét, app ngân hàng điền sẵn các trường này. Chúng tôi dùng ảnh QR của SePay, tạo từ một URL có tham số acc, bank, amount và des (nội dung). SePay cũng theo dõi giao dịch vào tài khoản và gọi webhook của chúng tôi.

Bước 1: mã đơn hàng đặt trong nội dung chuyển khoản

Khi khách bấm mua, API tạo một đơn PENDING có hạn 24 giờ và một mã duy nhất, ví dụ DLT8F3K2Q7M: tiền tố cố định cộng 8 ký tự Crockford base32. Bảng chữ cái này bỏ các ký tự dễ nhầm như I, L, O, U, nên khách khó gõ sai khi phải nhập tay. Mã được đặt vào nội dung chuyển khoản trong QR.

App ngân hàng thường thêm tiền tố, bỏ dấu cách hoặc đổi chữ hoa thường. Vì vậy chúng tôi không so sánh cả chuỗi mà tìm mã ở bất kỳ vị trí nào:

const ORDER_CODE = /DLT[0-9A-HJKMNP-TV-Z]{8}/i
const code = content.match(ORDER_CODE)?.[0]?.toUpperCase()

Bước 2: xác thực webhook

Webhook là một URL công khai, nên ai cũng có thể gửi một thông báo giả "đã nhận tiền". SePay gửi kèm header Authorization: Apikey .... Chúng tôi so sánh nó với khóa trong biến môi trường bằng timingSafeEqual. Mọi thao tác với nhà cung cấp nằm sau một interface PaymentProvider; một nhà cung cấp ký bằng HMAC sẽ cài đặt cùng phương thức verifyWebhook trên raw body, nên route webhook giữ lại raw body ngay từ đầu.

Bước 3: idempotency và so khớp

Nhà cung cấp có thể gửi lại cùng một thông báo nhiều lần. Mỗi thông báo được lưu thành một PaymentEvent với ràng buộc duy nhất trên cặp (provider, providerTxnId). Lần gửi lại vi phạm ràng buộc này, được đánh dấu DUPLICATE và vẫn trả 200 để nhà cung cấp ngừng gửi lại. Sau đó:

  • Chỉ xử lý giao dịch tiền vào; giao dịch tiền ra bị bỏ qua.
  • Không có mã hoặc mã không tồn tại: UNMATCHED, chờ quản trị viên xử lý.
  • Số tiền khác số tiền của đơn: AMOUNT_MISMATCH, đơn vẫn ở trạng thái chờ. Chúng tôi so sánh chính xác, không chấp nhận "gần đúng".
  • Đơn đã thanh toán: DUPLICATE.
  • Đơn đã hết hạn nhưng tiền về trong vòng 7 ngày sau đó: vẫn cấp quyền, vì khách thật sự đã trả tiền.

Bước 4: cấp quyền trong một transaction

Khi khớp, một transaction Prisma đặt đơn sang PAID và tạo quyền sử dụng. Gói mua đứt có quyền không hết hạn; gói theo thời hạn mua thêm sẽ được cộng nối từ ngày hết hạn hiện tại chứ không tính lại từ hôm nay. Sau đó cache quyền của khách bị xóa và quản trị viên nhận thông báo.

await prisma.$transaction(async (tx) => {
  await tx.toolOrder.update({ where: { id }, data: { status: 'PAID', paidAt: new Date() } })
  await grantEntitlement(tx, order)
})

Điều không được làm: tin vào trình duyệt

Trang thanh toán hiển thị QR và hỏi trạng thái đơn mỗi 5 giây, tối đa 15 phút. Nhưng trang này chỉ đọc trạng thái. Không có đường nào từ trình duyệt có thể đánh dấu đơn đã trả; chỉ webhook hoặc quản trị viên mới cấp quyền. Nếu cho phép trình duyệt báo "đã thanh toán", bất kỳ ai mở DevTools cũng có thể nhận quyền miễn phí.

Phương án dự phòng thủ công

Chuyển khoản là thao tác của con người: khách có thể quên nội dung, gõ sai mã hoặc chuyển thiếu. Thiết kế vì vậy có hai thao tác cho quản trị viên: xác nhận một đơn đang chờ (bắt buộc ghi chú) và ghép một giao dịch chưa khớp vào đúng đơn. Một job nền chạy mỗi 15 phút chuyển các đơn quá hạn sang EXPIRED. Hoàn tiền cũng làm thủ công và thu hồi quyền đã cấp.

Tóm tắt

  • Đặt mã đơn khó nhầm vào nội dung chuyển khoản và tìm nó bằng regex.
  • Xác thực mọi webhook và lưu mọi thông báo, kể cả thông báo không khớp.
  • Dùng ràng buộc duy nhất của cơ sở dữ liệu làm khóa idempotency.
  • So số tiền chính xác và cấp quyền trong một transaction.
  • Không bao giờ cấp quyền từ phía trình duyệt; luôn có đường xử lý thủ công.

Bài viết liên quan