Skip to content

feat(payment): add SePay gateway (VietQR bank transfer, VND) - #5673

Open
trinknx wants to merge 25 commits into
Wei-Shaw:mainfrom
trinknx:payment-sepay
Open

feat(payment): add SePay gateway (VietQR bank transfer, VND)#5673
trinknx wants to merge 25 commits into
Wei-Shaw:mainfrom
trinknx:payment-sepay

Conversation

@trinknx

@trinknx trinknx commented Aug 15, 2026

Copy link
Copy Markdown

Summary

Adds SePay (https://sepay.vn — a popular Vietnamese bank-transfer payment gateway) as a new payment provider, implementing the VietQR + webhook flow:

  • Customers create an order → the panel shows a VietQR image (generated via vietqr.app, the generator SePay recommends, with a locally-built EMV payload as machine-readable fallback) plus bank-transfer details (account, amount, transfer content with a copy button)
  • The customer transfers via any Vietnamese banking app (QR prefill or manual transfer)
  • SePay detects the incoming bank transaction and calls our webhook (POST /api/v1/payment/webhook/sepay) → the order is confirmed and fulfilled automatically
  • QueryOrder polls SePay API v2 (/v2/transactions) so missed webhooks are reconciled by the verify/cancel flows

Features

  • Provider (backend/internal/payment/provider/sepay.go): offline payment creation (no upstream call), QueryOrder via API v2, webhook verification with HMAC-SHA256 (X-SePay-Signature, ±300 s replay window, raw-body signing, constant-time compare) or API-key fallback, refund disabled (SePay has no refund API — enabling it is rejected at instance create/update)
  • Webhook contract: responds exactly {"success":true} HTTP 200 as SePay requires; lenient transfer-code resolution (banks may uppercase content and SePay extracts codes as contiguous alphanumeric strings, e.g. sub220260815... from sub2_20260815...), guarded strictly on provider == sepay
  • VND currency support: SUBSCRIPTION_USD_TO_VND_RATE setting powers both directions — subscription USD × rate = VND charge and recharge VND ÷ rate = USD balance (with a clear error when unset; also fixes the recharge multiplier being silently rounded to 2 decimals on save)
  • Checkout UX: bank-transfer details card under the QR (account number/name/BIN/amount/transfer content + copy), credited-amount preview mirrors the VND conversion
  • Admin: provider instance form (API token, sandbox base URL override, bank account/BIN/name, webhook secret), payment-method toggle, sensitive config masking and pending-order config protection consistent with other providers
  • i18n: en + zh

Transfer content format

sub + 17 alphanumeric chars (order-id date + random, separators stripped). Admins configure SePay's payment-code extraction accordingly (prefix sub, suffix length 17, digits+letters). Matching is case-insensitive and tolerates prefix/separator mutations end-to-end (webhook → instance resolution → fulfillment → query).

Testing

  • TDD throughout; unit tests at every layer (EMV builder with standard CRC vector, HMAC auth incl. replay/mismatch, query mapping incl. 401/429, lenient resolution, currency conversion, config validation, webhook response shape)
  • Validated end-to-end against the real SePay sandbox (test mode): live webhook delivery through a public tunnel (HMAC verified, order auto-completed, balance credited), QueryOrder reconciliation, cancel-time upstream verify, replay idempotency (no double credit), and negative cases (wrong secret, replayed timestamp, underpaid amount, wrong bank account, outgoing transfer)
  • Two real-world bugs found by sandbox testing were fixed with regression tests: separator-stripped payment codes and VND recharge crediting 1 USD-unit per VND
  • Existing providers (easypay/alipay/wxpay/stripe/airwallex) untouched: all their suites pass, all shared-code changes are additive switch-cases/branches gated on provider == sepay; CNY conversion output is byte-identical (locked by existing tests)

Admin setup (production)

  1. SePay dashboard: create a live API token, a webhook to https://<panel>/api/v1/payment/webhook/sepay (HMAC, secret matching the instance config), and the payment-code prefix config above
  2. Admin panel: create a SePay provider instance (API token, bank account + BIN, webhook secret), enable the SePay payment type, set SUBSCRIPTION_USD_TO_VND_RATE if subscriptions/recharge should convert VND↔USD

Notes for reviewers

  • docs/superpowers/* are the (Vietnamese) design/plan working documents from the implementation process — happy to translate, relocate, or drop them from the PR if preferred
  • Contributor agrees to the repository CLA

trinknx added 25 commits August 14, 2026 22:12
Real sandbox testing showed SePay extracts payment codes as contiguous
alphanumeric strings, dropping the sub2_ underscore, while order lookup
used case-insensitive-only matching and QueryOrder searched q= with the
raw out_trade_no. Add a shared payment.NormalizeTransferCode, a
normalized pending-order scan fallback in the service layer, and a
normalized q= retry in QueryOrder.
The SePay QR dialog previously showed only the QR image with no transfer
details, leaving customers unable to pay manually or simulate transfers.
Expose transfer_info (account number/name, bank bin, amount, transfer
content) on the create-order response for sepay instances and render it
under the QR with a copy button for the transfer content.
Real banks keep transfer content verbatim, so shipping sub2_<date>... with
the underscore broke SePay's alphanumeric-only payment-code extraction.
Emit the normalized form (SUB22026...) in the QR and transfer_info display;
webhook/query resolution already maps it back to the canonical
out_trade_no.
Recharging through VND methods credited 1 balance unit per VND paid
(1000 VND -> $1000) because the recharge multiplier cannot express
1/25000 — the config formatter rounds it to two decimals and it silently
resets to 1. Divide VND recharge amounts by SUBSCRIPTION_USD_TO_VND_RATE
(the multiplier still composes), reject VND recharge when the rate is
unset, store the multiplier with exact precision, and mirror the
conversion in the checkout credited-amount display.
Emit sub22026... (separators stripped, case preserved) instead of the
uppercased normalized form, matching the format validated against the
SePay sandbox. Extraction stays case-insensitive on both sides.
The locally generated EMV payload did not scan reliably in banking apps.
Return a battle-tested VietQR image URL (vietqr.app, the generator SePay
recommends) as qr_image_url; the frontend renders it instead of the
self-drawn canvas when present. The EMV payload stays as fallback data.
@github-actions

Copy link
Copy Markdown
Contributor

Thank you for your contribution! Before we can merge this PR, we need you to sign our Contributor License Agreement (CLA).

To sign, please reply with the following comment:

I have read the CLA Document and I hereby sign the CLA

You only need to sign once — it will be valid for all your future contributions to this project.


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant