Product Management
Đăng nhập
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Testing VN Payment Gateways — VNPay sandbox

Mở đầu — vì sao bài này quan trọng

Nếu bạn làm QA cho một sản phẩm số ở Việt Nam — dù là sàn thương mại điện tử, ứng dụng đặt xe, khóa học online hay phần mềm SaaS thu phí — thì gần như chắc chắn bạn sẽ chạm tới cổng thanh toán (payment gateway). Và trong hệ sinh thái Việt Nam, VNPay là cái tên gần như bắt buộc phải biết: nó kết nối với hàng loạt ngân hàng nội địa, ví điện tử, thẻ ATM nội địa (thẻ NAPAS) lẫn thẻ quốc tế.

Vấn đề là: thanh toán không giống các API CRUD thông thường. Một request tạo user sai thì bạn xóa đi làm lại. Nhưng một luồng thanh toán lỗi có thể khiến khách bị trừ tiền mà đơn không được ghi nhận, hoặc tệ hơn — đơn được ghi nhận nhưng chưa hề nhận được tiền. Đây là loại bug làm sập niềm tin khách hàng và có thể gây thiệt hại tài chính thật. Vì thế test cổng thanh toán đòi hỏi một tư duy khác: bạn phải kiểm tra tính toàn vẹn của chữ ký (signature), xử lý callback bất đồng bộ (IPN), và đối chiếu trạng thái ở nhiều điểm.

Bài này tập trung riêng vào VNPay sandbox — cách nó vận hành, cách dùng Postman để dựng và kiểm thử luồng thanh toán, và những cạm bẫy đặc thù của cổng thanh toán Việt Nam mà tài liệu tiếng Anh hiếm khi nói tới. Bài 37 sẽ nói về MoMo/ZaloPay, còn bài 45 nói chung về webhook — ở đây ta đào sâu đúng phần VNPay.

Khái niệm cốt lõi

Kiến trúc luồng thanh toán VNPay

VNPay dùng mô hình redirect + IPN, khác với kiểu API thuần request/response mà bạn quen. Có ba điểm chạm chính:

  • Payment URLhttps://sandbox.vnpayment.vn/paymentv2/vpcpay.html. Đây KHÔNG phải REST endpoint bạn gọi bằng POST và nhận JSON. Nó là một URL bạn build query string có chữ ký, rồi redirect trình duyệt khách sang đó để họ chọn ngân hàng và nhập OTP.
  • Return URL (vnp_ReturnUrl) — sau khi khách thanh toán xong, VNPay redirect trình duyệt về URL này của bạn kèm kết quả trên query string. Đây là kênh hiển thị cho người dùng, KHÔNG được tin để ghi nhận đơn (khách có thể tắt trình duyệt giữa chừng).
  • IPN URL (Instant Payment Notification) — VNPay gọi server-to-server tới backend của bạn để báo kết quả cuối cùng. Đây mới là nguồn sự thật (source of truth) để cập nhật đơn hàng. IPN là một webhook.
Điểm mấu chốt QA phải nắm: Return URL và IPN là hai kênh độc lập. Đơn phải được ghi nhận qua IPN, còn Return URL chỉ để nói "cảm ơn bạn đã thanh toán". Rất nhiều bug production đến từ việc dev ghi nhận đơn ngay trên Return URL.

Chữ ký bảo mật — vnp_SecureHash

Mọi tham số gửi tới VNPay và mọi tham số VNPay gửi lại đều được ký bằng thuật toán HMAC-SHA512 với một khóa bí mật (vnp_HashSecret) mà VNPay cấp cho merchant trong môi trường sandbox. Quy trình ký:

  • Lấy tất cả tham số vnp_* (trừ vnp_SecureHash).
  • Sắp xếp theo thứ tự alphabet của key.
  • Nối thành query string key=value&key=value... (giá trị đã URL-encode).
  • Tính HMAC-SHA512(chuỗi_đó, vnp_HashSecret) → ra vnp_SecureHash.
Đây là phần dễ sai nhất. Chỉ cần sai thứ tự sắp xếp, sai cách encode dấu cách (+ hay %20), hay quên loại bỏ tham số rỗng là chữ ký lệch và VNPay trả về mã lỗi 97 (chữ ký không hợp lệ).

