Tạo đơn hàng nạp tiền ví V-credit (Webhook VietQR)
Feature URL
Module
payment
Status
shipped
Priority
P0
Platforms
fe · be
AC progress
18 / 19
Last reviewed
2026-05-29
Mục tiêu
Cho phép user nạp V-Credits bằng QR chuyển khoản ngân hàng do SePay hỗ trợ, không cần checkout form redirect. Backend tạo payment order kèm nội dung chuyển khoản duy nhất, sau đó nhận webhook giao dịch ngân hàng từ SePay để xác định đơn hàng, validate số tiền và cộng V-Credits đúng một lần.
Phạm vi
Trong phạm vi (In scope):
- API
POST /wallet/topups/sepay-qrđể tạo mới hoặc resume QR top-up. - Tạo
PaymentOrderproviderSEPAY, statusCREATED, payment methodBANK_TRANSFER_QR. - Sinh
providerOrderIddạngTOPUP_<20 ký tự>và dùng làmtransferContent. - Tạo QR URL từ cấu hình tài khoản nhận tiền SePay/VietQR.
- Public webhook
POST /payments/sepay/webhook. - Alias public webhook
POST /v1/payments/sepay/webhook. - Xác thực webhook theo cấu hình
SEPAY_WEBHOOK_AUTH_MODE:none,api_key, hoặchmac. - Xác định đơn hàng bằng
payload.code == payment_orders.provider_order_id. - Validate giao dịch vào, amount khớp order và order còn xử lý được trước khi cộng V-Credits.
- Sau khi webhook xác nhận order thành công, publish realtime update tới owner qua WebSocket
/user/queue/payment-orders.
Ngoài phạm vi (Out of scope):
- Checkout form SePay cũ, xem feature
wallet-topup-create. - Đồng bộ order detail qua SePay order API, xem feature
payment-order-detail-sync. - Contract callback IPN/webhook + realtime dùng chung, xem feature
payment-sepay-ipn-webhook-realtime. - Huỷ đơn top-up, xem feature
wallet-topup-cancel. - Webhook IPN checkout gateway cũ
POST /payments/sepay/ipn. - Frontend màn hiển thị QR/countdown đầy đủ; feature này chỉ định nghĩa contract realtime để client subscribe.
- Tự động hoàn tiền khi user chuyển sai nội dung hoặc sai số tiền.
User Stories
- Là user đã đăng nhập, tôi muốn nạp ví bằng QR ngân hàng để thanh toán nhanh mà không phải rời app qua checkout form.
- Là client app, tôi muốn nhận
qrUrlvàtransferContentđể hiển thị QR và hướng dẫn user chuyển khoản đúng nội dung. - Là client app, tôi muốn nhận WebSocket event khi webhook xác nhận thanh toán để cập nhật màn chờ nạp tiền mà không phải polling liên tục.
- Là hệ thống ví, tôi muốn webhook xác định đúng payment order bằng mã chuyển khoản và chỉ cộng credit khi amount khớp.
- Là ops/support, tôi muốn webhook không fail khi SePay gửi giao dịch test hoặc giao dịch không thuộc hệ thống, nhưng phải ghi history khi match được order.
Luồng chức năng
sequenceDiagram
actor User
participant App as FE/Mobile
participant BE
participant Bank
participant SePay
participant Wallet
participant WS as WebSocket
User->>App: Chọn số tiền nạp bằng QR
App->>BE: POST /wallet/topups/sepay-qr
BE->>BE: Validate amount và min payment amount
alt Không có providerOrderId
BE->>BE: Tạo PaymentOrder CREATED, provider SEPAY
BE->>BE: Sinh providerOrderId TOPUP_xxx
else Có providerOrderId
BE->>BE: Tìm order owner-only, validate amount/status/expiry
end
BE->>BE: Build qrUrl với acc, bank, amount, des=providerOrderId
BE-->>App: WalletTopupQrResponse(qrUrl, transferContent)
User->>Bank: Chuyển khoản đúng amount + transferContent
Bank-->>SePay: Giao dịch vào tài khoản nhận tiền
SePay->>BE: POST /payments/sepay/webhook
BE->>BE: Verify webhook request
BE->>BE: paymentCode = payload.code
BE->>BE: Tìm PaymentOrder provider=SEPAY, providerOrderId=paymentCode
alt Không có code hoặc không match order
BE-->>SePay: {"success": true}
else Match order
BE->>BE: Validate transferType=in, amount khớp, status CREATED
BE->>Wallet: Confirm payment order, cộng V-Credits idempotent
BE->>BE: Publish PaymentOrderConfirmedEvent sau khi order SUCCESS
WS-->>App: MESSAGE /user/queue/payment-orders (status SUCCESS)
BE-->>SePay: {"success": true}
endAcceptance Criteria
- AC-1: API
POST /wallet/topups/sepay-qryêu cầu user đã đăng nhập và quyềnwallet:topup. - AC-2: Body nhận
amountbắt buộc;providerOrderIdlà tuỳ chọn để resume QR top-up. - AC-3:
amountphải là số nguyên dương và >=app.vcredit.min-payment-amount-vnd. - AC-4: Khi tạo mới, backend tạo
PaymentOrderproviderSEPAY, statusCREATED, payment methodBANK_TRANSFER_QR, cóexpiredAt. - AC-5: Response
WalletTopupQrResponsetrảpaymentOrderId,providerOrderId,amount,qrUrl,bankCode,bankAccountNumber,transferContent,expiredAt. - AC-6:
transferContentphải bằngproviderOrderIdđể SePay có thể trả webhook fieldcode. - AC-7: Webhook
POST /payments/sepay/webhookvàPOST /v1/payments/sepay/webhooklà public endpoint, không yêu cầu Bearer token. - AC-8: Webhook luôn trả
{"success": true}khi request hợp lệ về mặt JSON, kể cảcoderỗng hoặc không match order. - AC-9: Backend xác định order bằng
payload.codeso vớiPaymentOrder.providerOrderId, kèm providerSEPAY. - AC-10: Nếu
payload.coderỗng hoặc không tìm thấy order, backend không cộng credit. - AC-11: Nếu order đã
SUCCESS, webhook lặp lại không cộng credit lần hai. - AC-12: Nếu order không ở trạng thái xử lý được, webhook không cộng credit và ghi history khi order match được.
- AC-13: Chỉ giao dịch
transferType = inmới được xử lý cộng credit. - AC-14:
transferAmountphải bằngPaymentOrder.amount; sai số tiền thì không cộng credit. - AC-15: Khi hợp lệ, backend gọi confirm payment order để set order
SUCCESS, lưu provider transaction id và tạo wallet ledger idempotent. - AC-16: Khi webhook làm order chuyển sang
SUCCESS, backend publishPaymentOrderConfirmedEventtrong transaction xử lý payment. - AC-17: Sau khi transaction commit, backend push
PaymentOrderRealtimePayloadtới owner qua/user/queue/payment-orders. - AC-18: Payload realtime phải có
eventType=STATUS_CHANGED,status=SUCCESS,orderId,userId,amount,total,provider,providerOrderId,providerTransactionId,paidAt,occurredAt. - AC-19: FE/Mobile cần subscribe
/user/queue/payment-orderskhi đang ở màn chờ top-up QR và reconcile bằng REST khi nhận event hoặc sau reconnect.
Quy tắc nghiệp vụ
- Nội dung chuyển khoản là khóa đối soát chính của flow QR/webhook.
payload.codetừ SePay phải bằngPaymentOrder.providerOrderId; không dùngcontentraw để tìm order trực tiếp.- User phải giữ nguyên
transferContentkhi chuyển khoản. - SePay test webhook có thể gửi
coderỗng; backend phải ACK để chứng minh endpoint hoạt động nhưng không được cộng ví. - Amount trong webhook là nguồn xác nhận thanh toán chỉ sau khi đã match đúng order.
- Không cộng ví cho giao dịch ra (
transferType != in), amount không hợp lệ, amount mismatch, hoặc order terminal. - Cộng V-Credits phải idempotent theo payment order.
- WebSocket chỉ là kênh thông báo realtime sau khi payment transaction commit; DB/API vẫn là nguồn sự thật khi client cần reconcile.
- Client chỉ nên tin event realtime như tín hiệu refresh màn chờ thanh toán, không dùng event để tự cộng số dư nếu chưa reconcile với wallet/order API.
- Local/dev có thể dùng
SEPAY_WEBHOOK_AUTH_MODE=none; test/prod nên dùnghmachoặcapi_key. - URL public cấu hình trên SePay phải bao gồm context path
/api, ví dụ/api/payments/sepay/webhook.
Dữ liệu & Trạng thái
Entity nghiệp vụ:
CreateWalletTopupRequest:amount,providerOrderIdvà các field redirect/method kế thừa từ top-up checkout.WalletTopupQrResponse:paymentOrderId,providerOrderId,amount,paymentMethod,qrUrl,bankCode,bankAccountNumber,transferContent,expiredAt.PaymentOrder: order local chứa user owner, amount, provider, providerOrderId, paymentMethod, status, expiredAt, histories, ipnData.PaymentProviderQrCode: QR provider payload gồmqrUrl, bank code, account number, transfer content.WalletTransaction: ledger ví được tạo khi order confirm thành công.SepayWebhookPayload: payload webhook SePay gồmid,gateway,transactionDate,accountNumber,code,content,transferType,transferAmount.PaymentOrderConfirmedEvent: domain event phát ra khi order được xác nhận thanh toán thành công.PaymentOrderRealtimePayload: snapshot realtime gửi tới owner qua/user/queue/payment-orders.
Trạng thái user-facing:
CREATED: order QR đã tạo, đang chờ user chuyển khoản hoặc chờ webhook.SUCCESS: webhook hợp lệ đã xác nhận thanh toán và ví đã cộng credit.REALTIME_NOTIFIED: client đã nhận WebSocket eventSTATUS_CHANGED/SUCCESSvà nên refresh order/wallet state.EXPIRED: order quá hạn, không được resume và không nên cộng credit tự động.CANCELLED: order đã huỷ, webhook sau đó không được cộng credit.FAILED: order lỗi, không được xử lý như thanh toán thành công.
Endpoint BE hiện có:
POST /api/wallet/topups/sepay-qrPOST /api/payments/sepay/webhookPOST /api/v1/payments/sepay/webhook- WebSocket destination client subscribe:
/user/queue/payment-orders
Cấu hình liên quan:
SEPAY_QR_BASE_URL: base URL QR, defaulthttps://qr.sepay.vn/img.SEPAY_QR_BANK_CODE: bank code/name của tài khoản nhận tiền.SEPAY_QR_ACCOUNT_NUMBER: số tài khoản nhận tiền.SEPAY_WEBHOOK_AUTH_MODE:none,api_key, hoặchmac.SEPAY_WEBHOOK_API_KEY: API key khi dùng modeapi_key.SEPAY_WEBHOOK_SECRET: secret khi dùng modehmac.SEPAY_WEBHOOK_CLOCK_SKEW_SECONDS: độ lệch timestamp cho HMAC.
Liên quan
- Phụ thuộc: wallet-topup-create
- Ảnh hưởng: wallet-topup-create, wallet-topup-cancel, payment-order-detail-sync, payment-sepay-ipn-webhook-realtime