Bỏ qua nội dung

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 PaymentOrder provider SEPAY, status CREATED, payment method BANK_TRANSFER_QR.
  • Sinh providerOrderId dạng TOPUP_<20 ký tự> và dùng làm transferContent.
  • 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ặc hmac.
  • 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

  • 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.
  • client app, tôi muốn nhận qrUrltransferContent để hiển thị QR và hướng dẫn user chuyển khoản đúng nội dung.
  • 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.
  • 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.
  • 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}
    end

Acceptance Criteria

  • AC-1: API POST /wallet/topups/sepay-qr yêu cầu user đã đăng nhập và quyền wallet:topup.
  • AC-2: Body nhận amount bắt buộc; providerOrderId là tuỳ chọn để resume QR top-up.
  • AC-3: amount phải là số nguyên dương và >= app.vcredit.min-payment-amount-vnd.
  • AC-4: Khi tạo mới, backend tạo PaymentOrder provider SEPAY, status CREATED, payment method BANK_TRANSFER_QR, có expiredAt.
  • AC-5: Response WalletTopupQrResponse trả paymentOrderId, providerOrderId, amount, qrUrl, bankCode, bankAccountNumber, transferContent, expiredAt.
  • AC-6: transferContent phải bằng providerOrderId để SePay có thể trả webhook field code.
  • AC-7: Webhook POST /payments/sepay/webhookPOST /v1/payments/sepay/webhook là 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ả code rỗng hoặc không match order.
  • AC-9: Backend xác định order bằng payload.code so với PaymentOrder.providerOrderId, kèm provider SEPAY.
  • AC-10: Nếu payload.code rỗ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 = in mới được xử lý cộng credit.
  • AC-14: transferAmount phải bằng PaymentOrder.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 publish PaymentOrderConfirmedEvent trong transaction xử lý payment.
  • AC-17: Sau khi transaction commit, backend push PaymentOrderRealtimePayload tớ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-orders khi đ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.code từ SePay phải bằng PaymentOrder.providerOrderId; không dùng content raw để tìm order trực tiếp.
  • User phải giữ nguyên transferContent khi chuyển khoản.
  • SePay test webhook có thể gửi code rỗ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ùng hmac hoặc api_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, providerOrderId và 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ồm qrUrl, bank code, account number, transfer content.
  • WalletTransaction: ledger ví được tạo khi order confirm thành công.
  • SepayWebhookPayload: payload webhook SePay gồm id, 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 event STATUS_CHANGED/SUCCESS và 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-qr
  • POST /api/payments/sepay/webhook
  • POST /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, default https://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ặc hmac.
  • SEPAY_WEBHOOK_API_KEY: API key khi dùng mode api_key.
  • SEPAY_WEBHOOK_SECRET: secret khi dùng mode hmac.
  • SEPAY_WEBHOOK_CLOCK_SKEW_SECONDS: độ lệch timestamp cho HMAC.

Liên quan