From fbadb004a7c2432f9accd8bf377d75f997e162f9 Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Fri, 14 Aug 2026 22:12:38 +0700
Subject: [PATCH 01/25] docs: add SePay payment gateway design spec
---
.gitignore | 2 +
...2026-08-14-sepay-payment-gateway-design.md | 163 ++++++++++++++++++
2 files changed, 165 insertions(+)
create mode 100644 docs/superpowers/specs/2026-08-14-sepay-payment-gateway-design.md
diff --git a/.gitignore b/.gitignore
index 4ea6ff311b0..957c99ac166 100644
--- a/.gitignore
+++ b/.gitignore
@@ -136,6 +136,8 @@ docs/*
!docs/PAYMENT_CN.md
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
!docs/ASYNC_IMAGE_TASKS.md
+!docs/superpowers/
+!docs/superpowers/**
!docs/legal/
!docs/legal/*.md
.serena/
diff --git a/docs/superpowers/specs/2026-08-14-sepay-payment-gateway-design.md b/docs/superpowers/specs/2026-08-14-sepay-payment-gateway-design.md
new file mode 100644
index 00000000000..a2648e5e6c8
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-14-sepay-payment-gateway-design.md
@@ -0,0 +1,163 @@
+# Design: Tích hợp SePay Payment Gateway (VietQR + Webhook)
+
+Ngày: 2026-08-14
+Branch: `payment-sepay`
+Trạng thái: Đã duyệt bởi user
+
+## 1. Mục tiêu & phạm vi
+
+Thêm payment gateway **SePay** vào sub2api theo luồng **VietQR + Webhook** (chuyển khoản ngân hàng QR):
+
+- User tạo đơn → hệ thống sinh QR VietQR chuẩn NAPAS (tự build payload EMV, **không gọi API SePay** khi tạo payment) → khách quét QR bằng app ngân hàng, chuyển khoản với nội dung chứa mã đơn → SePay phát hiện giao dịch qua webhook → hệ thống match mã đơn và xác nhận.
+- **API v2** (`https://userapi.sepay.vn/v2`) chỉ dùng cho `QueryOrder`/verify (Bearer token, rate limit 3 req/s).
+- Hỗ trợ **cả recharge (nạp tiền VND) và subscription** (cần thêm setting quy đổi USD→VND).
+- **Refund: không hỗ trợ** — SePay không có API hoàn tiền (tiền về bằng chuyển khoản thủ công).
+
+Ngoài phạm vi: hosted checkout "Cổng thanh toán SePay" (`/v1/checkout/init`, thẻ/QN NAPAS), VA theo đơn hàng (BIDV/Sacombank/VCB), OAuth 2.0 webhook.
+
+Tài liệu tham khảo SePay (URL docs mới — URL cũ `/vi/sepay-api/v2` đã 404):
+- Tổng quan API v2: https://developer.sepay.vn/vi/sepay-api/v2/gioi-thieu
+- Xác thực: https://developer.sepay.vn/vi/sepay-api/v2/xac-thuc
+- Giao dịch: https://developer.sepay.vn/vi/sepay-api/v2/giao-dich/danh-sach
+- Webhook payload: https://developer.sepay.vn/vi/sepay-webhooks/tich-hop-webhook
+- Webhook security: https://developer.sepay.vn/vi/sepay-webhooks/xac-thuc
+- QR & mã thanh toán: https://developer.sepay.vn/vi/sepay-webhooks/tao-qr-va-form-thanh-toan
+
+## 2. Luồng thanh toán
+
+```
+User chọn "SePay" → POST /orders → order (out_trade_no: sub2_20260814aB3kX9mQ)
+ → Provider.CreatePayment: KHÔNG gọi API SePay — tự sinh chuỗi QR EMV VietQR
+ (bin + số TK + amount + nội dung = out_trade_no) → trả field QRCode
+ → Frontend render QR bằng lib qrcode hiện có (PaymentQRDialog), poll VerifyOrder
+ → Khách quét QR bằng app ngân hàng → chuyển khoản (app tự điền TK/số tiền/nội dung)
+ → SePay phát hiện giao dịch → POST /api/v1/payment/webhook/sepay (JSON + HMAC)
+ → Match code ↔ out_trade_no → fulfillment xác nhận (validate amount sẵn có)
+```
+
+Điểm thiết kế then chốt: **mã chuyển khoản = `out_trade_no`** (format `sub2_` + YYYYMMDD + 8 ký tự alphanumeric random — đã unique). Admin cấu hình "Cấu hình mã thanh toán" trên SePay dashboard (prefix `sub2_`) để SePay trích `code` từ nội dung chuyển khoản. Việc khớp mã chịu được hai dạng `code` (có/không prefix) — xem 3.3.
+
+## 3. Provider `backend/internal/payment/provider/sepay.go`
+
+### 3.1 Config instance
+
+Lưu trong config map đã encrypt của provider instance (pattern easypay):
+
+| Key | Bắt buộc | Mặc định | Ý nghĩa |
+|---|---|---|---|
+| `apiToken` | ✅ | | Bearer token (64 ký tự) cho API v2 — QueryOrder/verify |
+| `apiBase` | | `https://userapi.sepay.vn` | Override cho sandbox `https://userapi-sandbox.sepay.vn` |
+| `bankAccountNumber` | ✅ | | Số tài khoản thụ hưởng |
+| `bankBin` | ✅ | | Mã ngân hàng 6 số (VCB `970436`, ACB `970416`, Techcombank `970407`...) |
+| `accountName` | | | Tên người nhận, hiển thị ở hint UI |
+| `webhookSecret` | khuyến nghị | | HMAC-SHA256 secret (`X-SePay-Signature`) |
+| `webhookApiKey` | nếu không có secret | | Dùng so `Authorization: Apikey XXX` |
+| `currency` | | `VND` | |
+
+### 3.2 CreatePayment
+
+Build payload EMV VietQR (chuẩn EMVCo QRCPS-MPM của NAPAS) bằng TLV builder tự viết (~80 dòng, kèm CRC16-CCITT poly 0x1021 init 0xFFFF):
+
+| Tag | Giá trị |
+|---|---|
+| 00 | `01` (Point of Initiation) |
+| 01 | `12` (dynamic — có amount) |
+| 38 | template con: 00=`A000000727` (GUID NAPAS), 01=`bankBin`, 02=`bankAccountNumber` |
+| 53 | `704` (VND) |
+| 54 | amount — integer VND (zero-decimal) |
+| 58 | `VN` |
+| 62 | template con: 08 = `out_trade_no` (nội dung chuyển khoản) |
+| 63 | CRC16 của chuỗi payload + `6304` |
+
+Trả `CreatePaymentResponse{QRCode: payload, Currency: "VND"}`. Không có `TradeNo` (chưa có giao dịch upstream — giống easypay popup mode). Frontend dùng lại `PaymentQRDialog` render bằng lib `qrcode` — **không** phụ thuộc `vietqr.app`.
+
+### 3.3 VerifyNotification(rawBody, headers)
+
+Xác thực (ưu tiên theo thứ tự):
+1. Nếu có `webhookSecret`: kiểm tra `X-SePay-Signature: sha256={hex}` với `hmac_sha256(X-SePay-Timestamp + "." + rawBody, secret)`, so constant-time (`hmac.Equal`), và timestamp lệch ≤ 300 giây (chống replay). Dùng **raw body bytes gốc**, không re-serialize.
+2. Nếu không: so `Authorization: Apikey {key}` với `webhookApiKey` constant-time.
+
+Parse JSON payload webhook:
+```json
+{
+ "id": 92704, "gateway": "Vietcombank",
+ "transactionDate": "2024-07-02 11:08:33",
+ "accountNumber": "1017588888", "subAccount": "",
+ "code": "SUB2_20260814AB3KX9MQ", "content": "...",
+ "transferType": "in", "transferAmount": 5000000,
+ "referenceCode": "FT24012345678", "accumulated": 0
+}
+```
+
+- `transferType == "out"` → trả `nil, nil` (event không liên quan — handler ack 200).
+- `code` null/rỗng → error "missing payment code".
+- Match: normalize `code` (uppercase, strip non-alphanumeric) so với `out_trade_no` đã normalize tương tự (bank app có thể uppercase nội dung). Vì SePay trích `code` theo prefix cấu hình trên dashboard (có thể trả về mã **không gồm** prefix `sub2_`), phép khớp thử cả hai dạng: full `out_trade_no` và `out_trade_no` đã bỏ prefix `sub2_`. Chiến lược hai dạng này áp dụng thống nhất cho cả `VerifyNotification`, `extractOutTradeNo` (lookup instance) và `QueryOrder`.
+- Map: `OrderID` = out_trade_no khớp, `Amount` = `transferAmount`, `TradeNo` = `referenceCode` (fallback `id`), `Status` = success, `RawData` = rawBody, `Metadata` = {accountNumber, gateway} để đối chiếu instance.
+
+### 3.4 QueryOrder(tradeNo)
+
+`tradeNo` theo convention của hệ thống là out_trade_no. Gọi:
+```
+GET {apiBase}/v2/transactions?q={out_trade_no}&transfer_type=in
+Authorization: Bearer {apiToken}
+```
+Tìm transaction có `code` khớp (normalize như 3.3) → `QueryOrderResponse{Status: "paid", Amount: amount_in, TradeNo: reference_number, PaidAt: transaction_date}`. Không thấy → `Status: "pending"`. Lỗi HTTP 401/429 → error rõ ràng kèm `Retry-After` nếu có.
+
+### 3.5 Refund / QueryRefund / CancelPayment
+
+Không implement. SePay không có API hoàn tiền. `GetRefundEligibleProviders` phải exclude sepay.
+
+## 4. Tích hợp hệ thống (backend)
+
+- `backend/internal/payment/types.go`: thêm `TypeSePay PaymentType = "sepay"` + case trong `GetBasePaymentType`.
+- `backend/internal/payment/provider/factory.go`: `case payment.TypeSePay: return NewSePay(instanceID, config)`.
+- `backend/internal/handler/payment_webhook_handler.go`:
+ - Route mới `webhook.POST("/sepay", webhookHandler.SepayNotify)` trong `routes/payment.go`.
+ - `extractOutTradeNo`: case sepay — parse JSON lấy `code` (để `GetWebhookProviders` lookup đúng instance khi có nhiều tài khoản ngân hàng; không tìm thấy order → fallback thử tất cả instance sepay — HMAC secret riêng từng instance sẽ tự xác định đúng cái nào).
+ - `writeSuccessResponse`: case sepay → **JSON `{"success": true}` HTTP 200** — SePay bắt buộc đúng body này mới tính là thành công (khác với text "success" mặc định).
+- Registry: `Register` theo `SupportedTypes()` trả về `[]PaymentType{TypeSePay}` — không cần đổi registry.go.
+
+## 5. Subscription USD→VND
+
+Song song với `SUBSCRIPTION_USD_TO_CNY_RATE` hiện có:
+
+- Setting mới `SUBSCRIPTION_USD_TO_VND_RATE` (1 USD = X VND, 0 = tắt).
+- `calculateSubscriptionGatewayBaseAmount(amount, usdToCnyRate, currency)` mở rộng nhận thêm `usdToVndRate`: rate > 0 và currency CNY → dùng rate CNY; rate > 0 và currency VND → dùng rate VND, round 0 chữ số thập phân. Currency khác → giữ hành vi price trực tiếp.
+- Expose qua: `PaymentConfig` struct, admin settings DTO (`payment_subscription_usd_to_vnd_rate`), checkout-info DTO (frontend hiển thị giá quy đổi như đang làm với CNY).
+- Validation: subscription order qua sepay khi rate VND = 0 → chặn lúc create order với lỗi rõ ràng (tránh giá 9.9 VND).
+
+## 6. Frontend
+
+- `frontend/src/components/payment/providerConfig.ts`:
+ - `sepay: ['sepay']` trong mapping provider→methods, thêm vào `METHOD_ORDER`, thêm `WEBHOOK_PATHS.sepay = '/api/v1/payment/webhook/sepay'` (dùng cho hint notify URL).
+ - Danh sách config fields (mục 3.1) với `sensitive: true` cho `apiToken`, `webhookSecret`, `webhookApiKey`.
+- Admin settings: form provider SePay + hint (đặc biệt: hướng dẫn cấu hình prefix mã thanh toán trên SePay dashboard phải khớp `sub2_`).
+- User checkout: method card "SePay — Chuyển khoản ngân hàng (VND)"; QR dialog reused; hiển thị thêm hint số TK + tên + "chuyển đúng nội dung và số tiền".
+- i18n: `en`, `zh` (các locale repo đang có).
+
+## 7. Error handling & security
+
+- HMAC fail / sai API key → 400 "verify failed" → SePay retry theo schedule của chúng.
+- Thiếu config bắt buộc → error khi tạo provider instance (giống easypay `NewEasyPay`).
+- So sánh secret luôn constant-time (`hmac.Equal`).
+- Amount mismatch (chuyển thiếu/thừa): fulfillment hiện có ghi audit `PAYMENT_AMOUNT_MISMATCH` + từ chối → webhook trả 500 → SePay retry; admin xử lý thủ công (hoàn qua chuyển khoản + hủy/thử lại đơn trong admin UI).
+- `maxWebhookBodySize` 1MB áp dụng như các provider khác.
+- Rate limit API v2 3 req/s: QueryOrder là single call, không loop — không cần throttle riêng.
+
+## 8. Testing
+
+- `sepay_test.go`:
+ - EMV builder: vector chuẩn (payload VietQR công khai + CRC đúng), amount integer VND, nội dung chứa out_trade_no.
+ - VerifyNotification: HMAC đúng/sai, replay >300s, thiếu signature, `Authorization: Apikey` đúng/sai, `transferType=out` → nil, `code` null → error, match case-insensitive.
+ - QueryOrder: mock HTTP server — tìm thấy (paid), không thấy (pending), 401, 429.
+ - Config validation: thiếu từng field bắt buộc.
+- `payment_webhook_handler_test.go`: response sepay là JSON `{"success":true}`; `extractOutTradeNo` với body sepay.
+- Service tests: USD→VND rate (rate>0 convert + round 0 dp; rate=0 chặn subscription sepay; recharge không bị ảnh hưởng).
+- Frontend specs: `providerConfig.spec.ts`, `paymentFlow.spec.ts` cập nhật cho sepay.
+
+## 9. Vấn đề đã cân nhắc và loại bỏ
+
+- **URL ảnh `vietqr.app`** thay vì tự sinh EMV: bị loại — phụ thuộc dịch vụ ngoài cho mọi giao dịch + phải thêm chế độ `
` cho frontend + lộ mã đơn/số tiền qua query string bên thứ 3.
+- **Framework generic bank-transfer**: over-engineering, YAGNI.
+- **Sinh mã thanh toán riêng** (random hex riêng, khác out_trade_no): phức tạp hóa storage/matching mà out_trade_no đã unique + khó đoán (8 ký tự random).
+- **Dùng `id` làm TradeNo chính**: `referenceCode` ngân hàng hữu ích hơn cho đối soát; `id` chỉ là fallback.
From b20bb4a6fcd28cbc889c8a8805cf58ad4c8204d5 Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Fri, 14 Aug 2026 22:34:57 +0700
Subject: [PATCH 02/25] docs: add SePay payment gateway implementation plan
---
.../plans/2026-08-14-sepay-payment-gateway.md | 2101 +++++++++++++++++
1 file changed, 2101 insertions(+)
create mode 100644 docs/superpowers/plans/2026-08-14-sepay-payment-gateway.md
diff --git a/docs/superpowers/plans/2026-08-14-sepay-payment-gateway.md b/docs/superpowers/plans/2026-08-14-sepay-payment-gateway.md
new file mode 100644
index 00000000000..d9fd1ca9485
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-14-sepay-payment-gateway.md
@@ -0,0 +1,2101 @@
+# SePay Payment Gateway Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add the SePay bank-transfer gateway (VietQR + webhook) as a new payment provider supporting VND recharge and subscription orders.
+
+**Architecture:** New `sepay` provider in `backend/internal/payment/provider/` following the EasyPay pattern. Payment creation is offline (build VietQR EMV payload locally); SePay API v2 is only used for `QueryOrder`; webhook `POST /api/v1/payment/webhook/sepay` confirms payments (HMAC-SHA256 or API-key auth, JSON `{"success":true}` response). A new `SUBSCRIPTION_USD_TO_VND_RATE` setting converts USD plan prices to VND.
+
+**Tech Stack:** Go (gin, ent, shopspring/decimal) — no new dependencies. Vue 3 + vitest frontend. Spec: `docs/superpowers/specs/2026-08-14-sepay-payment-gateway-design.md`.
+
+## Global Constraints
+
+- Repo layout: Go backend in `backend/` (module `github.com/Wei-Shaw/sub2api`), Vue frontend in `frontend/`. Run backend tests from `backend/`: `cd backend && go test ./internal/...`.
+- SePay webhook success response MUST be HTTP 200 with body `{"success": true}` (JSON) — SePay retries otherwise.
+- VND is zero-decimal: amounts are integers, no cents. QR amount tag carries plain integer digits.
+- Do not break existing providers (easypay, alipay, wxpay, stripe, airwallex). Keep their tests green.
+- Webhook verification uses the raw body bytes exactly as received; signature string is `sha256={hex(hmac_sha256(timestamp + "." + rawBody, secret))}`; reject timestamp skew > 300 s.
+- Bank apps may uppercase transfer content and SePay's code extraction may drop the `sub2_` prefix, so any code→order matching must tolerate both (case-insensitive, with/without prefix).
+- Order ID format: `sub2_` + YYYYMMDD + 8 alphanumeric chars (constant `orderIDPrefix = "sub2_"` in `backend/internal/service/payment_service.go:49`).
+- API v2: base `https://userapi.sepay.vn` (sandbox `https://userapi-sandbox.sepay.vn`), `Authorization: Bearer {apiToken}`, rate limit 3 req/s → `GET /v2/transactions?q={code}&transfer_type=in`.
+- No refund API: `Refund` returns a "not supported" error; admin must not enable refund for sepay instances.
+- All new exported Go symbols need doc comments matching repo style (short English comments).
+- Commit after every task (git identity already configured repo-local).
+
+---
+
+### Task 1: `sepay` payment type + VND currency resolution
+
+**Files:**
+- Modify: `backend/internal/payment/types.go` (constants block at lines 12-20, `GetBasePaymentType` at ~line 81)
+- Modify: `backend/internal/payment/currency.go` (add `CurrencyVND` const)
+- Modify: `backend/internal/service/payment_currency.go:10-19`
+- Test: `backend/internal/payment/types_test.go` (create or append)
+- Test: `backend/internal/service/payment_currency_test.go` (create or append)
+
+**Interfaces:**
+- Produces: `payment.TypeSePay PaymentType = "sepay"` (used by every later task); `payment.CurrencyVND = "VND"`; service function `paymentProviderConfigCurrency("sepay", cfg) == "VND"`.
+
+- [ ] **Step 1: Write failing tests**
+
+`backend/internal/payment/types_test.go` (append inside package `payment`; create file with `package payment` if missing):
+
+```go
+package payment
+
+import "testing"
+
+func TestGetBasePaymentTypeSePay(t *testing.T) {
+ if got := GetBasePaymentType("sepay"); got != TypeSePay {
+ t.Fatalf("GetBasePaymentType(sepay) = %q, want %q", got, TypeSePay)
+ }
+ if got := GetBasePaymentType(string(TypeSePay)); got != TypeSePay {
+ t.Fatalf("GetBasePaymentType(TypeSePay) = %q, want %q", got, TypeSePay)
+ }
+}
+```
+
+`backend/internal/service/payment_currency_test.go` (same package as other payment service tests — `service`):
+
+```go
+package service
+
+import (
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+)
+
+func TestPaymentProviderConfigCurrencySePay(t *testing.T) {
+ if got := paymentProviderConfigCurrency(payment.TypeSePay, map[string]string{}); got != "VND" {
+ t.Fatalf("sepay currency = %q, want VND", got)
+ }
+ // SePay is VND-only: a bogus currency config must not leak CNY default.
+ if got := paymentProviderConfigCurrency(payment.TypeSePay, map[string]string{"currency": "USD"}); got != "VND" {
+ t.Fatalf("sepay currency with override = %q, want VND", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd backend && go test ./internal/payment/ -run TestGetBasePaymentTypeSePay -v && go test ./internal/service/ -run TestPaymentProviderConfigCurrencySePay -v`
+Expected: FAIL — `TypeSePay` undefined / currency returns `CNY`.
+
+- [ ] **Step 3: Implement**
+
+`backend/internal/payment/types.go` — add to the constant block after `TypeAirwallex` (line 20):
+
+```go
+ TypeSePay PaymentType = "sepay"
+```
+
+In `GetBasePaymentType`, add as the first case (before the EasyPay case):
+
+```go
+ case t == TypeSePay:
+ return TypeSePay
+```
+
+`backend/internal/payment/currency.go` — add near `DefaultPaymentCurrency`:
+
+```go
+// CurrencyVND is the only currency SePay bank transfers support.
+const CurrencyVND = "VND"
+```
+
+`backend/internal/service/payment_currency.go` — extend the switch in `paymentProviderConfigCurrency`:
+
+```go
+ case payment.TypeSePay:
+ // SePay monitors Vietnamese bank transfers: VND only, not configurable.
+ return payment.CurrencyVND
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd backend && go test ./internal/payment/ ./internal/service/ -run 'TestGetBasePaymentTypeSePay|TestPaymentProviderConfigCurrencySePay' -v`
+Expected: PASS both.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/internal/payment/types.go backend/internal/payment/currency.go backend/internal/service/payment_currency.go backend/internal/payment/types_test.go backend/internal/service/payment_currency_test.go
+git commit -m "feat(payment): add sepay payment type with VND-only currency"
+```
+
+---
+
+### Task 2: VietQR EMV payload builder
+
+**Files:**
+- Create: `backend/internal/payment/provider/vietqr.go`
+- Test: `backend/internal/payment/provider/vietqr_test.go`
+
+**Interfaces:**
+- Produces (package `provider`): `buildVietQRPayload(bin, accountNumber string, amountVND int64, content string) string` — used by Task 3.
+
+- [ ] **Step 1: Write failing tests**
+
+`backend/internal/payment/provider/vietqr_test.go`:
+
+```go
+package provider
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestCRC16CCITTFalse(t *testing.T) {
+ // Standard check value for CRC-16/CCITT-FALSE.
+ if got := crc16CCITTFalse("123456789"); got != 0x29B1 {
+ t.Fatalf("crc16CCITTFalse(123456789) = %#04x, want 0x29b1", got)
+ }
+}
+
+func parseTLV(t *testing.T, payload, tag string) string {
+ t.Helper()
+ for i := 0; i+4 <= len(payload); {
+ id := payload[i : i+2]
+ len, ok := parseTwoDigitInt(payload[i+2 : i+4])
+ if !ok || i+4+len > len(payload) {
+ t.Fatalf("malformed TLV at offset %d", i)
+ }
+ value := payload[i+4 : i+4+len]
+ if id == tag {
+ return value
+ }
+ i += 4 + len
+ }
+ return ""
+}
+
+func parseTwoDigitInt(s string) (int, bool) {
+ n := 0
+ for i := 0; i < len(s); i++ {
+ if s[i] < '0' || s[i] > '9' {
+ return 0, false
+ }
+ n = n*10 + int(s[i]-'0')
+ }
+ return n, true
+}
+
+func TestBuildVietQRPayload(t *testing.T) {
+ got := buildVietQRPayload("970422", "0123456789", 10000, "sub2_20260814aB3kX9mQ")
+
+ if want := "000201010212"; !strings.HasPrefix(got, want) {
+ t.Fatalf("prefix = %q, want %q", got[:12], want)
+ }
+ if v := parseTLV(t, got, "53"); v != "704" {
+ t.Fatalf("currency tag 53 = %q, want 704", v)
+ }
+ if v := parseTLV(t, got, "54"); v != "10000" {
+ t.Fatalf("amount tag 54 = %q, want 10000", v)
+ }
+ if v := parseTLV(t, got, "58"); v != "VN" {
+ t.Fatalf("country tag 58 = %q, want VN", v)
+ }
+ merchant := parseTLV(t, got, "38")
+ if v := parseTLV(t, merchant, "00"); v != "A000000727" {
+ t.Fatalf("napas GUID = %q, want A000000727", v)
+ }
+ if v := parseTLV(t, merchant, "01"); v != "970422" {
+ t.Fatalf("bin = %q, want 970422", v)
+ }
+ if v := parseTLV(t, merchant, "02"); v != "0123456789" {
+ t.Fatalf("account = %q, want 0123456789", v)
+ }
+ if v := parseTLV(t, parseTLV(t, got, "62"), "08"); v != "sub2_20260814aB3kX9mQ" {
+ t.Fatalf("content = %q", v)
+ }
+
+ // CRC tag must cover payload + "6304" and match the trailing 4 hex chars.
+ idx := strings.LastIndex(got, "6304")
+ if idx < 0 {
+ t.Fatal("missing CRC tag")
+ }
+ if crc := crc16CCITTFalse(got[:idx+4]); fmt.Sprintf("%04X", crc) != got[idx+4:] {
+ t.Fatalf("CRC = %s, want %04X", got[idx+4:], crc)
+ }
+}
+```
+
+(imports: `"fmt"`, `"strings"`, `"testing"`.)
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd backend && go test ./internal/payment/provider/ -run 'TestCRC16|TestBuildVietQRPayload' -v`
+Expected: FAIL — `undefined: crc16CCITTFalse` / `buildVietQRPayload`.
+
+- [ ] **Step 3: Implement**
+
+`backend/internal/payment/provider/vietqr.go`:
+
+```go
+package provider
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+// buildVietQRPayload builds an EMVCo merchant-presented QR string following
+// the VietQR/NAPAS standard: banking apps scan it and prefill the beneficiary
+// account, amount and transfer content.
+func buildVietQRPayload(bin, accountNumber string, amountVND int64, content string) string {
+ merchantAccount := tlv("00", "A000000727") + tlv("01", bin) + tlv("02", accountNumber)
+ payload := tlv("00", "01") + // Payload Format Indicator
+ tlv("01", "12") + // Point of Initiation: dynamic (amount included)
+ tlv("38", merchantAccount) + // Merchant Account Information (NAPAS)
+ tlv("53", "704") + // Transaction Currency: VND
+ tlv("54", strconv.FormatInt(amountVND, 10)) + // Transaction Amount
+ tlv("58", "VN") + // Country Code
+ tlv("62", tlv("08", content)) // Additional Data: purpose (transfer content)
+ return payload + "6304" + strings.ToUpper(fmt.Sprintf("%04X", crc16CCITTFalse(payload+"6304")))
+}
+
+// tlv encodes one EMVCo TLV field with a two-digit length prefix.
+func tlv(tag, value string) string {
+ return tag + fmt.Sprintf("%02d", len(value)) + value
+}
+
+// crc16CCITTFalse computes CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no
+// reflection, no final XOR) — the checksum mandated by EMVCo QR (tag 63).
+func crc16CCITTFalse(data string) uint16 {
+ crc := uint16(0xFFFF)
+ for i := 0; i < len(data); i++ {
+ crc ^= uint16(data[i]) << 8
+ for bit := 0; bit < 8; bit++ {
+ if crc&0x8000 != 0 {
+ crc = (crc << 1) ^ 0x1021
+ } else {
+ crc <<= 1
+ }
+ }
+ }
+ return crc
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd backend && go test ./internal/payment/provider/ -run 'TestCRC16|TestBuildVietQRPayload' -v`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/internal/payment/provider/vietqr.go backend/internal/payment/provider/vietqr_test.go
+git commit -m "feat(payment): add VietQR EMV payload builder"
+```
+
+---
+
+### Task 3: SePay provider — config, CreatePayment, Refund
+
+**Files:**
+- Create: `backend/internal/payment/provider/sepay.go`
+- Test: `backend/internal/payment/provider/sepay_test.go`
+
+**Interfaces:**
+- Consumes: `buildVietQRPayload` (Task 2), `payment.TypeSePay` (Task 1).
+- Produces: `NewSePay(instanceID string, config map[string]string) (*SePay, error)`; methods on `*SePay`: `Name() string`, `ProviderKey() string`, `SupportedTypes() []payment.PaymentType`, `MerchantIdentityMetadata() map[string]string`, `CreatePayment(context.Context, payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error)`, `Refund(context.Context, payment.RefundRequest) (*payment.RefundResponse, error)`, `QueryOrder(context.Context, string) (*payment.QueryOrderResponse, error)` (Task 5), `VerifyNotification(context.Context, string, map[string]string) (*payment.PaymentNotification, error)` (Task 4); helper `sepayCodeMatchesOrder(code, outTradeNo string) bool`.
+
+- [ ] **Step 1: Write failing tests**
+
+`backend/internal/payment/provider/sepay_test.go`:
+
+```go
+package provider
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+)
+
+func sepayTestConfig() map[string]string {
+ return map[string]string{
+ "apiToken": "tok_64_chars_00000000000000000000000000000000000000000000000000000000",
+ "bankAccountNumber": "0123456789",
+ "bankBin": "970422",
+ "webhookSecret": "secret",
+ }
+}
+
+func TestNewSePayConfigValidation(t *testing.T) {
+ cases := []struct {
+ name string
+ mutate func(map[string]string)
+ wantErr string
+ }{
+ {"missing apiToken", func(c map[string]string) { delete(c, "apiToken") }, "apiToken"},
+ {"missing bankAccountNumber", func(c map[string]string) { delete(c, "bankAccountNumber") }, "bankAccountNumber"},
+ {"missing bankBin", func(c map[string]string) { delete(c, "bankBin") }, "bankBin"},
+ {"no webhook auth", func(c map[string]string) { delete(c, "webhookSecret") }, "webhook"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ cfg := sepayTestConfig()
+ tc.mutate(cfg)
+ _, err := NewSePay("1", cfg)
+ if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
+ t.Fatalf("err = %v, want containing %q", err, tc.wantErr)
+ }
+ })
+ }
+}
+
+func TestNewSePayApiKeyOnlyConfigIsValid(t *testing.T) {
+ cfg := sepayTestConfig()
+ delete(cfg, "webhookSecret")
+ cfg["webhookApiKey"] = "key"
+ if _, err := NewSePay("1", cfg); err != nil {
+ t.Fatalf("apikey-only config should be valid: %v", err)
+ }
+}
+
+func TestSePayCreatePayment(t *testing.T) {
+ p, err := NewSePay("1", sepayTestConfig())
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := p.CreatePayment(context.Background(), payment.CreatePaymentRequest{
+ OrderID: "sub2_20260814aB3kX9mQ",
+ Amount: "50000",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.Currency != "VND" {
+ t.Fatalf("currency = %q, want VND", resp.Currency)
+ }
+ if resp.QRCode == "" || !strings.Contains(resp.QRCode, "6304") {
+ t.Fatalf("QRCode = %q, want EMV payload", resp.QRCode)
+ }
+ // QR amount tag must carry the integer VND amount.
+ if !strings.Contains(resp.QRCode, tlv("54", "50000")) {
+ t.Fatalf("QR payload missing amount TLV: %s", resp.QRCode)
+ }
+ if !strings.Contains(resp.QRCode, "sub2_20260814aB3kX9mQ") {
+ t.Fatalf("QR payload missing transfer content: %s", resp.QRCode)
+ }
+}
+
+func TestSePayCreatePaymentRejectsNonIntegerAmount(t *testing.T) {
+ p, _ := NewSePay("1", sepayTestConfig())
+ if _, err := p.CreatePayment(context.Background(), payment.CreatePaymentRequest{OrderID: "x", Amount: "50.5"}); err == nil {
+ t.Fatal("expected error for fractional VND amount")
+ }
+ if _, err := p.CreatePayment(context.Background(), payment.CreatePaymentRequest{OrderID: "x", Amount: "0"}); err == nil {
+ t.Fatal("expected error for zero amount")
+ }
+}
+
+func TestSePayRefundUnsupported(t *testing.T) {
+ p, _ := NewSePay("1", sepayTestConfig())
+ _, err := p.Refund(context.Background(), payment.RefundRequest{})
+ if err == nil || !strings.Contains(err.Error(), "not supported") {
+ t.Fatalf("err = %v, want not supported", err)
+ }
+}
+
+func TestSepayCodeMatchesOrder(t *testing.T) {
+ const out = "sub2_20260814aB3kX9mQ"
+ cases := []struct{ code string; want bool }{
+ {"sub2_20260814aB3kX9mQ", true},
+ {"SUB2_20260814AB3KX9MQ", true}, // bank uppercased content
+ {"20260814aB3kX9mQ", true}, // SePay stripped the prefix
+ {"20260814AB3KX9MQ", true}, // stripped + uppercased
+ {"sub2_19990101zzzzzzzz", false},
+ {"", false},
+ }
+ for _, tc := range cases {
+ if got := sepayCodeMatchesOrder(tc.code, out); got != tc.want {
+ t.Errorf("sepayCodeMatchesOrder(%q) = %v, want %v", tc.code, got, tc.want)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd backend && go test ./internal/payment/provider/ -run 'TestNewSePay|TestSePay|TestSepayCodeMatches' -v`
+Expected: FAIL — `undefined: NewSePay`.
+
+- [ ] **Step 3: Implement**
+
+Create `backend/internal/payment/provider/sepay.go` with everything except `VerifyNotification`/`QueryOrder` (added in Tasks 4-5). No stubs are needed: `NewSePay` returns the concrete `*SePay` type, and the provider is only registered as a `payment.Provider` in Task 6, after the remaining methods exist.
+
+```go
+// Package provider contains concrete payment provider implementations.
+package provider
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+)
+
+// SePay constants.
+const (
+ defaultSepayAPIBase = "https://userapi.sepay.vn"
+ sepayHTTPTimeout = 10 * time.Second
+ maxSepayResponseSize = 1 << 20 // 1MB
+ maxSepayErrorSummary = 512
+ sepayWebhookMaxSkewSecs = 300
+)
+
+// SePay implements payment.Provider for the SePay bank-transfer gateway.
+// Payments are VietQR transfers; creation is offline (local EMV payload),
+// confirmation arrives via webhook, and the SePay API v2 is used only to
+// query transaction status.
+type SePay struct {
+ instanceID string
+ config map[string]string
+ httpClient *http.Client
+}
+
+// NewSePay creates a SePay provider.
+// config keys: apiToken, apiBase, bankAccountNumber, bankBin, accountName,
+// webhookSecret (recommended), webhookApiKey (fallback auth).
+func NewSePay(instanceID string, config map[string]string) (*SePay, error) {
+ for _, k := range []string{"apiToken", "bankAccountNumber", "bankBin"} {
+ if strings.TrimSpace(config[k]) == "" {
+ return nil, fmt.Errorf("sepay config missing required key: %s", k)
+ }
+ }
+ if strings.TrimSpace(config["webhookSecret"]) == "" && strings.TrimSpace(config["webhookApiKey"]) == "" {
+ return nil, fmt.Errorf("sepay config requires webhookSecret (recommended) or webhookApiKey")
+ }
+ cfg := make(map[string]string, len(config))
+ for k, v := range config {
+ cfg[k] = v
+ }
+ if strings.TrimSpace(cfg["apiBase"]) == "" {
+ cfg["apiBase"] = defaultSepayAPIBase
+ }
+ cfg["apiBase"] = strings.TrimRight(strings.TrimSpace(cfg["apiBase"]), "/")
+ return &SePay{
+ instanceID: instanceID,
+ config: cfg,
+ httpClient: &http.Client{Timeout: sepayHTTPTimeout},
+ }, nil
+}
+
+func (s *SePay) Name() string { return "SePay" }
+func (s *SePay) ProviderKey() string { return payment.TypeSePay }
+func (s *SePay) SupportedTypes() []payment.PaymentType {
+ return []payment.PaymentType{payment.TypeSePay}
+}
+
+func (s *SePay) MerchantIdentityMetadata() map[string]string {
+ if s == nil {
+ return nil
+ }
+ return map[string]string{"bankAccountNumber": strings.TrimSpace(s.config["bankAccountNumber"])}
+}
+
+// sepayNormalizeCode canonicalizes a transfer code for matching: uppercase,
+// keep only letters and digits (drops the sub2_ underscore, tolerates bank
+// content mutations such as accents or extra separators).
+func sepayNormalizeCode(code string) string {
+ var b strings.Builder
+ for _, r := range strings.ToUpper(strings.TrimSpace(code)) {
+ if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
+ b.WriteRune(r)
+ }
+ }
+ return b.String()
+}
+
+// sepayCodeMatchesOrder reports whether a webhook/query code refers to the
+// given out_trade_no, tolerating bank-side uppercasing and prefix omission.
+func sepayCodeMatchesOrder(code, outTradeNo string) bool {
+ c := sepayNormalizeCode(code)
+ if c == "" {
+ return false
+ }
+ full := sepayNormalizeCode(outTradeNo)
+ if c == full {
+ return true
+ }
+ return strings.HasPrefix(full, "SUB2") && c == strings.TrimPrefix(full, "SUB2")
+}
+
+// CreatePayment builds the VietQR payload offline. No upstream call: the
+// transfer only exists once the customer pays, confirmed via webhook.
+func (s *SePay) CreatePayment(_ context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
+ amountVND, err := strconv.ParseInt(strings.TrimSpace(req.Amount), 10, 64)
+ if err != nil || amountVND <= 0 {
+ return nil, fmt.Errorf("sepay amount must be a positive integer VND value, got %q", req.Amount)
+ }
+ payload := buildVietQRPayload(
+ strings.TrimSpace(s.config["bankBin"]),
+ strings.TrimSpace(s.config["bankAccountNumber"]),
+ amountVND,
+ req.OrderID,
+ )
+ return &payment.CreatePaymentResponse{QRCode: payload, Currency: payment.CurrencyVND}, nil
+}
+
+// Refund is not supported: SePay has no refund API — refunds must be issued
+// manually via bank transfer and the order adjusted in the admin panel.
+func (s *SePay) Refund(_ context.Context, _ payment.RefundRequest) (*payment.RefundResponse, error) {
+ return nil, fmt.Errorf("sepay refund is not supported: issue refunds manually via bank transfer")
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd backend && go test ./internal/payment/provider/ -run 'TestNewSePay|TestSePay|TestSepayCodeMatches' -v`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/internal/payment/provider/sepay.go backend/internal/payment/provider/sepay_test.go
+git commit -m "feat(payment): add SePay provider config, VietQR CreatePayment, refund stub"
+```
+
+---
+
+### Task 4: SePay VerifyNotification (HMAC-SHA256 / API key)
+
+**Files:**
+- Modify: `backend/internal/payment/provider/sepay.go` (add VerifyNotification)
+- Test: `backend/internal/payment/provider/sepay_test.go` (append)
+
+**Interfaces:**
+- Consumes: `*SePay` from Task 3.
+- Produces: `VerifyNotification(ctx, rawBody string, headers map[string]string) (*payment.PaymentNotification, error)` — headers keys are lowercase (the webhook handler lowercases them). Returns `(nil, nil)` for `transferType == "out"`.
+
+- [ ] **Step 1: Write failing tests**
+
+Append to `backend/internal/payment/provider/sepay_test.go`:
+
+```go
+func sepayNotifyBody(code string, amount int64) string {
+ if code == "" {
+ return `{"id":92704,"gateway":"Vietcombank","transactionDate":"2024-07-02 11:08:33","accountNumber":"1017588888","subAccount":"","code":null,"content":"chuyen tien","transferType":"in","transferAmount":` + strconv.FormatInt(amount, 10) + `,"accumulated":0,"referenceCode":"FT24012345678"}`
+ }
+ return `{"id":92704,"gateway":"Vietcombank","transactionDate":"2024-07-02 11:08:33","accountNumber":"1017588888","subAccount":"","code":"` + code + `","content":"` + code + ` chuyen tien","transferType":"in","transferAmount":` + strconv.FormatInt(amount, 10) + `,"accumulated":0,"referenceCode":"FT24012345678"}`
+}
+
+func sepaySignedHeaders(body string, secret string, ts int64) map[string]string {
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write([]byte(strconv.FormatInt(ts, 10) + "." + body))
+ return map[string]string{
+ "x-sepay-signature": "sha256=" + hex.EncodeToString(mac.Sum(nil)),
+ "x-sepay-timestamp": strconv.FormatInt(ts, 10),
+ }
+}
+
+func TestSePayVerifyNotificationHMAC(t *testing.T) {
+ p, _ := NewSePay("1", sepayTestConfig())
+ body := sepayNotifyBody("sub2_20260814aB3kX9mQ", 50000)
+ now := time.Now().Unix()
+
+ n, err := p.VerifyNotification(context.Background(), body, sepaySignedHeaders(body, "secret", now))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n.OrderID != "sub2_20260814aB3kX9mQ" || n.Amount != 50000 || n.TradeNo != "FT24012345678" {
+ t.Fatalf("notification = %+v", n)
+ }
+ if n.Status != payment.NotificationStatusSuccess {
+ t.Fatalf("status = %q", n.Status)
+ }
+ if n.Metadata["accountNumber"] != "1017588888" || n.Metadata["gateway"] != "Vietcombank" {
+ t.Fatalf("metadata = %v", n.Metadata)
+ }
+}
+
+func TestSePayVerifyNotificationHMACFailures(t *testing.T) {
+ p, _ := NewSePay("1", sepayTestConfig())
+ body := sepayNotifyBody("sub2_20260814aB3kX9mQ", 50000)
+ now := time.Now().Unix()
+
+ if _, err := p.VerifyNotification(context.Background(), body, sepaySignedHeaders(body, "wrong", now)); err == nil {
+ t.Fatal("expected signature mismatch error")
+ }
+ if _, err := p.VerifyNotification(context.Background(), body, map[string]string{"x-sepay-timestamp": strconv.FormatInt(now, 10)}); err == nil {
+ t.Fatal("expected missing signature error")
+ }
+ if _, err := p.VerifyNotification(context.Background(), body, sepaySignedHeaders(body, "secret", now-3600)); err == nil {
+ t.Fatal("expected timestamp skew error")
+ }
+ // Signed over different body.
+ if _, err := p.VerifyNotification(context.Background(), sepayNotifyBody("other", 1), sepaySignedHeaders(body, "secret", now)); err == nil {
+ t.Fatal("expected signature mismatch for altered body")
+ }
+}
+
+func TestSePayVerifyNotificationApiKey(t *testing.T) {
+ cfg := sepayTestConfig()
+ delete(cfg, "webhookSecret")
+ cfg["webhookApiKey"] = "key123"
+ p, _ := NewSePay("1", cfg)
+ body := sepayNotifyBody("sub2_20260814aB3kX9mQ", 50000)
+
+ if _, err := p.VerifyNotification(context.Background(), body, map[string]string{"authorization": "Apikey key123"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := p.VerifyNotification(context.Background(), body, map[string]string{"authorization": "Apikey nope"}); err == nil {
+ t.Fatal("expected api key mismatch")
+ }
+ if _, err := p.VerifyNotification(context.Background(), body, nil); err == nil {
+ t.Fatal("expected missing header error")
+ }
+}
+
+func TestSePayVerifyNotificationOutAndNullCode(t *testing.T) {
+ p, _ := NewSePay("1", sepayTestConfig())
+ now := time.Now().Unix()
+
+ outBody := `{"id":1,"gateway":"VCB","transactionDate":"2024-07-02 11:08:33","accountNumber":"1","subAccount":"","code":"sub2_20260814aB3kX9mQ","content":"x","transferType":"out","transferAmount":100,"referenceCode":"FT1"}`
+ n, err := p.VerifyNotification(context.Background(), outBody, sepaySignedHeaders(outBody, "secret", now))
+ if err != nil || n != nil {
+ t.Fatalf("out transaction: n=%v err=%v, want nil/nil", n, err)
+ }
+
+ nullCode := sepayNotifyBody("", 50000)
+ if _, err := p.VerifyNotification(context.Background(), nullCode, sepaySignedHeaders(nullCode, "secret", now)); err == nil {
+ t.Fatal("expected missing payment code error")
+ }
+}
+```
+
+Add these imports to the test file: `"crypto/hmac"`, `"crypto/sha256"`, `"encoding/hex"`, `"strconv"`, `"time"`.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd backend && go test ./internal/payment/provider/ -run 'TestSePayVerifyNotification' -v`
+Expected: FAIL — `p.VerifyNotification undefined`.
+
+- [ ] **Step 3: Implement**
+
+Append to `backend/internal/payment/provider/sepay.go` (add imports `"crypto/hmac"`, `"crypto/sha256"`, `"encoding/hex"`, `"encoding/json"`):
+
+```go
+// sepayWebhookPayload mirrors the SePay transaction webhook JSON body.
+type sepayWebhookPayload struct {
+ ID int64 `json:"id"`
+ Gateway string `json:"gateway"`
+ TransactionDate string `json:"transactionDate"`
+ AccountNumber string `json:"accountNumber"`
+ SubAccount string `json:"subAccount"`
+ Code *string `json:"code"`
+ Content string `json:"content"`
+ TransferType string `json:"transferType"`
+ Description string `json:"description"`
+ TransferAmount int64 `json:"transferAmount"`
+ Accumulated int64 `json:"accumulated"`
+ ReferenceCode string `json:"referenceCode"`
+}
+
+// VerifyNotification authenticates and parses a SePay webhook. Outgoing
+// transactions return (nil, nil) so the caller acks with 200. OrderID carries
+// the raw extracted code; the service layer resolves it to the canonical
+// out_trade_no (banks may uppercase content, SePay may drop the prefix).
+func (s *SePay) VerifyNotification(_ context.Context, rawBody string, headers map[string]string) (*payment.PaymentNotification, error) {
+ if err := s.verifyWebhookAuth(rawBody, headers); err != nil {
+ return nil, err
+ }
+ var payload sepayWebhookPayload
+ if err := json.Unmarshal([]byte(rawBody), &payload); err != nil {
+ return nil, fmt.Errorf("sepay parse notify: %w", err)
+ }
+ if strings.TrimSpace(payload.TransferType) != "in" {
+ return nil, nil
+ }
+ code := ""
+ if payload.Code != nil {
+ code = strings.TrimSpace(*payload.Code)
+ }
+ if code == "" {
+ return nil, fmt.Errorf("sepay notify missing payment code")
+ }
+ tradeNo := strings.TrimSpace(payload.ReferenceCode)
+ if tradeNo == "" {
+ tradeNo = strconv.FormatInt(payload.ID, 10)
+ }
+ metadata := map[string]string{"accountNumber": payload.AccountNumber}
+ if payload.Gateway != "" {
+ metadata["gateway"] = payload.Gateway
+ }
+ return &payment.PaymentNotification{
+ TradeNo: tradeNo,
+ OrderID: code,
+ Amount: float64(payload.TransferAmount),
+ Status: payment.NotificationStatusSuccess,
+ RawData: rawBody,
+ Metadata: metadata,
+ }, nil
+}
+
+// verifyWebhookAuth checks HMAC-SHA256 (preferred) or the Apikey header.
+// Signature: sha256={hex(hmac_sha256(timestamp + "." + rawBody, secret))}.
+func (s *SePay) verifyWebhookAuth(rawBody string, headers map[string]string) error {
+ if secret := strings.TrimSpace(s.config["webhookSecret"]); secret != "" {
+ signature := strings.TrimSpace(headers["x-sepay-signature"])
+ if !strings.HasPrefix(signature, "sha256=") {
+ return fmt.Errorf("missing X-SePay-Signature")
+ }
+ timestamp := strings.TrimSpace(headers["x-sepay-timestamp"])
+ ts, err := strconv.ParseInt(timestamp, 10, 64)
+ if err != nil {
+ return fmt.Errorf("invalid X-SePay-Timestamp")
+ }
+ skew := time.Now().Unix() - ts
+ if skew < 0 {
+ skew = -skew
+ }
+ if skew > sepayWebhookMaxSkewSecs {
+ return fmt.Errorf("sepay webhook timestamp outside ±%d second window", sepayWebhookMaxSkewSecs)
+ }
+ mac := hmac.New(sha256.New, []byte(secret))
+ _, _ = mac.Write([]byte(timestamp + "." + rawBody))
+ expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
+ if !hmac.Equal([]byte(expected), []byte(signature)) {
+ return fmt.Errorf("sepay webhook signature mismatch")
+ }
+ return nil
+ }
+ apiKey := strings.TrimSpace(s.config["webhookApiKey"])
+ auth := strings.TrimSpace(headers["authorization"])
+ const apikeyPrefix = "Apikey "
+ if !strings.HasPrefix(auth, apikeyPrefix) {
+ return fmt.Errorf("missing Authorization Apikey header")
+ }
+ if !hmac.Equal([]byte(strings.TrimSpace(strings.TrimPrefix(auth, apikeyPrefix))), []byte(apiKey)) {
+ return fmt.Errorf("sepay webhook api key mismatch")
+ }
+ return nil
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd backend && go test ./internal/payment/provider/ -run 'TestSePayVerifyNotification' -v`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/internal/payment/provider/sepay.go backend/internal/payment/provider/sepay_test.go
+git commit -m "feat(payment): SePay webhook verification (HMAC-SHA256 / API key)"
+```
+
+---
+
+### Task 5: SePay QueryOrder via API v2
+
+**Files:**
+- Modify: `backend/internal/payment/provider/sepay.go` (add QueryOrder)
+- Test: `backend/internal/payment/provider/sepay_test.go` (append)
+
+**Interfaces:**
+- Consumes: `*SePay` from Task 3, `sepayCodeMatchesOrder`.
+- Produces: `QueryOrder(ctx, tradeNo string) (*payment.QueryOrderResponse, error)` — `tradeNo` is the order's out_trade_no (service convention, see Task 8).
+
+- [ ] **Step 1: Write failing tests**
+
+Append to `backend/internal/payment/provider/sepay_test.go` (add `"net/http"`, `"net/http/httptest"`, `"net/url"` imports as needed):
+
+```go
+func sepayQueryServer(t *testing.T, queries *[]url.Values, respond func(w http.ResponseWriter, r *http.Request)) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ *queries = append(*queries, r.URL.Query())
+ respond(w, r)
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func TestSePayQueryOrderPaid(t *testing.T) {
+ var queries []url.Values
+ srv := sepayQueryServer(t, &queries, func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("Authorization"); got != "Bearer "+sepayTestConfig()["apiToken"] {
+ t.Errorf("auth header = %q", got)
+ }
+ _, _ = w.Write([]byte(`{"status":"success","data":[{"id":"a1b2","transaction_date":"2026-08-14 09:30:00","transfer_type":"in","amount_in":50000,"transaction_content":"sub2_20260814aB3kX9mQ chuyen tien","reference_number":"FT26069ABC","code":"SUB2_20260814AB3KX9MQ"}]}`))
+ })
+ cfg := sepayTestConfig()
+ cfg["apiBase"] = srv.URL
+ p, _ := NewSePay("1", cfg)
+
+ resp, err := p.QueryOrder(context.Background(), "sub2_20260814aB3kX9mQ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.Status != payment.ProviderStatusPaid || resp.Amount != 50000 || resp.TradeNo != "FT26069ABC" {
+ t.Fatalf("resp = %+v", resp)
+ }
+ if resp.PaidAt != "2026-08-14 09:30:00" {
+ t.Fatalf("paidAt = %q", resp.PaidAt)
+ }
+ if len(queries) != 1 || queries[0].Get("q") != "sub2_20260814aB3kX9mQ" || queries[0].Get("transfer_type") != "in" {
+ t.Fatalf("queries = %v", queries)
+ }
+}
+
+func TestSePayQueryOrderPending(t *testing.T) {
+ var queries []url.Values
+ srv := sepayQueryServer(t, &queries, func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"status":"success","data":[{"id":"c3","transaction_date":"2026-08-14 09:30:00","transfer_type":"in","amount_in":1,"code":"SUB2_19990101ZZZZZZZZ"}]}`))
+ })
+ cfg := sepayTestConfig()
+ cfg["apiBase"] = srv.URL
+ p, _ := NewSePay("1", cfg)
+
+ resp, err := p.QueryOrder(context.Background(), "sub2_20260814aB3kX9mQ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.Status != payment.ProviderStatusPending {
+ t.Fatalf("status = %q, want pending (code does not match order)", resp.Status)
+ }
+}
+
+func TestSePayQueryOrderHTTPErrors(t *testing.T) {
+ for _, tc := range []struct {
+ status int
+ body string
+ wantErr string
+ }{
+ {http.StatusUnauthorized, `{"error":{"code":"unauthorized"}}`, "unauthorized"},
+ {http.StatusTooManyRequests, `{"error":{"code":"rate_limited"}}`, "rate"},
+ {http.StatusInternalServerError, `boom`, "HTTP 500"},
+ } {
+ var queries []url.Values
+ srv := sepayQueryServer(t, &queries, func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(tc.status)
+ _, _ = w.Write([]byte(tc.body))
+ })
+ cfg := sepayTestConfig()
+ cfg["apiBase"] = srv.URL
+ p, _ := NewSePay("1", cfg)
+ _, err := p.QueryOrder(context.Background(), "sub2_20260814aB3kX9mQ")
+ if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
+ t.Errorf("status %d: err = %v, want containing %q", tc.status, err, tc.wantErr)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd backend && go test ./internal/payment/provider/ -run 'TestSePayQueryOrder' -v`
+Expected: FAIL — `p.QueryOrder undefined`.
+
+- [ ] **Step 3: Implement**
+
+Append to `backend/internal/payment/provider/sepay.go` (add imports `"io"`, `"net/url"`):
+
+```go
+// sepayTransaction mirrors one element of GET /v2/transactions data.
+type sepayTransaction struct {
+ ID string `json:"id"`
+ TransactionDate string `json:"transaction_date"`
+ TransferType string `json:"transfer_type"`
+ AmountIn int64 `json:"amount_in"`
+ TransactionContent string `json:"transaction_content"`
+ ReferenceNumber string `json:"reference_number"`
+ Code string `json:"code"`
+}
+
+// QueryOrder looks up the order's transfer in SePay API v2. tradeNo carries
+// the order's out_trade_no; the q= search covers the extracted payment code.
+func (s *SePay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
+ outTradeNo := strings.TrimSpace(tradeNo)
+ if outTradeNo == "" {
+ return nil, fmt.Errorf("sepay query: empty order reference")
+ }
+ endpoint := s.config["apiBase"] + "/v2/transactions?q=" + url.QueryEscape(outTradeNo) + "&transfer_type=in"
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+s.config["apiToken"])
+ resp, err := s.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("sepay query: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxSepayResponseSize))
+ if err != nil {
+ return nil, fmt.Errorf("sepay query read: %w", err)
+ }
+ switch {
+ case resp.StatusCode == http.StatusUnauthorized:
+ return nil, fmt.Errorf("sepay query unauthorized: check apiToken")
+ case resp.StatusCode == http.StatusTooManyRequests:
+ return nil, fmt.Errorf("sepay query rate limited (retry after %ss)", resp.Header.Get("Retry-After"))
+ case resp.StatusCode < 200 || resp.StatusCode >= 300:
+ return nil, fmt.Errorf("sepay query HTTP %d: %s", resp.StatusCode, summarizeSepayBody(body))
+ }
+ var parsed struct {
+ Status string `json:"status"`
+ Data []sepayTransaction `json:"data"`
+ }
+ if err := json.Unmarshal(body, &parsed); err != nil {
+ return nil, fmt.Errorf("sepay query parse: %w", err)
+ }
+ for _, tx := range parsed.Data {
+ if !sepayCodeMatchesOrder(tx.Code, outTradeNo) {
+ continue
+ }
+ return &payment.QueryOrderResponse{
+ TradeNo: strings.TrimSpace(tx.ReferenceNumber),
+ Status: payment.ProviderStatusPaid,
+ Amount: float64(tx.AmountIn),
+ PaidAt: strings.TrimSpace(tx.TransactionDate),
+ Metadata: s.MerchantIdentityMetadata(),
+ }, nil
+ }
+ return &payment.QueryOrderResponse{
+ TradeNo: outTradeNo,
+ Status: payment.ProviderStatusPending,
+ Metadata: s.MerchantIdentityMetadata(),
+ }, nil
+}
+
+func summarizeSepayBody(body []byte) string {
+ summary := strings.Join(strings.Fields(string(body)), " ")
+ if summary == "" {
+ return ""
+ }
+ if len(summary) > maxSepayErrorSummary {
+ return summary[:maxSepayErrorSummary] + "..."
+ }
+ return summary
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd backend && go test ./internal/payment/provider/ -run 'TestSePayQueryOrder' -v && go test ./internal/payment/provider/`
+Expected: PASS (whole provider package green).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/internal/payment/provider/sepay.go backend/internal/payment/provider/sepay_test.go
+git commit -m "feat(payment): SePay QueryOrder via API v2 transactions"
+```
+
+---
+
+### Task 6: Register SePay in the provider factory
+
+**Files:**
+- Modify: `backend/internal/payment/provider/factory.go:11-22`
+- Test: `backend/internal/payment/provider/factory_test.go` (create or append)
+
+**Interfaces:**
+- Consumes: `NewSePay` (Task 3).
+- Produces: `CreateProvider("sepay", ...)` returns a `*SePay`.
+
+- [ ] **Step 1: Write failing test**
+
+`backend/internal/payment/provider/factory_test.go` (create with `package provider` if missing):
+
+```go
+package provider
+
+import (
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+)
+
+func TestCreateProviderSePay(t *testing.T) {
+ p, err := CreateProvider(payment.TypeSePay, "7", sepayTestConfig())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p.ProviderKey() != payment.TypeSePay {
+ t.Fatalf("provider key = %q", p.ProviderKey())
+ }
+ if _, err := CreateProvider(payment.TypeSePay, "7", map[string]string{}); err == nil {
+ t.Fatal("expected config validation error from factory")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd backend && go test ./internal/payment/provider/ -run TestCreateProviderSePay -v`
+Expected: FAIL — `unknown provider key: sepay`.
+
+- [ ] **Step 3: Implement**
+
+In `factory.go`, add to the switch in `CreateProvider` after the airwallex case:
+
+```go
+ case payment.TypeSePay:
+ return NewSePay(instanceID, config)
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd backend && go test ./internal/payment/provider/ -run TestCreateProviderSePay -v`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/internal/payment/provider/factory.go backend/internal/payment/provider/factory_test.go
+git commit -m "feat(payment): register SePay in provider factory"
+```
+
+---
+
+### Task 7: Webhook route + handler for SePay
+
+**Files:**
+- Modify: `backend/internal/handler/payment_webhook_handler.go` (SepayNotify, extractOutTradeNo case, writeSuccessResponse case)
+- Modify: `backend/internal/server/routes/payment.go:59-69` (route)
+- Test: `backend/internal/handler/payment_webhook_handler_test.go` (append)
+
+**Interfaces:**
+- Consumes: `payment.TypeSePay`, `SePay.VerifyNotification` (Task 4).
+- Produces: `POST /api/v1/payment/webhook/sepay`; success body `{"success":true}`.
+
+- [ ] **Step 1: Write failing tests**
+
+The existing `backend/internal/handler/payment_webhook_handler_test.go` (build tag `//go:build unit`) tests `writeSuccessResponse` and `extractOutTradeNo` directly with `gin.CreateTestContext`. Extend both tables.
+
+Add a case to the `TestWriteSuccessResponse` table (after the airwallex case):
+
+```go
+ {
+ name: "sepay returns JSON success body",
+ providerKey: payment.TypeSePay,
+ wantCode: http.StatusOK,
+ wantContentType: "application/json",
+ wantBody: `{"success":true}`,
+ },
+```
+
+(The table-driven test asserts `w.Code`, content type and `w.Body.String()`; the JSON body comparison works because gin renders `{"success":true}` with no spaces.)
+
+Add two cases to the `TestExtractOutTradeNo` table:
+
+```go
+ {
+ name: "sepay json payload with code",
+ providerKey: payment.TypeSePay,
+ rawBody: `{"code":"sub2_20260814aB3kX9mQ","transferType":"in","transferAmount":50000}`,
+ want: "sub2_20260814aB3kX9mQ",
+ },
+ {
+ name: "sepay json payload with null code",
+ providerKey: payment.TypeSePay,
+ rawBody: `{"code":null,"transferType":"in","transferAmount":50000}`,
+ want: "",
+ },
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd backend && go test -tags=unit ./internal/handler/ -run 'TestWriteSuccessResponse|TestExtractOutTradeNo' -v`
+Expected: FAIL — the new table cases fail (sepay falls into `default` → plain-text `success`; extractOutTradeNo returns `""` for the JSON code body).
+
+- [ ] **Step 3: Implement**
+
+`backend/internal/handler/payment_webhook_handler.go` — add after `AirwallexWebhook`:
+
+```go
+// SepayNotify handles SePay transaction webhooks.
+// POST /api/v1/payment/webhook/sepay
+func (h *PaymentWebhookHandler) SepayNotify(c *gin.Context) {
+ h.handleNotify(c, payment.TypeSePay)
+}
+```
+
+In `extractOutTradeNo`, add a case (JSON body, `code` may be null → empty):
+
+```go
+ case payment.TypeSePay:
+ var payload struct {
+ Code *string `json:"code"`
+ }
+ if err := json.Unmarshal([]byte(rawBody), &payload); err == nil && payload.Code != nil {
+ return strings.TrimSpace(*payload.Code)
+ }
+```
+
+In `writeSuccessResponse`, add before `default`:
+
+```go
+ case payment.TypeSePay:
+ // SePay requires exactly {"success":true} with HTTP 200/201.
+ c.JSON(http.StatusOK, gin.H{"success": true})
+```
+
+`backend/internal/server/routes/payment.go` — add to the webhook group:
+
+```go
+ webhook.POST("/sepay", webhookHandler.SepayNotify)
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd backend && go test -tags=unit ./internal/handler/ -run 'TestWriteSuccessResponse|TestExtractOutTradeNo' -v && go test -tags=unit ./internal/handler/`
+Expected: PASS (whole handler unit suite green). Also verify the route: `grep -n "webhook/sepay" backend/internal/server/routes/payment.go` shows the new POST line.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/internal/handler/payment_webhook_handler.go backend/internal/server/routes/payment.go backend/internal/handler/payment_webhook_handler_test.go
+git commit -m "feat(payment): SePay webhook route with required JSON success body"
+```
+
+---
+
+### Task 8: Lenient sepay code → order resolution (service layer)
+
+**Files:**
+- Modify: `backend/internal/service/payment_webhook_provider.go:32-35` (GetWebhookProviders prologue)
+- Modify: `backend/internal/service/payment_fulfillment.go:47-58` (HandlePaymentNotification NotFound branch)
+- Modify: `backend/internal/service/payment_order_lifecycle.go:249-253` (paymentOrderQueryReference case list)
+- Test: `backend/internal/service/payment_sepay_resolution_test.go` (create)
+
+**Interfaces:**
+- Consumes: `payment.TypeSePay` (Task 1); ent predicate `paymentorder.OutTradeNoEqualFold` (exists, `backend/ent/paymentorder/where.go:714`).
+- Produces: `(*PaymentService).resolveSepayOutTradeNo(ctx, code) string`; `(*PaymentService).resolveSepayNotificationOrderID(ctx, providerKey, code) (int64, bool)`; `paymentOrderQueryReference` returns `order.OutTradeNo` for sepay.
+
+- [ ] **Step 1: Write failing tests**
+
+Create `backend/internal/service/payment_sepay_resolution_test.go`, following the sqlite/enttest pattern of `payment_fulfillment_order_not_found_test.go` and the order-creation pattern of `payment_fulfillment_test.go` (`createPaymentFulfillmentSubscriptionOrder`):
+
+```go
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "database/sql"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/ent/dbent"
+ "github.com/Wei-Shaw/sub2api/ent/enttest"
+ "github.com/Wei-Shaw/sub2api/ent/dialect"
+ entsql "github.com/Wei-Shaw/sub2api/ent/dialect/sql"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+
+ "github.com/stretchr/testify/require"
+)
+
+func newSepayResolutionTestClient(t *testing.T) *dbent.Client {
+ t.Helper()
+ db, err := sql.Open("sqlite", "file:sepay_resolution_"+strconv.FormatInt(time.Now().UnixNano(), 10)+"?mode=memory&_fk=1")
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+ _, err = db.Exec("PRAGMA foreign_keys = ON")
+ require.NoError(t, err)
+ drv := entsql.OpenDB(dialect.SQLite, db)
+ client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
+ t.Cleanup(func() { _ = client.Close() })
+ return client
+}
+
+func createSepayTestOrder(t *testing.T, ctx context.Context, client *dbent.Client, outTradeNo string) *dbent.PaymentOrder {
+ t.Helper()
+ user, err := client.User.Create().
+ SetEmail("sepay-" + strconv.FormatInt(time.Now().UnixNano(), 10) + "@example.com").
+ SetPasswordHash("hash").
+ SetUsername("sepay-user").
+ Save(ctx)
+ require.NoError(t, err)
+ order, err := client.PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(50000).
+ SetPayAmount(50000).
+ SetFeeRate(0).
+ SetRechargeCode("PAY-SEPAY-" + strconv.FormatInt(time.Now().UnixNano(), 10)).
+ SetOutTradeNo(outTradeNo).
+ SetPaymentType(payment.TypeSePay).
+ SetOrderType(payment.OrderTypeBalance).
+ SetStatus(OrderStatusPending).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ SetSrcURL("/api/v1/payment/orders").
+ Save(ctx)
+ require.NoError(t, err)
+ return order
+}
+
+// TestResolveSepayOutTradeNo verifies bank-side mutations of the transfer code:
+// exact, uppercased, prefix-stripped and prefix-stripped+uppercased variants
+// all resolve to the canonical out_trade_no.
+func TestResolveSepayOutTradeNo(t *testing.T) {
+ ctx := context.Background()
+ client := newSepayResolutionTestClient(t)
+ svc := &PaymentService{entClient: client, providersLoaded: true}
+
+ const canonical = "sub2_20260814aB3kX9mQ"
+ order := createSepayTestOrder(t, ctx, client, canonical)
+
+ for _, code := range []string{
+ "sub2_20260814aB3kX9mQ",
+ "SUB2_20260814AB3KX9MQ",
+ "20260814aB3kX9mQ",
+ "20260814AB3KX9MQ",
+ } {
+ require.Equal(t, canonical, svc.resolveSepayOutTradeNo(ctx, code), "code %q", code)
+ }
+ require.Equal(t, "sub2_19990101zzzzzzzz",
+ svc.resolveSepayOutTradeNo(ctx, "sub2_19990101zzzzzzzz"), "unknown code round-trips unchanged")
+ require.Equal(t, "", svc.resolveSepayOutTradeNo(ctx, " "))
+
+ oid, ok := svc.resolveSepayNotificationOrderID(ctx, payment.TypeSePay, "20260814AB3KX9MQ")
+ require.True(t, ok)
+ require.Equal(t, order.ID, oid)
+
+ _, ok = svc.resolveSepayNotificationOrderID(ctx, payment.TypeAlipay, canonical)
+ require.False(t, ok, "non-sepay provider must not use sepay resolution")
+}
+```
+
+Also add the pure-function query-reference test to the same file:
+
+```go
+func TestPaymentOrderQueryReferenceSePay(t *testing.T) {
+ order := &dbent.PaymentOrder{OutTradeNo: "sub2_20260814aB3kX9mQ", PaymentType: payment.TypeSePay}
+ require.Equal(t, "sub2_20260814aB3kX9mQ", paymentOrderQueryReference(order, nil),
+ "sepay must query by out_trade_no (no upstream tradeNo exists while pending)")
+}
+```
+
+Note: if `SetSrcURL`/`SetSrcHost` are optional in the schema the calls can stay (they are plain setters); the required set mirrors `createPaymentFulfillmentSubscriptionOrder` in `payment_fulfillment_test.go:855-874`. If a required field is still missing, `go test` will name it in the compile error — add the corresponding `Set` with a trivial value.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd backend && go test -tags=unit ./internal/service/ -run 'TestResolveSepayOutTradeNo|TestPaymentOrderQueryReferenceSePay' -v`
+Expected: FAIL — `undefined: svc.resolveSepayOutTradeNo` / sepay takes the default branch.
+
+- [ ] **Step 3: Implement**
+
+`payment_webhook_provider.go` — add at the top of `GetWebhookProviders` (before `if outTradeNo != ""`):
+
+```go
+ if strings.TrimSpace(providerKey) == payment.TypeSePay {
+ outTradeNo = s.resolveSepayOutTradeNo(ctx, outTradeNo)
+ }
+```
+
+And add the helper (same file):
+
+```go
+// resolveSepayOutTradeNo maps a SePay webhook code back to the canonical
+// out_trade_no. Banks may uppercase the transfer content and SePay's payment
+// code extraction may drop the configured prefix, so resolution falls back to
+// case-insensitive lookups (raw code, then code with the sub2_ prefix).
+func (s *PaymentService) resolveSepayOutTradeNo(ctx context.Context, code string) string {
+ code = strings.TrimSpace(code)
+ if code == "" {
+ return ""
+ }
+ for _, cand := range []string{code, orderIDPrefix + code} {
+ order, err := s.entClient.PaymentOrder.Query().
+ Where(paymentorder.OutTradeNoEqualFold(cand)).Only(ctx)
+ if err == nil && order != nil {
+ return order.OutTradeNo
+ }
+ }
+ return code
+}
+```
+
+`payment_fulfillment.go` — in `HandlePaymentNotification`, extend the NotFound branch (after the legacy `parseLegacyPaymentOrderID` attempt, before returning `ErrOrderNotFound`):
+
+```go
+ if oid, ok := s.resolveSepayNotificationOrderID(ctx, pk, n.OrderID); ok {
+ return s.confirmPayment(ctx, oid, n.TradeNo, n.Amount, pk, n.Metadata)
+ }
+```
+
+And add the helper (same file):
+
+```go
+// resolveSepayNotificationOrderID resolves lenient SePay codes (uppercased or
+// prefix-stripped by banks) to the internal order ID.
+func (s *PaymentService) resolveSepayNotificationOrderID(ctx context.Context, providerKey, code string) (int64, bool) {
+ if strings.TrimSpace(providerKey) != payment.TypeSePay {
+ return 0, false
+ }
+ code = strings.TrimSpace(code)
+ if code == "" {
+ return 0, false
+ }
+ for _, cand := range []string{code, orderIDPrefix + code} {
+ order, err := s.entClient.PaymentOrder.Query().
+ Where(paymentorder.OutTradeNoEqualFold(cand)).Only(ctx)
+ if err == nil && order != nil {
+ return order.ID, true
+ }
+ }
+ return 0, false
+}
+```
+
+`payment_order_lifecycle.go` — in `paymentOrderQueryReference`, add sepay to the out_trade_no case:
+
+```go
+ case payment.TypeAlipay, payment.TypeEasyPay, payment.TypeWxpay, payment.TypeSePay:
+ return strings.TrimSpace(order.OutTradeNo)
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd backend && go test -tags=unit ./internal/service/ -run 'TestResolveSepayOutTradeNo|TestPaymentOrderQueryReferenceSePay' -v && go test ./internal/service/`
+Expected: PASS (whole service package green).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/internal/service/payment_webhook_provider.go backend/internal/service/payment_fulfillment.go backend/internal/service/payment_order_lifecycle.go backend/internal/service/payment_sepay_resolution_test.go
+git commit -m "feat(payment): lenient sepay code resolution for webhooks and queries"
+```
+
+---
+
+### Task 9: Provider registration keys, refund block, sensitive + protected config fields
+
+**Files:**
+- Modify: `backend/internal/service/payment_config_providers.go` (`validProviderKeys` map at line 180-182; both sensitive/protected maps at lines 118-131; refund guard in `CreateProviderInstance` ~line 207 and `UpdateProviderInstance` ~line 423)
+- Test: `backend/internal/service/payment_config_providers_test.go` (append)
+
+**Interfaces:**
+- Consumes: `payment.TypeSePay`.
+- Produces: admin can create sepay provider instances (`validProviderKeys`); enabling `refund_enabled` on sepay returns `VALIDATION_ERROR`; sepay secrets are masked by the admin GET API and preserved on empty re-submit; `bankAccountNumber`/`bankBin` cannot change while orders are in progress; helper `providerSupportsRefund(providerKey string) bool`.
+
+- [ ] **Step 1: Write failing test**
+
+Append to `backend/internal/service/payment_config_providers_test.go` (plain unit test, no DB):
+
+```go
+func TestSepayProviderRegistrationAndRefundBlock(t *testing.T) {
+ if !validProviderKeys[payment.TypeSePay] {
+ t.Error("sepay must be a valid provider key")
+ }
+ if err := validateProviderRequest(payment.TypeSePay, "SePay VN", "sepay"); err != nil {
+ t.Fatalf("sepay provider request should validate: %v", err)
+ }
+ if providerSupportsRefund(payment.TypeSePay) {
+ t.Error("sepay has no refund API and must not report refund support")
+ }
+ if !providerSupportsRefund(payment.TypeStripe) {
+ t.Error("stripe refund support regression")
+ }
+ if err := validateProviderRefundSupport(payment.TypeSePay, true); err == nil {
+ t.Error("enabling refund on sepay must be rejected")
+ }
+ if err := validateProviderRefundSupport(payment.TypeSePay, false); err != nil {
+ t.Errorf("refund disabled should always be accepted: %v", err)
+ }
+ if err := validateProviderRefundSupport(payment.TypeStripe, true); err != nil {
+ t.Errorf("stripe refund enabled should be accepted: %v", err)
+ }
+}
+
+func TestSepaySensitiveConfigFields(t *testing.T) {
+ for _, field := range []string{"apiToken", "webhookSecret", "webhookApiKey"} {
+ if !isSensitiveProviderConfigField(payment.TypeSePay, field) {
+ t.Errorf("%s should be sensitive for sepay", field)
+ }
+ }
+ for _, field := range []string{"bankAccountNumber", "bankBin", "accountName", "apiBase"} {
+ if isSensitiveProviderConfigField(payment.TypeSePay, field) {
+ t.Errorf("%s should not be sensitive for sepay", field)
+ }
+ }
+ if !hasPendingOrderProtectedConfigChange(payment.TypeSePay,
+ map[string]string{"bankAccountNumber": "1"},
+ map[string]string{"bankAccountNumber": "2"}) {
+ t.Error("bankAccountNumber change must be blocked with pending orders")
+ }
+ if hasPendingOrderProtectedConfigChange(payment.TypeSePay,
+ map[string]string{"accountName": "A"},
+ map[string]string{"accountName": "B"}) {
+ t.Error("accountName change must be allowed with pending orders")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd backend && go test -tags=unit ./internal/service/ -run 'TestSepayProviderRegistration|TestSepaySensitiveConfigFields' -v`
+Expected: FAIL — sepay absent from `validProviderKeys`/maps, helpers undefined.
+
+- [ ] **Step 3: Implement**
+
+In `payment_config_providers.go`:
+
+1. `validProviderKeys` (line 181):
+
+```go
+ payment.TypeEasyPay: true, payment.TypeAlipay: true, payment.TypeWxpay: true, payment.TypeStripe: true, payment.TypeAirwallex: true, payment.TypeSePay: true,
+```
+
+2. Add refund-support helpers next to `validProviderKeys`:
+
+```go
+// refundCapableProviders lists provider keys whose upstream API supports
+// refunds. SePay monitors bank transfers and has no refund API.
+var refundCapableProviders = map[string]bool{
+ payment.TypeEasyPay: true, payment.TypeAlipay: true, payment.TypeWxpay: true, payment.TypeStripe: true, payment.TypeAirwallex: true,
+}
+
+func providerSupportsRefund(providerKey string) bool {
+ return refundCapableProviders[providerKey]
+}
+
+// validateProviderRefundSupport rejects enabling refunds on providers whose
+// upstream has no refund API (currently sepay only).
+func validateProviderRefundSupport(providerKey string, refundEnabled bool) error {
+ if refundEnabled && !providerSupportsRefund(providerKey) {
+ return infraerrors.BadRequest("VALIDATION_ERROR",
+ fmt.Sprintf("provider %s does not support refunds", providerKey))
+ }
+ return nil
+}
+```
+
+3. Call the guard in `CreateProviderInstance` before the ent `Create()` (line ~206):
+
+```go
+ if err := validateProviderRefundSupport(req.ProviderKey, req.RefundEnabled); err != nil {
+ return nil, err
+ }
+```
+
+4. And in `UpdateProviderInstance` inside `if req.RefundEnabled != nil {` (line ~423), before `u.SetRefundEnabled(*req.RefundEnabled)`:
+
+```go
+ if err := validateProviderRefundSupport(inst.ProviderKey, *req.RefundEnabled); err != nil {
+ return nil, err
+ }
+```
+
+(`inst` is the loaded instance variable already in scope in that function — confirm its actual name by reading the surrounding code.)
+
+5. `providerSensitiveConfigFields`:
+
+```go
+ payment.TypeSePay: {"apitoken": {}, "webhooksecret": {}, "webhookapikey": {}},
+```
+
+6. `providerPendingOrderProtectedConfigFields`:
+
+```go
+ payment.TypeSePay: {"apitoken": {}, "webhooksecret": {}, "webhookapikey": {}, "bankaccountnumber": {}, "bankbin": {}},
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd backend && go test -tags=unit ./internal/service/ -run 'TestSepayProviderRegistration|TestSepaySensitiveConfigFields' -v`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/internal/service/payment_config_providers.go backend/internal/service/payment_config_providers_test.go
+git commit -m "feat(payment): sepay provider registration, refund block, config field protection"
+```
+
+---
+
+### Task 10: `SUBSCRIPTION_USD_TO_VND_RATE` backend plumbing
+
+**Files:**
+- Modify: `backend/internal/service/payment_config_service.go` (const ~line 30, struct ~line 63, request ~line 96, keys slice ~line 222, parse ~line 251, validate ~line 330, save ~line 374)
+- Modify: `backend/internal/service/payment_amounts.go` (normalize helper)
+- Modify: `backend/internal/service/payment_order.go` (guard ~line 68; signature/callers at lines 72, 88, 646-666)
+- Modify: `backend/internal/handler/payment_handler.go` (checkout-info ~lines 150, 167)
+- Modify: `backend/internal/handler/dto/settings.go` (~line 275)
+- Modify: `backend/internal/handler/admin/setting_handler.go` (~line 358)
+- Modify: `backend/internal/handler/admin/setting_handler_update.go` (~lines 310, 2050, 2326, 2397)
+- Test: `backend/internal/service/payment_order_sepay_vnd_test.go` (create)
+- Test: existing tests calling `calculateCreateOrderPayAmountForOrderType` / `calculateSubscriptionGatewayBaseAmount` (update call sites — find with `grep -rn "calculateCreateOrderPayAmountForOrderType\|calculateSubscriptionGatewayBaseAmount" backend/internal --include="*_test.go"`)
+
+**Interfaces:**
+- Consumes: `payment.CurrencyVND` (Task 1), `PaymentConfig`.
+- Produces: `PaymentConfig.SubscriptionUSDToVNDRate float64` (JSON `subscription_usd_to_vnd_rate`); `calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate float64, currency, orderType string, cfg *PaymentConfig)`; `calculateSubscriptionGatewayBaseAmount(amount float64, cfg *PaymentConfig, currency string)`; checkout/admin DTO field `subscription_usd_to_vnd_rate` / `payment_subscription_usd_to_vnd_rate`; error code `SUBSCRIPTION_VND_RATE_REQUIRED`; validation error `INVALID_SUBSCRIPTION_USD_TO_VND_RATE`.
+
+- [ ] **Step 1: Write failing tests**
+
+`backend/internal/service/payment_order_sepay_vnd_test.go`:
+
+```go
+package service
+
+import (
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+)
+
+func TestCalculateSubscriptionGatewayBaseAmountVND(t *testing.T) {
+ cfg := &PaymentConfig{SubscriptionUSDToVNDRate: 25000}
+ cases := []struct {
+ name string
+ cfg *PaymentConfig
+ currency string
+ amount float64
+ want float64
+ }{
+ {"vnd rate applies", cfg, payment.CurrencyVND, 9.9, 247500},
+ {"vnd rate zero keeps price", &PaymentConfig{}, payment.CurrencyVND, 9.9, 9.9},
+ {"cny unaffected", &PaymentConfig{SubscriptionUSDToCNYRate: 7.2}, payment.DefaultPaymentCurrency, 10, 72},
+ {"other currency untouched", cfg, "USD", 9.9, 9.9},
+ {"nil cfg safe", nil, payment.CurrencyVND, 9.9, 9.9},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := calculateSubscriptionGatewayBaseAmount(tc.amount, tc.cfg, tc.currency); got != tc.want {
+ t.Fatalf("= %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestCreateOrderPayAmountForOrderTypeVND(t *testing.T) {
+ cfg := &PaymentConfig{SubscriptionUSDToVNDRate: 25000}
+ str, amt, err := calculateCreateOrderPayAmountForOrderType(9.9, 0, payment.CurrencyVND, payment.OrderTypeSubscription, cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if str != "247500" || amt != 247500 {
+ t.Fatalf("str=%q amt=%v, want 247500", str, amt)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd backend && go test ./internal/service/ -run 'TestCalculateSubscriptionGatewayBaseAmountVND|TestCreateOrderPayAmountForOrderTypeVND' -v`
+Expected: FAIL — signatures undefined / VND not converted.
+
+- [ ] **Step 3: Implement**
+
+`payment_config_service.go` — five edits, each mirroring the adjacent CNY line (grep `SubscriptionUSDToCNYRate` to find them):
+
+1. Const block:
+```go
+ // SettingSubscriptionUSDToVNDRate 是订阅 VND 换算汇率(1 USD = X VND)。
+ // 0/未配置 = 关闭换算。SePay(VND)订阅必须配置该项,否则下单被拒绝。
+ SettingSubscriptionUSDToVNDRate = "SUBSCRIPTION_USD_TO_VND_RATE"
+```
+2. `PaymentConfig` struct field after `SubscriptionUSDToCNYRate`:
+```go
+ SubscriptionUSDToVNDRate float64 `json:"subscription_usd_to_vnd_rate"`
+```
+3. `UpdatePaymentConfigRequest` field after `SubscriptionUSDToCNYRate`:
+```go
+ SubscriptionUSDToVNDRate *float64 `json:"subscription_usd_to_vnd_rate"`
+```
+4. Keys slice: append `SettingSubscriptionUSDToVNDRate` next to `SettingSubscriptionUSDToCNYRate`.
+5. Parse:
+```go
+ SubscriptionUSDToVNDRate: normalizeSubscriptionUSDToVNDRate(pcParseFloat(vals[SettingSubscriptionUSDToVNDRate], 0)),
+```
+6. Validation (mirror the CNY `INVALID_SUBSCRIPTION_USD_TO_CNY_RATE` block):
+```go
+ if req.SubscriptionUSDToVNDRate != nil {
+ v := *req.SubscriptionUSDToVNDRate
+ if v < 0 {
+ return infraerrors.BadRequest("INVALID_SUBSCRIPTION_USD_TO_VND_RATE", "subscription USD to VND rate must be 0 (disabled) or a positive number")
+ }
+ }
+```
+7. Save:
+```go
+ if req.SubscriptionUSDToVNDRate != nil {
+ m[SettingSubscriptionUSDToVNDRate] = formatPositiveFloatExact(req.SubscriptionUSDToVNDRate)
+ }
+```
+
+`payment_amounts.go` — add next to `normalizeSubscriptionUSDToCNYRate`:
+
+```go
+// normalizeSubscriptionUSDToVNDRate 将非法值归一为 0(换算关闭)。
+func normalizeSubscriptionUSDToVNDRate(rate float64) float64 {
+ return normalizeSubscriptionUSDToCNYRate(rate)
+}
+```
+
+`payment_order.go` — three edits:
+
+1. Guard in `CreateOrder` right after `ValidateMethodCurrencyConsistency` returns (before the first `calculateCreateOrderPayAmountForOrderType` call):
+```go
+ if req.OrderType == payment.OrderTypeSubscription && methodCurrency == payment.CurrencyVND {
+ if normalizeSubscriptionUSDToVNDRate(cfg.SubscriptionUSDToVNDRate) <= 0 {
+ return nil, infraerrors.BadRequest("SUBSCRIPTION_VND_RATE_REQUIRED",
+ "subscription orders via VND methods require the USD to VND rate to be configured")
+ }
+ }
+```
+2. Change both call sites (lines ~72 and ~88) to pass `cfg` instead of `cfg.SubscriptionUSDToCNYRate`:
+```go
+ payAmountStr, payAmount, err := calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, methodCurrency, req.OrderType, cfg)
+```
+(and identically for the `selectedCurrency != methodCurrency` re-computation.)
+3. Replace the two functions at the bottom of the file:
+```go
+func calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate float64, currency, orderType string, cfg *PaymentConfig) (string, float64, error) {
+ paymentAmount := limitAmount
+ if orderType == payment.OrderTypeSubscription {
+ paymentAmount = calculateSubscriptionGatewayBaseAmount(limitAmount, cfg, currency)
+ }
+ return calculateCreateOrderPayAmount(paymentAmount, feeRate, currency)
+}
+
+// calculateSubscriptionGatewayBaseAmount 计算订阅订单的网关扣款基数。
+// 换算是显式 opt-in:CNY 通道按 SUBSCRIPTION_USD_TO_CNY_RATE、VND 通道按
+// SUBSCRIPTION_USD_TO_VND_RATE(1 USD = rate),未配置时保持 price 直付。
+func calculateSubscriptionGatewayBaseAmount(amount float64, cfg *PaymentConfig, currency string) float64 {
+ if cfg == nil {
+ return amount
+ }
+ var rate float64
+ switch currency {
+ case payment.DefaultPaymentCurrency:
+ rate = normalizeSubscriptionUSDToCNYRate(cfg.SubscriptionUSDToCNYRate)
+ case payment.CurrencyVND:
+ rate = normalizeSubscriptionUSDToVNDRate(cfg.SubscriptionUSDToVNDRate)
+ default:
+ return amount
+ }
+ if rate <= 0 {
+ return amount
+ }
+ return decimal.NewFromFloat(amount).
+ Mul(decimal.NewFromFloat(rate)).
+ Round(int32(payment.CurrencyMaxFractionDigits(currency))).
+ InexactFloat64()
+}
+```
+
+Then fix all callers, including tests: `cd backend && go build ./... && go vet ./internal/service/` and update every `calculateCreateOrderPayAmountForOrderType(..., someRate)` / `calculateSubscriptionGatewayBaseAmount(amount, rate, currency)` call found by:
+`grep -rn "calculateCreateOrderPayAmountForOrderType\|calculateSubscriptionGatewayBaseAmount" backend/internal --include="*_test.go"`.
+In tests, pass a `&PaymentConfig{SubscriptionUSDToCNYRate: oldRate}` (or the full cfg the test already has) in place of the old float argument.
+
+`payment_handler.go` — checkout-info: add to the struct and the response construction next to the CNY field:
+```go
+ SubscriptionUSDToVNDRate float64 `json:"subscription_usd_to_vnd_rate"`
+```
+```go
+ SubscriptionUSDToVNDRate: cfg.SubscriptionUSDToVNDRate,
+```
+
+Admin plumbing (mirror every `PaymentSubscriptionUSDToCNYRate` occurrence — grep it in each file):
+- `dto/settings.go`: `PaymentSubscriptionUSDToVNDRate float64 `json:"payment_subscription_usd_to_vnd_rate"``.
+- `setting_handler.go`: `PaymentSubscriptionUSDToVNDRate: paymentCfg.SubscriptionUSDToVNDRate,`.
+- `setting_handler_update.go`: request field `PaymentSubscriptionUSDToVNDRate *float64 `json:"payment_subscription_usd_to_vnd_rate"``; apply `SubscriptionUSDToVNDRate: req.PaymentSubscriptionUSDToVNDRate` in the update call; include in the response mapping and in the "payment settings changed" dirty-check condition (`req.PaymentSubscriptionUSDToVNDRate != nil || ...`).
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd backend && go build ./... && go test ./internal/service/ -run 'VND' -v && go test ./internal/service/ ./internal/handler/...`
+Expected: PASS everywhere; no compile errors from signature change.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/internal/service backend/internal/handler
+git commit -m "feat(payment): SUBSCRIPTION_USD_TO_VND_RATE for sepay subscription orders"
+```
+
+---
+
+### Task 11: Frontend — provider config, method plumbing, i18n, admin options
+
+**Files:**
+- Modify: `frontend/src/components/payment/providerConfig.ts` (lines 39-45, 51, 107-113, 127-164)
+- Modify: `frontend/src/components/payment/paymentFlow.ts` (lines 12-21)
+- Modify: `frontend/src/i18n/locales/en/misc.ts` + `frontend/src/i18n/locales/zh/misc.ts` (methods block)
+- Modify: `frontend/src/i18n/locales/en/admin/settings.ts` + `frontend/src/i18n/locales/zh/admin/settings.ts` (provider + field labels)
+- Modify: `frontend/src/views/admin/SettingsView.vue` (~lines 12056-12060, 12112-12117)
+- Test: `frontend/src/components/payment/__tests__/providerConfig.spec.ts` (append)
+
+**Interfaces:**
+- Consumes: backend route `POST /api/v1/payment/webhook/sepay` (Task 7).
+- Produces: admin can create sepay provider instances; user checkout shows a "SePay" method; label resolution keys `payment.methods.sepay`, `admin.settings.payment.providerSepay`, `admin.settings.payment.field_apiToken`, `field_bankAccountNumber`, `field_bankBin`, `field_accountName`, `field_webhookApiKey`, `field_sepayApiBaseHint`.
+
+- [ ] **Step 1: Write failing test**
+
+Append to `frontend/src/components/payment/__tests__/providerConfig.spec.ts` (mirror the airwallex describe at the top of that file):
+
+```ts
+describe('PROVIDER_CONFIG_FIELDS.sepay', () => {
+ const findField = (key: string) =>
+ (PROVIDER_CONFIG_FIELDS.sepay || []).find(f => f.key === key)
+
+ it('declares sepay supported types and method order', () => {
+ expect(PROVIDER_SUPPORTED_TYPES.sepay).toEqual(['sepay'])
+ expect(METHOD_ORDER).toContain('sepay')
+ expect(WEBHOOK_PATHS.sepay).toBe('/api/v1/payment/webhook/sepay')
+ })
+
+ it('marks credentials as sensitive and bank details as required', () => {
+ expect(findField('apiToken')?.sensitive).toBe(true)
+ expect(findField('webhookSecret')?.sensitive).toBe(true)
+ expect(findField('webhookApiKey')?.sensitive).toBe(true)
+ expect(findField('bankAccountNumber')?.optional).toBeUndefined()
+ expect(findField('bankBin')?.optional).toBeUndefined()
+ expect(findField('accountName')?.optional).toBe(true)
+ expect(findField('webhookApiKey')?.optional).toBe(true)
+ expect(findField('apiBase')?.defaultValue).toBe('https://userapi.sepay.vn')
+ })
+})
+```
+
+Adjust the import at the top of the spec to also bring in `METHOD_ORDER` and `WEBHOOK_PATHS` if not already imported.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd frontend && npx vitest run src/components/payment/__tests__/providerConfig.spec.ts`
+Expected: FAIL — sepay entries missing.
+
+- [ ] **Step 3: Implement**
+
+`providerConfig.ts`:
+
+```ts
+export const PROVIDER_SUPPORTED_TYPES: Record = {
+ easypay: ['alipay', 'wxpay'],
+ alipay: ['alipay'],
+ wxpay: ['wxpay'],
+ stripe: ['card', 'alipay', 'wxpay', 'link'],
+ airwallex: ['airwallex'],
+ sepay: ['sepay'],
+}
+```
+
+```ts
+export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct', 'stripe', 'airwallex', 'sepay'] as const
+```
+
+```ts
+export const WEBHOOK_PATHS: Record = {
+ easypay: '/api/v1/payment/webhook/easypay',
+ alipay: '/api/v1/payment/webhook/alipay',
+ wxpay: '/api/v1/payment/webhook/wxpay',
+ stripe: '/api/v1/payment/webhook/stripe',
+ airwallex: '/api/v1/payment/webhook/airwallex',
+ sepay: '/api/v1/payment/webhook/sepay',
+}
+```
+
+Add to `PROVIDER_CONFIG_FIELDS` (after airwallex):
+
+```ts
+ sepay: [
+ { key: 'apiToken', label: '', sensitive: true },
+ { key: 'apiBase', label: '', sensitive: false, defaultValue: 'https://userapi.sepay.vn', hintKey: 'admin.settings.payment.field_sepayApiBaseHint' },
+ { key: 'bankAccountNumber', label: '', sensitive: false },
+ { key: 'bankBin', label: '', sensitive: false },
+ { key: 'accountName', label: '', sensitive: false, optional: true },
+ { key: 'webhookSecret', label: '', sensitive: true },
+ { key: 'webhookApiKey', label: '', sensitive: true, optional: true },
+ ],
+```
+
+(`field_apiBase` and `field_webhookSecret` labels already exist — they are shared with airwallex/stripe. `PROVIDER_CALLBACK_PATHS` gets no sepay entry: SePay webhooks are configured in the SePay dashboard, not passed per-request.)
+
+`paymentFlow.ts`:
+
+```ts
+const VISIBLE_METHOD_ALIASES = {
+ alipay: 'alipay',
+ alipay_direct: 'alipay',
+ wxpay: 'wxpay',
+ wxpay_direct: 'wxpay',
+ stripe: 'stripe',
+ airwallex: 'airwallex',
+ sepay: 'sepay',
+} as const
+
+export type VisiblePaymentMethod = 'alipay' | 'wxpay' | 'stripe' | 'airwallex' | 'sepay'
+```
+
+i18n `en/misc.ts` methods block (next to `airwallex: 'Airwallex',`):
+
+```ts
+ sepay: 'SePay',
+```
+
+`zh/misc.ts` (next to `airwallex: 'Airwallex',`):
+
+```ts
+ sepay: 'SePay',
+```
+
+`en/admin/settings.ts` — next to the airwallex provider/field keys (locate `providerAirwallex` and the airwallex field hints):
+
+```ts
+ providerSepay: 'SePay',
+ field_apiToken: 'API Token',
+ field_bankAccountNumber: 'Bank Account Number',
+ field_bankBin: 'Bank BIN',
+ field_accountName: 'Account Holder Name',
+ field_webhookApiKey: 'Webhook API Key',
+ field_sepayApiBaseHint: 'Defaults to https://userapi.sepay.vn. Use https://userapi-sandbox.sepay.vn for sandbox testing.',
+```
+
+`zh/admin/settings.ts` — same keys:
+
+```ts
+ providerSepay: 'SePay',
+ field_apiToken: 'API Token',
+ field_bankAccountNumber: '银行账号',
+ field_bankBin: '银行 BIN 码',
+ field_accountName: '户名',
+ field_webhookApiKey: 'Webhook API Key',
+ field_sepayApiBaseHint: '默认 https://userapi.sepay.vn,沙箱环境使用 https://userapi-sandbox.sepay.vn。',
+```
+
+(Place them at the correct nesting level inside the `payment` section — grep `providerAirwallex` in each file for the exact spot. The `webhookSecret`/`apiBase` field labels already exist and are reused.)
+
+`SettingsView.vue` — two one-line additions:
+
+```ts
+ { value: "sepay", label: t("payment.methods.sepay") },
+```
+in `allPaymentTypes`, and
+
+```ts
+ { value: "sepay", label: t("admin.settings.payment.providerSepay") },
+```
+in `providerKeyOptions`.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd frontend && npx vitest run src/components/payment/__tests__/providerConfig.spec.ts src/components/payment/__tests__/paymentFlow.spec.ts src/components/payment/__tests__/PaymentMethodSelector.spec.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/src/components/payment/providerConfig.ts frontend/src/components/payment/paymentFlow.ts frontend/src/i18n frontend/src/views/admin/SettingsView.vue frontend/src/components/payment/__tests__/providerConfig.spec.ts
+git commit -m "feat(payment-frontend): sepay provider config, method plumbing and i18n"
+```
+
+---
+
+### Task 12: Frontend — VND subscription rate display + admin input
+
+**Files:**
+- Modify: `frontend/src/types/payment.ts` (lines ~37 and ~72: both interfaces containing `subscription_usd_to_cny_rate`)
+- Modify: `frontend/src/api/admin/settings.ts` (~lines 656, 966)
+- Modify: `frontend/src/api/admin/payment.ts` (~lines 27, 47)
+- Modify: `frontend/src/views/user/PaymentView.vue` (~lines 505, 523-527, 596-600)
+- Modify: `frontend/src/views/admin/SettingsView.vue` (~lines 7790-7815 input markup, 9471 form default, 11270 submit payload)
+- Modify: `frontend/src/i18n/locales/en/admin/settings.ts` + `zh/admin/settings.ts` (rate label keys)
+- Test: `frontend/src/views/user/__tests__/PaymentView.spec.ts` (update defaults), `frontend/src/views/admin/__tests__/SettingsView.spec.ts` (update defaults)
+
+**Interfaces:**
+- Consumes: backend JSON fields `subscription_usd_to_vnd_rate` (checkout-info) and `payment_subscription_usd_to_vnd_rate` (admin settings) from Task 10.
+- Produces: user-facing subscription prices in VND; admin input for the rate.
+
+- [ ] **Step 1: Update tests first (defaults must include the new field)**
+
+In `PaymentView.spec.ts` and `SettingsView.spec.ts`, every mock/fixture that contains `subscription_usd_to_cny_rate: 0` (grep both files) gains `subscription_usd_to_vnd_rate: 0` (PaymentView fixtures, e.g. line ~109) / `payment_subscription_usd_to_vnd_rate: 0` (SettingsView fixtures). Then add this VND twin next to the existing CNY conversion test (`mountSubscriptionConfirm` + `formatPaymentAmount` are already used in that file — see the `subscription_usd_to_cny_rate: 7.15` test around line 297):
+
+```ts
+ it('converts subscription price to VND when the VND rate is configured', async () => {
+ const wrapper = await mountSubscriptionConfirm({
+ checkout: {
+ subscription_usd_to_vnd_rate: 25000,
+ },
+ method: {
+ currency: 'VND',
+ },
+ plan: {
+ price: 9.99,
+ original_price: 12.99,
+ },
+ })
+
+ const text = wrapper.text()
+ const convertedPrice = formatPaymentAmount(249750, 'VND')
+ const convertedOriginalPrice = formatPaymentAmount(324750, 'VND')
+
+ expect(text).toContain(convertedPrice)
+ expect(text).toContain(convertedOriginalPrice)
+ expect(text).not.toContain(formatPaymentAmount(9.99, 'VND'))
+ })
+```
+
+(If `mountSubscriptionConfirm`'s `method` fixture needs a payment-type key for the visible method, pass the same value the CNY test uses — the currency field is what drives the conversion path.)
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd frontend && npx vitest run src/views/user/__tests__/PaymentView.spec.ts src/views/admin/__tests__/SettingsView.spec.ts`
+Expected: FAIL — new field missing from types/defaults; VND conversion not implemented.
+
+- [ ] **Step 3: Implement**
+
+`types/payment.ts` — add to both interfaces next to the CNY field:
+
+```ts
+ subscription_usd_to_vnd_rate: number
+```
+
+`api/admin/settings.ts` — same, in both spots (response + request types):
+
+```ts
+ payment_subscription_usd_to_vnd_rate: number;
+```
+```ts
+ payment_subscription_usd_to_vnd_rate?: number;
+```
+
+`api/admin/payment.ts` — same two spots:
+
+```ts
+ subscription_usd_to_vnd_rate: number
+```
+```ts
+ subscription_usd_to_vnd_rate?: number
+```
+
+`PaymentView.vue`:
+1. Default checkout object (~line 505): add `subscription_usd_to_vnd_rate: 0,`.
+2. Next to `subscriptionUsdToCnyRate` (~line 523):
+
+```ts
+// 订阅 VND 换算汇率(1 USD = X VND)。0 = 未配置(后端会拒绝 VND 订阅下单)。
+const subscriptionUsdToVndRate = computed(() => {
+ const rate = checkout.value.subscription_usd_to_vnd_rate
+ return Number.isFinite(rate) && rate > 0 ? rate : 0
+})
+```
+3. Replace `subscriptionPaymentAmountForCurrency` (~line 596):
+
+```ts
+function subscriptionPaymentAmountForCurrency(value: number, currency: string): number {
+ if (currency === 'VND') {
+ const vndRate = subscriptionUsdToVndRate.value
+ return vndRate > 0 ? roundPaymentAmount(value * vndRate, currency) : roundPaymentAmount(value, currency)
+ }
+ const rate = subscriptionUsdToCnyRate.value
+ if (rate <= 0 || currency !== DEFAULT_PAYMENT_CURRENCY) return roundPaymentAmount(value, currency)
+ return roundPaymentAmount(value * rate, currency)
+}
+```
+
+`SettingsView.vue`:
+1. Form default (~line 9471): `payment_subscription_usd_to_vnd_rate: 0,`.
+2. Submit payload (~line 11270): `payment_subscription_usd_to_vnd_rate: Number(form.payment_subscription_usd_to_vnd_rate) || 0,`.
+3. Input markup — duplicate the CNY rate input block (~lines 7789-7810) directly below it and change: label key `subscriptionUsdToVndRate`, model `payment_subscription_usd_to_vnd_rate`, placeholder key `subscriptionUsdToVndRateDisabled`, `step="1"` (VND rates are large integers):
+
+```html
+
+
+
+
+```
+
+i18n `en/admin/settings.ts` (next to `subscriptionUsdToCnyRate` keys — grep for placement):
+
+```ts
+ subscriptionUsdToVndRate: 'Subscription USD→VND Rate (1 USD = X VND)',
+ subscriptionUsdToVndRateDisabled: 'Disabled — VND subscription checkout will be rejected',
+```
+
+`zh/admin/settings.ts`:
+
+```ts
+ subscriptionUsdToVndRate: '订阅 USD→VND 汇率(1 USD = X VND)',
+ subscriptionUsdToVndRateDisabled: '未配置 — VND 订阅下单将被拒绝',
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd frontend && npx vitest run`
+Expected: PASS (whole frontend suite green; fix any fixture that still misses the new field).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/src
+git commit -m "feat(payment-frontend): VND subscription rate display and admin input"
+```
+
+---
+
+### Task 13: Full verification
+
+**Files:** none (verification only).
+
+- [ ] **Step 1: Backend build + full tests**
+
+Run: `cd backend && go build ./... && go test ./... && go test -tags=unit ./...`
+Expected: all PASS. (Two passes are needed: handler/service unit tests carry the `//go:build unit` tag; provider package tests are untagged.)
+
+- [ ] **Step 2: Frontend type-check + full tests**
+
+Run: `cd frontend && npx vitest run && npm run build`
+Expected: build succeeds, tests PASS. (If `npm run build` is not the build script, check `frontend/package.json` scripts and use the build/typecheck script present.)
+
+- [ ] **Step 3: Smoke checklist (code review level, no live SePay account needed)**
+
+Verify by reading code (no external calls):
+1. `POST /api/v1/payment/webhook/sepay` replies exactly `{"success":true}` on the happy path (Task 7 test proves it).
+2. A sepay provider instance with `payment_mode` unset still renders the QR: `CreatePayment` returns `QRCode` and the frontend `determinePaymentLaunchKind` picks `qr_waiting` because `prefersQr` is true whenever `qrCode` is set and `paymentMode` is not redirect/popup.
+3. `grep -rn "sepay" backend/internal/service/payment_config_service.go backend/internal/service/payment_order.go` shows the VND guard runs before any order row is written.
+
+- [ ] **Step 4: Final commit if anything was fixed**
+
+```bash
+git add -A && git commit -m "chore(payment): sepay integration final fixes"
+```
+
+---
+
+## Notes for implementers
+
+- The repo's dev guide is `DEV_GUIDE.md`; payment docs live in `docs/PAYMENT.md` (both worth skimming before starting).
+- The spec for this plan: `docs/superpowers/specs/2026-08-14-sepay-payment-gateway-design.md`.
+- Admin setup (documented for the user, not code): on the SePay dashboard create a webhook pointing at `https:///api/v1/payment/webhook/sepay` with HMAC secret (or API key), and configure the payment-code extraction so codes match the `sub2_` prefix used in transfer content.
+- SePay docs: https://developer.sepay.vn/vi/sepay-webhooks/tich-hop-webhook (payload), .../xac-thuc (auth), https://developer.sepay.vn/vi/sepay-api/v2/giao-dich/danh-sach (query API).
From 246f5198d4c303bb351ba7713387c70fb0b8395a Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Fri, 14 Aug 2026 22:51:58 +0700
Subject: [PATCH 03/25] feat(payment): add sepay payment type with VND-only
currency
---
backend/internal/payment/currency.go | 3 +++
backend/internal/payment/types.go | 3 +++
backend/internal/payment/types_test.go | 12 ++++++++++++
backend/internal/service/payment_currency.go | 3 +++
.../internal/service/payment_currency_test.go | 17 +++++++++++++++++
5 files changed, 38 insertions(+)
create mode 100644 backend/internal/payment/types_test.go
create mode 100644 backend/internal/service/payment_currency_test.go
diff --git a/backend/internal/payment/currency.go b/backend/internal/payment/currency.go
index 53ba608b9a0..9512e3fbecb 100644
--- a/backend/internal/payment/currency.go
+++ b/backend/internal/payment/currency.go
@@ -9,6 +9,9 @@ import (
const DefaultPaymentCurrency = "CNY"
+// CurrencyVND is the only currency SePay bank transfers support.
+const CurrencyVND = "VND"
+
type paymentCurrencyAmountUnit struct {
apiMinorUnit int
maxFractionDigits int
diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go
index 6421c4afed0..6c9f4515e28 100644
--- a/backend/internal/payment/types.go
+++ b/backend/internal/payment/types.go
@@ -18,6 +18,7 @@ const (
TypeLink PaymentType = "link"
TypeEasyPay PaymentType = "easypay"
TypeAirwallex PaymentType = "airwallex"
+ TypeSePay PaymentType = "sepay"
)
// Order status constants shared across payment and service layers.
@@ -82,6 +83,8 @@ const ConfigKeyPublishableKey = "publishableKey"
// For example, "alipay_direct" -> "alipay".
func GetBasePaymentType(t string) string {
switch {
+ case t == TypeSePay:
+ return TypeSePay
case t == TypeEasyPay:
return TypeEasyPay
case t == TypeAirwallex:
diff --git a/backend/internal/payment/types_test.go b/backend/internal/payment/types_test.go
new file mode 100644
index 00000000000..38c6822ed73
--- /dev/null
+++ b/backend/internal/payment/types_test.go
@@ -0,0 +1,12 @@
+package payment
+
+import "testing"
+
+func TestGetBasePaymentTypeSePay(t *testing.T) {
+ if got := GetBasePaymentType("sepay"); got != TypeSePay {
+ t.Fatalf("GetBasePaymentType(sepay) = %q, want %q", got, TypeSePay)
+ }
+ if got := GetBasePaymentType(string(TypeSePay)); got != TypeSePay {
+ t.Fatalf("GetBasePaymentType(TypeSePay) = %q, want %q", got, TypeSePay)
+ }
+}
diff --git a/backend/internal/service/payment_currency.go b/backend/internal/service/payment_currency.go
index 64fe94dd169..91d3b75d34f 100644
--- a/backend/internal/service/payment_currency.go
+++ b/backend/internal/service/payment_currency.go
@@ -9,6 +9,9 @@ import (
func paymentProviderConfigCurrency(providerKey string, cfg map[string]string) string {
switch strings.TrimSpace(providerKey) {
+ case payment.TypeSePay:
+ // SePay monitors Vietnamese bank transfers: VND only, not configurable.
+ return payment.CurrencyVND
case payment.TypeStripe, payment.TypeAirwallex:
currency, err := payment.NormalizePaymentCurrency(cfg["currency"])
if err == nil {
diff --git a/backend/internal/service/payment_currency_test.go b/backend/internal/service/payment_currency_test.go
new file mode 100644
index 00000000000..9cc3a0ebb90
--- /dev/null
+++ b/backend/internal/service/payment_currency_test.go
@@ -0,0 +1,17 @@
+package service
+
+import (
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+)
+
+func TestPaymentProviderConfigCurrencySePay(t *testing.T) {
+ if got := paymentProviderConfigCurrency(payment.TypeSePay, map[string]string{}); got != "VND" {
+ t.Fatalf("sepay currency = %q, want VND", got)
+ }
+ // SePay is VND-only: a bogus currency config must not leak CNY default.
+ if got := paymentProviderConfigCurrency(payment.TypeSePay, map[string]string{"currency": "USD"}); got != "VND" {
+ t.Fatalf("sepay currency with override = %q, want VND", got)
+ }
+}
From 798b396fb1470c879cb92bca3966d06882e61468 Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Fri, 14 Aug 2026 22:56:43 +0700
Subject: [PATCH 04/25] feat(payment): add VietQR EMV payload builder
---
backend/internal/payment/provider/vietqr.go | 44 ++++++++++
.../internal/payment/provider/vietqr_test.go | 81 +++++++++++++++++++
2 files changed, 125 insertions(+)
create mode 100644 backend/internal/payment/provider/vietqr.go
create mode 100644 backend/internal/payment/provider/vietqr_test.go
diff --git a/backend/internal/payment/provider/vietqr.go b/backend/internal/payment/provider/vietqr.go
new file mode 100644
index 00000000000..5e01fc88a48
--- /dev/null
+++ b/backend/internal/payment/provider/vietqr.go
@@ -0,0 +1,44 @@
+package provider
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+// buildVietQRPayload builds an EMVCo merchant-presented QR string following
+// the VietQR/NAPAS standard: banking apps scan it and prefill the beneficiary
+// account, amount and transfer content.
+func buildVietQRPayload(bin, accountNumber string, amountVND int64, content string) string {
+ merchantAccount := tlv("00", "A000000727") + tlv("01", bin) + tlv("02", accountNumber)
+ payload := tlv("00", "01") + // Payload Format Indicator
+ tlv("01", "12") + // Point of Initiation: dynamic (amount included)
+ tlv("38", merchantAccount) + // Merchant Account Information (NAPAS)
+ tlv("53", "704") + // Transaction Currency: VND
+ tlv("54", strconv.FormatInt(amountVND, 10)) + // Transaction Amount
+ tlv("58", "VN") + // Country Code
+ tlv("62", tlv("08", content)) // Additional Data: purpose (transfer content)
+ return payload + "6304" + strings.ToUpper(fmt.Sprintf("%04X", crc16CCITTFalse(payload+"6304")))
+}
+
+// tlv encodes one EMVCo TLV field with a two-digit length prefix.
+func tlv(tag, value string) string {
+ return tag + fmt.Sprintf("%02d", len(value)) + value
+}
+
+// crc16CCITTFalse computes CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no
+// reflection, no final XOR) — the checksum mandated by EMVCo QR (tag 63).
+func crc16CCITTFalse(data string) uint16 {
+ crc := uint16(0xFFFF)
+ for i := 0; i < len(data); i++ {
+ crc ^= uint16(data[i]) << 8
+ for bit := 0; bit < 8; bit++ {
+ if crc&0x8000 != 0 {
+ crc = (crc << 1) ^ 0x1021
+ } else {
+ crc <<= 1
+ }
+ }
+ }
+ return crc
+}
diff --git a/backend/internal/payment/provider/vietqr_test.go b/backend/internal/payment/provider/vietqr_test.go
new file mode 100644
index 00000000000..4e5ade265e2
--- /dev/null
+++ b/backend/internal/payment/provider/vietqr_test.go
@@ -0,0 +1,81 @@
+package provider
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+)
+
+func TestCRC16CCITTFalse(t *testing.T) {
+ // Standard check value for CRC-16/CCITT-FALSE.
+ if got := crc16CCITTFalse("123456789"); got != 0x29B1 {
+ t.Fatalf("crc16CCITTFalse(123456789) = %#04x, want 0x29b1", got)
+ }
+}
+
+func parseTLV(t *testing.T, payload, tag string) string {
+ t.Helper()
+ for i := 0; i+4 <= len(payload); {
+ id := payload[i : i+2]
+ ln, ok := parseTwoDigitInt(payload[i+2 : i+4])
+ if !ok || i+4+ln > len(payload) {
+ t.Fatalf("malformed TLV at offset %d", i)
+ }
+ value := payload[i+4 : i+4+ln]
+ if id == tag {
+ return value
+ }
+ i += 4 + ln
+ }
+ return ""
+}
+
+func parseTwoDigitInt(s string) (int, bool) {
+ n := 0
+ for i := 0; i < len(s); i++ {
+ if s[i] < '0' || s[i] > '9' {
+ return 0, false
+ }
+ n = n*10 + int(s[i]-'0')
+ }
+ return n, true
+}
+
+func TestBuildVietQRPayload(t *testing.T) {
+ got := buildVietQRPayload("970422", "0123456789", 10000, "sub2_20260814aB3kX9mQ")
+
+ if want := "000201010212"; !strings.HasPrefix(got, want) {
+ t.Fatalf("prefix = %q, want %q", got[:12], want)
+ }
+ if v := parseTLV(t, got, "53"); v != "704" {
+ t.Fatalf("currency tag 53 = %q, want 704", v)
+ }
+ if v := parseTLV(t, got, "54"); v != "10000" {
+ t.Fatalf("amount tag 54 = %q, want 10000", v)
+ }
+ if v := parseTLV(t, got, "58"); v != "VN" {
+ t.Fatalf("country tag 58 = %q, want VN", v)
+ }
+ merchant := parseTLV(t, got, "38")
+ if v := parseTLV(t, merchant, "00"); v != "A000000727" {
+ t.Fatalf("napas GUID = %q, want A000000727", v)
+ }
+ if v := parseTLV(t, merchant, "01"); v != "970422" {
+ t.Fatalf("bin = %q, want 970422", v)
+ }
+ if v := parseTLV(t, merchant, "02"); v != "0123456789" {
+ t.Fatalf("account = %q, want 0123456789", v)
+ }
+ if v := parseTLV(t, parseTLV(t, got, "62"), "08"); v != "sub2_20260814aB3kX9mQ" {
+ t.Fatalf("content = %q", v)
+ }
+
+ // CRC tag must cover payload + "6304" and match the trailing 4 hex chars.
+ idx := strings.LastIndex(got, "6304")
+ if idx < 0 {
+ t.Fatal("missing CRC tag")
+ }
+ if crc := crc16CCITTFalse(got[:idx+4]); fmt.Sprintf("%04X", crc) != got[idx+4:] {
+ t.Fatalf("CRC = %s, want %04X", got[idx+4:], crc)
+ }
+}
From c59680daaedc12e39c1f2fa316d76afeb91e5bce Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Fri, 14 Aug 2026 23:00:38 +0700
Subject: [PATCH 05/25] feat(payment): add SePay provider config, VietQR
CreatePayment, refund stub
---
backend/internal/payment/provider/sepay.go | 121 ++++++++++++++++++
.../internal/payment/provider/sepay_test.go | 115 +++++++++++++++++
2 files changed, 236 insertions(+)
create mode 100644 backend/internal/payment/provider/sepay.go
create mode 100644 backend/internal/payment/provider/sepay_test.go
diff --git a/backend/internal/payment/provider/sepay.go b/backend/internal/payment/provider/sepay.go
new file mode 100644
index 00000000000..e43307d732e
--- /dev/null
+++ b/backend/internal/payment/provider/sepay.go
@@ -0,0 +1,121 @@
+// Package provider contains concrete payment provider implementations.
+package provider
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+)
+
+// SePay constants.
+const (
+ defaultSepayAPIBase = "https://userapi.sepay.vn"
+ sepayHTTPTimeout = 10 * time.Second
+ maxSepayResponseSize = 1 << 20 // 1MB
+ maxSepayErrorSummary = 512
+ sepayWebhookMaxSkewSecs = 300
+)
+
+// SePay implements payment.Provider for the SePay bank-transfer gateway.
+// Payments are VietQR transfers; creation is offline (local EMV payload),
+// confirmation arrives via webhook, and the SePay API v2 is used only to
+// query transaction status.
+type SePay struct {
+ instanceID string
+ config map[string]string
+ httpClient *http.Client
+}
+
+// NewSePay creates a SePay provider.
+// config keys: apiToken, apiBase, bankAccountNumber, bankBin, accountName,
+// webhookSecret (recommended), webhookApiKey (fallback auth).
+func NewSePay(instanceID string, config map[string]string) (*SePay, error) {
+ for _, k := range []string{"apiToken", "bankAccountNumber", "bankBin"} {
+ if strings.TrimSpace(config[k]) == "" {
+ return nil, fmt.Errorf("sepay config missing required key: %s", k)
+ }
+ }
+ if strings.TrimSpace(config["webhookSecret"]) == "" && strings.TrimSpace(config["webhookApiKey"]) == "" {
+ return nil, fmt.Errorf("sepay config requires webhookSecret (recommended) or webhookApiKey")
+ }
+ cfg := make(map[string]string, len(config))
+ for k, v := range config {
+ cfg[k] = v
+ }
+ if strings.TrimSpace(cfg["apiBase"]) == "" {
+ cfg["apiBase"] = defaultSepayAPIBase
+ }
+ cfg["apiBase"] = strings.TrimRight(strings.TrimSpace(cfg["apiBase"]), "/")
+ return &SePay{
+ instanceID: instanceID,
+ config: cfg,
+ httpClient: &http.Client{Timeout: sepayHTTPTimeout},
+ }, nil
+}
+
+func (s *SePay) Name() string { return "SePay" }
+func (s *SePay) ProviderKey() string { return payment.TypeSePay }
+func (s *SePay) SupportedTypes() []payment.PaymentType {
+ return []payment.PaymentType{payment.TypeSePay}
+}
+
+func (s *SePay) MerchantIdentityMetadata() map[string]string {
+ if s == nil {
+ return nil
+ }
+ return map[string]string{"bankAccountNumber": strings.TrimSpace(s.config["bankAccountNumber"])}
+}
+
+// sepayNormalizeCode canonicalizes a transfer code for matching: uppercase,
+// keep only letters and digits (drops the sub2_ underscore, tolerates bank
+// content mutations such as accents or extra separators).
+func sepayNormalizeCode(code string) string {
+ var b strings.Builder
+ for _, r := range strings.ToUpper(strings.TrimSpace(code)) {
+ if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
+ b.WriteRune(r)
+ }
+ }
+ return b.String()
+}
+
+// sepayCodeMatchesOrder reports whether a webhook/query code refers to the
+// given out_trade_no, tolerating bank-side uppercasing and prefix omission.
+func sepayCodeMatchesOrder(code, outTradeNo string) bool {
+ c := sepayNormalizeCode(code)
+ if c == "" {
+ return false
+ }
+ full := sepayNormalizeCode(outTradeNo)
+ if c == full {
+ return true
+ }
+ return strings.HasPrefix(full, "SUB2") && c == strings.TrimPrefix(full, "SUB2")
+}
+
+// CreatePayment builds the VietQR payload offline. No upstream call: the
+// transfer only exists once the customer pays, confirmed via webhook.
+func (s *SePay) CreatePayment(_ context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
+ amountVND, err := strconv.ParseInt(strings.TrimSpace(req.Amount), 10, 64)
+ if err != nil || amountVND <= 0 {
+ return nil, fmt.Errorf("sepay amount must be a positive integer VND value, got %q", req.Amount)
+ }
+ payload := buildVietQRPayload(
+ strings.TrimSpace(s.config["bankBin"]),
+ strings.TrimSpace(s.config["bankAccountNumber"]),
+ amountVND,
+ req.OrderID,
+ )
+ return &payment.CreatePaymentResponse{QRCode: payload, Currency: payment.CurrencyVND}, nil
+}
+
+// Refund is not supported: SePay has no refund API — refunds must be issued
+// manually via bank transfer and the order adjusted in the admin panel.
+func (s *SePay) Refund(_ context.Context, _ payment.RefundRequest) (*payment.RefundResponse, error) {
+ return nil, fmt.Errorf("sepay refund is not supported: issue refunds manually via bank transfer")
+}
diff --git a/backend/internal/payment/provider/sepay_test.go b/backend/internal/payment/provider/sepay_test.go
new file mode 100644
index 00000000000..9451b56ef47
--- /dev/null
+++ b/backend/internal/payment/provider/sepay_test.go
@@ -0,0 +1,115 @@
+package provider
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+)
+
+func sepayTestConfig() map[string]string {
+ return map[string]string{
+ "apiToken": "tok_64_chars_00000000000000000000000000000000000000000000000000000000",
+ "bankAccountNumber": "0123456789",
+ "bankBin": "970422",
+ "webhookSecret": "secret",
+ }
+}
+
+func TestNewSePayConfigValidation(t *testing.T) {
+ cases := []struct {
+ name string
+ mutate func(map[string]string)
+ wantErr string
+ }{
+ {"missing apiToken", func(c map[string]string) { delete(c, "apiToken") }, "apiToken"},
+ {"missing bankAccountNumber", func(c map[string]string) { delete(c, "bankAccountNumber") }, "bankAccountNumber"},
+ {"missing bankBin", func(c map[string]string) { delete(c, "bankBin") }, "bankBin"},
+ {"no webhook auth", func(c map[string]string) { delete(c, "webhookSecret") }, "webhook"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ cfg := sepayTestConfig()
+ tc.mutate(cfg)
+ _, err := NewSePay("1", cfg)
+ if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
+ t.Fatalf("err = %v, want containing %q", err, tc.wantErr)
+ }
+ })
+ }
+}
+
+func TestNewSePayApiKeyOnlyConfigIsValid(t *testing.T) {
+ cfg := sepayTestConfig()
+ delete(cfg, "webhookSecret")
+ cfg["webhookApiKey"] = "key"
+ if _, err := NewSePay("1", cfg); err != nil {
+ t.Fatalf("apikey-only config should be valid: %v", err)
+ }
+}
+
+func TestSePayCreatePayment(t *testing.T) {
+ p, err := NewSePay("1", sepayTestConfig())
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := p.CreatePayment(context.Background(), payment.CreatePaymentRequest{
+ OrderID: "sub2_20260814aB3kX9mQ",
+ Amount: "50000",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.Currency != "VND" {
+ t.Fatalf("currency = %q, want VND", resp.Currency)
+ }
+ if resp.QRCode == "" || !strings.Contains(resp.QRCode, "6304") {
+ t.Fatalf("QRCode = %q, want EMV payload", resp.QRCode)
+ }
+ // QR amount tag must carry the integer VND amount.
+ if !strings.Contains(resp.QRCode, tlv("54", "50000")) {
+ t.Fatalf("QR payload missing amount TLV: %s", resp.QRCode)
+ }
+ if !strings.Contains(resp.QRCode, "sub2_20260814aB3kX9mQ") {
+ t.Fatalf("QR payload missing transfer content: %s", resp.QRCode)
+ }
+}
+
+func TestSePayCreatePaymentRejectsNonIntegerAmount(t *testing.T) {
+ p, _ := NewSePay("1", sepayTestConfig())
+ if _, err := p.CreatePayment(context.Background(), payment.CreatePaymentRequest{OrderID: "x", Amount: "50.5"}); err == nil {
+ t.Fatal("expected error for fractional VND amount")
+ }
+ if _, err := p.CreatePayment(context.Background(), payment.CreatePaymentRequest{OrderID: "x", Amount: "0"}); err == nil {
+ t.Fatal("expected error for zero amount")
+ }
+}
+
+func TestSePayRefundUnsupported(t *testing.T) {
+ p, _ := NewSePay("1", sepayTestConfig())
+ _, err := p.Refund(context.Background(), payment.RefundRequest{})
+ if err == nil || !strings.Contains(err.Error(), "not supported") {
+ t.Fatalf("err = %v, want not supported", err)
+ }
+}
+
+func TestSepayCodeMatchesOrder(t *testing.T) {
+ const out = "sub2_20260814aB3kX9mQ"
+ cases := []struct {
+ code string
+ want bool
+ }{
+ {"sub2_20260814aB3kX9mQ", true},
+ {"SUB2_20260814AB3KX9MQ", true}, // bank uppercased content
+ {"20260814aB3kX9mQ", true}, // SePay stripped the prefix
+ {"20260814AB3KX9MQ", true}, // stripped + uppercased
+ {"sub2_19990101zzzzzzzz", false},
+ {"", false},
+ }
+ for _, tc := range cases {
+ if got := sepayCodeMatchesOrder(tc.code, out); got != tc.want {
+ t.Errorf("sepayCodeMatchesOrder(%q) = %v, want %v", tc.code, got, tc.want)
+ }
+ }
+}
From 0146352c3f5ca909beda9d8479075f53252d671e Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Fri, 14 Aug 2026 23:06:43 +0700
Subject: [PATCH 06/25] feat(payment): SePay webhook verification (HMAC-SHA256
/ API key)
---
backend/internal/payment/provider/sepay.go | 100 ++++++++++++++++++
.../internal/payment/provider/sepay_test.go | 95 +++++++++++++++++
2 files changed, 195 insertions(+)
diff --git a/backend/internal/payment/provider/sepay.go b/backend/internal/payment/provider/sepay.go
index e43307d732e..827ebf74a3f 100644
--- a/backend/internal/payment/provider/sepay.go
+++ b/backend/internal/payment/provider/sepay.go
@@ -3,6 +3,10 @@ package provider
import (
"context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
"fmt"
"net/http"
"strconv"
@@ -119,3 +123,99 @@ func (s *SePay) CreatePayment(_ context.Context, req payment.CreatePaymentReques
func (s *SePay) Refund(_ context.Context, _ payment.RefundRequest) (*payment.RefundResponse, error) {
return nil, fmt.Errorf("sepay refund is not supported: issue refunds manually via bank transfer")
}
+
+// sepayWebhookPayload mirrors the SePay transaction webhook JSON body.
+type sepayWebhookPayload struct {
+ ID int64 `json:"id"`
+ Gateway string `json:"gateway"`
+ TransactionDate string `json:"transactionDate"`
+ AccountNumber string `json:"accountNumber"`
+ SubAccount string `json:"subAccount"`
+ Code *string `json:"code"`
+ Content string `json:"content"`
+ TransferType string `json:"transferType"`
+ Description string `json:"description"`
+ TransferAmount int64 `json:"transferAmount"`
+ Accumulated int64 `json:"accumulated"`
+ ReferenceCode string `json:"referenceCode"`
+}
+
+// VerifyNotification authenticates and parses a SePay webhook. Outgoing
+// transactions return (nil, nil) so the caller acks with 200. OrderID carries
+// the raw extracted code; the service layer resolves it to the canonical
+// out_trade_no (banks may uppercase content, SePay may drop the prefix).
+func (s *SePay) VerifyNotification(_ context.Context, rawBody string, headers map[string]string) (*payment.PaymentNotification, error) {
+ if err := s.verifyWebhookAuth(rawBody, headers); err != nil {
+ return nil, err
+ }
+ var payload sepayWebhookPayload
+ if err := json.Unmarshal([]byte(rawBody), &payload); err != nil {
+ return nil, fmt.Errorf("sepay parse notify: %w", err)
+ }
+ if strings.TrimSpace(payload.TransferType) != "in" {
+ return nil, nil
+ }
+ code := ""
+ if payload.Code != nil {
+ code = strings.TrimSpace(*payload.Code)
+ }
+ if code == "" {
+ return nil, fmt.Errorf("sepay notify missing payment code")
+ }
+ tradeNo := strings.TrimSpace(payload.ReferenceCode)
+ if tradeNo == "" {
+ tradeNo = strconv.FormatInt(payload.ID, 10)
+ }
+ metadata := map[string]string{"accountNumber": payload.AccountNumber}
+ if payload.Gateway != "" {
+ metadata["gateway"] = payload.Gateway
+ }
+ return &payment.PaymentNotification{
+ TradeNo: tradeNo,
+ OrderID: code,
+ Amount: float64(payload.TransferAmount),
+ Status: payment.NotificationStatusSuccess,
+ RawData: rawBody,
+ Metadata: metadata,
+ }, nil
+}
+
+// verifyWebhookAuth checks HMAC-SHA256 (preferred) or the Apikey header.
+// Signature: sha256={hex(hmac_sha256(timestamp + "." + rawBody, secret))}.
+func (s *SePay) verifyWebhookAuth(rawBody string, headers map[string]string) error {
+ if secret := strings.TrimSpace(s.config["webhookSecret"]); secret != "" {
+ signature := strings.TrimSpace(headers["x-sepay-signature"])
+ if !strings.HasPrefix(signature, "sha256=") {
+ return fmt.Errorf("missing X-SePay-Signature")
+ }
+ timestamp := strings.TrimSpace(headers["x-sepay-timestamp"])
+ ts, err := strconv.ParseInt(timestamp, 10, 64)
+ if err != nil {
+ return fmt.Errorf("invalid X-SePay-Timestamp")
+ }
+ skew := time.Now().Unix() - ts
+ if skew < 0 {
+ skew = -skew
+ }
+ if skew > sepayWebhookMaxSkewSecs {
+ return fmt.Errorf("sepay webhook timestamp outside ±%d second window", sepayWebhookMaxSkewSecs)
+ }
+ mac := hmac.New(sha256.New, []byte(secret))
+ _, _ = mac.Write([]byte(timestamp + "." + rawBody))
+ expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
+ if !hmac.Equal([]byte(expected), []byte(signature)) {
+ return fmt.Errorf("sepay webhook signature mismatch")
+ }
+ return nil
+ }
+ apiKey := strings.TrimSpace(s.config["webhookApiKey"])
+ auth := strings.TrimSpace(headers["authorization"])
+ const apikeyPrefix = "Apikey "
+ if !strings.HasPrefix(auth, apikeyPrefix) {
+ return fmt.Errorf("missing Authorization Apikey header")
+ }
+ if !hmac.Equal([]byte(strings.TrimSpace(strings.TrimPrefix(auth, apikeyPrefix))), []byte(apiKey)) {
+ return fmt.Errorf("sepay webhook api key mismatch")
+ }
+ return nil
+}
diff --git a/backend/internal/payment/provider/sepay_test.go b/backend/internal/payment/provider/sepay_test.go
index 9451b56ef47..476ff04d102 100644
--- a/backend/internal/payment/provider/sepay_test.go
+++ b/backend/internal/payment/provider/sepay_test.go
@@ -2,8 +2,13 @@ package provider
import (
"context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "strconv"
"strings"
"testing"
+ "time"
"github.com/Wei-Shaw/sub2api/internal/payment"
)
@@ -113,3 +118,93 @@ func TestSepayCodeMatchesOrder(t *testing.T) {
}
}
}
+
+func sepayNotifyBody(code string, amount int64) string {
+ if code == "" {
+ return `{"id":92704,"gateway":"Vietcombank","transactionDate":"2024-07-02 11:08:33","accountNumber":"1017588888","subAccount":"","code":null,"content":"chuyen tien","transferType":"in","transferAmount":` + strconv.FormatInt(amount, 10) + `,"accumulated":0,"referenceCode":"FT24012345678"}`
+ }
+ return `{"id":92704,"gateway":"Vietcombank","transactionDate":"2024-07-02 11:08:33","accountNumber":"1017588888","subAccount":"","code":"` + code + `","content":"` + code + ` chuyen tien","transferType":"in","transferAmount":` + strconv.FormatInt(amount, 10) + `,"accumulated":0,"referenceCode":"FT24012345678"}`
+}
+
+func sepaySignedHeaders(body string, secret string, ts int64) map[string]string {
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write([]byte(strconv.FormatInt(ts, 10) + "." + body))
+ return map[string]string{
+ "x-sepay-signature": "sha256=" + hex.EncodeToString(mac.Sum(nil)),
+ "x-sepay-timestamp": strconv.FormatInt(ts, 10),
+ }
+}
+
+func TestSePayVerifyNotificationHMAC(t *testing.T) {
+ p, _ := NewSePay("1", sepayTestConfig())
+ body := sepayNotifyBody("sub2_20260814aB3kX9mQ", 50000)
+ now := time.Now().Unix()
+
+ n, err := p.VerifyNotification(context.Background(), body, sepaySignedHeaders(body, "secret", now))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n.OrderID != "sub2_20260814aB3kX9mQ" || n.Amount != 50000 || n.TradeNo != "FT24012345678" {
+ t.Fatalf("notification = %+v", n)
+ }
+ if n.Status != payment.NotificationStatusSuccess {
+ t.Fatalf("status = %q", n.Status)
+ }
+ if n.Metadata["accountNumber"] != "1017588888" || n.Metadata["gateway"] != "Vietcombank" {
+ t.Fatalf("metadata = %v", n.Metadata)
+ }
+}
+
+func TestSePayVerifyNotificationHMACFailures(t *testing.T) {
+ p, _ := NewSePay("1", sepayTestConfig())
+ body := sepayNotifyBody("sub2_20260814aB3kX9mQ", 50000)
+ now := time.Now().Unix()
+
+ if _, err := p.VerifyNotification(context.Background(), body, sepaySignedHeaders(body, "wrong", now)); err == nil {
+ t.Fatal("expected signature mismatch error")
+ }
+ if _, err := p.VerifyNotification(context.Background(), body, map[string]string{"x-sepay-timestamp": strconv.FormatInt(now, 10)}); err == nil {
+ t.Fatal("expected missing signature error")
+ }
+ if _, err := p.VerifyNotification(context.Background(), body, sepaySignedHeaders(body, "secret", now-3600)); err == nil {
+ t.Fatal("expected timestamp skew error")
+ }
+ // Signed over different body.
+ if _, err := p.VerifyNotification(context.Background(), sepayNotifyBody("other", 1), sepaySignedHeaders(body, "secret", now)); err == nil {
+ t.Fatal("expected signature mismatch for altered body")
+ }
+}
+
+func TestSePayVerifyNotificationApiKey(t *testing.T) {
+ cfg := sepayTestConfig()
+ delete(cfg, "webhookSecret")
+ cfg["webhookApiKey"] = "key123"
+ p, _ := NewSePay("1", cfg)
+ body := sepayNotifyBody("sub2_20260814aB3kX9mQ", 50000)
+
+ if _, err := p.VerifyNotification(context.Background(), body, map[string]string{"authorization": "Apikey key123"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := p.VerifyNotification(context.Background(), body, map[string]string{"authorization": "Apikey nope"}); err == nil {
+ t.Fatal("expected api key mismatch")
+ }
+ if _, err := p.VerifyNotification(context.Background(), body, nil); err == nil {
+ t.Fatal("expected missing header error")
+ }
+}
+
+func TestSePayVerifyNotificationOutAndNullCode(t *testing.T) {
+ p, _ := NewSePay("1", sepayTestConfig())
+ now := time.Now().Unix()
+
+ outBody := `{"id":1,"gateway":"VCB","transactionDate":"2024-07-02 11:08:33","accountNumber":"1","subAccount":"","code":"sub2_20260814aB3kX9mQ","content":"x","transferType":"out","transferAmount":100,"referenceCode":"FT1"}`
+ n, err := p.VerifyNotification(context.Background(), outBody, sepaySignedHeaders(outBody, "secret", now))
+ if err != nil || n != nil {
+ t.Fatalf("out transaction: n=%v err=%v, want nil/nil", n, err)
+ }
+
+ nullCode := sepayNotifyBody("", 50000)
+ if _, err := p.VerifyNotification(context.Background(), nullCode, sepaySignedHeaders(nullCode, "secret", now)); err == nil {
+ t.Fatal("expected missing payment code error")
+ }
+}
From ce53f9e3981f59f5bca355323dcf2422cbd1be01 Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Fri, 14 Aug 2026 23:13:58 +0700
Subject: [PATCH 07/25] feat(payment): SePay QueryOrder via API v2 transactions
---
backend/internal/payment/provider/sepay.go | 80 ++++++++++++++++++
.../internal/payment/provider/sepay_test.go | 83 +++++++++++++++++++
2 files changed, 163 insertions(+)
diff --git a/backend/internal/payment/provider/sepay.go b/backend/internal/payment/provider/sepay.go
index 827ebf74a3f..5ee608f4c6a 100644
--- a/backend/internal/payment/provider/sepay.go
+++ b/backend/internal/payment/provider/sepay.go
@@ -8,7 +8,9 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
+ "io"
"net/http"
+ "net/url"
"strconv"
"strings"
"time"
@@ -219,3 +221,81 @@ func (s *SePay) verifyWebhookAuth(rawBody string, headers map[string]string) err
}
return nil
}
+
+// sepayTransaction mirrors one element of GET /v2/transactions data.
+type sepayTransaction struct {
+ ID string `json:"id"`
+ TransactionDate string `json:"transaction_date"`
+ TransferType string `json:"transfer_type"`
+ AmountIn int64 `json:"amount_in"`
+ TransactionContent string `json:"transaction_content"`
+ ReferenceNumber string `json:"reference_number"`
+ Code string `json:"code"`
+}
+
+// QueryOrder looks up the order's transfer in SePay API v2. tradeNo carries
+// the order's out_trade_no; the q= search covers the extracted payment code.
+func (s *SePay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
+ outTradeNo := strings.TrimSpace(tradeNo)
+ if outTradeNo == "" {
+ return nil, fmt.Errorf("sepay query: empty order reference")
+ }
+ endpoint := s.config["apiBase"] + "/v2/transactions?q=" + url.QueryEscape(outTradeNo) + "&transfer_type=in"
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+s.config["apiToken"])
+ resp, err := s.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("sepay query: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxSepayResponseSize))
+ if err != nil {
+ return nil, fmt.Errorf("sepay query read: %w", err)
+ }
+ switch {
+ case resp.StatusCode == http.StatusUnauthorized:
+ return nil, fmt.Errorf("sepay query unauthorized: check apiToken")
+ case resp.StatusCode == http.StatusTooManyRequests:
+ return nil, fmt.Errorf("sepay query rate limited (retry after %ss)", resp.Header.Get("Retry-After"))
+ case resp.StatusCode < 200 || resp.StatusCode >= 300:
+ return nil, fmt.Errorf("sepay query HTTP %d: %s", resp.StatusCode, summarizeSepayBody(body))
+ }
+ var parsed struct {
+ Status string `json:"status"`
+ Data []sepayTransaction `json:"data"`
+ }
+ if err := json.Unmarshal(body, &parsed); err != nil {
+ return nil, fmt.Errorf("sepay query parse: %w", err)
+ }
+ for _, tx := range parsed.Data {
+ if !sepayCodeMatchesOrder(tx.Code, outTradeNo) {
+ continue
+ }
+ return &payment.QueryOrderResponse{
+ TradeNo: strings.TrimSpace(tx.ReferenceNumber),
+ Status: payment.ProviderStatusPaid,
+ Amount: float64(tx.AmountIn),
+ PaidAt: strings.TrimSpace(tx.TransactionDate),
+ Metadata: s.MerchantIdentityMetadata(),
+ }, nil
+ }
+ return &payment.QueryOrderResponse{
+ TradeNo: outTradeNo,
+ Status: payment.ProviderStatusPending,
+ Metadata: s.MerchantIdentityMetadata(),
+ }, nil
+}
+
+func summarizeSepayBody(body []byte) string {
+ summary := strings.Join(strings.Fields(string(body)), " ")
+ if summary == "" {
+ return ""
+ }
+ if len(summary) > maxSepayErrorSummary {
+ return summary[:maxSepayErrorSummary] + "..."
+ }
+ return summary
+}
diff --git a/backend/internal/payment/provider/sepay_test.go b/backend/internal/payment/provider/sepay_test.go
index 476ff04d102..a4ee5863532 100644
--- a/backend/internal/payment/provider/sepay_test.go
+++ b/backend/internal/payment/provider/sepay_test.go
@@ -5,6 +5,9 @@ import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
"strconv"
"strings"
"testing"
@@ -208,3 +211,83 @@ func TestSePayVerifyNotificationOutAndNullCode(t *testing.T) {
t.Fatal("expected missing payment code error")
}
}
+
+func sepayQueryServer(t *testing.T, queries *[]url.Values, respond func(w http.ResponseWriter, r *http.Request)) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ *queries = append(*queries, r.URL.Query())
+ respond(w, r)
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func TestSePayQueryOrderPaid(t *testing.T) {
+ var queries []url.Values
+ srv := sepayQueryServer(t, &queries, func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("Authorization"); got != "Bearer "+sepayTestConfig()["apiToken"] {
+ t.Errorf("auth header = %q", got)
+ }
+ _, _ = w.Write([]byte(`{"status":"success","data":[{"id":"a1b2","transaction_date":"2026-08-14 09:30:00","transfer_type":"in","amount_in":50000,"transaction_content":"sub2_20260814aB3kX9mQ chuyen tien","reference_number":"FT26069ABC","code":"SUB2_20260814AB3KX9MQ"}]}`))
+ })
+ cfg := sepayTestConfig()
+ cfg["apiBase"] = srv.URL
+ p, _ := NewSePay("1", cfg)
+
+ resp, err := p.QueryOrder(context.Background(), "sub2_20260814aB3kX9mQ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.Status != payment.ProviderStatusPaid || resp.Amount != 50000 || resp.TradeNo != "FT26069ABC" {
+ t.Fatalf("resp = %+v", resp)
+ }
+ if resp.PaidAt != "2026-08-14 09:30:00" {
+ t.Fatalf("paidAt = %q", resp.PaidAt)
+ }
+ if len(queries) != 1 || queries[0].Get("q") != "sub2_20260814aB3kX9mQ" || queries[0].Get("transfer_type") != "in" {
+ t.Fatalf("queries = %v", queries)
+ }
+}
+
+func TestSePayQueryOrderPending(t *testing.T) {
+ var queries []url.Values
+ srv := sepayQueryServer(t, &queries, func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"status":"success","data":[{"id":"c3","transaction_date":"2026-08-14 09:30:00","transfer_type":"in","amount_in":1,"code":"SUB2_19990101ZZZZZZZZ"}]}`))
+ })
+ cfg := sepayTestConfig()
+ cfg["apiBase"] = srv.URL
+ p, _ := NewSePay("1", cfg)
+
+ resp, err := p.QueryOrder(context.Background(), "sub2_20260814aB3kX9mQ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.Status != payment.ProviderStatusPending {
+ t.Fatalf("status = %q, want pending (code does not match order)", resp.Status)
+ }
+}
+
+func TestSePayQueryOrderHTTPErrors(t *testing.T) {
+ for _, tc := range []struct {
+ status int
+ body string
+ wantErr string
+ }{
+ {http.StatusUnauthorized, `{"error":{"code":"unauthorized"}}`, "unauthorized"},
+ {http.StatusTooManyRequests, `{"error":{"code":"rate_limited"}}`, "rate"},
+ {http.StatusInternalServerError, `boom`, "HTTP 500"},
+ } {
+ var queries []url.Values
+ srv := sepayQueryServer(t, &queries, func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(tc.status)
+ _, _ = w.Write([]byte(tc.body))
+ })
+ cfg := sepayTestConfig()
+ cfg["apiBase"] = srv.URL
+ p, _ := NewSePay("1", cfg)
+ _, err := p.QueryOrder(context.Background(), "sub2_20260814aB3kX9mQ")
+ if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
+ t.Errorf("status %d: err = %v, want containing %q", tc.status, err, tc.wantErr)
+ }
+ }
+}
From 578429453ef661dff846827725b2da83448ad17f Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Fri, 14 Aug 2026 23:18:22 +0700
Subject: [PATCH 08/25] feat(payment): register SePay in provider factory
---
backend/internal/payment/provider/factory.go | 2 ++
.../internal/payment/provider/factory_test.go | 20 +++++++++++++++++++
2 files changed, 22 insertions(+)
create mode 100644 backend/internal/payment/provider/factory_test.go
diff --git a/backend/internal/payment/provider/factory.go b/backend/internal/payment/provider/factory.go
index cc34d535deb..bea3fce3c17 100644
--- a/backend/internal/payment/provider/factory.go
+++ b/backend/internal/payment/provider/factory.go
@@ -19,6 +19,8 @@ func CreateProvider(providerKey string, instanceID string, config map[string]str
return NewStripe(instanceID, config)
case payment.TypeAirwallex:
return NewAirwallex(instanceID, config)
+ case payment.TypeSePay:
+ return NewSePay(instanceID, config)
default:
return nil, fmt.Errorf("unknown provider key: %s", providerKey)
}
diff --git a/backend/internal/payment/provider/factory_test.go b/backend/internal/payment/provider/factory_test.go
new file mode 100644
index 00000000000..4279b1f211d
--- /dev/null
+++ b/backend/internal/payment/provider/factory_test.go
@@ -0,0 +1,20 @@
+package provider
+
+import (
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+)
+
+func TestCreateProviderSePay(t *testing.T) {
+ p, err := CreateProvider(payment.TypeSePay, "7", sepayTestConfig())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p.ProviderKey() != payment.TypeSePay {
+ t.Fatalf("provider key = %q", p.ProviderKey())
+ }
+ if _, err := CreateProvider(payment.TypeSePay, "7", map[string]string{}); err == nil {
+ t.Fatal("expected config validation error from factory")
+ }
+}
From 96fb2d92403b2edf9b842588a5be340fc595ff23 Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Fri, 14 Aug 2026 23:24:35 +0700
Subject: [PATCH 09/25] feat(payment): SePay webhook route with required JSON
success body
---
.../handler/payment_webhook_handler.go | 16 ++++++++++++++++
.../handler/payment_webhook_handler_test.go | 19 +++++++++++++++++++
backend/internal/server/routes/payment.go | 1 +
3 files changed, 36 insertions(+)
diff --git a/backend/internal/handler/payment_webhook_handler.go b/backend/internal/handler/payment_webhook_handler.go
index dc70f120e76..c2eff4425ab 100644
--- a/backend/internal/handler/payment_webhook_handler.go
+++ b/backend/internal/handler/payment_webhook_handler.go
@@ -67,6 +67,12 @@ func (h *PaymentWebhookHandler) AirwallexWebhook(c *gin.Context) {
h.handleNotify(c, payment.TypeAirwallex)
}
+// SepayNotify handles SePay transaction webhooks.
+// POST /api/v1/payment/webhook/sepay
+func (h *PaymentWebhookHandler) SepayNotify(c *gin.Context) {
+ h.handleNotify(c, payment.TypeSePay)
+}
+
// handleNotify is the shared logic for all provider webhook handlers.
func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) {
var rawBody string
@@ -164,6 +170,13 @@ func extractOutTradeNo(rawBody, providerKey string) string {
if err := json.Unmarshal([]byte(rawBody), &payload); err == nil {
return strings.TrimSpace(payload.Data.Object.MerchantOrderID)
}
+ case payment.TypeSePay:
+ var payload struct {
+ Code *string `json:"code"`
+ }
+ if err := json.Unmarshal([]byte(rawBody), &payload); err == nil && payload.Code != nil {
+ return strings.TrimSpace(*payload.Code)
+ }
}
// For other providers (Stripe, Alipay direct, WxPay direct), the registry
// typically has only one instance, so no instance lookup is needed.
@@ -210,6 +223,9 @@ func writeSuccessResponse(c *gin.Context, providerKey string) {
c.JSON(http.StatusOK, wxpaySuccessResponse{Code: wxpaySuccessCode, Message: wxpaySuccessMessage})
case payment.TypeStripe, payment.TypeAirwallex:
c.String(http.StatusOK, "")
+ case payment.TypeSePay:
+ // SePay requires exactly {"success":true} with HTTP 200/201.
+ c.JSON(http.StatusOK, gin.H{"success": true})
default:
c.String(http.StatusOK, "success")
}
diff --git a/backend/internal/handler/payment_webhook_handler_test.go b/backend/internal/handler/payment_webhook_handler_test.go
index 5b613383c99..c0b4f9034fe 100644
--- a/backend/internal/handler/payment_webhook_handler_test.go
+++ b/backend/internal/handler/payment_webhook_handler_test.go
@@ -54,6 +54,13 @@ func TestWriteSuccessResponse(t *testing.T) {
wantContentType: "text/plain",
wantBody: "",
},
+ {
+ name: "sepay returns JSON success body",
+ providerKey: payment.TypeSePay,
+ wantCode: http.StatusOK,
+ wantContentType: "application/json",
+ wantBody: `{"success":true}`,
+ },
{
name: "easypay returns plain text success",
providerKey: "easypay",
@@ -178,6 +185,18 @@ func TestExtractOutTradeNo(t *testing.T) {
rawBody: `{"name":"payment_intent.succeeded","data":{"object":{"merchant_order_id":"sub2_awx_123"}}}`,
want: "sub2_awx_123",
},
+ {
+ name: "sepay json payload with code",
+ providerKey: payment.TypeSePay,
+ rawBody: `{"code":"sub2_20260814aB3kX9mQ","transferType":"in","transferAmount":50000}`,
+ want: "sub2_20260814aB3kX9mQ",
+ },
+ {
+ name: "sepay json payload with null code",
+ providerKey: payment.TypeSePay,
+ rawBody: `{"code":null,"transferType":"in","transferAmount":50000}`,
+ want: "",
+ },
}
for _, tt := range tests {
diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go
index ecda25f538c..a01fe59e12d 100644
--- a/backend/internal/server/routes/payment.go
+++ b/backend/internal/server/routes/payment.go
@@ -66,6 +66,7 @@ func RegisterPaymentRoutes(
webhook.POST("/wxpay", webhookHandler.WxpayNotify)
webhook.POST("/stripe", webhookHandler.StripeWebhook)
webhook.POST("/airwallex", webhookHandler.AirwallexWebhook)
+ webhook.POST("/sepay", webhookHandler.SepayNotify)
}
// --- Admin payment endpoints (admin auth) ---
From 35d4e63210a23d882a16bacb36062122609d4589 Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Fri, 14 Aug 2026 23:38:00 +0700
Subject: [PATCH 10/25] feat(payment): lenient sepay code resolution for
webhooks and queries
---
.../internal/service/payment_fulfillment.go | 23 ++++
.../service/payment_order_lifecycle.go | 2 +-
.../service/payment_sepay_resolution_test.go | 100 ++++++++++++++++++
.../service/payment_webhook_provider.go | 22 ++++
4 files changed, 146 insertions(+), 1 deletion(-)
create mode 100644 backend/internal/service/payment_sepay_resolution_test.go
diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go
index 4d442f3d1e3..ff03873ddee 100644
--- a/backend/internal/service/payment_fulfillment.go
+++ b/backend/internal/service/payment_fulfillment.go
@@ -48,6 +48,9 @@ func (s *PaymentService) HandlePaymentNotification(ctx context.Context, n *payme
if oid, ok := parseLegacyPaymentOrderID(n.OrderID, err); ok {
return s.confirmPayment(ctx, oid, n.TradeNo, n.Amount, pk, n.Metadata)
}
+ if oid, ok := s.resolveSepayNotificationOrderID(ctx, pk, n.OrderID); ok {
+ return s.confirmPayment(ctx, oid, n.TradeNo, n.Amount, pk, n.Metadata)
+ }
if dbent.IsNotFound(err) {
return fmt.Errorf("%w: out_trade_no=%s", ErrOrderNotFound, n.OrderID)
}
@@ -75,6 +78,26 @@ func parseLegacyPaymentOrderID(orderID string, lookupErr error) (int64, bool) {
return oid, true
}
+// resolveSepayNotificationOrderID resolves lenient SePay codes (uppercased or
+// prefix-stripped by banks) to the internal order ID.
+func (s *PaymentService) resolveSepayNotificationOrderID(ctx context.Context, providerKey, code string) (int64, bool) {
+ if strings.TrimSpace(providerKey) != payment.TypeSePay {
+ return 0, false
+ }
+ code = strings.TrimSpace(code)
+ if code == "" {
+ return 0, false
+ }
+ for _, cand := range []string{code, orderIDPrefix + code} {
+ order, err := s.entClient.PaymentOrder.Query().
+ Where(paymentorder.OutTradeNoEqualFold(cand)).Only(ctx)
+ if err == nil && order != nil {
+ return order.ID, true
+ }
+ }
+ return 0, false
+}
+
func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo string, paid float64, pk string, metadata map[string]string) error {
o, err := s.entClient.PaymentOrder.Get(ctx, oid)
if err != nil {
diff --git a/backend/internal/service/payment_order_lifecycle.go b/backend/internal/service/payment_order_lifecycle.go
index 46a2e00605d..624b16c3461 100644
--- a/backend/internal/service/payment_order_lifecycle.go
+++ b/backend/internal/service/payment_order_lifecycle.go
@@ -248,7 +248,7 @@ func paymentOrderQueryReference(order *dbent.PaymentOrder, prov payment.Provider
}
switch payment.GetBasePaymentType(providerKey) {
- case payment.TypeAlipay, payment.TypeEasyPay, payment.TypeWxpay:
+ case payment.TypeAlipay, payment.TypeEasyPay, payment.TypeWxpay, payment.TypeSePay:
return strings.TrimSpace(order.OutTradeNo)
default:
if tradeNo := strings.TrimSpace(order.PaymentTradeNo); tradeNo != "" {
diff --git a/backend/internal/service/payment_sepay_resolution_test.go b/backend/internal/service/payment_sepay_resolution_test.go
new file mode 100644
index 00000000000..7f9600aaf58
--- /dev/null
+++ b/backend/internal/service/payment_sepay_resolution_test.go
@@ -0,0 +1,100 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "database/sql"
+ "strconv"
+ "testing"
+ "time"
+
+ "entgo.io/ent/dialect"
+ entsql "entgo.io/ent/dialect/sql"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/enttest"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+
+ "github.com/stretchr/testify/require"
+)
+
+func newSepayResolutionTestClient(t *testing.T) *dbent.Client {
+ t.Helper()
+ db, err := sql.Open("sqlite", "file:sepay_resolution_"+strconv.FormatInt(time.Now().UnixNano(), 10)+"?mode=memory&_fk=1")
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+ _, err = db.Exec("PRAGMA foreign_keys = ON")
+ require.NoError(t, err)
+ drv := entsql.OpenDB(dialect.SQLite, db)
+ client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
+ t.Cleanup(func() { _ = client.Close() })
+ return client
+}
+
+func createSepayTestOrder(t *testing.T, ctx context.Context, client *dbent.Client, outTradeNo string) *dbent.PaymentOrder {
+ t.Helper()
+ user, err := client.User.Create().
+ SetEmail("sepay-" + strconv.FormatInt(time.Now().UnixNano(), 10) + "@example.com").
+ SetPasswordHash("hash").
+ SetUsername("sepay-user").
+ Save(ctx)
+ require.NoError(t, err)
+ order, err := client.PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(50000).
+ SetPayAmount(50000).
+ SetFeeRate(0).
+ SetRechargeCode("PAY-SEPAY-" + strconv.FormatInt(time.Now().UnixNano(), 10)).
+ SetOutTradeNo(outTradeNo).
+ SetPaymentTradeNo(outTradeNo).
+ SetPaymentType(payment.TypeSePay).
+ SetOrderType(payment.OrderTypeBalance).
+ SetStatus(OrderStatusPending).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ SetSrcURL("/api/v1/payment/orders").
+ Save(ctx)
+ require.NoError(t, err)
+ return order
+}
+
+// TestResolveSepayOutTradeNo verifies bank-side mutations of the transfer code:
+// exact, uppercased, prefix-stripped and prefix-stripped+uppercased variants
+// all resolve to the canonical out_trade_no.
+func TestResolveSepayOutTradeNo(t *testing.T) {
+ ctx := context.Background()
+ client := newSepayResolutionTestClient(t)
+ svc := &PaymentService{entClient: client, providersLoaded: true}
+
+ const canonical = "sub2_20260814aB3kX9mQ"
+ order := createSepayTestOrder(t, ctx, client, canonical)
+
+ for _, code := range []string{
+ "sub2_20260814aB3kX9mQ",
+ "SUB2_20260814AB3KX9MQ",
+ "20260814aB3kX9mQ",
+ "20260814AB3KX9MQ",
+ } {
+ require.Equal(t, canonical, svc.resolveSepayOutTradeNo(ctx, code), "code %q", code)
+ }
+ require.Equal(t, "sub2_19990101zzzzzzzz",
+ svc.resolveSepayOutTradeNo(ctx, "sub2_19990101zzzzzzzz"), "unknown code round-trips unchanged")
+ require.Equal(t, "", svc.resolveSepayOutTradeNo(ctx, " "))
+
+ oid, ok := svc.resolveSepayNotificationOrderID(ctx, payment.TypeSePay, "20260814AB3KX9MQ")
+ require.True(t, ok)
+ require.Equal(t, order.ID, oid)
+
+ _, ok = svc.resolveSepayNotificationOrderID(ctx, payment.TypeAlipay, canonical)
+ require.False(t, ok, "non-sepay provider must not use sepay resolution")
+}
+
+func TestPaymentOrderQueryReferenceSePay(t *testing.T) {
+ order := &dbent.PaymentOrder{OutTradeNo: "sub2_20260814aB3kX9mQ", PaymentType: payment.TypeSePay}
+ require.Equal(t, "sub2_20260814aB3kX9mQ", paymentOrderQueryReference(order, nil),
+ "sepay must query by out_trade_no (no upstream tradeNo exists while pending)")
+}
diff --git a/backend/internal/service/payment_webhook_provider.go b/backend/internal/service/payment_webhook_provider.go
index f2da40d9b4c..c75055920be 100644
--- a/backend/internal/service/payment_webhook_provider.go
+++ b/backend/internal/service/payment_webhook_provider.go
@@ -30,6 +30,9 @@ func (s *PaymentService) GetWebhookProvider(ctx context.Context, providerKey, ou
// Official WeChat Pay may require multiple candidates because the callback body
// cannot be bound to a merchant before decryption.
func (s *PaymentService) GetWebhookProviders(ctx context.Context, providerKey, outTradeNo string) ([]payment.Provider, error) {
+ if strings.TrimSpace(providerKey) == payment.TypeSePay {
+ outTradeNo = s.resolveSepayOutTradeNo(ctx, outTradeNo)
+ }
if outTradeNo != "" {
order, err := s.entClient.PaymentOrder.Query().Where(paymentorder.OutTradeNo(outTradeNo)).Only(ctx)
if err == nil {
@@ -82,6 +85,25 @@ func (s *PaymentService) GetWebhookProviders(ctx context.Context, providerKey, o
return []payment.Provider{prov}, nil
}
+// resolveSepayOutTradeNo maps a SePay webhook code back to the canonical
+// out_trade_no. Banks may uppercase the transfer content and SePay's payment
+// code extraction may drop the configured prefix, so resolution falls back to
+// case-insensitive lookups (raw code, then code with the sub2_ prefix).
+func (s *PaymentService) resolveSepayOutTradeNo(ctx context.Context, code string) string {
+ code = strings.TrimSpace(code)
+ if code == "" {
+ return ""
+ }
+ for _, cand := range []string{code, orderIDPrefix + code} {
+ order, err := s.entClient.PaymentOrder.Query().
+ Where(paymentorder.OutTradeNoEqualFold(cand)).Only(ctx)
+ if err == nil && order != nil {
+ return order.OutTradeNo
+ }
+ }
+ return code
+}
+
func (s *PaymentService) getPinnedOrderProvider(ctx context.Context, o *dbent.PaymentOrder) (payment.Provider, error) {
inst, err := s.getOrderProviderInstance(ctx, o)
if err != nil {
From dbb05d11c0ec146ff109bc9d36e48c297b58169a Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Fri, 14 Aug 2026 23:43:44 +0700
Subject: [PATCH 11/25] test(payment): make sepay query-reference test
discriminating
---
backend/internal/service/payment_sepay_resolution_test.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/internal/service/payment_sepay_resolution_test.go b/backend/internal/service/payment_sepay_resolution_test.go
index 7f9600aaf58..1cbac025b86 100644
--- a/backend/internal/service/payment_sepay_resolution_test.go
+++ b/backend/internal/service/payment_sepay_resolution_test.go
@@ -94,7 +94,7 @@ func TestResolveSepayOutTradeNo(t *testing.T) {
}
func TestPaymentOrderQueryReferenceSePay(t *testing.T) {
- order := &dbent.PaymentOrder{OutTradeNo: "sub2_20260814aB3kX9mQ", PaymentType: payment.TypeSePay}
+ order := &dbent.PaymentOrder{OutTradeNo: "sub2_20260814aB3kX9mQ", PaymentType: payment.TypeSePay, PaymentTradeNo: "sepay-upstream-trade-no"}
require.Equal(t, "sub2_20260814aB3kX9mQ", paymentOrderQueryReference(order, nil),
"sepay must query by out_trade_no (no upstream tradeNo exists while pending)")
}
From 4bd870b850012bd49637589bc35680a1c2e91466 Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Fri, 14 Aug 2026 23:55:18 +0700
Subject: [PATCH 12/25] feat(payment): sepay provider registration, refund
block, config field protection
---
.../service/payment_config_providers.go | 28 +++++++++++
.../service/payment_config_providers_test.go | 47 +++++++++++++++++++
2 files changed, 75 insertions(+)
diff --git a/backend/internal/service/payment_config_providers.go b/backend/internal/service/payment_config_providers.go
index d1bf2de7aa4..1f7677b4f39 100644
--- a/backend/internal/service/payment_config_providers.go
+++ b/backend/internal/service/payment_config_providers.go
@@ -116,6 +116,7 @@ var providerSensitiveConfigFields = map[string]map[string]struct{}{
payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}},
payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}},
payment.TypeAirwallex: {"apikey": {}, "webhooksecret": {}},
+ payment.TypeSePay: {"apitoken": {}, "webhooksecret": {}, "webhookapikey": {}},
}
// providerPendingOrderProtectedConfigFields lists config keys that cannot be
@@ -128,6 +129,7 @@ var providerPendingOrderProtectedConfigFields = map[string]map[string]struct{}{
payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}, "appid": {}, "mpappid": {}, "mchid": {}, "publickeyid": {}, "certserial": {}},
payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}, "currency": {}},
payment.TypeAirwallex: {"clientid": {}, "apikey": {}, "webhooksecret": {}, "apibase": {}, "accountid": {}, "currency": {}},
+ payment.TypeSePay: {"apitoken": {}, "webhooksecret": {}, "webhookapikey": {}, "bankaccountnumber": {}, "bankbin": {}},
}
func isSensitiveProviderConfigField(providerKey, fieldName string) bool {
@@ -178,9 +180,29 @@ func (s *PaymentConfigService) countPendingOrdersByPlan(ctx context.Context, pla
}
var validProviderKeys = map[string]bool{
+ payment.TypeEasyPay: true, payment.TypeAlipay: true, payment.TypeWxpay: true, payment.TypeStripe: true, payment.TypeAirwallex: true, payment.TypeSePay: true,
+}
+
+// refundCapableProviders lists provider keys whose upstream API supports
+// refunds. SePay monitors bank transfers and has no refund API.
+var refundCapableProviders = map[string]bool{
payment.TypeEasyPay: true, payment.TypeAlipay: true, payment.TypeWxpay: true, payment.TypeStripe: true, payment.TypeAirwallex: true,
}
+func providerSupportsRefund(providerKey string) bool {
+ return refundCapableProviders[providerKey]
+}
+
+// validateProviderRefundSupport rejects enabling refunds on providers whose
+// upstream has no refund API (currently sepay only).
+func validateProviderRefundSupport(providerKey string, refundEnabled bool) error {
+ if refundEnabled && !providerSupportsRefund(providerKey) {
+ return infraerrors.BadRequest("VALIDATION_ERROR",
+ fmt.Sprintf("provider %s does not support refunds", providerKey))
+ }
+ return nil
+}
+
func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
typesStr := joinTypes(req.SupportedTypes)
if err := validateProviderRequest(req.ProviderKey, req.Name, typesStr); err != nil {
@@ -203,6 +225,9 @@ func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req C
if err != nil {
return nil, err
}
+ if err := validateProviderRefundSupport(req.ProviderKey, req.RefundEnabled); err != nil {
+ return nil, err
+ }
allowUserRefund := req.AllowUserRefund && req.RefundEnabled
return s.entClient.PaymentProviderInstance.Create().
SetProviderKey(req.ProviderKey).SetName(req.Name).SetConfig(enc).
@@ -421,6 +446,9 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
u.SetLimits(*req.Limits)
}
if req.RefundEnabled != nil {
+ if err := validateProviderRefundSupport(current.ProviderKey, *req.RefundEnabled); err != nil {
+ return nil, err
+ }
u.SetRefundEnabled(*req.RefundEnabled)
// Cascade: turning off refund_enabled also disables allow_user_refund
if !*req.RefundEnabled {
diff --git a/backend/internal/service/payment_config_providers_test.go b/backend/internal/service/payment_config_providers_test.go
index 74fd2a34679..43d1bb1aab7 100644
--- a/backend/internal/service/payment_config_providers_test.go
+++ b/backend/internal/service/payment_config_providers_test.go
@@ -805,3 +805,50 @@ func validWxpayProviderConfigWithJSAPIAppID(t *testing.T) map[string]string {
cfg["mpAppId"] = "wx-mp-app-test"
return cfg
}
+
+func TestSepayProviderRegistrationAndRefundBlock(t *testing.T) {
+ if !validProviderKeys[payment.TypeSePay] {
+ t.Error("sepay must be a valid provider key")
+ }
+ if err := validateProviderRequest(payment.TypeSePay, "SePay VN", "sepay"); err != nil {
+ t.Fatalf("sepay provider request should validate: %v", err)
+ }
+ if providerSupportsRefund(payment.TypeSePay) {
+ t.Error("sepay has no refund API and must not report refund support")
+ }
+ if !providerSupportsRefund(payment.TypeStripe) {
+ t.Error("stripe refund support regression")
+ }
+ if err := validateProviderRefundSupport(payment.TypeSePay, true); err == nil {
+ t.Error("enabling refund on sepay must be rejected")
+ }
+ if err := validateProviderRefundSupport(payment.TypeSePay, false); err != nil {
+ t.Errorf("refund disabled should always be accepted: %v", err)
+ }
+ if err := validateProviderRefundSupport(payment.TypeStripe, true); err != nil {
+ t.Errorf("stripe refund enabled should be accepted: %v", err)
+ }
+}
+
+func TestSepaySensitiveConfigFields(t *testing.T) {
+ for _, field := range []string{"apiToken", "webhookSecret", "webhookApiKey"} {
+ if !isSensitiveProviderConfigField(payment.TypeSePay, field) {
+ t.Errorf("%s should be sensitive for sepay", field)
+ }
+ }
+ for _, field := range []string{"bankAccountNumber", "bankBin", "accountName", "apiBase"} {
+ if isSensitiveProviderConfigField(payment.TypeSePay, field) {
+ t.Errorf("%s should not be sensitive for sepay", field)
+ }
+ }
+ if !hasPendingOrderProtectedConfigChange(payment.TypeSePay,
+ map[string]string{"bankAccountNumber": "1"},
+ map[string]string{"bankAccountNumber": "2"}) {
+ t.Error("bankAccountNumber change must be blocked with pending orders")
+ }
+ if hasPendingOrderProtectedConfigChange(payment.TypeSePay,
+ map[string]string{"accountName": "A"},
+ map[string]string{"accountName": "B"}) {
+ t.Error("accountName change must be allowed with pending orders")
+ }
+}
From c64e2639caeb8c4c75c2374f43457830ee2a4952 Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Sat, 15 Aug 2026 00:10:06 +0700
Subject: [PATCH 13/25] feat(payment): SUBSCRIPTION_USD_TO_VND_RATE for sepay
subscription orders
---
.../internal/handler/admin/setting_handler.go | 1 +
.../handler/admin/setting_handler_update.go | 4 ++
backend/internal/handler/dto/settings.go | 1 +
backend/internal/handler/payment_handler.go | 2 +
backend/internal/service/payment_amounts.go | 5 +++
.../service/payment_config_service.go | 19 ++++++++-
backend/internal/service/payment_order.go | 35 ++++++++++++----
.../service/payment_order_result_test.go | 10 ++---
.../service/payment_order_sepay_vnd_test.go | 42 +++++++++++++++++++
9 files changed, 103 insertions(+), 16 deletions(-)
create mode 100644 backend/internal/service/payment_order_sepay_vnd_test.go
diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index a5f3d41d906..c68673dd98e 100644
--- a/backend/internal/handler/admin/setting_handler.go
+++ b/backend/internal/handler/admin/setting_handler.go
@@ -356,6 +356,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PaymentBalanceDisabled: paymentCfg.BalanceDisabled,
PaymentBalanceRechargeMultiplier: paymentCfg.BalanceRechargeMultiplier,
PaymentSubscriptionUSDToCNYRate: paymentCfg.SubscriptionUSDToCNYRate,
+ PaymentSubscriptionUSDToVNDRate: paymentCfg.SubscriptionUSDToVNDRate,
PaymentRechargeFeeRate: paymentCfg.RechargeFeeRate,
PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy,
PaymentProductNamePrefix: paymentCfg.ProductNamePrefix,
diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go
index 5884dc392e1..e0480735bb9 100644
--- a/backend/internal/handler/admin/setting_handler_update.go
+++ b/backend/internal/handler/admin/setting_handler_update.go
@@ -308,6 +308,7 @@ type UpdateSettingsRequest struct {
PaymentBalanceDisabled *bool `json:"payment_balance_disabled"`
PaymentBalanceRechargeMultiplier *float64 `json:"payment_balance_recharge_multiplier"`
PaymentSubscriptionUSDToCNYRate *float64 `json:"payment_subscription_usd_to_cny_rate"`
+ PaymentSubscriptionUSDToVNDRate *float64 `json:"payment_subscription_usd_to_vnd_rate"`
PaymentRechargeFeeRate *float64 `json:"payment_recharge_fee_rate"`
PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"`
PaymentProductNamePrefix *string `json:"payment_product_name_prefix"`
@@ -2048,6 +2049,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
BalanceDisabled: req.PaymentBalanceDisabled,
BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier,
SubscriptionUSDToCNYRate: req.PaymentSubscriptionUSDToCNYRate,
+ SubscriptionUSDToVNDRate: req.PaymentSubscriptionUSDToVNDRate,
RechargeFeeRate: req.PaymentRechargeFeeRate,
LoadBalanceStrategy: req.PaymentLoadBalanceStrat,
ProductNamePrefix: req.PaymentProductNamePrefix,
@@ -2324,6 +2326,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PaymentBalanceDisabled: updatedPaymentCfg.BalanceDisabled,
PaymentBalanceRechargeMultiplier: updatedPaymentCfg.BalanceRechargeMultiplier,
PaymentSubscriptionUSDToCNYRate: updatedPaymentCfg.SubscriptionUSDToCNYRate,
+ PaymentSubscriptionUSDToVNDRate: updatedPaymentCfg.SubscriptionUSDToVNDRate,
PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate,
PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy,
PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix,
@@ -2395,6 +2398,7 @@ func hasPaymentFields(req UpdateSettingsRequest) bool {
req.PaymentOrderTimeoutMin != nil || req.PaymentMaxPendingOrders != nil ||
req.PaymentEnabledTypes != nil || req.PaymentBalanceDisabled != nil ||
req.PaymentBalanceRechargeMultiplier != nil || req.PaymentSubscriptionUSDToCNYRate != nil ||
+ req.PaymentSubscriptionUSDToVNDRate != nil ||
req.PaymentRechargeFeeRate != nil ||
req.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil ||
req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil ||
diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go
index 5ab5eaa68b6..a4250c83432 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -273,6 +273,7 @@ type SystemSettings struct {
PaymentBalanceDisabled bool `json:"payment_balance_disabled"`
PaymentBalanceRechargeMultiplier float64 `json:"payment_balance_recharge_multiplier"`
PaymentSubscriptionUSDToCNYRate float64 `json:"payment_subscription_usd_to_cny_rate"`
+ PaymentSubscriptionUSDToVNDRate float64 `json:"payment_subscription_usd_to_vnd_rate"`
PaymentRechargeFeeRate float64 `json:"payment_recharge_fee_rate"`
PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"`
PaymentProductNamePrefix string `json:"payment_product_name_prefix"`
diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go
index 9aab3255044..42571d83a68 100644
--- a/backend/internal/handler/payment_handler.go
+++ b/backend/internal/handler/payment_handler.go
@@ -148,6 +148,7 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) {
BalanceDisabled: cfg.BalanceDisabled,
BalanceRechargeMultiplier: cfg.BalanceRechargeMultiplier,
SubscriptionUSDToCNYRate: cfg.SubscriptionUSDToCNYRate,
+ SubscriptionUSDToVNDRate: cfg.SubscriptionUSDToVNDRate,
RechargeFeeRate: cfg.RechargeFeeRate,
HelpText: cfg.HelpText,
HelpImageURL: cfg.HelpImageURL,
@@ -165,6 +166,7 @@ type checkoutInfoResponse struct {
BalanceDisabled bool `json:"balance_disabled"`
BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"`
SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"`
+ SubscriptionUSDToVNDRate float64 `json:"subscription_usd_to_vnd_rate"`
RechargeFeeRate float64 `json:"recharge_fee_rate"`
HelpText string `json:"help_text"`
HelpImageURL string `json:"help_image_url"`
diff --git a/backend/internal/service/payment_amounts.go b/backend/internal/service/payment_amounts.go
index 2fd00c5957d..d2f59c1b9d5 100644
--- a/backend/internal/service/payment_amounts.go
+++ b/backend/internal/service/payment_amounts.go
@@ -25,6 +25,11 @@ func normalizeSubscriptionUSDToCNYRate(rate float64) float64 {
return rate
}
+// normalizeSubscriptionUSDToVNDRate 将非法值归一为 0(换算关闭)。
+func normalizeSubscriptionUSDToVNDRate(rate float64) float64 {
+ return normalizeSubscriptionUSDToCNYRate(rate)
+}
+
func calculateCreditedBalance(paymentAmount, multiplier float64) float64 {
return decimal.NewFromFloat(paymentAmount).
Mul(decimal.NewFromFloat(normalizeBalanceRechargeMultiplier(multiplier))).
diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go
index aac1cc8e263..6d1e6cd4e88 100644
--- a/backend/internal/service/payment_config_service.go
+++ b/backend/internal/service/payment_config_service.go
@@ -27,7 +27,10 @@ const (
SettingBalanceRechargeMult = "BALANCE_RECHARGE_MULTIPLIER"
// SettingSubscriptionUSDToCNYRate 是订阅 CNY 换算汇率(1 USD = X CNY)。
// 0/未配置 = 关闭换算(订阅按 price 数值直付),显式配置后 CNY 通道订阅按 price × rate 收款。
- SettingSubscriptionUSDToCNYRate = "SUBSCRIPTION_USD_TO_CNY_RATE"
+ SettingSubscriptionUSDToCNYRate = "SUBSCRIPTION_USD_TO_CNY_RATE"
+ // SettingSubscriptionUSDToVNDRate 是订阅 VND 换算汇率(1 USD = X VND)。
+ // 0/未配置 = 关闭换算。SePay(VND)订阅必须配置该项,否则下单被拒绝。
+ SettingSubscriptionUSDToVNDRate = "SUBSCRIPTION_USD_TO_VND_RATE"
SettingRechargeFeeRate = "RECHARGE_FEE_RATE"
SettingProductNamePrefix = "PRODUCT_NAME_PREFIX"
SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX"
@@ -61,6 +64,7 @@ type PaymentConfig struct {
BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"`
// SubscriptionUSDToCNYRate 为 0 时订阅换算关闭(兼容存量行为)。
SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"`
+ SubscriptionUSDToVNDRate float64 `json:"subscription_usd_to_vnd_rate"`
RechargeFeeRate float64 `json:"recharge_fee_rate"`
LoadBalanceStrategy string `json:"load_balance_strategy"`
ProductNamePrefix string `json:"product_name_prefix"`
@@ -94,6 +98,7 @@ type UpdatePaymentConfigRequest struct {
BalanceDisabled *bool `json:"balance_disabled"`
BalanceRechargeMultiplier *float64 `json:"balance_recharge_multiplier"`
SubscriptionUSDToCNYRate *float64 `json:"subscription_usd_to_cny_rate"`
+ SubscriptionUSDToVNDRate *float64 `json:"subscription_usd_to_vnd_rate"`
RechargeFeeRate *float64 `json:"recharge_fee_rate"`
LoadBalanceStrategy *string `json:"load_balance_strategy"`
ProductNamePrefix *string `json:"product_name_prefix"`
@@ -219,7 +224,7 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo
keys := []string{
SettingPaymentEnabled, SettingMinRechargeAmount, SettingMaxRechargeAmount,
SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders,
- SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingBalanceRechargeMult, SettingSubscriptionUSDToCNYRate, SettingRechargeFeeRate, SettingLoadBalanceStrategy,
+ SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingBalanceRechargeMult, SettingSubscriptionUSDToCNYRate, SettingSubscriptionUSDToVNDRate, SettingRechargeFeeRate, SettingLoadBalanceStrategy,
SettingProductNamePrefix, SettingProductNameSuffix,
SettingHelpImageURL, SettingHelpText,
SettingCancelRateLimitOn, SettingCancelRateLimitMax,
@@ -249,6 +254,7 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme
BalanceDisabled: vals[SettingBalancePayDisabled] == "true",
BalanceRechargeMultiplier: normalizeBalanceRechargeMultiplier(pcParseFloat(vals[SettingBalanceRechargeMult], defaultBalanceRechargeMultiplier)),
SubscriptionUSDToCNYRate: normalizeSubscriptionUSDToCNYRate(pcParseFloat(vals[SettingSubscriptionUSDToCNYRate], 0)),
+ SubscriptionUSDToVNDRate: normalizeSubscriptionUSDToVNDRate(pcParseFloat(vals[SettingSubscriptionUSDToVNDRate], 0)),
RechargeFeeRate: pcParseFloat(vals[SettingRechargeFeeRate], 0),
LoadBalanceStrategy: vals[SettingLoadBalanceStrategy],
ProductNamePrefix: vals[SettingProductNamePrefix],
@@ -333,6 +339,12 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda
return infraerrors.BadRequest("INVALID_SUBSCRIPTION_USD_TO_CNY_RATE", "subscription USD to CNY rate must be 0 (disabled) or a positive number")
}
}
+ if req.SubscriptionUSDToVNDRate != nil {
+ v := *req.SubscriptionUSDToVNDRate
+ if v < 0 {
+ return infraerrors.BadRequest("INVALID_SUBSCRIPTION_USD_TO_VND_RATE", "subscription USD to VND rate must be 0 (disabled) or a positive number")
+ }
+ }
if req.RechargeFeeRate != nil {
v := *req.RechargeFeeRate
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > 100 {
@@ -374,6 +386,9 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda
if req.SubscriptionUSDToCNYRate != nil {
m[SettingSubscriptionUSDToCNYRate] = formatPositiveFloatExact(req.SubscriptionUSDToCNYRate)
}
+ if req.SubscriptionUSDToVNDRate != nil {
+ m[SettingSubscriptionUSDToVNDRate] = formatPositiveFloatExact(req.SubscriptionUSDToVNDRate)
+ }
if req.RechargeFeeRate != nil {
m[SettingRechargeFeeRate] = formatNonNegativeFloat(req.RechargeFeeRate)
}
diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go
index da7178dd7fe..318eab7164a 100644
--- a/backend/internal/service/payment_order.go
+++ b/backend/internal/service/payment_order.go
@@ -69,7 +69,13 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
return nil, err
}
}
- payAmountStr, payAmount, err := calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, methodCurrency, req.OrderType, cfg.SubscriptionUSDToCNYRate)
+ if req.OrderType == payment.OrderTypeSubscription && methodCurrency == payment.CurrencyVND {
+ if normalizeSubscriptionUSDToVNDRate(cfg.SubscriptionUSDToVNDRate) <= 0 {
+ return nil, infraerrors.BadRequest("SUBSCRIPTION_VND_RATE_REQUIRED",
+ "subscription orders via VND methods require the USD to VND rate to be configured")
+ }
+ }
+ payAmountStr, payAmount, err := calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, methodCurrency, req.OrderType, cfg)
if err != nil {
return nil, err
}
@@ -85,7 +91,7 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
selectedCurrency = paymentProviderConfigCurrency(sel.ProviderKey, sel.Config)
}
if selectedCurrency != methodCurrency {
- payAmountStr, payAmount, err = calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, selectedCurrency, req.OrderType, cfg.SubscriptionUSDToCNYRate)
+ payAmountStr, payAmount, err = calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, selectedCurrency, req.OrderType, cfg)
if err != nil {
return nil, err
}
@@ -643,20 +649,31 @@ func calculateCreateOrderPayAmount(limitAmount, feeRate float64, currency string
return payAmountStr, payAmount, nil
}
-func calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate float64, currency, orderType string, usdToCnyRate float64) (string, float64, error) {
+func calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate float64, currency, orderType string, cfg *PaymentConfig) (string, float64, error) {
paymentAmount := limitAmount
if orderType == payment.OrderTypeSubscription {
- paymentAmount = calculateSubscriptionGatewayBaseAmount(limitAmount, usdToCnyRate, currency)
+ paymentAmount = calculateSubscriptionGatewayBaseAmount(limitAmount, cfg, currency)
}
return calculateCreateOrderPayAmount(paymentAmount, feeRate, currency)
}
// calculateSubscriptionGatewayBaseAmount 计算订阅订单的网关扣款基数。
-// 换算是显式 opt-in:仅当管理员配置了订阅汇率(rate > 0,1 USD = rate CNY)
-// 且网关币种为 CNY 时,按 price × rate 换算;未配置时保持 price 直付的存量行为。
-func calculateSubscriptionGatewayBaseAmount(amount, usdToCnyRate float64, currency string) float64 {
- rate := normalizeSubscriptionUSDToCNYRate(usdToCnyRate)
- if rate <= 0 || currency != payment.DefaultPaymentCurrency {
+// 换算是显式 opt-in:CNY 通道按 SUBSCRIPTION_USD_TO_CNY_RATE、VND 通道按
+// SUBSCRIPTION_USD_TO_VND_RATE(1 USD = rate),未配置时保持 price 直付。
+func calculateSubscriptionGatewayBaseAmount(amount float64, cfg *PaymentConfig, currency string) float64 {
+ if cfg == nil {
+ return amount
+ }
+ var rate float64
+ switch currency {
+ case payment.DefaultPaymentCurrency:
+ rate = normalizeSubscriptionUSDToCNYRate(cfg.SubscriptionUSDToCNYRate)
+ case payment.CurrencyVND:
+ rate = normalizeSubscriptionUSDToVNDRate(cfg.SubscriptionUSDToVNDRate)
+ default:
+ return amount
+ }
+ if rate <= 0 {
return amount
}
return decimal.NewFromFloat(amount).
diff --git a/backend/internal/service/payment_order_result_test.go b/backend/internal/service/payment_order_result_test.go
index e77fbce4822..9d67a6cee9a 100644
--- a/backend/internal/service/payment_order_result_test.go
+++ b/backend/internal/service/payment_order_result_test.go
@@ -217,7 +217,7 @@ func TestCalculateCreateOrderPayAmountUsesCurrencyPrecision(t *testing.T) {
func TestCalculateCreateOrderPayAmountForSubscriptionConvertsCNYPriceWhenRateConfigured(t *testing.T) {
t.Parallel()
- amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "CNY", payment.OrderTypeSubscription, 7.15)
+ amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "CNY", payment.OrderTypeSubscription, &PaymentConfig{SubscriptionUSDToCNYRate: 7.15})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -229,7 +229,7 @@ func TestCalculateCreateOrderPayAmountForSubscriptionConvertsCNYPriceWhenRateCon
func TestCalculateCreateOrderPayAmountForSubscriptionAppliesFeeAfterCNYConversion(t *testing.T) {
t.Parallel()
- amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 2.5, "CNY", payment.OrderTypeSubscription, 7.15)
+ amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 2.5, "CNY", payment.OrderTypeSubscription, &PaymentConfig{SubscriptionUSDToCNYRate: 7.15})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -241,7 +241,7 @@ func TestCalculateCreateOrderPayAmountForSubscriptionAppliesFeeAfterCNYConversio
func TestCalculateCreateOrderPayAmountForSubscriptionKeepsNonCNYPrice(t *testing.T) {
t.Parallel()
- amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "USD", payment.OrderTypeSubscription, 7.15)
+ amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "USD", payment.OrderTypeSubscription, &PaymentConfig{SubscriptionUSDToCNYRate: 7.15})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -255,7 +255,7 @@ func TestCalculateCreateOrderPayAmountForSubscriptionKeepsNonCNYPrice(t *testing
func TestCalculateCreateOrderPayAmountForSubscriptionKeepsDirectPriceWhenRateDisabled(t *testing.T) {
t.Parallel()
- amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "CNY", payment.OrderTypeSubscription, 0)
+ amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "CNY", payment.OrderTypeSubscription, &PaymentConfig{SubscriptionUSDToCNYRate: 0})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -268,7 +268,7 @@ func TestCalculateCreateOrderPayAmountForSubscriptionKeepsDirectPriceWhenRateDis
func TestCalculateCreateOrderPayAmountForBalanceIgnoresSubscriptionRate(t *testing.T) {
t.Parallel()
- amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(50, 0, "CNY", payment.OrderTypeBalance, 7.15)
+ amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(50, 0, "CNY", payment.OrderTypeBalance, &PaymentConfig{SubscriptionUSDToCNYRate: 7.15})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
diff --git a/backend/internal/service/payment_order_sepay_vnd_test.go b/backend/internal/service/payment_order_sepay_vnd_test.go
new file mode 100644
index 00000000000..7f5ff2fe5b5
--- /dev/null
+++ b/backend/internal/service/payment_order_sepay_vnd_test.go
@@ -0,0 +1,42 @@
+package service
+
+import (
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+)
+
+func TestCalculateSubscriptionGatewayBaseAmountVND(t *testing.T) {
+ cfg := &PaymentConfig{SubscriptionUSDToVNDRate: 25000}
+ cases := []struct {
+ name string
+ cfg *PaymentConfig
+ currency string
+ amount float64
+ want float64
+ }{
+ {"vnd rate applies", cfg, payment.CurrencyVND, 9.9, 247500},
+ {"vnd rate zero keeps price", &PaymentConfig{}, payment.CurrencyVND, 9.9, 9.9},
+ {"cny unaffected", &PaymentConfig{SubscriptionUSDToCNYRate: 7.2}, payment.DefaultPaymentCurrency, 10, 72},
+ {"other currency untouched", cfg, "USD", 9.9, 9.9},
+ {"nil cfg safe", nil, payment.CurrencyVND, 9.9, 9.9},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := calculateSubscriptionGatewayBaseAmount(tc.amount, tc.cfg, tc.currency); got != tc.want {
+ t.Fatalf("= %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestCreateOrderPayAmountForOrderTypeVND(t *testing.T) {
+ cfg := &PaymentConfig{SubscriptionUSDToVNDRate: 25000}
+ str, amt, err := calculateCreateOrderPayAmountForOrderType(9.9, 0, payment.CurrencyVND, payment.OrderTypeSubscription, cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if str != "247500" || amt != 247500 {
+ t.Fatalf("str=%q amt=%v, want 247500", str, amt)
+ }
+}
From a3fcbc8e610deec42a812cb5fb103d46af3c1fc4 Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Sat, 15 Aug 2026 00:19:26 +0700
Subject: [PATCH 14/25] feat(payment-frontend): sepay provider config, method
plumbing and i18n
---
.../payment/__tests__/providerConfig.spec.ts | 25 +++++++++++++++++++
.../src/components/payment/paymentFlow.ts | 3 ++-
.../src/components/payment/providerConfig.ts | 13 +++++++++-
.../src/i18n/locales/en/admin/settings.ts | 7 ++++++
frontend/src/i18n/locales/en/misc.ts | 1 +
.../src/i18n/locales/zh/admin/settings.ts | 7 ++++++
frontend/src/i18n/locales/zh/misc.ts | 1 +
frontend/src/views/admin/SettingsView.vue | 2 ++
8 files changed, 57 insertions(+), 2 deletions(-)
diff --git a/frontend/src/components/payment/__tests__/providerConfig.spec.ts b/frontend/src/components/payment/__tests__/providerConfig.spec.ts
index 267693b5cbf..974eed6256f 100644
--- a/frontend/src/components/payment/__tests__/providerConfig.spec.ts
+++ b/frontend/src/components/payment/__tests__/providerConfig.spec.ts
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest'
import {
+ METHOD_ORDER,
PAYMENT_CURRENCY_OPTIONS,
PROVIDER_CONFIG_FIELDS,
+ PROVIDER_SUPPORTED_TYPES,
+ WEBHOOK_PATHS,
isBuiltInAlipayMethod,
isBuiltInWxpayMethod,
parseEasyPayCustomMethods,
@@ -93,3 +96,25 @@ describe('built-in payment method helpers', () => {
expect(isBuiltInWxpayMethod('card_wxpay')).toBe(false)
})
})
+
+describe('PROVIDER_CONFIG_FIELDS.sepay', () => {
+ const findField = (key: string) =>
+ (PROVIDER_CONFIG_FIELDS.sepay || []).find(f => f.key === key)
+
+ it('declares sepay supported types and method order', () => {
+ expect(PROVIDER_SUPPORTED_TYPES.sepay).toEqual(['sepay'])
+ expect(METHOD_ORDER).toContain('sepay')
+ expect(WEBHOOK_PATHS.sepay).toBe('/api/v1/payment/webhook/sepay')
+ })
+
+ it('marks credentials as sensitive and bank details as required', () => {
+ expect(findField('apiToken')?.sensitive).toBe(true)
+ expect(findField('webhookSecret')?.sensitive).toBe(true)
+ expect(findField('webhookApiKey')?.sensitive).toBe(true)
+ expect(findField('bankAccountNumber')?.optional).toBeUndefined()
+ expect(findField('bankBin')?.optional).toBeUndefined()
+ expect(findField('accountName')?.optional).toBe(true)
+ expect(findField('webhookApiKey')?.optional).toBe(true)
+ expect(findField('apiBase')?.defaultValue).toBe('https://userapi.sepay.vn')
+ })
+})
diff --git a/frontend/src/components/payment/paymentFlow.ts b/frontend/src/components/payment/paymentFlow.ts
index cd9ccfd38f1..8d11212d945 100644
--- a/frontend/src/components/payment/paymentFlow.ts
+++ b/frontend/src/components/payment/paymentFlow.ts
@@ -16,9 +16,10 @@ const VISIBLE_METHOD_ALIASES = {
wxpay_direct: 'wxpay',
stripe: 'stripe',
airwallex: 'airwallex',
+ sepay: 'sepay',
} as const
-export type VisiblePaymentMethod = 'alipay' | 'wxpay' | 'stripe' | 'airwallex'
+export type VisiblePaymentMethod = 'alipay' | 'wxpay' | 'stripe' | 'airwallex' | 'sepay'
export type StripeVisibleMethod = 'alipay' | 'wechat_pay'
export type PaymentLaunchKind =
| 'qr_waiting'
diff --git a/frontend/src/components/payment/providerConfig.ts b/frontend/src/components/payment/providerConfig.ts
index 395c32725fa..b458fb4e16e 100644
--- a/frontend/src/components/payment/providerConfig.ts
+++ b/frontend/src/components/payment/providerConfig.ts
@@ -42,13 +42,14 @@ export const PROVIDER_SUPPORTED_TYPES: Record = {
wxpay: ['wxpay'],
stripe: ['card', 'alipay', 'wxpay', 'link'],
airwallex: ['airwallex'],
+ sepay: ['sepay'],
}
/** Available payment modes for EasyPay providers. */
export const EASYPAY_PAYMENT_MODES = ['qrcode', 'popup'] as const
/** Fixed display order for user-facing payment methods */
-export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct', 'stripe', 'airwallex'] as const
+export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct', 'stripe', 'airwallex', 'sepay'] as const
export function isBuiltInAlipayMethod(type: string): boolean {
return type === 'alipay' || type === 'alipay_direct'
@@ -110,6 +111,7 @@ export const WEBHOOK_PATHS: Record = {
wxpay: '/api/v1/payment/webhook/wxpay',
stripe: '/api/v1/payment/webhook/stripe',
airwallex: '/api/v1/payment/webhook/airwallex',
+ sepay: '/api/v1/payment/webhook/sepay',
}
export const RETURN_PATH = '/payment/result'
@@ -161,6 +163,15 @@ export const PROVIDER_CONFIG_FIELDS: Record = {
{ key: 'currency', label: '', sensitive: false, defaultValue: 'CNY', hintKey: 'admin.settings.payment.field_paymentCurrencyHint', options: PAYMENT_CURRENCY_OPTIONS },
{ key: 'accountId', label: '', sensitive: false, optional: true, clearable: true, hintKey: 'admin.settings.payment.field_accountIdHint' },
],
+ sepay: [
+ { key: 'apiToken', label: '', sensitive: true },
+ { key: 'apiBase', label: '', sensitive: false, defaultValue: 'https://userapi.sepay.vn', hintKey: 'admin.settings.payment.field_sepayApiBaseHint' },
+ { key: 'bankAccountNumber', label: '', sensitive: false },
+ { key: 'bankBin', label: '', sensitive: false },
+ { key: 'accountName', label: '', sensitive: false, optional: true },
+ { key: 'webhookSecret', label: '', sensitive: true },
+ { key: 'webhookApiKey', label: '', sensitive: true, optional: true },
+ ],
}
// --- Helpers ---
diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts
index b20e2ebeee0..8d85a19fabc 100644
--- a/frontend/src/i18n/locales/en/admin/settings.ts
+++ b/frontend/src/i18n/locales/en/admin/settings.ts
@@ -736,6 +736,7 @@ export default {
providerWxpay: 'WeChat Pay (Direct)',
providerStripe: 'Stripe',
providerAirwallex: 'Airwallex',
+ providerSepay: 'SePay',
typeDisabled: 'type disabled',
enableTypesFirst: 'Enable at least one payment type above first',
easypayRedirect: 'Redirect',
@@ -776,6 +777,12 @@ export default {
field_currency: 'Payment currency',
field_accountId: 'Airwallex Account ID',
field_airwallexApiBaseHint: 'Must match the API key environment: use https://api-demo.airwallex.com/api/v1 for sandbox/demo keys, and https://api.airwallex.com/api/v1 for production keys. Mixed environments return credentials_invalid / Access Denied.',
+ field_apiToken: 'API Token',
+ field_bankAccountNumber: 'Bank Account Number',
+ field_bankBin: 'Bank BIN',
+ field_accountName: 'Account Holder Name',
+ field_webhookApiKey: 'Webhook API Key',
+ field_sepayApiBaseHint: 'Defaults to https://userapi.sepay.vn. Use https://userapi-sandbox.sepay.vn for sandbox testing.',
field_paymentCurrencyHint: 'Default is CNY. Stripe and Airwallex can choose HKD, USD, or another listed currency supported by the account; WeChat Pay, Alipay, and EasyPay remain CNY.',
field_accountIdHint: 'Leave this empty unless you use multiple accounts, an organization-level key, or connected-account payments. A single-account scoped API key uses the selected account by default.',
field_cid: 'Channel ID',
diff --git a/frontend/src/i18n/locales/en/misc.ts b/frontend/src/i18n/locales/en/misc.ts
index e8f5404f250..5ca7cd89b9e 100644
--- a/frontend/src/i18n/locales/en/misc.ts
+++ b/frontend/src/i18n/locales/en/misc.ts
@@ -302,6 +302,7 @@ export default {
wxpay: 'WeChat Pay',
stripe: 'Stripe',
airwallex: 'Airwallex',
+ sepay: 'SePay',
card: 'Card',
link: 'Link',
alipay_direct: 'Alipay (Direct)',
diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts
index 3967f25ed73..9dd66e8a37b 100644
--- a/frontend/src/i18n/locales/zh/admin/settings.ts
+++ b/frontend/src/i18n/locales/zh/admin/settings.ts
@@ -731,6 +731,7 @@ export default {
providerWxpay: '微信官方',
providerStripe: 'Stripe',
providerAirwallex: 'Airwallex',
+ providerSepay: 'SePay',
typeDisabled: '类型已禁用',
enableTypesFirst: '请先在上方启用至少一种服务商',
easypayRedirect: '跳转',
@@ -771,6 +772,12 @@ export default {
field_currency: '支付币种',
field_accountId: 'Airwallex 账户 ID',
field_airwallexApiBaseHint: '必须和 API Key 所属环境一致:沙箱/测试密钥使用 https://api-demo.airwallex.com/api/v1,生产密钥使用 https://api.airwallex.com/api/v1。环境混用会返回 credentials_invalid / Access Denied。',
+ field_apiToken: 'API Token',
+ field_bankAccountNumber: '银行账号',
+ field_bankBin: '银行 BIN 码',
+ field_accountName: '户名',
+ field_webhookApiKey: 'Webhook API Key',
+ field_sepayApiBaseHint: '默认 https://userapi.sepay.vn,沙箱环境使用 https://userapi-sandbox.sepay.vn。',
field_paymentCurrencyHint: '默认 CNY。Stripe 和 Airwallex 可按账户支持从下拉项选择 HKD、USD 等币种;微信、支付宝、易支付仍按 CNY。',
field_accountIdHint: '不涉及多账户、组织级密钥或连接账户收款时可以不填;单账户 Scoped API Key 会默认使用所选账户。',
field_cid: '支付渠道 ID',
diff --git a/frontend/src/i18n/locales/zh/misc.ts b/frontend/src/i18n/locales/zh/misc.ts
index 6a514084257..c503f70b0b8 100644
--- a/frontend/src/i18n/locales/zh/misc.ts
+++ b/frontend/src/i18n/locales/zh/misc.ts
@@ -326,6 +326,7 @@ export default {
wxpay: '微信支付',
stripe: 'Stripe',
airwallex: 'Airwallex',
+ sepay: 'SePay',
card: '银行卡',
link: 'Link',
alipay_direct: '支付宝(直连)',
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index b153865ff3b..16a8e761916 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -12058,6 +12058,7 @@ const allPaymentTypes = computed(() => [
{ value: "wxpay", label: t("payment.methods.wxpay") },
{ value: "stripe", label: t("payment.methods.stripe") },
{ value: "airwallex", label: t("payment.methods.airwallex") },
+ { value: "sepay", label: t("payment.methods.sepay") },
]);
function isPaymentTypeEnabled(type: string): boolean {
@@ -12115,6 +12116,7 @@ const providerKeyOptions = computed(() => [
{ value: "wxpay", label: t("admin.settings.payment.providerWxpay") },
{ value: "stripe", label: t("admin.settings.payment.providerStripe") },
{ value: "airwallex", label: t("admin.settings.payment.providerAirwallex") },
+ { value: "sepay", label: t("admin.settings.payment.providerSepay") },
]);
const enabledProviderKeyOptions = computed(() => {
From c9eed06391e6f0ba06a27ba21d2eb7acc9aae2ec Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Sat, 15 Aug 2026 00:28:23 +0700
Subject: [PATCH 15/25] feat(payment-frontend): VND subscription rate display
and admin input
---
frontend/src/api/admin/payment.ts | 2 ++
frontend/src/api/admin/settings.ts | 2 ++
.../src/i18n/locales/en/admin/settings.ts | 2 ++
.../src/i18n/locales/zh/admin/settings.ts | 2 ++
frontend/src/types/payment.ts | 2 ++
frontend/src/views/admin/SettingsView.vue | 26 +++++++++++++++++++
.../admin/__tests__/SettingsView.spec.ts | 1 +
frontend/src/views/user/PaymentView.vue | 11 +++++++-
.../views/user/__tests__/PaymentView.spec.ts | 24 +++++++++++++++++
9 files changed, 71 insertions(+), 1 deletion(-)
diff --git a/frontend/src/api/admin/payment.ts b/frontend/src/api/admin/payment.ts
index 1d4305948eb..ab91cadb158 100644
--- a/frontend/src/api/admin/payment.ts
+++ b/frontend/src/api/admin/payment.ts
@@ -25,6 +25,7 @@ export interface AdminPaymentConfig {
balance_disabled: boolean
balance_recharge_multiplier: number
subscription_usd_to_cny_rate: number
+ subscription_usd_to_vnd_rate: number
recharge_fee_rate: number
load_balance_strategy: string
product_name_prefix: string
@@ -45,6 +46,7 @@ export interface UpdatePaymentConfigRequest {
balance_disabled?: boolean
balance_recharge_multiplier?: number
subscription_usd_to_cny_rate?: number
+ subscription_usd_to_vnd_rate?: number
recharge_fee_rate?: number
load_balance_strategy?: string
product_name_prefix?: string
diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts
index b176024ae6b..c288e194849 100644
--- a/frontend/src/api/admin/settings.ts
+++ b/frontend/src/api/admin/settings.ts
@@ -654,6 +654,7 @@ export interface SystemSettings {
payment_balance_disabled: boolean;
payment_balance_recharge_multiplier: number;
payment_subscription_usd_to_cny_rate: number;
+ payment_subscription_usd_to_vnd_rate: number;
payment_recharge_fee_rate: number;
payment_load_balance_strategy: string;
payment_product_name_prefix: string;
@@ -964,6 +965,7 @@ export interface UpdateSettingsRequest {
payment_balance_disabled?: boolean;
payment_balance_recharge_multiplier?: number;
payment_subscription_usd_to_cny_rate?: number;
+ payment_subscription_usd_to_vnd_rate?: number;
payment_recharge_fee_rate?: number;
payment_load_balance_strategy?: string;
payment_product_name_prefix?: string;
diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts
index 8d85a19fabc..133fb3795bf 100644
--- a/frontend/src/i18n/locales/en/admin/settings.ts
+++ b/frontend/src/i18n/locales/en/admin/settings.ts
@@ -699,6 +699,8 @@ export default {
subscriptionUsdToCnyRateHint:
'CNY charged per 1 USD of plan price on CNY channels (e.g. 7.15). 0 or empty = disabled, plan price is charged as-is. When enabled, all plan prices must be set in USD',
subscriptionUsdToCnyRateDisabled: 'Disabled (price charged as-is)',
+ subscriptionUsdToVndRate: 'Subscription USD→VND Rate (1 USD = X VND)',
+ subscriptionUsdToVndRateDisabled: 'Disabled — VND subscription checkout will be rejected',
rechargeFeeRate: 'Recharge Fee Rate',
rechargeFeeRateHint: 'Percentage of service fee charged on top of recharge amount, 0 means no fee',
rechargeFeePreview: 'Preview: Recharge 100, fee {fee}',
diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts
index 9dd66e8a37b..4b6c63d233e 100644
--- a/frontend/src/i18n/locales/zh/admin/settings.ts
+++ b/frontend/src/i18n/locales/zh/admin/settings.ts
@@ -694,6 +694,8 @@ export default {
subscriptionUsdToCnyRateHint:
'CNY 支付通道下,套餐每 1 USD 价格收取多少 CNY(如 7.15)。0 或留空 = 不换算,订阅按 price 数值直接收款。启用后所有套餐 price 必须按 USD 定价',
subscriptionUsdToCnyRateDisabled: '未启用(按 price 直付)',
+ subscriptionUsdToVndRate: '订阅 USD→VND 汇率(1 USD = X VND)',
+ subscriptionUsdToVndRateDisabled: '未配置 — VND 订阅下单将被拒绝',
rechargeFeeRate: '充值手续费率',
rechargeFeeRateHint: '用户充值时额外收取的手续费百分比,0 表示不收取手续费',
rechargeFeePreview: '预览:充值 100 元,手续费 {fee} 元',
diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts
index b94dbf42e4e..061faa067d9 100644
--- a/frontend/src/types/payment.ts
+++ b/frontend/src/types/payment.ts
@@ -35,6 +35,7 @@ export interface PaymentConfig {
balance_disabled: boolean
balance_recharge_multiplier: number
subscription_usd_to_cny_rate: number
+ subscription_usd_to_vnd_rate: number
enabled_payment_types: PaymentType[]
help_image_url: string
help_text: string
@@ -70,6 +71,7 @@ export interface CheckoutInfoResponse {
balance_recharge_multiplier: number
/** Subscription CNY conversion rate (1 USD = X CNY); 0 = disabled, plan price is charged as-is */
subscription_usd_to_cny_rate: number
+ subscription_usd_to_vnd_rate: number
recharge_fee_rate: number
help_text: string
help_image_url: string
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index 16a8e761916..d0267448d70 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -7813,6 +7813,29 @@
}}
+
+
+
+
{{ scanHint }}
+
+
{{ t('payment.qr.transferTitle') }}
+
+
+ {{ t('payment.qr.transferAccount') }}
+
+ {{ transferInfo.accountNumber }} ({{ transferInfo.bankBin }})
+
+
+
+ {{ t('payment.qr.transferAccountName') }}
+ {{ transferInfo.accountName }}
+
+
+ {{ t('payment.qr.transferAmount') }}
+ {{ transferInfo.amount }} ₫
+
+
+ {{ t('payment.qr.transferContent') }}
+
+
+
+
{{ t('payment.qr.transferHint') }}
+
@@ -225,6 +255,7 @@ import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { getPaymentPopupFeatures, isBuiltInAlipayMethod, isBuiltInWxpayMethod } from '@/components/payment/providerConfig'
+import type { PaymentTransferDisplayInfo } from '@/components/payment/paymentFlow'
import { currencySymbol, formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import type { PaymentOrder } from '@/types/payment'
import Icon from '@/components/icons/Icon.vue'
@@ -250,6 +281,7 @@ const props = defineProps<{
currency?: string
outTradeNo?: string
mobileAlipayDeepLink?: boolean
+ transferInfo?: PaymentTransferDisplayInfo
}>()
type PaymentOutcome = 'success' | 'cancelled' | 'expired'
@@ -326,6 +358,21 @@ const scanHint = computed(() => {
return ''
})
+const contentCopied = ref(false)
+let contentCopiedTimer: ReturnType | undefined
+
+async function copyTransferContent() {
+ if (!props.transferInfo?.content) return
+ try {
+ await navigator.clipboard.writeText(props.transferInfo.content)
+ contentCopied.value = true
+ if (contentCopiedTimer) clearTimeout(contentCopiedTimer)
+ contentCopiedTimer = setTimeout(() => { contentCopied.value = false }, 2000)
+ } catch {
+ // clipboard unavailable (insecure context) — the code stays selectable for manual copy
+ }
+}
+
const countdownDisplay = computed(() => {
const m = Math.floor(remainingSeconds.value / 60)
const s = remainingSeconds.value % 60
diff --git a/frontend/src/components/payment/paymentFlow.ts b/frontend/src/components/payment/paymentFlow.ts
index 8d11212d945..13cddd60ac8 100644
--- a/frontend/src/components/payment/paymentFlow.ts
+++ b/frontend/src/components/payment/paymentFlow.ts
@@ -32,6 +32,14 @@ export type PaymentLaunchKind =
| 'wechat_jsapi'
| 'unhandled'
+export interface PaymentTransferDisplayInfo {
+ accountNumber: string
+ accountName: string
+ bankBin: string
+ amount: string
+ content: string
+}
+
export interface PaymentRecoverySnapshot {
orderId: number
amount: number
@@ -40,6 +48,7 @@ export interface PaymentRecoverySnapshot {
paymentType: string
payUrl: string
outTradeNo: string
+ transferInfo?: PaymentTransferDisplayInfo
clientSecret: string
intentId: string
currency: string
@@ -168,6 +177,15 @@ export function decidePaymentLaunch(
payAmount: result.pay_amount,
orderType: context.orderType,
paymentMode: (result.payment_mode || '').trim(),
+ transferInfo: result.transfer_info
+ ? {
+ accountNumber: result.transfer_info.account_number || '',
+ accountName: result.transfer_info.account_name || '',
+ bankBin: result.transfer_info.bank_bin || '',
+ amount: result.transfer_info.amount || '',
+ content: result.transfer_info.content || '',
+ }
+ : undefined,
resumeToken: result.resume_token || '',
alipayMobilePrecreateDeepLink: result.alipay_mobile_precreate_deep_link === true,
}, context.now)
@@ -298,6 +316,7 @@ export function readPaymentRecoverySnapshot(
|| typeof parsed.resumeToken !== 'string'
|| (parsed.alipayMobilePrecreateDeepLink != null && typeof parsed.alipayMobilePrecreateDeepLink !== 'boolean')
|| typeof parsed.createdAt !== 'number'
+ || (parsed.transferInfo != null && typeof parsed.transferInfo !== 'object')
) {
return null
}
@@ -319,6 +338,7 @@ export function readPaymentRecoverySnapshot(
paymentType: parsed.paymentType,
payUrl: parsed.payUrl,
outTradeNo: parsed.outTradeNo || '',
+ transferInfo: parsed.transferInfo,
clientSecret: parsed.clientSecret,
intentId: parsed.intentId || '',
currency: parsed.currency || '',
diff --git a/frontend/src/i18n/locales/en/misc.ts b/frontend/src/i18n/locales/en/misc.ts
index 5ca7cd89b9e..e9f46d56cb6 100644
--- a/frontend/src/i18n/locales/en/misc.ts
+++ b/frontend/src/i18n/locales/en/misc.ts
@@ -328,6 +328,13 @@ export default {
scanAlipay: 'Alipay QR Payment',
scanWxpay: 'WeChat QR Payment',
scanAlipayHint: 'Open Alipay on your phone and scan the QR code to pay',
+ transferTitle: 'Bank Transfer Details',
+ transferAccount: 'Account Number',
+ transferAccountName: 'Account Name',
+ transferAmount: 'Amount',
+ transferContent: 'Transfer Content',
+ transferCopy: 'Copy transfer content',
+ transferHint: 'Scan the QR with your banking app, or transfer manually. The amount and transfer content must match exactly — the order completes automatically once the transfer arrives.',
scanWxpayHint: 'Open WeChat on your phone and scan the QR code to pay',
payInNewWindow: 'Complete Payment in New Window',
payInNewWindowHint: 'The payment page has opened in a new window. Please complete the payment there and return to this page.',
diff --git a/frontend/src/i18n/locales/zh/misc.ts b/frontend/src/i18n/locales/zh/misc.ts
index c503f70b0b8..5ea7576bda1 100644
--- a/frontend/src/i18n/locales/zh/misc.ts
+++ b/frontend/src/i18n/locales/zh/misc.ts
@@ -352,6 +352,13 @@ export default {
scanAlipay: '支付宝扫码支付',
scanWxpay: '微信扫码支付',
scanAlipayHint: '请使用手机打开支付宝,扫描二维码完成支付',
+ transferTitle: '银行转账信息',
+ transferAccount: '银行账号',
+ transferAccountName: '户名',
+ transferAmount: '金额',
+ transferContent: '转账内容',
+ transferCopy: '复制转账内容',
+ transferHint: '请使用银行 App 扫码,或按上方信息手动转账。金额与转账内容必须完全一致 — 款项到账后订单自动完成。',
scanWxpayHint: '请使用手机打开微信,扫描二维码完成支付',
payInNewWindow: '请在新窗口中完成支付',
payInNewWindowHint: '支付页面已在新窗口打开,请在新窗口中完成支付后返回此页面',
diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts
index 061faa067d9..f3425c0d640 100644
--- a/frontend/src/types/payment.ts
+++ b/frontend/src/types/payment.ts
@@ -200,7 +200,17 @@ export interface WechatJSAPIPayload {
paySign?: string
}
+/** Manual bank-transfer details for gateways whose QR encodes a transfer (SePay VietQR). */
+export interface PaymentTransferInfo {
+ account_number?: string
+ account_name?: string
+ bank_bin?: string
+ amount?: string
+ content?: string
+}
+
export interface CreateOrderResult {
+ transfer_info?: PaymentTransferInfo
order_id: number
amount: number
pay_url?: string
diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue
index 8382bd25d2c..37fb22ec6b1 100644
--- a/frontend/src/views/user/PaymentView.vue
+++ b/frontend/src/views/user/PaymentView.vue
@@ -25,6 +25,7 @@
:order-type="paymentState.orderType"
:currency="paymentState.currency || selectedCurrency"
:out-trade-no="paymentState.outTradeNo"
+ :transfer-info="paymentState.transferInfo"
:mobile-alipay-deep-link="paymentState.alipayMobilePrecreateDeepLink"
@done="onPaymentDone"
@success="onPaymentSuccess"
@@ -351,6 +352,7 @@ function emptyPaymentState(): PaymentRecoverySnapshot {
orderId: 0,
amount: 0,
qrCode: '',
+ transferInfo: undefined,
expiresAt: '',
paymentType: '',
payUrl: '',
From 2f74d4510b680a0bd9a4edf632044ba2f47249c2 Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Sat, 15 Aug 2026 10:15:33 +0700
Subject: [PATCH 22/25] fix(payment): emit separator-free sepay transfer
content
Real banks keep transfer content verbatim, so shipping sub2_... 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.
---
backend/internal/payment/provider/sepay.go | 10 +++++++++-
backend/internal/payment/provider/sepay_test.go | 4 ++--
backend/internal/service/payment_order.go | 2 +-
.../internal/service/payment_sepay_resolution_test.go | 2 +-
4 files changed, 13 insertions(+), 5 deletions(-)
diff --git a/backend/internal/payment/provider/sepay.go b/backend/internal/payment/provider/sepay.go
index 933b2b132a2..89e8333809f 100644
--- a/backend/internal/payment/provider/sepay.go
+++ b/backend/internal/payment/provider/sepay.go
@@ -104,6 +104,14 @@ func sepayCodeMatchesOrder(code, outTradeNo string) bool {
// CreatePayment builds the VietQR payload offline. No upstream call: the
// transfer only exists once the customer pays, confirmed via webhook.
+//
+// The transfer content is the separator-free normalized form of the order ID:
+// SePay extracts payment codes as contiguous alphanumeric strings (its pattern
+// classes have no separator), so shipping "sub2_2026..." in the QR would make
+// real bank transfers — which keep the content verbatim — fail code
+// extraction. The normalized form ("SUB22026...") is what banks type, what
+// SePay extracts, and what webhook/query matching already resolves back to
+// the canonical out_trade_no.
func (s *SePay) CreatePayment(_ context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
amountVND, err := strconv.ParseInt(strings.TrimSpace(req.Amount), 10, 64)
if err != nil || amountVND <= 0 {
@@ -113,7 +121,7 @@ func (s *SePay) CreatePayment(_ context.Context, req payment.CreatePaymentReques
strings.TrimSpace(s.config["bankBin"]),
strings.TrimSpace(s.config["bankAccountNumber"]),
amountVND,
- req.OrderID,
+ payment.NormalizeTransferCode(req.OrderID),
)
return &payment.CreatePaymentResponse{QRCode: payload, Currency: payment.CurrencyVND}, nil
}
diff --git a/backend/internal/payment/provider/sepay_test.go b/backend/internal/payment/provider/sepay_test.go
index 8a0157b8bdd..0f41106f355 100644
--- a/backend/internal/payment/provider/sepay_test.go
+++ b/backend/internal/payment/provider/sepay_test.go
@@ -79,8 +79,8 @@ func TestSePayCreatePayment(t *testing.T) {
if !strings.Contains(resp.QRCode, tlv("54", "50000")) {
t.Fatalf("QR payload missing amount TLV: %s", resp.QRCode)
}
- if !strings.Contains(resp.QRCode, "sub2_20260814aB3kX9mQ") {
- t.Fatalf("QR payload missing transfer content: %s", resp.QRCode)
+ if want := "SUB220260814AB3KX9MQ"; !strings.Contains(resp.QRCode, want) {
+ t.Fatalf("QR payload missing normalized transfer content %q: %s", want, resp.QRCode)
}
}
diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go
index 9454afdad42..d617f13a2fb 100644
--- a/backend/internal/service/payment_order.go
+++ b/backend/internal/service/payment_order.go
@@ -763,7 +763,7 @@ func buildPaymentTransferInfo(sel *payment.InstanceSelection, pr *payment.Create
AccountName: strings.TrimSpace(sel.Config["accountName"]),
BankBin: strings.TrimSpace(sel.Config["bankBin"]),
Amount: payment.FormatAmountForCurrency(payAmount, pr.Currency),
- Content: order.OutTradeNo,
+ Content: payment.NormalizeTransferCode(order.OutTradeNo),
}
}
diff --git a/backend/internal/service/payment_sepay_resolution_test.go b/backend/internal/service/payment_sepay_resolution_test.go
index e26364250f2..6ec9d50f6ce 100644
--- a/backend/internal/service/payment_sepay_resolution_test.go
+++ b/backend/internal/service/payment_sepay_resolution_test.go
@@ -124,7 +124,7 @@ func TestBuildPaymentTransferInfoSePay(t *testing.T) {
require.Equal(t, "SEPAY TEST", info.AccountName)
require.Equal(t, "970422", info.BankBin)
require.Equal(t, "250000", info.Amount)
- require.Equal(t, "sub2_20260815YujbZRZd", info.Content)
+ require.Equal(t, "SUB220260815YUJBZRZD", info.Content)
require.Nil(t, buildPaymentTransferInfo(nil, pr, 250000, order))
require.Nil(t, buildPaymentTransferInfo(&payment.InstanceSelection{ProviderKey: payment.TypeAlipay}, pr, 250000, order))
From 85a0b28cecfa4ed512113c6dab1f71e3ad668750 Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Sat, 15 Aug 2026 10:49:12 +0700
Subject: [PATCH 23/25] feat(payment): convert VND recharge to USD balance via
configured rate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
backend/internal/service/payment_amounts.go | 17 ++++++++++
.../service/payment_config_service.go | 4 ++-
backend/internal/service/payment_order.go | 9 +++--
.../service/payment_order_sepay_vnd_test.go | 34 +++++++++++++++++++
frontend/src/views/user/PaymentView.vue | 10 +++++-
5 files changed, 70 insertions(+), 4 deletions(-)
diff --git a/backend/internal/service/payment_amounts.go b/backend/internal/service/payment_amounts.go
index d2f59c1b9d5..f6722df0009 100644
--- a/backend/internal/service/payment_amounts.go
+++ b/backend/internal/service/payment_amounts.go
@@ -4,6 +4,7 @@ import (
"math"
"github.com/Wei-Shaw/sub2api/internal/payment"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/shopspring/decimal"
)
@@ -30,6 +31,22 @@ func normalizeSubscriptionUSDToVNDRate(rate float64) float64 {
return normalizeSubscriptionUSDToCNYRate(rate)
}
+// calculateRechargeCreditedBalance converts a recharge amount in the method
+// currency into the panel's USD-denominated balance. VND methods divide by
+// the configured USD→VND rate (the recharge multiplier still applies on top);
+// other currencies keep the legacy multiplier-only behavior.
+func calculateRechargeCreditedBalance(payAmount float64, methodCurrency string, cfg *PaymentConfig) (float64, error) {
+ if methodCurrency == payment.CurrencyVND {
+ rate := normalizeSubscriptionUSDToVNDRate(cfg.SubscriptionUSDToVNDRate)
+ if rate <= 0 {
+ return 0, infraerrors.BadRequest("RECHARGE_VND_RATE_REQUIRED",
+ "balance recharge via VND methods requires the USD to VND rate to be configured")
+ }
+ payAmount = decimal.NewFromFloat(payAmount).Div(decimal.NewFromFloat(rate)).InexactFloat64()
+ }
+ return calculateCreditedBalance(payAmount, cfg.BalanceRechargeMultiplier), nil
+}
+
func calculateCreditedBalance(paymentAmount, multiplier float64) float64 {
return decimal.NewFromFloat(paymentAmount).
Mul(decimal.NewFromFloat(normalizeBalanceRechargeMultiplier(multiplier))).
diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go
index 6d1e6cd4e88..bb254800d65 100644
--- a/backend/internal/service/payment_config_service.go
+++ b/backend/internal/service/payment_config_service.go
@@ -381,7 +381,9 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda
m[SettingBalancePayDisabled] = formatBoolOrEmpty(req.BalanceDisabled)
}
if req.BalanceRechargeMultiplier != nil {
- m[SettingBalanceRechargeMult] = formatPositiveFloat(req.BalanceRechargeMultiplier)
+ // Exact precision: VND-scale multipliers (e.g. 0.00004) round to
+ // "0.00" under the 2-decimal formatter and silently reset to 1.
+ m[SettingBalanceRechargeMult] = formatPositiveFloatExact(req.BalanceRechargeMultiplier)
}
if req.SubscriptionUSDToCNYRate != nil {
m[SettingSubscriptionUSDToCNYRate] = formatPositiveFloatExact(req.SubscriptionUSDToCNYRate)
diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go
index d617f13a2fb..6a42f9b4fe5 100644
--- a/backend/internal/service/payment_order.go
+++ b/backend/internal/service/payment_order.go
@@ -58,8 +58,6 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
if plan != nil {
orderAmount = plan.Price
limitAmount = plan.Price
- } else if req.OrderType == payment.OrderTypeBalance {
- orderAmount = calculateCreditedBalance(req.Amount, cfg.BalanceRechargeMultiplier)
}
feeRate := cfg.RechargeFeeRate
methodCurrency := payment.DefaultPaymentCurrency
@@ -69,6 +67,13 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
return nil, err
}
}
+ if req.OrderType == payment.OrderTypeBalance {
+ credited, cerr := calculateRechargeCreditedBalance(req.Amount, methodCurrency, cfg)
+ if cerr != nil {
+ return nil, cerr
+ }
+ orderAmount = credited
+ }
if req.OrderType == payment.OrderTypeSubscription && methodCurrency == payment.CurrencyVND {
if normalizeSubscriptionUSDToVNDRate(cfg.SubscriptionUSDToVNDRate) <= 0 {
return nil, infraerrors.BadRequest("SUBSCRIPTION_VND_RATE_REQUIRED",
diff --git a/backend/internal/service/payment_order_sepay_vnd_test.go b/backend/internal/service/payment_order_sepay_vnd_test.go
index 7f5ff2fe5b5..5b52e5656a9 100644
--- a/backend/internal/service/payment_order_sepay_vnd_test.go
+++ b/backend/internal/service/payment_order_sepay_vnd_test.go
@@ -1,6 +1,7 @@
package service
import (
+ "strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/payment"
@@ -40,3 +41,36 @@ func TestCreateOrderPayAmountForOrderTypeVND(t *testing.T) {
t.Fatalf("str=%q amt=%v, want 247500", str, amt)
}
}
+
+func TestCalculateRechargeCreditedBalanceVND(t *testing.T) {
+ cases := []struct {
+ name string
+ payAmount float64
+ currency string
+ cfg *PaymentConfig
+ want float64
+ wantErrSubstr string
+ }{
+ {"vnd divides by rate", 250000, payment.CurrencyVND, &PaymentConfig{SubscriptionUSDToVNDRate: 25000}, 10, ""},
+ {"multiplier composes on vnd", 250000, payment.CurrencyVND, &PaymentConfig{SubscriptionUSDToVNDRate: 25000, BalanceRechargeMultiplier: 0.5}, 5, ""},
+ {"vnd without rate rejected", 250000, payment.CurrencyVND, &PaymentConfig{}, 0, "RECHARGE_VND_RATE_REQUIRED"},
+ {"cny keeps multiplier-only", 100, payment.DefaultPaymentCurrency, &PaymentConfig{BalanceRechargeMultiplier: 0.14}, 14, ""},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := calculateRechargeCreditedBalance(tc.payAmount, tc.currency, tc.cfg)
+ if tc.wantErrSubstr != "" {
+ if err == nil || !strings.Contains(err.Error(), tc.wantErrSubstr) {
+ t.Fatalf("err = %v, want containing %q", err, tc.wantErrSubstr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != tc.want {
+ t.Fatalf("= %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue
index 37fb22ec6b1..dbb3a1bf351 100644
--- a/frontend/src/views/user/PaymentView.vue
+++ b/frontend/src/views/user/PaymentView.vue
@@ -531,7 +531,15 @@ const subscriptionUsdToVndRate = computed(() => {
const rate = checkout.value.subscription_usd_to_vnd_rate
return Number.isFinite(rate) && rate > 0 ? rate : 0
})
-const creditedAmount = computed(() => Math.round((validAmount.value * balanceRechargeMultiplier.value) * 100) / 100)
+// VND 充值按 USD→VND 汇率折算为美元余额(与后端 calculateRechargeCreditedBalance 严格镜像);
+// 其他币种维持倍率行为。
+const creditedAmount = computed(() => {
+ const rate = subscriptionUsdToVndRate.value
+ const amount = rate > 0 && selectedCurrency.value === 'VND'
+ ? validAmount.value / rate
+ : validAmount.value * balanceRechargeMultiplier.value
+ return Math.round(amount * 100) / 100
+})
// Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+
const planGridClass = computed(() => {
From 9896ef9ebebd0579c97b563ccda2aee1981689b2 Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Sat, 15 Aug 2026 10:58:05 +0700
Subject: [PATCH 24/25] fix(payment): keep sepay transfer content lowercase
prefix
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.
---
backend/internal/payment/provider/sepay.go | 15 +++++++--------
backend/internal/payment/provider/sepay_test.go | 4 ++--
backend/internal/payment/transfer_code.go | 15 +++++++++++++++
backend/internal/payment/transfer_code_test.go | 15 +++++++++++++++
backend/internal/service/payment_order.go | 2 +-
.../service/payment_sepay_resolution_test.go | 2 +-
6 files changed, 41 insertions(+), 12 deletions(-)
diff --git a/backend/internal/payment/provider/sepay.go b/backend/internal/payment/provider/sepay.go
index 89e8333809f..98e3694402f 100644
--- a/backend/internal/payment/provider/sepay.go
+++ b/backend/internal/payment/provider/sepay.go
@@ -105,13 +105,12 @@ func sepayCodeMatchesOrder(code, outTradeNo string) bool {
// CreatePayment builds the VietQR payload offline. No upstream call: the
// transfer only exists once the customer pays, confirmed via webhook.
//
-// The transfer content is the separator-free normalized form of the order ID:
-// SePay extracts payment codes as contiguous alphanumeric strings (its pattern
-// classes have no separator), so shipping "sub2_2026..." in the QR would make
-// real bank transfers — which keep the content verbatim — fail code
-// extraction. The normalized form ("SUB22026...") is what banks type, what
-// SePay extracts, and what webhook/query matching already resolves back to
-// the canonical out_trade_no.
+// The transfer content is the order ID with separators stripped, case
+// preserved ("sub22026..."): SePay extracts payment codes as contiguous
+// alphanumeric strings (its pattern classes have no separator), so shipping
+// "sub2_2026..." verbatim would make real bank transfers fail code
+// extraction. Webhook/query matching resolves the stripped form back to the
+// canonical out_trade_no case-insensitively.
func (s *SePay) CreatePayment(_ context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
amountVND, err := strconv.ParseInt(strings.TrimSpace(req.Amount), 10, 64)
if err != nil || amountVND <= 0 {
@@ -121,7 +120,7 @@ func (s *SePay) CreatePayment(_ context.Context, req payment.CreatePaymentReques
strings.TrimSpace(s.config["bankBin"]),
strings.TrimSpace(s.config["bankAccountNumber"]),
amountVND,
- payment.NormalizeTransferCode(req.OrderID),
+ payment.StripTransferSeparators(req.OrderID),
)
return &payment.CreatePaymentResponse{QRCode: payload, Currency: payment.CurrencyVND}, nil
}
diff --git a/backend/internal/payment/provider/sepay_test.go b/backend/internal/payment/provider/sepay_test.go
index 0f41106f355..4085eff338e 100644
--- a/backend/internal/payment/provider/sepay_test.go
+++ b/backend/internal/payment/provider/sepay_test.go
@@ -79,8 +79,8 @@ func TestSePayCreatePayment(t *testing.T) {
if !strings.Contains(resp.QRCode, tlv("54", "50000")) {
t.Fatalf("QR payload missing amount TLV: %s", resp.QRCode)
}
- if want := "SUB220260814AB3KX9MQ"; !strings.Contains(resp.QRCode, want) {
- t.Fatalf("QR payload missing normalized transfer content %q: %s", want, resp.QRCode)
+ if want := "sub220260814aB3kX9mQ"; !strings.Contains(resp.QRCode, want) {
+ t.Fatalf("QR payload missing stripped transfer content %q: %s", want, resp.QRCode)
}
}
diff --git a/backend/internal/payment/transfer_code.go b/backend/internal/payment/transfer_code.go
index 0f4c9e51c2e..88b9dc3b90f 100644
--- a/backend/internal/payment/transfer_code.go
+++ b/backend/internal/payment/transfer_code.go
@@ -14,3 +14,18 @@ func NormalizeTransferCode(code string) string {
}
return b.String()
}
+
+// StripTransferSeparators removes separator characters from a transfer code,
+// preserving case: sub2_20260815ZPbOX0Kl -> sub220260815ZPbOX0Kl. SePay's
+// payment-code extraction treats codes as contiguous alphanumeric strings and
+// prefix matching is case-insensitive, so the lowercase prefix and the mixed-
+// case suffix pass extraction exactly as typed.
+func StripTransferSeparators(code string) string {
+ var b strings.Builder
+ for _, r := range strings.TrimSpace(code) {
+ if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
+ b.WriteRune(r)
+ }
+ }
+ return b.String()
+}
diff --git a/backend/internal/payment/transfer_code_test.go b/backend/internal/payment/transfer_code_test.go
index 85c5493e382..17ffc481514 100644
--- a/backend/internal/payment/transfer_code_test.go
+++ b/backend/internal/payment/transfer_code_test.go
@@ -17,3 +17,18 @@ func TestNormalizeTransferCode(t *testing.T) {
}
}
}
+
+func TestStripTransferSeparators(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"sub2_20260815ZPbOX0Kl", "sub220260815ZPbOX0Kl"},
+ {"sub2_20260815YujbZRZd", "sub220260815YujbZRZd"},
+ {"SUB2_20260815", "SUB220260815"},
+ {"", ""},
+ {"___", ""},
+ }
+ for _, tc := range cases {
+ if got := StripTransferSeparators(tc.in); got != tc.want {
+ t.Errorf("StripTransferSeparators(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go
index 6a42f9b4fe5..2be88442c9e 100644
--- a/backend/internal/service/payment_order.go
+++ b/backend/internal/service/payment_order.go
@@ -768,7 +768,7 @@ func buildPaymentTransferInfo(sel *payment.InstanceSelection, pr *payment.Create
AccountName: strings.TrimSpace(sel.Config["accountName"]),
BankBin: strings.TrimSpace(sel.Config["bankBin"]),
Amount: payment.FormatAmountForCurrency(payAmount, pr.Currency),
- Content: payment.NormalizeTransferCode(order.OutTradeNo),
+ Content: payment.StripTransferSeparators(order.OutTradeNo),
}
}
diff --git a/backend/internal/service/payment_sepay_resolution_test.go b/backend/internal/service/payment_sepay_resolution_test.go
index 6ec9d50f6ce..575f0bbc609 100644
--- a/backend/internal/service/payment_sepay_resolution_test.go
+++ b/backend/internal/service/payment_sepay_resolution_test.go
@@ -124,7 +124,7 @@ func TestBuildPaymentTransferInfoSePay(t *testing.T) {
require.Equal(t, "SEPAY TEST", info.AccountName)
require.Equal(t, "970422", info.BankBin)
require.Equal(t, "250000", info.Amount)
- require.Equal(t, "SUB220260815YUJBZRZD", info.Content)
+ require.Equal(t, "sub220260815YujbZRZd", info.Content)
require.Nil(t, buildPaymentTransferInfo(nil, pr, 250000, order))
require.Nil(t, buildPaymentTransferInfo(&payment.InstanceSelection{ProviderKey: payment.TypeAlipay}, pr, 250000, order))
From 159b513752c99bfcc614cb052d85862e204c5748 Mon Sep 17 00:00:00 2001
From: trinknx <259825374+trinknx@users.noreply.github.com>
Date: Sat, 15 Aug 2026 11:24:57 +0700
Subject: [PATCH 25/25] feat(payment): serve sepay QR via vietqr.app image
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.
---
backend/internal/payment/provider/sepay.go | 19 +++++++++------
.../internal/payment/provider/sepay_test.go | 4 ++++
backend/internal/payment/types.go | 1 +
backend/internal/service/payment_order.go | 1 +
backend/internal/service/payment_service.go | 1 +
.../components/payment/PaymentStatusPanel.vue | 24 +++++++++++++------
.../src/components/payment/paymentFlow.ts | 4 ++++
frontend/src/types/payment.ts | 1 +
frontend/src/views/user/PaymentView.vue | 2 ++
9 files changed, 43 insertions(+), 14 deletions(-)
diff --git a/backend/internal/payment/provider/sepay.go b/backend/internal/payment/provider/sepay.go
index 98e3694402f..e94d0bdca06 100644
--- a/backend/internal/payment/provider/sepay.go
+++ b/backend/internal/payment/provider/sepay.go
@@ -116,13 +116,18 @@ func (s *SePay) CreatePayment(_ context.Context, req payment.CreatePaymentReques
if err != nil || amountVND <= 0 {
return nil, fmt.Errorf("sepay amount must be a positive integer VND value, got %q", req.Amount)
}
- payload := buildVietQRPayload(
- strings.TrimSpace(s.config["bankBin"]),
- strings.TrimSpace(s.config["bankAccountNumber"]),
- amountVND,
- payment.StripTransferSeparators(req.OrderID),
- )
- return &payment.CreatePaymentResponse{QRCode: payload, Currency: payment.CurrencyVND}, nil
+ bin := strings.TrimSpace(s.config["bankBin"])
+ account := strings.TrimSpace(s.config["bankAccountNumber"])
+ content := payment.StripTransferSeparators(req.OrderID)
+ payload := buildVietQRPayload(bin, account, amountVND, content)
+ // Battle-tested image generator (the one SePay's own docs recommend) —
+ // banking apps scan its output reliably. The EMV payload above stays as
+ // the machine-readable fallback.
+ qrImageURL := "https://vietqr.app/img?acc=" + url.QueryEscape(account) +
+ "&bank=" + url.QueryEscape(bin) +
+ "&amount=" + strconv.FormatInt(amountVND, 10) +
+ "&des=" + url.QueryEscape(content)
+ return &payment.CreatePaymentResponse{QRCode: payload, QRImageURL: qrImageURL, Currency: payment.CurrencyVND}, nil
}
// Refund is not supported: SePay has no refund API — refunds must be issued
diff --git a/backend/internal/payment/provider/sepay_test.go b/backend/internal/payment/provider/sepay_test.go
index 4085eff338e..f3e3ce663ca 100644
--- a/backend/internal/payment/provider/sepay_test.go
+++ b/backend/internal/payment/provider/sepay_test.go
@@ -82,6 +82,10 @@ func TestSePayCreatePayment(t *testing.T) {
if want := "sub220260814aB3kX9mQ"; !strings.Contains(resp.QRCode, want) {
t.Fatalf("QR payload missing stripped transfer content %q: %s", want, resp.QRCode)
}
+ wantImg := "https://vietqr.app/img?acc=0123456789&bank=970422&amount=50000&des=" + url.QueryEscape("sub220260814aB3kX9mQ")
+ if resp.QRImageURL != wantImg {
+ t.Fatalf("QR image URL = %q, want %q", resp.QRImageURL, wantImg)
+ }
}
func TestSePayCreatePaymentRejectsNonIntegerAmount(t *testing.T) {
diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go
index 6c9f4515e28..3d817a4ec51 100644
--- a/backend/internal/payment/types.go
+++ b/backend/internal/payment/types.go
@@ -151,6 +151,7 @@ type CreatePaymentResponse struct {
TradeNo string // Third-party transaction ID
PayURL string // H5 payment URL (alipay/wxpay)
QRCode string // QR code content for scanning
+ QRImageURL string // Ready-made QR image URL (VietQR gateways)
ClientSecret string // Stripe PaymentIntent 客户端密钥
IntentID string // 前端 SDK 需要的服务商支付意图 ID
Currency string // 服务商支付币种
diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go
index 2be88442c9e..0283dc646a5 100644
--- a/backend/internal/service/payment_order.go
+++ b/backend/internal/service/payment_order.go
@@ -785,6 +785,7 @@ func buildCreateOrderResponse(order *dbent.PaymentOrder, req CreateOrderRequest,
OutTradeNo: order.OutTradeNo,
PayURL: pr.PayURL,
QRCode: pr.QRCode,
+ QRImageURL: pr.QRImageURL,
ClientSecret: pr.ClientSecret,
IntentID: pr.IntentID,
Currency: pr.Currency,
diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go
index 12cce925f7b..cbde64bfbe0 100644
--- a/backend/internal/service/payment_service.go
+++ b/backend/internal/service/payment_service.go
@@ -108,6 +108,7 @@ type CreateOrderResponse struct {
PaymentType string `json:"payment_type"`
OutTradeNo string `json:"out_trade_no,omitempty"`
TransferInfo *PaymentTransferInfo `json:"transfer_info,omitempty"`
+ QRImageURL string `json:"qr_image_url,omitempty"`
PayURL string `json:"pay_url,omitempty"`
QRCode string `json:"qr_code,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue
index cafd72410ad..9fa3a73e43a 100644
--- a/frontend/src/components/payment/PaymentStatusPanel.vue
+++ b/frontend/src/components/payment/PaymentStatusPanel.vue
@@ -171,13 +171,22 @@
{{ scanTitle }}
-
-
-
-
-
-
-
+
![VietQR]()
+
+
+
+
+
+
+
+
+
{{ scanHint }}
@@ -274,6 +283,7 @@ const props = defineProps<{
amount?: number
payAmount?: number
qrCode: string
+ qrImageUrl?: string
expiresAt: string
paymentType: string
payUrl?: string
diff --git a/frontend/src/components/payment/paymentFlow.ts b/frontend/src/components/payment/paymentFlow.ts
index 13cddd60ac8..9ab9b955fbe 100644
--- a/frontend/src/components/payment/paymentFlow.ts
+++ b/frontend/src/components/payment/paymentFlow.ts
@@ -44,6 +44,7 @@ export interface PaymentRecoverySnapshot {
orderId: number
amount: number
qrCode: string
+ qrImageUrl?: string
expiresAt: string
paymentType: string
payUrl: string
@@ -165,6 +166,7 @@ export function decidePaymentLaunch(
orderId: result.order_id,
amount: result.amount,
qrCode: result.qr_code || '',
+ qrImageUrl: result.qr_image_url || '',
expiresAt: result.expires_at || '',
paymentType: visibleMethod,
payUrl: result.pay_url || '',
@@ -317,6 +319,7 @@ export function readPaymentRecoverySnapshot(
|| (parsed.alipayMobilePrecreateDeepLink != null && typeof parsed.alipayMobilePrecreateDeepLink !== 'boolean')
|| typeof parsed.createdAt !== 'number'
|| (parsed.transferInfo != null && typeof parsed.transferInfo !== 'object')
+ || (parsed.qrImageUrl != null && typeof parsed.qrImageUrl !== 'string')
) {
return null
}
@@ -334,6 +337,7 @@ export function readPaymentRecoverySnapshot(
orderId: parsed.orderId,
amount: parsed.amount,
qrCode: parsed.qrCode,
+ qrImageUrl: parsed.qrImageUrl || '',
expiresAt: parsed.expiresAt,
paymentType: parsed.paymentType,
payUrl: parsed.payUrl,
diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts
index f3425c0d640..aa4fb8890fe 100644
--- a/frontend/src/types/payment.ts
+++ b/frontend/src/types/payment.ts
@@ -215,6 +215,7 @@ export interface CreateOrderResult {
amount: number
pay_url?: string
qr_code?: string
+ qr_image_url?: string
client_secret?: string
intent_id?: string
currency?: string
diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue
index dbb3a1bf351..11b522b8239 100644
--- a/frontend/src/views/user/PaymentView.vue
+++ b/frontend/src/views/user/PaymentView.vue
@@ -19,6 +19,7 @@
:amount="paymentState.amount"
:pay-amount="paymentState.payAmount"
:qr-code="paymentState.qrCode"
+ :qr-image-url="paymentState.qrImageUrl"
:expires-at="paymentState.expiresAt"
:payment-type="paymentState.paymentType"
:pay-url="paymentState.payUrl"
@@ -352,6 +353,7 @@ function emptyPaymentState(): PaymentRecoverySnapshot {
orderId: 0,
amount: 0,
qrCode: '',
+ qrImageUrl: '',
transferInfo: undefined,
expiresAt: '',
paymentType: '',