1. Khi nào dùng VNPay (so với Momo/ZaloPay/Stripe)
- Bạn target khách Việt Nam trong nước — VNPay phủ 40+ ngân hàng (Vietcombank, BIDV, Techcombank, MB, ACB, VPBank, etc.)
- Cần thẻ ATM nội địa (không phải thẻ tín dụng) — VNPay là leader.
- Đơn hàng > 500k — VNPay hỗ trợ tốt, Momo phù hợp đơn nhỏ hơn.
- Yêu cầu integrate đa kênh: web + app + POS — VNPay có SDK đầy đủ.
VNPay phí 1.6-2.2% mỗi giao dịch (tuỳ ngân hàng + volume) — đắt hơn Momo (1.1-1.8%) nhưng ổn định + phổ thông hơn. Stripe không cho merchant VN, không phải lựa chọn cho doanh nghiệp trong nước.
2. Bước 1: Đăng ký merchant sandbox
Truy cập sandbox.vnpayment.vn → Đăng ký tài khoản test → Nhận TmnCode (mã merchant) + HashSecret (secret key dùng ký HMAC). Sandbox cấp ngay trong 5 phút, không cần giấy tờ. Sau khi code xong + test đủ 5 scenario, mới xin go-live production cần GPKD + STK doanh nghiệp.
3. Bước 2: Cài đặt env + cấu hình
Trong Next.js project, thêm .env.local:
envVNPAY_TMN_CODE=YOUR_TMN_CODE_FROM_SANDBOX VNPAY_HASH_SECRET=YOUR_HASH_SECRET_FROM_SANDBOX VNPAY_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html VNPAY_RETURN_URL=https://yoursite.vn/payment/return VNPAY_IPN_URL=https://yoursite.vn/api/vnpay/ipn
VNPAY_URL cho production: vnpayment.vn/paymentv2/vpcpay.html (bỏ "sandbox.").
4. Bước 3: Tạo URL payment
Tạo file src/app/api/vnpay/create/route.ts:
tsimport crypto from 'crypto'; import { NextResponse } from 'next/server'; function sortObject(obj: Record<string, string>) { return Object.keys(obj).sort().reduce<Record<string, string>>((acc, k) => { acc[k] = encodeURIComponent(obj[k]).replace(/%20/g, '+'); return acc; }, {}); } export async function POST(req: Request) { const { amount, orderId, orderInfo } = await req.json(); const date = new Date(); const pad = (n: number) => String(n).padStart(2, '0'); const createDate = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; const params: Record<string, string> = { vnp_Version: '2.1.0', vnp_Command: 'pay', vnp_TmnCode: process.env.VNPAY_TMN_CODE!, vnp_Amount: String(amount * 100), // VNPay yêu cầu * 100 vnp_CurrCode: 'VND', vnp_TxnRef: orderId, vnp_OrderInfo: orderInfo, vnp_OrderType: 'other', vnp_Locale: 'vn', vnp_ReturnUrl: process.env.VNPAY_RETURN_URL!, vnp_IpAddr: req.headers.get('x-forwarded-for') || '127.0.0.1', vnp_CreateDate: createDate, }; const sorted = sortObject(params); const signData = Object.entries(sorted).map(([k, v]) => `${k}=${v}`).join('&'); const hmac = crypto.createHmac('sha512', process.env.VNPAY_HASH_SECRET!); const signed = hmac.update(Buffer.from(signData, 'utf-8')).digest('hex'); const paymentUrl = `${process.env.VNPAY_URL}?${signData}&vnp_SecureHash=${signed}`; return NextResponse.json({ url: paymentUrl }); }
5. Bước 4: Handle Return URL (frontend redirect)
Sau khi user thanh toán xong, VNPay redirect về VNPAY_RETURN_URL với query params chứa kết quả. Tạo src/app/payment/return/page.tsx:
tsx// Verify signature lần nữa ở client để hiện success/fail page export default function ReturnPage({ searchParams }: { searchParams: Record<string, string> }) { const responseCode = searchParams.vnp_ResponseCode; const ok = responseCode === '00'; return ( <div> {ok ? <h1>Thanh toán thành công</h1> : <h1>Thanh toán thất bại</h1>} <p>Mã giao dịch: {searchParams.vnp_TxnRef}</p> <p>Số tiền: {Number(searchParams.vnp_Amount) / 100} VND</p> </div> ); }
6. Bước 5: Handle IPN Callback (verify thật)
Tạo src/app/api/vnpay/ipn/route.ts:
tsexport async function GET(req: Request) { const url = new URL(req.url); const params: Record<string, string> = {}; url.searchParams.forEach((v, k) => { if (k !== 'vnp_SecureHash' && k !== 'vnp_SecureHashType') params[k] = v; }); const secureHash = url.searchParams.get('vnp_SecureHash'); const sorted = Object.keys(params).sort().reduce<Record<string, string>>((a, k) => { a[k] = encodeURIComponent(params[k]).replace(/%20/g, '+'); return a; }, {}); const signData = Object.entries(sorted).map(([k, v]) => `${k}=${v}`).join('&'); const hmac = crypto.createHmac('sha512', process.env.VNPAY_HASH_SECRET!); const computed = hmac.update(Buffer.from(signData, 'utf-8')).digest('hex'); if (computed !== secureHash) { return new Response(JSON.stringify({ RspCode: '97', Message: 'Invalid signature' })); } const orderId = params.vnp_TxnRef; const responseCode = params.vnp_ResponseCode; if (responseCode === '00') { // Update DB: orderId → status = 'paid' // Send email confirmation } return new Response(JSON.stringify({ RspCode: '00', Message: 'Confirm Success' })); }
7. Bước 6: Test đủ 5 scenario sandbox
- Success: thẻ NCB 9704198526191432198 — số CVV bất kỳ, OTP 123456.
- Insufficient funds: thẻ NCB 9704195798459170488 — sẽ báo không đủ tiền.
- Card not registered: thẻ không kích hoạt Internet Banking.
- Wrong OTP: nhập OTP sai 3 lần.
- User cancel: bấm "Hủy" giữa flow.
Mỗi case test đủ flow: Return URL hiện đúng status + IPN callback fire đúng + DB update đúng. Log mọi request VNPay vào file để debug nếu cần.
8. Bước 7: Xin go-live production
Gửi email cho VNPay (sales@vnpay.vn) kèm: GPKD doanh nghiệp + STK ngân hàng + screenshot website + 5 test case sandbox đã pass. Mất 5-7 ngày làm việc duyệt. Sau khi duyệt, đổi VNPAY_URL bỏ "sandbox." + đổi TmnCode + HashSecret production.
9. Các bug phổ biến + cách xử lý
- "Sai chữ ký" — kiểm tra sort key alphabetic + encodeURIComponent + replace %20 → +.
- "Giao dịch không hợp lệ" — TmnCode sai hoặc môi trường (sandbox vs production) không khớp.
- Amount lệch — quên × 100 (VNPay tính theo đồng × 100).
- IPN không fire — VNPay không gọi được URL của bạn (firewall, IP whitelist, port).
- Timezone lệch — vnp_CreateDate phải GMT+7, không phải UTC.
Cần Alodev tích hợp VNPay vào website doanh nghiệp? Dịch vụ trọn gói 15-25tr bao gồm code + test + go-live + 3 tháng support. Liên hệ tại /lien-he.