← Tất cả bài viết

Tích hợp VNPay trong Next.js — năm chỗ tài liệu không nói rõ

Tài liệu VNPay có đủ thông tin để tích hợp. Vấn đề là nó không nói cho bạn biết điều gì sẽ hỏng nếu làm sai, và mỗi lỗi lại trả về đúng một dòng chữ chung chung. Bài này đi theo hướng ngược lại: bắt đầu từ triệu chứng.

Áp dụng cho Next.js App Router, nhưng phần quirk thì đúng với mọi ngôn ngữ.

1. Số tiền phải nhân 100

VNPay nhận số tiền theo đơn vị nhỏ nhất. Đơn 100.000₫ phải gửi đi 10000000.

const vnpAmount = amountVnd * 100

Triệu chứng khi quên: đơn hàng vẫn tạo được, cổng vẫn mở, khách vẫn trả tiền — chỉ là trả 1.000₫ cho đơn 100.000₫. Không có lỗi nào cả, và bạn chỉ phát hiện khi đối soát.

Nguy hiểm hơn: khi nhận IPN bạn phải chia lại 100 trước khi so với số tiền trong database. So thẳng thì mọi đơn đều lệch và bạn sẽ tưởng mình bị tấn công.

Giữ quy tắc này ở đúng một chỗ — trong lớp adapter của VNPay — và để phần còn lại của hệ thống chỉ biết đến số tiền VND nguyên. Mỗi cổng nhân chia một kiểu; để quy tắc đó rò ra ngoài là bắt đầu chuỗi bug dài.

2. Chữ ký: sort key, encode kiểu form, HMAC-SHA512

Đây là chỗ tốn thời gian nhất. Chuỗi để ký phải:

  1. Sắp xếp tham số theo tên key (thứ tự bảng chữ cái)
  2. Encode kiểu application/x-www-form-urlencoded — khoảng trắng thành +, không phải %20
  3. Nối thành key=value&key=value
  4. HMAC-SHA512 với hash secret

Ba chỗ hay sai:

  • Dùng encodeURIComponent — nó cho ra %20, chữ ký sai ngay.
  • Ký cả vnp_SecureHash — trường này phải bị loại ra trước khi ký.
  • Sort theo thứ tự thêm vào thay vì theo tên key. Với một số tham số thì hai thứ tự trùng nhau, nên nó chạy được lúc test rồi hỏng khi thêm trường mới.

Triệu chứng: VNPay trả về Sai chữ ký — không kèm bất kỳ thông tin nào về chỗ lệch. Cách lần nhanh nhất là in ra chuỗi trước khi hash và so từng ký tự với ví dụ trong tài liệu.

Lúc verify IPN gửi về, làm đúng quy trình ngược lại: tách vnp_SecureHash ra, ký lại phần còn lại, rồi so bằng hàm so sánh thời gian hằng định (crypto.timingSafeEqual trong Node) chứ đừng so bằng ===.

3. Mọi mốc thời gian là GMT+7

vnp_CreateDatevnp_ExpireDate theo định dạng yyyyMMddHHmmss, giờ Việt Nam — bất kể server bạn chạy ở múi giờ nào.

Server trên Vercel hay AWS chạy UTC. Chênh 7 tiếng.

Triệu chứng: local chạy tốt (máy bạn ở GMT+7), production thì giao dịch hết hạn ngay hoặc bị từ chối — và rõ nhất vào khoảng nửa đêm giờ Việt Nam, khi ngày ở hai múi giờ khác nhau. Loại bug chỉ xuất hiện lúc 23h này là loại tốn nhiều đêm nhất.

4. Return URL không phải xác nhận thanh toán

Sau khi trả tiền, VNPay chuyển trình duyệt khách về vnp_ReturnUrl của bạn. Đừng ghi database ở đó.

Return URL:

  • có thể không bao giờ được gọi — khách đóng trình duyệt giữa chừng, mất mạng, tắt máy
  • có thể bị giả mạo — nó chỉ là một URL trên thanh địa chỉ

Nguồn sự thật duy nhất là IPN: lệnh gọi server-to-server mà VNPay gửi thẳng tới máy chủ bạn. Trang return chỉ nên đọc trạng thái từ database rồi hiển thị.

Cách kiểm rất đơn giản: tạo một đơn, thanh toán, rồi đóng tab ngay trước khi nó chuyển về. Đơn vẫn phải thành công.

5. IPN phải được trả lời đúng định dạng

VNPay chờ một JSON cụ thể:

return Response.json({ RspCode: "00", Message: "Confirm Success" })

Trả về 200 OK với thân rỗng là chưa đủ. Sai định dạng thì VNPay coi như chưa nhận được và gọi lại nhiều lần.

Điều đó dẫn thẳng tới yêu cầu tiếp theo.

Idempotency không phải tuỳ chọn

Vì IPN sẽ được gọi lại, mọi handler phải xử lý cùng một giao dịch nhiều lần mà chỉ cộng tiền đúng một lần.

Cách chắc chắn nhất là để database ép buộc: đặt unique constraint trên mã giao dịch phía cổng, rồi kiểm trước khi ghi.

const existing = await db.payment.findUnique({ where: { gatewayTxnId } })
if (existing?.status === "COMPLETED") {
  return Response.json({ RspCode: "00", Message: "Confirm Success" })
}

Lưu ý dòng return: lần gọi lặp vẫn phải trả về thành công. Trả về lỗi thì VNPay lại retry tiếp, và bạn tự tạo ra một vòng lặp.

Đồng thời đối chiếu số tiền trong IPN với đơn trong database. Lệch thì từ chối bằng RspCode: "97" chứ đừng cập nhật theo số tiền cổng gửi về.

Test IPN ở máy local

IPN là lệnh gọi từ máy chủ VNPay tới máy chủ bạn, nên localhost không nhận được. Bạn cần một URL HTTPS công khai — dùng ngrok hoặc cloudflared.

Hoặc cách rẻ hơn: viết một mock provider trả về đúng hình dạng payload của VNPay, rồi phát triển toàn bộ luồng với nó. Chỉ đụng tới sandbox thật ở bước cuối, khi đã có URL công khai. Cách này cũng giúp CI chạy được mà không cần tài khoản sandbox nào.

Checklist trước khi lên production

  • Nhân 100 lúc gửi, chia 100 lúc nhận — và quy tắc đó chỉ nằm trong adapter
  • Chuỗi ký: sort theo key, encode kiểu form, loại vnp_SecureHash
  • So chữ ký bằng hàm thời gian hằng định
  • Mọi mốc thời gian GMT+7, không phụ thuộc múi giờ server
  • Return URL chỉ đọc, không ghi
  • IPN trả JSON đúng định dạng, kể cả với lần gọi lặp
  • Unique constraint trên mã giao dịch của cổng
  • Đối chiếu số tiền, lệch thì từ chối
  • Đã thử đóng tab giữa chừng và đơn vẫn thành công