Thanh toán
Tài liệu kỹ thuật tầng thanh toán: luồng chuẩn, quirk từng cổng và lý do đằng sau từng quyết định trong src/lib/payments/.
Cập nhật lần cuối: 29/07/2026 · Một phần của bộ tài liệu PhoStack
Ba quy tắc sống còn (áp dụng cho MỌI cổng)
1. Return URL không phải xác nhận thanh toán
Return URL chỉ là điều hướng người dùng — có thể không bao giờ được gọi (user tắt browser giữa chừng) hoặc bị giả mạo. Nguồn sự thật duy nhất là IPN server-to-server. Trang return chỉ đọc trạng thái từ DB, không bao giờ ghi.
2. IPN sẽ gọi lại nhiều lần — idempotency bắt buộc
Cả ba cổng nội địa đều retry IPN khi không nhận được response đúng format. Route handler dedupe theo gatewayTxnId:
const existing = await db.payment.findUnique({ where: { gatewayTxnId } });
if (existing?.status === "COMPLETED") return okResponse(); // đã xử lý — xác nhận luôn
3. Toàn bộ hệ sinh thái chạy GMT+7
Server deploy trên Vercel/AWS chạy UTC; cả ba cổng expect giờ Việt Nam trong các trường ngày giờ. Đây là nguồn của lỗi "local chạy được, production sai" — vì local của bạn ở VN còn server thì không. Mọi format thời gian trong adapter đều convert GMT+7 tường minh.
Quirk từng cổng
VNPay (providers/vnpay.ts)
| Quirk | Chi tiết |
|---|---|
| Amount ×100 | Đơn 100.000₫ → gửi 10000000. Quên nhân = khách trả 1.000₫ cho đơn 100.000₫. |
| Chữ ký | HMAC-SHA512 trên query string sort key + encode kiểu application/x-www-form-urlencoded — space thành +, không phải %20. Sai một ký tự → Invalid Checksum không kèm thông tin gì. |
| Thời gian | vnp_CreateDate/vnp_ExpireDate format yyyyMMddHHmmss theo GMT+7. |
| IPN response | Phải trả JSON {"RspCode":"00","Message":"Confirm Success"} — 200 OK suông là chưa đủ, sai body là VNPay retry tiếp. |
| Thành công | Cả vnp_ResponseCode lẫn vnp_TransactionStatus phải là "00". |
MoMo (providers/momo.ts)
| Quirk | Chi tiết |
|---|---|
| Chữ ký | HMAC-SHA256, raw signature build theo thứ tự cố định trong docs (accessKey=...&amount=...), KHÔNG sort alphabet — copy pattern VNPay sang là sai ngay. Mỗi endpoint (create/refund/query) một bộ trường khác nhau. |
| IPN response | Expect HTTP 204 No Content — trả 200 kèm body là retry mãi. |
| Sandbox | Tệ nhất trong ba cổng (môi trường chung, app test không ổn định) → dev/CI dùng mock, chỉ test sandbox bước cuối. |
| IPN local | Yêu cầu HTTPS công khai — test local qua tunnel (ngrok/cloudflared). |
ZaloPay (providers/zalopay.ts)
| Quirk | Chi tiết |
|---|---|
app_trans_id | Bắt buộc format yyMMdd_xxxxx, prefix là ngày hiện tại theo GMT+7. Server UTC không convert → đúng 0h đêm giờ VN là toàn bộ giao dịch fail. Bug chỉ xuất hiện lúc nửa đêm. |
| Hai khoá | key1 ký request tạo đơn; key2 verify MAC của callback — dùng nhầm là invalid hết. |
| Callback data | Payload callback là JSON string lồng trong field data, MAC tính trên chuỗi đó. |
| Callback response | JSON {"return_code": 1, "return_message": "success"}; return_code khác 1 là retry. |
Stripe (providers/stripe.ts) — khách quốc tế
| Quirk | Chi tiết |
|---|---|
| VND zero-decimal | Amount giữ nguyên VND, KHÔNG nhân 100 (USD thì phải nhân — đừng copy docs USD) |
| Webhook raw body | Chữ ký ký trên ${t}.${rawBody} — phải verify trên body gốc từng byte; route handler truyền qua khoá __rawBody/__headers |
| Header chữ ký | stripe-signature: t=<epoch>,v1=<hmac> — HMAC-SHA256 với webhook secret, kèm tolerance chống replay (mặc định 5 phút) |
| Event lọc | Chỉ checkout.session.completed + payment_status === "paid" là tiền vào; event khác xác nhận 200 rồi bỏ qua |
| orderId | Mang qua client_reference_id + metadata[orderId] |
Mock (providers/mock.ts)
Mặc định khi không cổng nào đủ env. createPayment trả URL nội bộ /mock-checkout (trang giả lập cổng); verifyIpn chấp nhận payload có mockSignature === "valid". Bị lọc khỏi availableGateways() ở production.
Thêm một cổng mới
- Viết adapter trong
providers/<gateway>.tsimplementPaymentProvider— mọi quirk ghi chúQUIRK:tại chỗ. - Thêm gateway vào
paymentGatewaySchema(types.ts). - Thêm nhóm env optional vào
env.ts+.env.example. - Đăng ký trong
buildProviders()(index.ts) với điều kiện đủ env. - Cập nhật bảng quirk trong tài liệu này.
App code không đổi một dòng nào — đó là toàn bộ mục đích của kiến trúc adapter.