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 URL —
https://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.
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)→ ravnp_SecureHash.
+ 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_Amount— số 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ạngyyyyMMddHHmmss, giờ Việt Nam (GMT+7).vnp_ResponseCode(trong callback) —00là 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=00 mà khô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_TmnCode và vnp_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 rasignDatavà 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_TxnReftrù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_CreateDatephả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_TmnCodevàvnp_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_SecureHashtừsignDatavà 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=00nhưngvnp_SecureHashsai. 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ằngvnp_Amountgửi đi luôn bằng số hiển thị × 100 và là số nguyên.
- Nâng cao: dùng API
querydrtruy vấn trạng thái mộtvnp_TxnRefvừa thanh toán, verify chữ ký response, và assertvnp_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.