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/ipnVNPAY_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.