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à app đặt đồ ăn, khóa học online, hay một sàn thương mại điện tử — thì sớm hay muộn bạn cũng phải test luồng thanh toán. Và ở đây có một sự thật hơi khắc nghiệt: payment là chỗ mà một con bug không chỉ làm khó chịu, mà làm mất tiền thật. Khách bị trừ tiền nhưng đơn không lên, hệ thống ghi nhận thanh toán thành công nhưng ví thực tế lại thất bại, hay một cú "double charge" trừ hai lần cho một đơn — những lỗi này trực tiếp làm khách mất niềm tin, gọi tổng đài, và đôi khi kéo cả một làn sóng phốt trên Facebook.
Điều khiến thanh toán ở Việt Nam trở nên đặc thù là hệ sinh thái riêng của nó. Ở nước ngoài bạn quen với Stripe, PayPal. Nhưng người dùng Việt Nam thanh toán chủ yếu qua ví điện tử (MoMo, ZaloPay), cổng thẻ nội địa/quốc tế (VNPay), QR VietQR, và chuyển khoản ngân hàng. Mỗi cổng có luồng redirect riêng, cơ chế callback riêng, và quan trọng nhất là cơ chế IPN (Instant Payment Notification) — bản tin server-to-server báo kết quả giao dịch. Nếu bạn chỉ test bằng cách "bấm thanh toán rồi thấy màn hình thành công", bạn đang bỏ sót đúng phần dễ hỏng nhất.
Bài này tập trung 100% vào chuyện đó: cách hiểu luồng thanh toán MoMo, ZaloPay, VNPay, cách test IPN/callback, cách xử lý các trạng thái mập mờ, và cách đối chiếu (reconciliation) để đảm bảo tiền và đơn khớp nhau. Đây là kiến thức bạn có thể mang thẳng vào công việc ngày mai.
Khái niệm cốt lõi
Ba nhân vật chính trên thị trường
MoMo là ví điện tử lớn nhất Việt Nam, với hàng chục triệu người dùng. Luồng phổ biến là người dùng bị chuyển sang app MoMo (deeplink trên mobile) hoặc quét mã QR (trên web) để xác nhận. MoMo mạnh ở nhóm khách trẻ, thành thị.
ZaloPay thuộc hệ sinh thái Zalo (VNG), tận dụng lượng người dùng Zalo khổng lồ, mạnh ở khu vực phía Nam và các luồng tích hợp trong chính app Zalo. Luồng cũng tương tự MoMo: redirect/deeplink sang app hoặc QR.
VNPay khác về bản chất: nó là một payment gateway gom nhiều phương thức — thẻ ATM nội địa (Napas), thẻ quốc tế Visa/Master, QR, Internet Banking của hàng chục ngân hàng. Người dùng được redirect sang trang cổng VNPay, chọn ngân hàng, rồi nhập OTP. VNPay thường là lựa chọn "một cổng cho tất cả" của các merchant muốn phủ rộng phương thức thanh toán.
Với tư cách QA, bạn không cần thuộc lòng khác biệt marketing, nhưng cần nắm: mỗi cổng có một luồng redirect, một chữ ký (signature/checksum) riêng, và một cơ chế báo kết quả riêng.
Luồng thanh toán tổng quát
Dù là cổng nào, khung xương luồng đều giống nhau:
- Tạo đơn (create order): Backend của merchant gọi API của cổng để tạo giao dịch, gửi kèm số tiền, mã đơn (
orderId), và một chữ ký số (chữ ký này được tạo bằng secret key, để cổng biết request là thật). Cổng trả về mộtpayUrlhoặcdeeplink. - Người dùng thanh toán: App/web đẩy người dùng sang cổng hoặc app ví. Người dùng xác nhận (nhập PIN, OTP, hoặc quét QR).
- Redirect về (return URL): Sau khi xong, người dùng được đưa về
returnUrlcủa merchant — đây là trải nghiệm phía client, dùng để hiển thị "thành công/thất bại" cho mắt người dùng. - IPN / callback (server-to-server): Song song đó, cổng gửi một request thẳng đến server merchant (
ipnUrl) để báo kết quả chính thức. Đây mới là nguồn sự thật. Merchant phải verify chữ ký, cập nhật trạng thái đơn, và trả về mã xác nhận đã nhận.
Chữ ký số (signature/checksum)
Mỗi cổng bắt buộc ký dữ liệu bằng thuật toán riêng: MoMo và ZaloPay thường dùng HMAC-SHA256, VNPay dùng HMAC-SHA512 với chuỗi tham số sắp xếp theo alphabet. QA cần test các case: chữ ký đúng (chấp nhận), chữ ký sai (từ chối), thiếu tham số, thừa tham số, và tấn công thay đổi số tiền — kẻ xấu sửa amount từ 500.000 xuống 1.000 rồi gửi lại. Nếu hệ thống verify chữ ký đúng, mọi thay đổi tham số phải làm chữ ký fail và giao dịch bị từ chối.
Idempotency và các trạng thái
Một khái niệm cực kỳ quan trọng: idempotency — cùng một IPN gửi nhiều lần (cổng thường retry nếu chưa nhận được xác nhận) phải chỉ được xử lý một lần. Nếu không, một đơn có thể được cộng tiền/cấp hàng nhiều lần.
Các trạng thái bạn phải cover: pending (đã tạo, chưa thanh toán), success, failed, cancelled (người dùng bấm hủy), expired (hết hạn QR/phiên), và refunded. Đặc biệt để ý trạng thái treo — khi return không khớp IPN.
Tình huống thực tế
Ví dụ 1 — Đơn "thanh toán thành công" nhưng khách không nhận được khóa học
Một nền tảng học online tại TP.HCM (tạm gọi EduViet) tích hợp MoMo để bán khóa học 499.000đ. Trong tuần ra mắt, bộ phận CSKH nhận 23 ticket dạng: "Tôi đã trừ tiền qua MoMo nhưng vào tài khoản không thấy khóa học".
QA điều tra và phát hiện: hệ thống cấp quyền học dựa trên returnUrl. Khi khách thanh toán trên 3G yếu, MoMo trừ tiền và gửi IPN thành công, nhưng bước redirect về returnUrl bị rớt mạng — trình duyệt không bao giờ load trang return. Vì logic cấp khóa nằm ở return, khách trả tiền mà không được cấp gì. Trong khi đó IPN đã về server nhưng handler IPN lại chỉ... ghi log, không cập nhật đơn.
Bài học: Trạng thái đơn và việc cấp hàng phải nằm ở handler IPN, không phải ở return. QA cần một test case rõ ràng: "Chặn returnUrl (đóng tab ngay sau khi confirm), xác nhận IPN vẫn về và đơn vẫn được cấp đầy đủ." Đây gần như là test case số 1 cho mọi tích hợp payment ví.
Ví dụ 2 — Double charge dịp sale ở một sàn TMĐT
Một sàn thương mại điện tử chạy campaign giảm giá 12/12, lượng giao dịch VNPay tăng gấp 8 lần bình thường. Một số khách phản ánh bị trừ tiền hai lần cho một đơn.
Nguyên nhân: dưới tải cao, VNPay gửi IPN, server phản hồi chậm quá 30 giây nên VNPay coi là chưa nhận và retry gửi IPN lần hai. Handler IPN của sàn không idempotent — mỗi lần nhận IPN nó lại tạo một bản ghi thanh toán mới và trừ tồn kho. Với ví thì có thể sinh double crediting; với logic đơn thì sinh đơn trùng.
QA sau sự cố đã bổ sung test: gửi cùng một IPN (cùng transId) hai, ba lần liên tiếp và song song, khẳng định chỉ một bản ghi được tạo, các lần sau trả về "đã xử lý" mà không tác động lại dữ liệu. Họ cũng test dưới tải bằng cách bắn 200 IPN đồng thời qua script.
Bài học: IPN idempotency không phải "nice to have", nó là bắt buộc. Khóa theo transId/orderId ở tầng database (unique constraint) là tuyến phòng thủ cuối cùng. Và luôn test trong điều kiện concurrency, không chỉ tuần tự.
Ví dụ 3 — Lỗ hổng sửa số tiền trên môi trường sandbox
Trong lần review một tích hợp ZaloPay của một startup fintech nhỏ, QA thử nghịch: chặn request tạo đơn bằng proxy (Burp/Charles), sửa amount từ 200.000 xuống 2.000, rồi cho đi tiếp. Kết quả đáng sợ — đơn được tạo với giá 2.000 vì backend tin tưởng số tiền do client gửi lên thay vì tự tính lại từ giỏ hàng phía server.
May là phát hiện ở sandbox. Fix là: số tiền phải được server tính lại từ dữ liệu gốc (giá sản phẩm trong DB), client không được quyền quyết định, và khi verify IPN phải so khớp amount trả về với số tiền đơn gốc.
Bài học: Payment testing luôn phải bao gồm góc nhìn bảo mật cơ bản — man-in-the-middle trên chính request của mình, thử tamper số tiền, thử replay chữ ký cũ. Không cần là chuyên gia security, chỉ cần một proxy và một tư duy "nếu tôi là kẻ gian".
Hướng dẫn từng bước
Đây là quy trình test một tích hợp payment VN từ đầu:
- Lấy tài khoản sandbox và tài liệu. Cả MoMo, ZaloPay, VNPay đều có môi trường test riêng với merchant credential (partnerCode, accessKey, secretKey / app_id, key1, key2 / vnp_TmnCode, vnp_HashSecret) và thẻ/OTP test. Đọc kỹ tài liệu về thuật toán ký và danh sách response code — mỗi cổng có bảng mã riêng.
- Vẽ luồng thành sơ đồ. Trước khi test, tự vẽ ra các actor: client, backend merchant, cổng. Xác định đâu là returnUrl (client-facing), đâu là ipnUrl (server-facing). Hiểu luồng trước, test sau.
- Test happy path cho từng phương thức. Với VNPay, test riêng: thẻ ATM nội địa, thẻ quốc tế, QR. Với MoMo/ZaloPay: QR trên web và deeplink trên mobile. Mỗi phương thức là một luồng con.
- Test IPN độc lập với return. Đây là phần cốt lõi. Dùng công cụ như ngrok để phơi ipnUrl local ra internet cho sandbox gọi tới, hoặc dùng Postman/curl để tự giả lập IPN: dựng request với chữ ký hợp lệ và bắn vào endpoint IPN, kiểm tra đơn được cập nhật đúng.
- Test các trạng thái thất bại và biên. Hủy giữa chừng, để QR hết hạn, nhập sai OTP quá số lần, số dư ví không đủ, timeout. Với mỗi case, kiểm tra cả UI (thông báo cho người dùng) lẫn DB (trạng thái đơn phải chính xác, không được treo
success).
- Test idempotency và replay. Gửi lại IPN nhiều lần (tuần tự và song song). Gửi lại IPN cũ của một giao dịch khác. Xác nhận không có double credit.
- Test bảo mật cơ bản. Tamper
amount, gửi chữ ký sai/thiếu, replay chữ ký cũ. Tất cả phải bị từ chối.
- Đối chiếu (reconciliation). Cuối cùng, dùng API query trạng thái giao dịch của cổng để đối chiếu với DB merchant. Test kịch bản lệch: cổng báo success nhưng DB vẫn pending — hệ thống có job đối soát tự chữa (reconcile) không?
Lỗi thường gặp & mẹo
- Chỉ test returnUrl, quên IPN. Đây là lỗi kinh điển nhất. Luôn test bằng cách đóng tab ngay sau khi confirm để giả lập return bị mất, và xác nhận IPN vẫn hoàn tất đơn.
- Không test idempotency. Sandbox ít khi retry, nên bug double-charge chỉ lộ ra ở production dưới tải. Chủ động giả lập IPN trùng ngay từ khâu test.
- Bỏ qua đối chiếu số tiền và đơn vị. VNPay thường tính tiền theo đơn vị nhỏ nhất (nhân 100: 500.000đ gửi đi là 50000000). Lỗi thiếu/thừa hai số 0 là bug hay gặp. Luôn assert đúng con số cụ thể.
- Quên test timezone và thời gian hết hạn. Trường thời gian giao dịch, hạn thanh toán QR thường theo giờ VN (GMT+7); server sai timezone làm đơn "hết hạn" sớm.
- Test với số tiền tròn đẹp. Hãy thử các số lẻ như 100.499đ, số nhỏ nhất cổng cho phép, số rất lớn — biên số tiền hay lộ bug định dạng.
- Mẹo dùng proxy: Charles/Burp/Proxyman giúp bạn xem đúng payload gửi đi và trả về, phát hiện chữ ký sai và tamper thử. Đây là công cụ không thể thiếu khi test payment.
- Mẹo môi trường: Tuyệt đối phân biệt rõ credential sandbox và production. Một sự cố phổ biến là test với secret key production trên môi trường staging và tạo giao dịch thật.
- Mẹo dữ liệu: Luôn kiểm tra DB, không chỉ tin UI. UI báo "thành công" nhưng bản ghi đơn có thể vẫn
pendingdo IPN chưa xử lý — đó chính là bug bạn cần bắt.
Bài tập thực hành
- Vẽ sơ đồ luồng. Chọn một cổng (ví dụ MoMo). Vẽ đầy đủ sequence diagram: client → backend → cổng, tách rõ returnUrl và ipnUrl. Đánh dấu bước nào quyết định trạng thái đơn.
- Viết bộ test case IPN. Soạn ít nhất 8 test case chỉ cho riêng IPN, bao gồm: chữ ký hợp lệ, chữ ký sai, thiếu tham số, IPN trùng (idempotency), IPN của giao dịch không tồn tại,
amountkhông khớp đơn gốc, IPN đến trước khi return, IPN đến sau khi phiên đã expired.
- Giả lập IPN bằng Postman. Với một sandbox bất kỳ, dựng một request giả lập IPN có chữ ký hợp lệ (tự tính HMAC theo tài liệu cổng) và bắn vào endpoint local qua ngrok. Xác nhận đơn chuyển sang
success. Sau đó gửi lại đúng request đó lần hai và kiểm tra không phát sinh xử lý thừa.
- Kịch bản tamper. Dùng một proxy để chặn request tạo đơn, thử sửa số tiền, và ghi lại hành vi hệ thống. Viết một bug report giả định (theo chuẩn severity/priority) cho lỗ hổng nếu tìm thấy.
- Ma trận trạng thái. Lập bảng đối chiếu: với mỗi trạng thái (
pending,success,failed,cancelled,expired,refunded), mô tả kỳ vọng ở ba nơi — UI người dùng, DB đơn hàng, và trạng thái tại cổng — rồi tự kiểm nghiệm trên sandbox.
Tóm tắt
Test luồng thanh toán VN không phải là "bấm nút rồi xem có chữ thành công". Điểm cốt lõi là hiểu rằng mỗi cổng — MoMo (ví lớn nhất), ZaloPay (hệ Zalo, mạnh phía Nam), VNPay (gateway đa phương thức: thẻ, QR, internet banking) — đều có luồng redirect, chữ ký số và cơ chế IPN riêng. Nguồn sự thật của một giao dịch luôn là IPN server-to-server đã verify chữ ký, không phải returnUrl mà mắt người dùng nhìn thấy.
Ba trụ cột bạn phải luôn test: (1) IPN độc lập với return — đơn phải hoàn tất kể cả khi return bị mất mạng; (2) idempotency — IPN trùng chỉ được xử lý một lần, test cả trong điều kiện concurrency; (3) bảo mật cơ bản — chống tamper số tiền và replay chữ ký. Bổ sung thêm các case biên về số tiền, đơn vị (VNPay nhân 100), timezone GMT+7, và cuối cùng là đối chiếu (reconciliation) giữa DB merchant và cổng.
Nhớ ba tình huống thực: khách bị trừ tiền mà không được cấp khóa học vì logic đặt sai ở return; double charge vì IPN không idempotent dưới tải; và lỗ hổng sửa số tiền vì backend tin client. Cả ba đều là bug làm mất tiền hoặc mất niềm tin thật — và cả ba đều bắt được nếu bạn test đúng chỗ. Payment là nơi QA tạo ra giá trị rõ ràng nhất: mỗi bug bạn chặn ở sandbox là tiền thật không bị mất ở production.