Tài liệu / Thanh toán

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)

QuirkChi 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 gianvnp_CreateDate/vnp_ExpireDate format yyyyMMddHHmmss theo GMT+7.
IPN responsePhả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ôngCả vnp_ResponseCode lẫn vnp_TransactionStatus phải là "00".

MoMo (providers/momo.ts)

QuirkChi 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 responseExpect HTTP 204 No Content — trả 200 kèm body là retry mãi.
SandboxTệ 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 localYêu cầu HTTPS công khai — test local qua tunnel (ngrok/cloudflared).

ZaloPay (providers/zalopay.ts)

QuirkChi tiết
app_trans_idBắ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 dataPayload callback là JSON string lồng trong field data, MAC tính trên chuỗi đó.
Callback responseJSON {"return_code": 1, "return_message": "success"}; return_code khác 1 là retry.

Stripe (providers/stripe.ts) — khách quốc tế

QuirkChi tiết
VND zero-decimalAmount giữ nguyên VND, KHÔNG nhân 100 (USD thì phải nhân — đừng copy docs USD)
Webhook raw bodyChữ 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ọcChỉ checkout.session.completed + payment_status === "paid" là tiền vào; event khác xác nhận 200 rồi bỏ qua
orderIdMang 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

  1. Viết adapter trong providers/<gateway>.ts implement PaymentProvider — mọi quirk ghi chú QUIRK: tại chỗ.
  2. Thêm gateway vào paymentGatewaySchema (types.ts).
  3. Thêm nhóm env optional vào env.ts + .env.example.
  4. Đăng ký trong buildProviders() (index.ts) với điều kiện đủ env.
  5. 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.