Các tham số bắt buộc

Những tham số bạn sẽ làm việc nhiều nhất trong Postman:

  • vnp_TmnCode — mã website merchant (sandbox cấp).
  • vnp_Amountsố tiền nhân 100 (VNPay không dùng số thập phân; 50.000đ ghi là 5000000).
  • vnp_TxnRef — mã giao dịch của bạn, phải duy nhất trong ngày.
  • vnp_OrderInfo — mô tả đơn (lưu ý Unicode tiếng Việt).
  • vnp_CreateDate / vnp_ExpireDate — định dạng yyyyMMddHHmmss, giờ Việt Nam (GMT+7).
  • vnp_ResponseCode (trong callback) — 00 là thành công, các mã khác là lỗi/hủy.

Tình huống thực tế

Tình huống 1 — Sàn TMĐT "ChợViệt" và cái bug 30 triệu treo

Một startup TMĐT giả định tên ChợViệt tích hợp VNPay để bán đồ gia dụng. Trong đợt sale 12/12, họ ghi nhận doanh thu hệ thống là 480 triệu, nhưng đối soát với VNPay chỉ thấy về 450 triệu. Chênh 30 triệu.

QA vào cuộc, dùng Postman mô phỏng lại luồng và phát hiện: dev ghi nhận đơn "đã thanh toán" ngay tại Return URL khi thấy vnp_ResponseCode=00. Nhưng một nhóm khách mạng yếu, sau khi nhập OTP thành công thì trình duyệt redirect về nhưng request tới IPN của backend bị timeout. Tệ hơn, có kẻ đã thủ công sửa query string trên Return URL, thêm vnp_ResponseCode=00không có chữ ký hợp lệ — và backend vẫn ghi nhận đơn vì nó không verify chữ ký ở Return URL.

Bài học: QA phải có test case riêng cho việc "giả mạo Return URL". Trong Postman, bạn gửi request tới Return URL với vnp_ResponseCode=00 nhưng chữ ký sai/thiếu, và assert rằng đơn KHÔNG được cập nhật thành công. Nguồn sự thật phải là IPN đã verify chữ ký.

Tình huống 2 — Fintech "PayHub" và lỗi làm tròn vnp_Amount

Một công ty fintech giả định PayHub bán gói subscription 199.000đ/tháng. Khi test, QA phát hiện VNPay luôn trả mã lỗi 04 (số tiền không hợp lệ). Truy vào Postman, họ thấy dev truyền vnp_Amount=199000 — tức đúng số tiền hiển thị. Nhưng VNPay yêu cầu nhân 100, nên đúng ra phải là 19900000.

Nguy hiểm hơn: với gói khuyến mãi 49.500đ, dev tính 49500 * 100 = 4950000 bằng JavaScript float và ở một case khác bị ra 4949999.9999. VNPay từ chối vì vnp_Amount phải là số nguyên.

Bài học: Trong pre-request script của Postman, hãy làm rõ phép nhân và ép về số nguyên: Math.round(amount * 100). Và thêm test case đối chiếu: số tiền hiển thị cho khách × 100 = vnp_Amount gửi đi. Đây là loại bug âm thầm mà chỉ QA cẩn thận mới bắt được.

Tình huống 3 — Khóa học online và bug Unicode trong vnp_OrderInfo

Một nền tảng khóa học (bối cảnh tương tự Vietnamcos) cho phép khách mua khóa "Kiểm thử API nâng cao". Khi test bằng Postman, QA thấy chữ ký vnp_SecureHash cứ bị VNPay từ chối với mã 97 mỗi khi tên khóa có dấu tiếng Việt.

Nguyên nhân: vnp_OrderInfo="Thanh toán khóa Kiểm thử API" chứa ký tự Unicode và dấu cách. Dev encode chuỗi này bằng %20 cho dấu cách khi build URL, nhưng khi tính chữ ký lại encode bằng dấu +. Hai chuỗi khác nhau → chữ ký lệch. VNPay yêu cầu encode nhất quán giữa chuỗi ký và chuỗi gửi đi.

Bài học: Chuẩn hóa encoding trong pre-request script — dùng cùng một hàm encode cho cả việc build chữ ký lẫn build URL. Bài 35 nói sâu về Unicode/encoding; ở đây chỉ cần nhớ: với cổng thanh toán, sai một byte encode là sập cả chữ ký.

Hướng dẫn từng bước

Ta sẽ dựng một request VNPay trong Postman từ đầu.

Bước 1 — Đăng ký sandbox và lấy thông tin merchant. Vào sandbox.vnpayment.vn, đăng ký tài khoản merchant sandbox. Bạn sẽ nhận vnp_TmnCodevnp_HashSecret. Lưu chúng vào Environment của Postman (bài 5), đặt vnp_HashSecret là biến kiểu secret để không lộ ra ngoài.

Bước 2 — Tạo Environment. Thêm các biến:

vnp_TmnCode        = MÃ_SANDBOX_CỦA_BẠN
vnp_HashSecret     = KHÓA_BÍ_MẬT   (kiểu secret)
vnp_Url            = https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
vnp_ReturnUrl      = https://webhook.site/xxxx   (dùng tạm để bắt callback)

Mẹo: dùng webhook.site làm Return URL và IPN URL tạm thời để quan sát query string VNPay gửi về mà chưa cần dựng backend.

Bước 3 — Viết pre-request script build tham số và chữ ký. Trong tab Pre-request Script của request:

const CryptoJS = require('crypto-js');

const tmnCode = pm.environment.get('vnp_TmnCode'); const secret = pm.environment.get('vnp_HashSecret');

// định dạng thời gian GMT+7 function ymd(d){ const p = n => String(n).padStart(2,'0'); return d.getFullYear()+p(d.getMonth()+1)+p(d.getDate()) +p(d.getHours())+p(d.getMinutes())+p(d.getSeconds()); } const now = new Date(); const expire = new Date(now.getTime() + 15601000);

