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/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/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/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/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") + } +} diff --git a/backend/internal/payment/provider/sepay.go b/backend/internal/payment/provider/sepay.go new file mode 100644 index 00000000000..e94d0bdca06 --- /dev/null +++ b/backend/internal/payment/provider/sepay.go @@ -0,0 +1,335 @@ +// Package provider contains concrete payment provider implementations. +package provider + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "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 + } + // Key must match the webhook notification shape ("accountNumber", set in + // VerifyNotification) so snapshot validation accepts both the webhook and + // the query/reconcile paths. The snapshot builder stores this value as the + // order's pinned merchant_id. + return map[string]string{"accountNumber": 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 { + return payment.NormalizeTransferCode(code) +} + +// 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. +// +// 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 { + return nil, fmt.Errorf("sepay amount must be a positive integer VND value, got %q", req.Amount) + } + 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 +// 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") +} + +// 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 +} + +// 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. +// Banks and SePay's code extraction drop separators (the sub2_ underscore), so +// when the raw out_trade_no yields no match the query retries with its +// separator-stripped normalized form. +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") + } + for _, q := range sepayQueryVariants(outTradeNo) { + resp, err := s.queryTransactions(ctx, q) + if err != nil { + return nil, err + } + for _, tx := range resp { + 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 +} + +// sepayQueryVariants returns the q= search terms for an out_trade_no: the raw +// value first, then the normalized (letters+digits only) form which matches +// content the bank or SePay extracted without separators. +func sepayQueryVariants(outTradeNo string) []string { + normalized := sepayNormalizeCode(outTradeNo) + if normalized == "" || strings.EqualFold(normalized, outTradeNo) { + return []string{outTradeNo} + } + return []string{outTradeNo, normalized} +} + +func (s *SePay) queryTransactions(ctx context.Context, q string) ([]sepayTransaction, error) { + endpoint := s.config["apiBase"] + "/v2/transactions?q=" + url.QueryEscape(q) + "&transfer_type=in&per_page=100" + 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) + } + return parsed.Data, 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 new file mode 100644 index 00000000000..f3e3ce663ca --- /dev/null +++ b/backend/internal/payment/provider/sepay_test.go @@ -0,0 +1,356 @@ +package provider + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "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 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) { + 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) + } +} + +// TestSePayMerchantIdentityMetadata pins the identity metadata contract shared +// by the query/reconcile path and the snapshot validator: the key is +// "accountNumber" (same as webhook notifications), never "bankAccountNumber". +func TestSePayMerchantIdentityMetadata(t *testing.T) { + provider, err := NewSePay("1", sepayTestConfig()) + if err != nil { + t.Fatal(err) + } + metadata := provider.MerchantIdentityMetadata() + if metadata["accountNumber"] != "0123456789" { + t.Fatalf("metadata[accountNumber] = %q, want %q (metadata = %v)", metadata["accountNumber"], "0123456789", metadata) + } + if _, ok := metadata["bankAccountNumber"]; ok { + t.Fatalf("metadata must not carry legacy bankAccountNumber key: %v", metadata) + } +} + +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) + } + } +} + +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") + } +} + +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) + } + // Query metadata must use the same identity key as the webhook path + // ("accountNumber") so snapshot validation accepts reconciled orders. + if resp.Metadata["accountNumber"] != "0123456789" { + t.Fatalf("metadata accountNumber = %q, want %q (metadata = %v)", resp.Metadata["accountNumber"], "0123456789", resp.Metadata) + } + if _, ok := resp.Metadata["bankAccountNumber"]; ok { + t.Fatalf("metadata must not carry legacy bankAccountNumber key: %v", resp.Metadata) + } + if len(queries) != 1 || queries[0].Get("q") != "sub2_20260814aB3kX9mQ" || queries[0].Get("transfer_type") != "in" || queries[0].Get("per_page") != "100" { + 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) + } + } +} + +func TestSePayQueryOrderRetriesNormalizedQuery(t *testing.T) { + // Real-sandbox behavior: SePay's q= search does not match the raw + // out_trade_no when the extracted code/content dropped the sub2_ + // underscore; the normalized (letters+digits) query does. + var queries []url.Values + srv := sepayQueryServer(t, &queries, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("q") == "sub2_20260815YujbZRZd" { + _, _ = w.Write([]byte(`{"status":"success","data":[]}`)) + return + } + _, _ = w.Write([]byte(`{"status":"success","data":[{"id":"t1","transaction_date":"2026-08-15 02:48:51","transfer_type":"in","amount_in":250000,"transaction_content":"sub220260815YujbZRZd chuyen tien","reference_number":"SB991D714D42E5","code":"sub220260815YujbZRZd"}]}`)) + }) + cfg := sepayTestConfig() + cfg["apiBase"] = srv.URL + p, _ := NewSePay("1", cfg) + + resp, err := p.QueryOrder(context.Background(), "sub2_20260815YujbZRZd") + if err != nil { + t.Fatal(err) + } + if resp.Status != payment.ProviderStatusPaid || resp.Amount != 250000 || resp.TradeNo != "SB991D714D42E5" { + t.Fatalf("resp = %+v, want paid 250000", resp) + } + if len(queries) != 2 { + t.Fatalf("expected 2 queries (raw then normalized), got %d: %v", len(queries), queries) + } + if got := queries[0].Get("q"); got != "sub2_20260815YujbZRZd" { + t.Fatalf("first q = %q", got) + } + if got := queries[1].Get("q"); got != "SUB220260815YUJBZRZD" { + t.Fatalf("second (normalized) q = %q", got) + } +} 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) + } +} diff --git a/backend/internal/payment/transfer_code.go b/backend/internal/payment/transfer_code.go new file mode 100644 index 00000000000..88b9dc3b90f --- /dev/null +++ b/backend/internal/payment/transfer_code.go @@ -0,0 +1,31 @@ +package payment + +import "strings" + +// NormalizeTransferCode canonicalizes a bank-transfer payment code for +// matching: uppercase, keep only letters and digits. Banks and SePay's code +// extraction may drop separators (the sub2_ underscore) or change case. +func NormalizeTransferCode(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() +} + +// 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 new file mode 100644 index 00000000000..17ffc481514 --- /dev/null +++ b/backend/internal/payment/transfer_code_test.go @@ -0,0 +1,34 @@ +package payment + +import "testing" + +func TestNormalizeTransferCode(t *testing.T) { + cases := []struct{ in, want string }{ + {"sub2_20260815YujbZRZd", "SUB220260815YUJBZRZD"}, + {"sub220260815YujbZRZd", "SUB220260815YUJBZRZD"}, + {"SUB2_20260815YUJBZRZD", "SUB220260815YUJBZRZD"}, + {" sub2-2026.x y ", "SUB22026XY"}, + {"", ""}, + {"___", ""}, + } + for _, tc := range cases { + if got := NormalizeTransferCode(tc.in); got != tc.want { + t.Errorf("NormalizeTransferCode(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +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/payment/types.go b/backend/internal/payment/types.go index 6421c4afed0..3d817a4ec51 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: @@ -148,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/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/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 79dfb919a68..361c7d2a098 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -964,6 +964,7 @@ func TestAPIContracts(t *testing.T) { "payment_balance_disabled": false, "payment_balance_recharge_multiplier": 0, "payment_subscription_usd_to_cny_rate": 0, + "payment_subscription_usd_to_vnd_rate": 0, "payment_recharge_fee_rate": 0, "payment_load_balance_strategy": "", "payment_product_name_prefix": "", @@ -1275,6 +1276,7 @@ func TestAPIContracts(t *testing.T) { "payment_balance_disabled": false, "payment_balance_recharge_multiplier": 0, "payment_subscription_usd_to_cny_rate": 0, + "payment_subscription_usd_to_vnd_rate": 0, "payment_recharge_fee_rate": 0, "payment_load_balance_strategy": "", "payment_product_name_prefix": "", 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) --- diff --git a/backend/internal/service/payment_amounts.go b/backend/internal/service/payment_amounts.go index 2fd00c5957d..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" ) @@ -25,6 +26,27 @@ func normalizeSubscriptionUSDToCNYRate(rate float64) float64 { return rate } +// normalizeSubscriptionUSDToVNDRate 将非法值归一为 0(换算关闭)。 +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_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..04952f55191 100644 --- a/backend/internal/service/payment_config_providers_test.go +++ b/backend/internal/service/payment_config_providers_test.go @@ -805,3 +805,63 @@ 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") + } +} + +func TestValidProviderKeysRefundCapabilitySync(t *testing.T) { + t.Parallel() + + for key := range validProviderKeys { + if key == payment.TypeSePay { + continue + } + if !providerSupportsRefund(key) { + t.Errorf("provider %s is valid but not refund-capable; keep validProviderKeys and refundCapableProviders in sync", key) + } + } +} diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index aac1cc8e263..bb254800d65 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 { @@ -369,11 +381,16 @@ 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) } + 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_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) + } +} diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index 4d442f3d1e3..acebe522fe7 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,22 @@ func parseLegacyPaymentOrderID(orderID string, lookupErr error) (int64, bool) { return oid, true } +// resolveSepayNotificationOrderID resolves lenient SePay codes (uppercased, +// prefix-stripped, or separator-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 + } + if order := s.findSepayOrderByCode(ctx, code); 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_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go index 50ccd485ed3..44669a9074b 100644 --- a/backend/internal/service/payment_fulfillment_test.go +++ b/backend/internal/service/payment_fulfillment_test.go @@ -579,6 +579,53 @@ func TestValidateProviderNotificationMetadataRejectsStripeCurrencyMismatch(t *te assert.ErrorContains(t, err, "stripe currency mismatch") } +func TestValidateProviderNotificationMetadataSePayAccountNumber(t *testing.T) { + t.Parallel() + + order := &dbent.PaymentOrder{ + PaymentType: payment.TypeSePay, + ProviderSnapshot: map[string]any{ + "schema_version": 2, + "merchant_id": "0123456789", + }, + } + + // Matching bank account passes. + assert.NoError(t, validateProviderNotificationMetadata(order, payment.TypeSePay, map[string]string{ + "accountNumber": "0123456789", + "gateway": "Vietcombank", + })) + + // Transfer to a different bank account must not fulfill the order. + err := validateProviderNotificationMetadata(order, payment.TypeSePay, map[string]string{ + "accountNumber": "9999999999", + }) + assert.ErrorContains(t, err, "sepay accountNumber mismatch") + + // Missing accountNumber in the notification is rejected when the snapshot pins one. + err = validateProviderNotificationMetadata(order, payment.TypeSePay, map[string]string{ + "gateway": "Vietcombank", + }) + assert.ErrorContains(t, err, "sepay notification missing accountNumber") + + // The legacy QueryOrder metadata shape (key "bankAccountNumber") must not + // validate: both provider paths must carry "accountNumber". + err = validateProviderNotificationMetadata(order, payment.TypeSePay, map[string]string{ + "bankAccountNumber": "0123456789", + }) + assert.ErrorContains(t, err, "sepay notification missing accountNumber") + + // Legacy orders without a snapshotted bank account tolerate any account. + assert.NoError(t, validateProviderNotificationMetadata(&dbent.PaymentOrder{ + PaymentType: payment.TypeSePay, + ProviderSnapshot: map[string]any{ + "schema_version": 2, + }, + }, payment.TypeSePay, map[string]string{ + "accountNumber": "9999999999", + })) +} + func TestPaymentAmountToleranceForThreeDecimalCurrency(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index da7178dd7fe..0283dc646a5 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,7 +67,20 @@ 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.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", + "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 +96,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 } @@ -305,6 +316,12 @@ func buildPaymentOrderProviderSnapshot(sel *payment.InstanceSelection, req Creat } snapshot["currency"] = paymentProviderConfigCurrency(providerKey, sel.Config) } + if providerKey == payment.TypeSePay { + if bankAccountNumber := strings.TrimSpace(sel.Config["bankAccountNumber"]); bankAccountNumber != "" { + snapshot["merchant_id"] = bankAccountNumber + } + snapshot["currency"] = paymentProviderConfigCurrency(providerKey, sel.Config) + } if len(snapshot) == 1 { return nil @@ -643,20 +660,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). @@ -729,9 +757,25 @@ func classifyCreatePaymentError(req CreateOrderRequest, providerKey string, err return infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment gateway error: %s", err.Error())) } +// buildPaymentTransferInfo exposes manual bank-transfer details for providers +// whose QR encodes a bank transfer (SePay VietQR). +func buildPaymentTransferInfo(sel *payment.InstanceSelection, pr *payment.CreatePaymentResponse, payAmount float64, order *dbent.PaymentOrder) *PaymentTransferInfo { + if sel == nil || pr == nil || strings.TrimSpace(sel.ProviderKey) != payment.TypeSePay { + return nil + } + return &PaymentTransferInfo{ + AccountNumber: strings.TrimSpace(sel.Config["bankAccountNumber"]), + AccountName: strings.TrimSpace(sel.Config["accountName"]), + BankBin: strings.TrimSpace(sel.Config["bankBin"]), + Amount: payment.FormatAmountForCurrency(payAmount, pr.Currency), + Content: payment.StripTransferSeparators(order.OutTradeNo), + } +} + func buildCreateOrderResponse(order *dbent.PaymentOrder, req CreateOrderRequest, payAmount float64, sel *payment.InstanceSelection, pr *payment.CreatePaymentResponse, resultType payment.CreatePaymentResultType) *CreateOrderResponse { return &CreateOrderResponse{ OrderID: order.ID, + TransferInfo: buildPaymentTransferInfo(sel, pr, payAmount, order), Amount: order.Amount, PayAmount: payAmount, FeeRate: order.FeeRate, @@ -741,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_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_order_lifecycle_test.go b/backend/internal/service/payment_order_lifecycle_test.go index 658a1806c62..756f9eb7426 100644 --- a/backend/internal/service/payment_order_lifecycle_test.go +++ b/backend/internal/service/payment_order_lifecycle_test.go @@ -5,11 +5,15 @@ package service import ( "context" "database/sql" + "net/http" + "net/http/httptest" + "strconv" "testing" "time" dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/enttest" + "github.com/Wei-Shaw/sub2api/ent/paymentauditlog" "github.com/Wei-Shaw/sub2api/internal/payment" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/stretchr/testify/require" @@ -669,6 +673,186 @@ func TestReconcilePendingWxpayOrdersBackfillsPaidOrder(t *testing.T) { require.Len(t, redeemRepo.useCalls, 1) } +// sepayQueryRegressionEnv wires a pending sepay order whose provider snapshot +// pins merchant_id (bank account number) and currency, backed by a real SePay +// provider instance whose QueryOrder hits a local stub of the SePay API v2. +// This mirrors the missed-webhook rescue path: VerifyOrderByOutTradeNo -> +// reconcilePaid -> checkPaidWithOptions -> HandlePaymentNotification. +type sepayQueryRegressionEnv struct { + client *dbent.Client + order *dbent.PaymentOrder + user *dbent.User + userRepo *mockUserRepo + redeemRepo *paymentOrderLifecycleRedeemRepo + svc *PaymentService +} + +func newSepayQueryRegressionEnv(t *testing.T, instanceBankAccount, snapshotBankAccount string) *sepayQueryRegressionEnv { + t.Helper() + ctx := context.Background() + client := newPaymentOrderLifecycleTestClient(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer tok_sepay_query_regression", r.Header.Get("Authorization")) + _, _ = w.Write([]byte(`{"status":"success","data":[{"id":"ft1","transaction_date":"2026-08-14 09:30:00","transfer_type":"in","amount_in":50000,"transaction_content":"sub2_sepay_query_meta chuyen tien","reference_number":"FT2608SEPAY","code":"sub2_sepay_query_meta"}]}`)) + })) + t.Cleanup(srv.Close) + + inst, err := client.PaymentProviderInstance.Create(). + SetProviderKey(payment.TypeSePay). + SetName("sepay-query-regression"). + SetConfig(encryptWebhookProviderConfig(t, map[string]string{ + "apiToken": "tok_sepay_query_regression", + "apiBase": srv.URL, + "bankAccountNumber": instanceBankAccount, + "bankBin": "970422", + "webhookSecret": "secret", + })). + SetSupportedTypes(payment.TypeSePay). + SetEnabled(true). + Save(ctx) + require.NoError(t, err) + + user, err := client.User.Create(). + SetEmail("sepay-query-" + instanceBankAccount + "@example.com"). + SetPasswordHash("hash"). + SetUsername("sepay-query-regression-user"). + Save(ctx) + require.NoError(t, err) + + // Snapshot mirrors what buildPaymentOrderProviderSnapshot produces for + // sepay: bank account number stored as merchant_id, currency pinned to VND. + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(50000). + SetPayAmount(50000). + SetFeeRate(0). + SetRechargeCode("SEPAY-QUERY-REGRESSION"). + SetOutTradeNo("sub2_sepay_query_meta"). + SetPaymentType(payment.TypeSePay). + SetPaymentTradeNo(""). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusPending). + SetExpiresAt(time.Now().Add(time.Hour)). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + SetProviderSnapshot(map[string]any{ + "schema_version": 2, + "provider_instance_id": strconv.FormatInt(inst.ID, 10), + "provider_key": payment.TypeSePay, + "merchant_id": snapshotBankAccount, + "currency": "VND", + }). + Save(ctx) + require.NoError(t, err) + + userRepo := &mockUserRepo{ + getByIDUser: &User{ + ID: user.ID, + Email: user.Email, + Username: user.Username, + Balance: 0, + }, + } + userRepo.updateBalanceFn = func(ctx context.Context, id int64, amount float64) error { + require.Equal(t, user.ID, id) + if userRepo.getByIDUser != nil { + userRepo.getByIDUser.Balance += amount + } + return nil + } + redeemRepo := &paymentOrderLifecycleRedeemRepo{ + codesByCode: map[string]*RedeemCode{ + order.RechargeCode: { + ID: 1, + Code: order.RechargeCode, + Type: RedeemTypeBalance, + Value: order.Amount, + Status: StatusUnused, + }, + }, + } + redeemService := NewRedeemService( + redeemRepo, + userRepo, + nil, + nil, + nil, + client, + nil, + nil, + ) + + return &sepayQueryRegressionEnv{ + client: client, + order: order, + user: user, + userRepo: userRepo, + redeemRepo: redeemRepo, + svc: &PaymentService{ + entClient: client, + loadBalancer: newWebhookProviderTestLoadBalancer(client), + redeemService: redeemService, + userRepo: userRepo, + }, + } +} + +// TestVerifyOrderByOutTradeNoFulfillsSepaySnapshotOrder guards the query/ +// reconcile path (missed-webhook rescue): the SePay QueryOrder metadata must +// carry the identity key ("accountNumber") that the snapshot validator reads, +// so a paid upstream transfer actually fulfills the order. With the legacy +// "bankAccountNumber" metadata key, validation failed with "sepay notification +// missing accountNumber" and crediting never happened while the user was told +// the order was paid. +func TestVerifyOrderByOutTradeNoFulfillsSepaySnapshotOrder(t *testing.T) { + env := newSepayQueryRegressionEnv(t, "0123456789", "0123456789") + ctx := context.Background() + + got, err := env.svc.VerifyOrderByOutTradeNo(ctx, env.order.OutTradeNo, env.user.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusCompleted, got.Status) + require.Equal(t, "FT2608SEPAY", got.PaymentTradeNo) + + reloaded, err := env.client.PaymentOrder.Get(ctx, env.order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusCompleted, reloaded.Status) + + require.Equal(t, 50000.0, env.userRepo.getByIDUser.Balance) + require.Len(t, env.redeemRepo.useCalls, 1) +} + +// TestVerifyOrderByOutTradeNoRejectsSepayAccountMismatch asserts the negative: +// query metadata whose accountNumber differs from the snapshot-pinned +// merchant_id must not fulfill the order (e.g. bank account rotated after the +// order was created). +func TestVerifyOrderByOutTradeNoRejectsSepayAccountMismatch(t *testing.T) { + env := newSepayQueryRegressionEnv(t, "999988887777", "0123456789") + ctx := context.Background() + + got, err := env.svc.VerifyOrderByOutTradeNo(ctx, env.order.OutTradeNo, env.user.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusPending, got.Status) + + reloaded, err := env.client.PaymentOrder.Get(ctx, env.order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusPending, reloaded.Status) + + require.Equal(t, 0.0, env.userRepo.getByIDUser.Balance) + require.Empty(t, env.redeemRepo.useCalls) + + mismatchCount, err := env.client.PaymentAuditLog.Query(). + Where( + paymentauditlog.OrderIDEQ(strconv.FormatInt(env.order.ID, 10)), + paymentauditlog.ActionEQ("PAYMENT_PROVIDER_METADATA_MISMATCH"), + ). + Count(ctx) + require.NoError(t, err) + require.Equal(t, 1, mismatchCount, "metadata mismatch must be audited") +} + func TestVerifyOrderByOutTradeNoUsesOutTradeNoWhenPaymentTradeNoAlreadyExistsForAlipay(t *testing.T) { ctx := context.Background() client := newPaymentOrderLifecycleTestClient(t) diff --git a/backend/internal/service/payment_order_provider_snapshot.go b/backend/internal/service/payment_order_provider_snapshot.go index c5d8f86ff50..d32fcef3434 100644 --- a/backend/internal/service/payment_order_provider_snapshot.go +++ b/backend/internal/service/payment_order_provider_snapshot.go @@ -188,6 +188,16 @@ func validateProviderSnapshotMetadata(order *dbent.PaymentOrder, providerKey str return fmt.Errorf("easypay pid mismatch: expected %s, got %s", expected, actual) } } + case payment.TypeSePay: + if expected := strings.TrimSpace(snapshot.MerchantID); expected != "" { + actual := strings.TrimSpace(metadata["accountNumber"]) + if actual == "" { + return fmt.Errorf("sepay notification missing accountNumber") + } + if !strings.EqualFold(expected, actual) { + return fmt.Errorf("sepay accountNumber mismatch: expected %s, got %s", expected, actual) + } + } case payment.TypeStripe: if expected := strings.TrimSpace(snapshot.Currency); expected != "" { actual := strings.ToUpper(strings.TrimSpace(metadata["currency"])) diff --git a/backend/internal/service/payment_order_provider_snapshot_test.go b/backend/internal/service/payment_order_provider_snapshot_test.go index 127202bc239..9ddd36ac969 100644 --- a/backend/internal/service/payment_order_provider_snapshot_test.go +++ b/backend/internal/service/payment_order_provider_snapshot_test.go @@ -7,6 +7,7 @@ import ( "strconv" "testing" + dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/internal/payment" "github.com/stretchr/testify/require" ) @@ -188,6 +189,29 @@ func TestBuildPaymentOrderProviderSnapshot_IncludesProviderCurrency(t *testing.T require.Equal(t, "acct-78", airwallexSnapshot["merchant_id"]) } +func TestBuildPaymentOrderProviderSnapshot_IncludesSePayVNDAndBankAccount(t *testing.T) { + t.Parallel() + + snapshot := buildPaymentOrderProviderSnapshot(&payment.InstanceSelection{ + InstanceID: "99", + ProviderKey: payment.TypeSePay, + Config: map[string]string{ + "apiToken": "secret-token", + "bankAccountNumber": "0123456789", + "bankBin": "970422", + "webhookSecret": "secret", + }, + }, CreateOrderRequest{PaymentType: payment.TypeSePay}) + + require.Equal(t, payment.CurrencyVND, snapshot["currency"]) + require.Equal(t, "0123456789", snapshot["merchant_id"]) + require.NotContains(t, snapshot, "apiToken") + require.NotContains(t, snapshot, "webhookSecret") + + order := &dbent.PaymentOrder{ProviderSnapshot: snapshot} + require.Equal(t, "VND", PaymentOrderCurrency(order)) +} + func valueOrEmpty(v *string) string { if v == nil { return "" 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..5b52e5656a9 --- /dev/null +++ b/backend/internal/service/payment_order_sepay_vnd_test.go @@ -0,0 +1,76 @@ +package service + +import ( + "strings" + "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) + } +} + +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/backend/internal/service/payment_sepay_resolution_test.go b/backend/internal/service/payment_sepay_resolution_test.go new file mode 100644 index 00000000000..575f0bbc609 --- /dev/null +++ b/backend/internal/service/payment_sepay_resolution_test.go @@ -0,0 +1,131 @@ +//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", + // Banks and SePay's code extraction drop separators: the sub2_ + // underscore disappears from the extracted code. + "sub220260814aB3kX9mQ", + "SUB220260814AB3KX9MQ", + } { + require.Equal(t, canonical, svc.resolveSepayOutTradeNo(ctx, code), "code %q", code) + } + oid2, ok2 := svc.resolveSepayNotificationOrderID(ctx, payment.TypeSePay, "sub220260814ab3kx9mq") + require.True(t, ok2, "separator-stripped code must resolve via normalized pending-order scan") + require.Equal(t, order.ID, oid2) + 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, 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)") +} + +func TestBuildPaymentTransferInfoSePay(t *testing.T) { + order := &dbent.PaymentOrder{OutTradeNo: "sub2_20260815YujbZRZd"} + sepaySel := &payment.InstanceSelection{ + ProviderKey: payment.TypeSePay, + Config: map[string]string{ + "bankAccountNumber": "0000000001", + "bankBin": "970422", + "accountName": "SEPAY TEST", + }, + } + pr := &payment.CreatePaymentResponse{QRCode: "payload", Currency: payment.CurrencyVND} + + info := buildPaymentTransferInfo(sepaySel, pr, 250000, order) + require.NotNil(t, info) + require.Equal(t, "0000000001", info.AccountNumber) + 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.Nil(t, buildPaymentTransferInfo(nil, pr, 250000, order)) + require.Nil(t, buildPaymentTransferInfo(&payment.InstanceSelection{ProviderKey: payment.TypeAlipay}, pr, 250000, order)) +} diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index 792a842a234..cbde64bfbe0 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -87,6 +87,17 @@ type CreateOrderRequest struct { Locale string } +// PaymentTransferInfo describes a manual bank transfer for gateways whose QR +// encodes a transfer (SePay VietQR): customers whose banking app cannot apply +// the QR prefill still see the account, amount and required transfer content. +type PaymentTransferInfo struct { + AccountNumber string `json:"account_number,omitempty"` + AccountName string `json:"account_name,omitempty"` + BankBin string `json:"bank_bin,omitempty"` + Amount string `json:"amount,omitempty"` + Content string `json:"content,omitempty"` +} + type CreateOrderResponse struct { OrderID int64 `json:"order_id"` Amount float64 `json:"amount"` @@ -96,6 +107,8 @@ type CreateOrderResponse struct { ResultType payment.CreatePaymentResultType `json:"result_type,omitempty"` 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/backend/internal/service/payment_webhook_provider.go b/backend/internal/service/payment_webhook_provider.go index f2da40d9b4c..34f6f1ec568 100644 --- a/backend/internal/service/payment_webhook_provider.go +++ b/backend/internal/service/payment_webhook_provider.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "strings" + "time" dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/paymentorder" @@ -30,6 +31,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 +86,57 @@ 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, tolerating bank-side mutations of the transfer content. +func (s *PaymentService) resolveSepayOutTradeNo(ctx context.Context, code string) string { + code = strings.TrimSpace(code) + if code == "" { + return "" + } + if order := s.findSepayOrderByCode(ctx, code); order != nil { + return order.OutTradeNo + } + return code +} + +// findSepayOrderByCode resolves a SePay transfer code to an order, tolerating +// bank mutations: case changes, a dropped sub2_ prefix, and stripped +// separators (banks and SePay's code extraction drop the underscore inside +// sub2_YYYYMMDD...). Exact and case-insensitive lookups run first; the +// fallback scans recent pending sepay orders comparing separator-insensitive +// normalized codes. +func (s *PaymentService) findSepayOrderByCode(ctx context.Context, code string) *dbent.PaymentOrder { + 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 + } + } + normalized := payment.NormalizeTransferCode(code) + if normalized == "" { + return nil + } + orders, err := s.entClient.PaymentOrder.Query(). + Where( + paymentorder.PaymentTypeEQ(payment.TypeSePay), + paymentorder.StatusEQ(OrderStatusPending), + paymentorder.CreatedAtGTE(time.Now().Add(-24*time.Hour)), + ). + Order(dbent.Desc(paymentorder.FieldCreatedAt)). + Limit(100). + All(ctx) + if err != nil { + return nil + } + for _, o := range orders { + if payment.NormalizeTransferCode(o.OutTradeNo) == normalized { + return o + } + } + return nil +} + func (s *PaymentService) getPinnedOrderProvider(ctx context.Context, o *dbent.PaymentOrder) (payment.Provider, error) { inst, err := s.getOrderProviderInstance(ctx, o) if err != nil { 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). 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..abf79d4712e --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-sepay-payment-gateway-design.md @@ -0,0 +1,172 @@ +# 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. + +## Amendment 2026-08-15 (sau khi test sandbox thật) + +SePay trích mã thanh toán dạng chuỗi alphanumeric liền — **mất dấu `_`** bên trong `sub2_YYYYMMDD...` (code thực tế nhận được: `sub220260815...`). Do đó mọi phép khớp mã phải so dạng normalized (chỉ giữ chữ+cif, không phân biệt hoa thường), đã fix ở commit `4b5408b80`: +- Thêm `payment.NormalizeTransferCode` dùng chung. +- Service layer: `findSepayOrderByCode` — sau exact/EqualFold, quét đơn PENDING (24h, sepay) khớp normalized. +- Provider `QueryOrder`: retry `q=` với dạng normalized khi lượt đầu không thấy (q= của SePay không match chuỗi chứa `_`). + +Đã xác minh end-to-end trên sandbox SePay thật (webhook HMAC qua tunnel + QueryOrder): đơn hoàn tất tự động, cộng tiền đúng. 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/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue index 9b2b78e800c..9fa3a73e43a 100644 --- a/frontend/src/components/payment/PaymentStatusPanel.vue +++ b/frontend/src/components/payment/PaymentStatusPanel.vue @@ -171,15 +171,54 @@

{{ scanTitle }}

- - -
- - - -
+ VietQR +

{{ 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 +264,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' @@ -243,6 +283,7 @@ const props = defineProps<{ amount?: number payAmount?: number qrCode: string + qrImageUrl?: string expiresAt: string paymentType: string payUrl?: string @@ -250,6 +291,7 @@ const props = defineProps<{ currency?: string outTradeNo?: string mobileAlipayDeepLink?: boolean + transferInfo?: PaymentTransferDisplayInfo }>() type PaymentOutcome = 'success' | 'cancelled' | 'expired' @@ -326,6 +368,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/__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..9ab9b955fbe 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' @@ -31,14 +32,24 @@ 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 qrCode: string + qrImageUrl?: string expiresAt: string paymentType: string payUrl: string outTradeNo: string + transferInfo?: PaymentTransferDisplayInfo clientSecret: string intentId: string currency: string @@ -155,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 || '', @@ -167,6 +179,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) @@ -297,6 +318,8 @@ 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') + || (parsed.qrImageUrl != null && typeof parsed.qrImageUrl !== 'string') ) { return null } @@ -314,10 +337,12 @@ export function readPaymentRecoverySnapshot( orderId: parsed.orderId, amount: parsed.amount, qrCode: parsed.qrCode, + qrImageUrl: parsed.qrImageUrl || '', expiresAt: parsed.expiresAt, 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/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..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}', @@ -736,6 +738,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 +779,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..e9f46d56cb6 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)', @@ -327,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/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 3967f25ed73..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} 元', @@ -731,6 +733,7 @@ export default { providerWxpay: '微信官方', providerStripe: 'Stripe', providerAirwallex: 'Airwallex', + providerSepay: 'SePay', typeDisabled: '类型已禁用', enableTypesFirst: '请先在上方启用至少一种服务商', easypayRedirect: '跳转', @@ -771,6 +774,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..5ea7576bda1 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: '支付宝(直连)', @@ -351,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 b94dbf42e4e..aa4fb8890fe 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 @@ -198,11 +200,22 @@ 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 qr_code?: string + qr_image_url?: string client_secret?: string intent_id?: string currency?: string diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index b153865ff3b..d0267448d70 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -7813,6 +7813,29 @@ }}

+
+ + +