const params = { vnp_Version: '2.1.0', vnp_Command: 'pay', vnp_TmnCode: tmnCode, vnp_Amount: String(Math.round(50000 * 100)), // 50.000đ vnp_CurrCode: 'VND', vnp_TxnRef: 'TEST' + now.getTime(), vnp_OrderInfo:'Thanh toan don hang test', vnp_OrderType:'other', vnp_Locale: 'vn', vnp_ReturnUrl: pm.environment.get('vnp_ReturnUrl'), vnp_IpAddr: '127.0.0.1', vnp_CreateDate: ymd(now), vnp_ExpireDate: ymd(expire) };

// sắp xếp key alphabet, encode nhất quán const sorted = Object.keys(params).sort(); const signData = sorted .map(k => ${k}=${encodeURIComponent(params[k]).replace(/%20/g,'+')}) .join('&');

const secureHash = CryptoJS.HmacSHA512(signData, secret).toString(CryptoJS.enc.Hex);

pm.environment.set('vnp_payUrl', pm.environment.get('vnp_Url') + '?' + signData + '&vnp_SecureHash=' + secureHash); pm.environment.set('vnp_TxnRef', params.vnp_TxnRef);

Bước 4 — Gửi và lấy Payment URL. Đặt request là GET {{vnp_payUrl}}. Vì đây là trang HTML redirect, bạn thường copy vnp_payUrl ra trình duyệt để hoàn tất thanh toán thủ công (chọn ngân hàng NCB sandbox, nhập thẻ test do VNPay cấp). Postman chủ yếu để build và verify chữ ký, không thay được bước nhập OTP trên UI.

Bước 5 — Xác thực callback bằng test script. Khi VNPay redirect về Return URL / gọi IPN, bạn copy query string đó vào một request Postman thứ hai để verify chữ ký trả về:

// giả lập nhận callback: tách vnp_SecureHash ra rồi tính lại
const received = pm.iterationData.get('vnp_SecureHash'); // hoặc từ query
// ... build lại signData từ các vnp_* còn lại, tính HMAC ...
pm.test('Chữ ký callback hợp lệ', function(){
  pm.expect(computedHash).to.eql(received);
});
pm.test('Giao dịch thành công', function(){
  pm.expect(pm.iterationData.get('vnp_ResponseCode')).to.eql('00');
});

Bước 6 — Test truy vấn kết quả (querydr). VNPay có API querydr để chủ động hỏi trạng thái một vnp_TxnRef. Đây là công cụ đối soát quan trọng — dùng nó để assert rằng trạng thái phía VNPay khớp với đơn trong hệ thống.

Lỗi thường gặp & mẹo

  • Mã 97 — chữ ký sai: 90% là do sắp xếp key không đúng alphabet, hoặc encode dấu cách không nhất quán (+ vs %20). Log ra signData và so sánh từng ký tự.
  • Mã 04 — số tiền không hợp lệ: quên nhân 100, hoặc để ra số thập phân. Luôn Math.round(amount * 100) và giữ dạng chuỗi số nguyên.
  • Tin vào Return URL: đây là lỗi kiến trúc nguy hiểm nhất. Luôn có test case giả mạo query string Return URL và assert đơn KHÔNG được cập nhật nếu chữ ký sai. Ghi nhận đơn phải qua IPN đã verify.
  • vnp_TxnRef trùng: trong sandbox, dùng lại mã đã thanh toán sẽ bị từ chối. Sinh mã theo timestamp để luôn duy nhất.
  • Sai múi giờ: vnp_CreateDate phải theo GMT+7. Nếu runner Postman chạy trên CI ở múi giờ UTC, giao dịch có thể bị coi là hết hạn. Ép cộng offset hoặc set TZ.
  • Quên trả HTTP 200 cho IPN: khi test backend, nhớ rằng VNPay yêu cầu IPN trả về JSON {"RspCode":"00","Message":"Confirm Success"}. Nếu không, VNPay sẽ retry IPN nhiều lần — sinh ra bug ghi nhận đơn trùng nếu backend không idempotent (liên hệ bài 38).
  • Mẹo verify song song: viết một Collection có 2 request — một build chữ ký, một verify chữ ký callback — để mỗi lần sửa logic encode bạn chạy cả hai và biết ngay lệch chỗ nào.

Bài tập thực hành

  • Dựng Environment sandbox: đăng ký merchant sandbox VNPay, lưu vnp_TmnCodevnp_HashSecret (kiểu secret) vào Environment. Tạo request build Payment URL bằng pre-request script ở trên với số tiền 100.000đ.
  • Test chữ ký: thêm một test script tính lại vnp_SecureHash từ signData và assert nó khớp với chuỗi bạn gắn vào URL. Cố tình đảo thứ tự sắp xếp một key và quan sát VNPay trả mã 97.
  • Mô phỏng tấn công Return URL: tạo một request gửi tới Return URL (dùng webhook.site hoặc một mock backend) với vnp_ResponseCode=00 nhưng vnp_SecureHash sai. Viết assertion khẳng định hệ thống phải TỪ CHỐI ghi nhận đơn này.
  • Kiểm tra vnp_Amount: viết test data với 3 số tiền — 199.000đ, 49.500đ, 1.000.000đ — và assert rằng vnp_Amount gửi đi luôn bằng số hiển thị × 100 và là số nguyên.
  • Nâng cao: dùng API querydr truy vấn trạng thái một vnp_TxnRef vừa thanh toán, verify chữ ký response, và assert vnp_TransactionStatus=00.

Tóm tắt

VNPay sandbox không phải một REST API thông thường — nó là luồng redirect + IPN có chữ ký HMAC-SHA512, và vai trò của Postman ở đây là build chính xác tham số, ký đúng chữ ký, và verify callback, chứ không thay được bước nhập OTP trên UI. Ba điều sống còn QA phải khắc cốt: (1) IPN là nguồn sự thật, Return URL chỉ để hiển thị; (2) vnp_Amount phải nhân 100 và là số nguyên; (3) encode phải nhất quán tuyệt đối giữa chuỗi ký và chuỗi gửi, đặc biệt với Unicode tiếng Việt và dấu cách. Nắm chắc ba điều này, bạn sẽ tránh được đúng những bug đã làm treo hàng chục triệu doanh thu trong các tình huống thực tế ở trên — và sẵn sàng bước sang bài 37 để test tiếp MoMo và ZaloPay.

Học xong bài này rồi? Tạo tài khoản miễn phí để lưu lại — lần sau vào là biết ngay đang dở ở đâu, và học hết khóa thì có chứng chỉ. Lưu tiến độ của tôi