diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index 32d6b7bebf..f1c17427ad 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -39,6 +39,12 @@ type EasyPay struct { httpClient *http.Client } +type easyPayCustomMethod struct { + Type string `json:"type"` + UpstreamType string `json:"upstreamType"` + DisplayName string `json:"displayName"` +} + // NewEasyPay creates a new EasyPay provider. // config keys: pid, pkey, apiBase, notifyUrl, returnUrl, cid, cidAlipay, cidWxpay func NewEasyPay(instanceID string, config map[string]string) (*EasyPay, error) { @@ -95,7 +101,13 @@ func (e *EasyPay) apiBase() string { func (e *EasyPay) Name() string { return "EasyPay" } func (e *EasyPay) ProviderKey() string { return payment.TypeEasyPay } func (e *EasyPay) SupportedTypes() []payment.PaymentType { - return []payment.PaymentType{payment.TypeAlipay, payment.TypeWxpay} + types := []payment.PaymentType{payment.TypeAlipay, payment.TypeWxpay} + for _, method := range e.customMethods() { + if method.Type != "" { + types = append(types, method.Type) + } + } + return types } func (e *EasyPay) MerchantIdentityMetadata() map[string]string { @@ -124,13 +136,14 @@ func (e *EasyPay) CreatePayment(ctx context.Context, req payment.CreatePaymentRe // TradeNo is empty; it arrives via the notify callback after payment. func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) { notifyURL, returnURL := e.resolveURLs(req) + paymentType := e.upstreamPaymentType(req.PaymentType) params := map[string]string{ - "pid": e.config["pid"], "type": req.PaymentType, + "pid": e.config["pid"], "type": paymentType, "out_trade_no": req.OrderID, "notify_url": notifyURL, "return_url": returnURL, "name": req.Subject, "money": req.Amount, } - if cid := e.resolveCID(req.PaymentType); cid != "" { + if cid := e.resolveCID(paymentType); cid != "" { params["cid"] = cid } if req.IsMobile { @@ -150,13 +163,14 @@ func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*paym // createAPIPayment calls mapi.php to get payurl/qrcode (existing behavior). func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) { notifyURL, returnURL := e.resolveURLs(req) + paymentType := e.upstreamPaymentType(req.PaymentType) params := map[string]string{ - "pid": e.config["pid"], "type": req.PaymentType, + "pid": e.config["pid"], "type": paymentType, "out_trade_no": req.OrderID, "notify_url": notifyURL, "return_url": returnURL, "name": req.Subject, "money": req.Amount, "clientip": req.ClientIP, } - if cid := e.resolveCID(req.PaymentType); cid != "" { + if cid := e.resolveCID(paymentType); cid != "" { params["cid"] = cid } if req.IsMobile { @@ -204,6 +218,41 @@ func (e *EasyPay) resolveURLs(req payment.CreatePaymentRequest) (string, string) return notifyURL, returnURL } +func (e *EasyPay) customMethods() []easyPayCustomMethod { + if e == nil { + return nil + } + raw := strings.TrimSpace(e.config["customMethods"]) + if raw == "" { + return nil + } + var methods []easyPayCustomMethod + if err := json.Unmarshal([]byte(raw), &methods); err != nil { + return nil + } + result := make([]easyPayCustomMethod, 0, len(methods)) + for _, method := range methods { + method.Type = strings.TrimSpace(method.Type) + method.UpstreamType = strings.TrimSpace(method.UpstreamType) + method.DisplayName = strings.TrimSpace(method.DisplayName) + if method.Type == "" || method.UpstreamType == "" { + continue + } + result = append(result, method) + } + return result +} + +func (e *EasyPay) upstreamPaymentType(paymentType string) string { + paymentType = strings.TrimSpace(paymentType) + for _, method := range e.customMethods() { + if paymentType == method.Type { + return method.UpstreamType + } + } + return paymentType +} + func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) { params := map[string]string{ "act": "order", "pid": e.config["pid"], diff --git a/backend/internal/payment/provider/easypay_refund_test.go b/backend/internal/payment/provider/easypay_refund_test.go index 9e0e4942c2..3b76329870 100644 --- a/backend/internal/payment/provider/easypay_refund_test.go +++ b/backend/internal/payment/provider/easypay_refund_test.go @@ -179,6 +179,102 @@ func TestEasyPayRefundResponseErrors(t *testing.T) { } } +func TestEasyPayCustomMethodsUseConfiguredUpstreamType(t *testing.T) { + t.Parallel() + + provider, err := NewEasyPay("test-instance", map[string]string{ + "pid": "pid-1", + "pkey": "pkey-1", + "apiBase": "https://pay.example.com", + "notifyUrl": "https://example.com/notify", + "returnUrl": "https://example.com/return", + "paymentMode": paymentModePopup, + "customMethods": `[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":"USDT-TRC20"}]`, + }) + if err != nil { + t.Fatalf("NewEasyPay: %v", err) + } + + resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{ + OrderID: "sub2-custom-1", + Amount: "1.00", + PaymentType: "usdt_trc20", + Subject: "Custom EasyPay", + }) + if err != nil { + t.Fatalf("CreatePayment: %v", err) + } + payURL, err := url.Parse(resp.PayURL) + if err != nil { + t.Fatalf("parse pay url: %v", err) + } + if got := payURL.Query().Get("type"); got != "usdt" { + t.Fatalf("pay url type = %q, want usdt (%s)", got, resp.PayURL) + } +} + +func TestEasyPayCustomMethodsResolveCIDFromConfiguredUpstreamType(t *testing.T) { + t.Parallel() + + provider, err := NewEasyPay("test-instance", map[string]string{ + "pid": "pid-1", + "pkey": "pkey-1", + "apiBase": "https://pay.example.com", + "notifyUrl": "https://example.com/notify", + "returnUrl": "https://example.com/return", + "paymentMode": paymentModePopup, + "cidAlipay": "cid-alipay", + "cidWxpay": "cid-wxpay", + "customMethods": `[{"type":"ldc","upstreamType":"alipay","displayName":"LDC"}]`, + }) + if err != nil { + t.Fatalf("NewEasyPay: %v", err) + } + + resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{ + OrderID: "sub2-custom-cid", + Amount: "1.00", + PaymentType: "ldc", + Subject: "Custom EasyPay CID", + }) + if err != nil { + t.Fatalf("CreatePayment: %v", err) + } + payURL, err := url.Parse(resp.PayURL) + if err != nil { + t.Fatalf("parse pay url: %v", err) + } + if got := payURL.Query().Get("type"); got != "alipay" { + t.Fatalf("pay url type = %q, want alipay (%s)", got, resp.PayURL) + } + if got := payURL.Query().Get("cid"); got != "cid-alipay" { + t.Fatalf("pay url cid = %q, want cid-alipay (%s)", got, resp.PayURL) + } +} + +func TestEasyPaySupportedTypesIncludeCustomMethods(t *testing.T) { + t.Parallel() + + provider, err := NewEasyPay("test-instance", map[string]string{ + "pid": "pid-1", + "pkey": "pkey-1", + "apiBase": "https://pay.example.com", + "notifyUrl": "https://example.com/notify", + "returnUrl": "https://example.com/return", + "customMethods": `[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":"USDT-TRC20"}]`, + }) + if err != nil { + t.Fatalf("NewEasyPay: %v", err) + } + + got := strings.Join(provider.SupportedTypes(), ",") + for _, want := range []string{"alipay", "wxpay", "ldc", "usdt_trc20"} { + if !strings.Contains(got, want) { + t.Fatalf("SupportedTypes() = %q, want it to include %q", got, want) + } + } +} + func newTestEasyPay(t *testing.T, apiBase string) *EasyPay { t.Helper() diff --git a/backend/internal/service/payment_config_limits.go b/backend/internal/service/payment_config_limits.go index 45b24bfce7..202eea9f26 100644 --- a/backend/internal/service/payment_config_limits.go +++ b/backend/internal/service/payment_config_limits.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance" @@ -31,6 +32,7 @@ func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) (*M continue } ml := pcAggregateMethodLimits(pt, insts) + ml.DisplayName = s.pcAggregateMethodDisplayName(pt, insts) ml.Currency = currency resp.Methods[ml.PaymentType] = ml } @@ -93,6 +95,7 @@ func (s *PaymentConfigService) GetMethodLimits(ctx context.Context, types []stri continue } ml := pcAggregateMethodLimits(pt, matching) + ml.DisplayName = s.pcAggregateMethodDisplayName(pt, matching) ml.Currency = currency result = append(result, ml) } @@ -163,6 +166,53 @@ func (s *PaymentConfigService) pcInstancePaymentCurrency(inst *dbent.PaymentProv return paymentProviderConfigCurrency(inst.ProviderKey, cfg) } +type easyPayCustomMethodDisplayConfig struct { + Type string `json:"type"` + DisplayName string `json:"displayName"` +} + +func (s *PaymentConfigService) pcAggregateMethodDisplayName(pt string, instances []*dbent.PaymentProviderInstance) string { + pt = strings.TrimSpace(pt) + if pt == "" { + return "" + } + for _, inst := range instances { + displayName := s.pcInstanceEasyPayCustomMethodDisplayName(inst, pt) + if displayName != "" { + return displayName + } + } + return "" +} + +func (s *PaymentConfigService) pcInstanceEasyPayCustomMethodDisplayName(inst *dbent.PaymentProviderInstance, pt string) string { + if inst == nil || inst.ProviderKey != payment.TypeEasyPay { + return "" + } + cfg := map[string]string{} + if s != nil { + decrypted, err := s.decryptConfig(inst.Config) + if err == nil && decrypted != nil { + cfg = decrypted + } + } + raw := strings.TrimSpace(cfg["customMethods"]) + if raw == "" { + return "" + } + + var methods []easyPayCustomMethodDisplayConfig + if err := json.Unmarshal([]byte(raw), &methods); err != nil { + return "" + } + for _, method := range methods { + if strings.TrimSpace(method.Type) == pt { + return strings.TrimSpace(method.DisplayName) + } + } + return "" +} + // pcGroupByPaymentType groups instances by user-facing payment type. // For Stripe providers, ALL sub-types (card, link, alipay, wxpay) map to "stripe" // because the user sees a single "Stripe" button, not individual sub-methods. diff --git a/backend/internal/service/payment_config_limits_test.go b/backend/internal/service/payment_config_limits_test.go index c0aa2b27a5..a70bc90a29 100644 --- a/backend/internal/service/payment_config_limits_test.go +++ b/backend/internal/service/payment_config_limits_test.go @@ -255,6 +255,28 @@ func TestGetAvailableMethodLimitsOmitsMixedCurrencyMethod(t *testing.T) { require.Equal(t, "PAYMENT_METHOD_CURRENCY_CONFLICT", appErr.Reason) } +func TestGetAvailableMethodLimitsIncludesEasyPayCustomMethodDisplayName(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + + _, err := client.PaymentProviderInstance.Create(). + SetProviderKey(payment.TypeEasyPay). + SetName("EasyPay Custom"). + SetConfig(`{"customMethods":"[{\"type\":\"ldc\",\"upstreamType\":\"ldc\",\"displayName\":\"LDC Pay\"}]"}`). + SetSupportedTypes("alipay,wxpay,ldc"). + SetEnabled(true). + Save(ctx) + require.NoError(t, err) + + svc := &PaymentConfigService{entClient: client} + resp, err := svc.GetAvailableMethodLimits(ctx) + require.NoError(t, err) + + limits, ok := resp.Methods["ldc"] + require.True(t, ok, "expected custom EasyPay method limits to be visible") + require.Equal(t, "LDC Pay", limits.DisplayName) +} + func TestPcComputeGlobalRange(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/payment_config_providers.go b/backend/internal/service/payment_config_providers.go index 7e92558568..d1bf2de7aa 100644 --- a/backend/internal/service/payment_config_providers.go +++ b/backend/internal/service/payment_config_providers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "regexp" "strconv" "strings" @@ -185,6 +186,11 @@ func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req C if err := validateProviderRequest(req.ProviderKey, req.Name, typesStr); err != nil { return nil, err } + if req.ProviderKey == payment.TypeEasyPay { + if err := validateEasyPayCustomMethods(req.Config, typesStr); err != nil { + return nil, err + } + } if err := s.validateVisibleMethodEnablementConflicts(ctx, 0, req.ProviderKey, typesStr, req.Enabled); err != nil { return nil, err } @@ -217,6 +223,67 @@ func validateProviderRequest(providerKey, name, supportedTypes string) error { return nil } +var easyPayCustomMethodCodePattern = regexp.MustCompile(`^[a-z0-9_-]+$`) + +type easyPayCustomMethodConfig struct { + Type string `json:"type"` + UpstreamType string `json:"upstreamType"` + DisplayName string `json:"displayName"` +} + +func validateEasyPayCustomMethods(config map[string]string, supportedTypes string) error { + if config == nil { + config = map[string]string{} + } + raw := strings.TrimSpace(config["customMethods"]) + methods := make([]easyPayCustomMethodConfig, 0) + if raw != "" { + if err := json.Unmarshal([]byte(raw), &methods); err != nil { + return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods must be a JSON array") + } + } + + customTypes := make(map[string]struct{}, len(methods)) + for _, method := range methods { + method.Type = strings.TrimSpace(method.Type) + method.UpstreamType = strings.TrimSpace(method.UpstreamType) + if method.Type == "" || method.UpstreamType == "" { + return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods upstreamType is required") + } + if !easyPayCustomMethodCodePattern.MatchString(method.Type) { + return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods type may only contain lowercase letters, digits, underscores, and hyphens") + } + if !easyPayCustomMethodCodePattern.MatchString(method.UpstreamType) { + return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods upstreamType may only contain lowercase letters, digits, underscores, and hyphens") + } + if easyPayCustomMethodTypeConflictsWithBuiltin(method.Type) { + return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods type cannot start with alipay or wxpay") + } + if _, exists := customTypes[method.Type]; exists { + return infraerrors.BadRequest("VALIDATION_ERROR", "duplicate customMethods type") + } + customTypes[method.Type] = struct{}{} + } + + for _, supportedType := range splitTypes(supportedTypes) { + supportedType = strings.TrimSpace(supportedType) + if supportedType == "" || supportedType == payment.TypeAlipay || supportedType == payment.TypeWxpay { + continue + } + if !easyPayCustomMethodCodePattern.MatchString(supportedType) { + return infraerrors.BadRequest("VALIDATION_ERROR", fmt.Sprintf("supported EasyPay custom type %s may only contain lowercase letters, digits, underscores, and hyphens", supportedType)) + } + if _, exists := customTypes[supportedType]; !exists { + return infraerrors.BadRequest("VALIDATION_ERROR", fmt.Sprintf("supported EasyPay custom type %s has no customMethods mapping", supportedType)) + } + } + return nil +} + +func easyPayCustomMethodTypeConflictsWithBuiltin(methodType string) bool { + return strings.HasPrefix(methodType, payment.TypeAlipay) || strings.HasPrefix(methodType, payment.TypeWxpay) +} + // UpdateProviderInstance updates a provider instance by ID (patch semantics). // NOTE: This function exceeds 30 lines due to per-field nil-check patch update // boilerplate and pending-order safety checks. @@ -279,6 +346,18 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in WithMetadata(map[string]string{"count": strconv.Itoa(count)}) } } + configToValidate := mergedConfig + if configToValidate == nil { + configToValidate, err = s.decryptConfig(current.Config) + if err != nil { + return nil, fmt.Errorf("decrypt existing config: %w", err) + } + } + if current.ProviderKey == payment.TypeEasyPay { + if err := validateEasyPayCustomMethods(configToValidate, nextSupportedTypes); err != nil { + return nil, err + } + } // Validate merged config when the instance will end up enabled. // This surfaces provider-level errors (e.g. wxpay missing certSerial) at save time, // so admins see them in the dialog instead of only when an order is created. @@ -287,13 +366,6 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in finalEnabled = *req.Enabled } if finalEnabled { - configToValidate := mergedConfig - if configToValidate == nil { - configToValidate, err = s.decryptConfig(current.Config) - if err != nil { - return nil, fmt.Errorf("decrypt existing config: %w", err) - } - } if err := s.validateProviderConfig(current.ProviderKey, configToValidate); err != nil { return nil, err } diff --git a/backend/internal/service/payment_config_providers_test.go b/backend/internal/service/payment_config_providers_test.go index 43708de73d..74fd2a3467 100644 --- a/backend/internal/service/payment_config_providers_test.go +++ b/backend/internal/service/payment_config_providers_test.go @@ -114,6 +114,92 @@ func TestValidateProviderRequest(t *testing.T) { } } +func TestValidateEasyPayCustomMethods(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config map[string]string + supportedTypes string + wantErr string + }{ + { + name: "valid custom methods", + config: map[string]string{"customMethods": `[{"type":"ldc","upstreamType":"epay","displayName":"LDC"}]`}, + supportedTypes: "alipay,wxpay,ldc", + }, + { + name: "malformed custom methods json", + config: map[string]string{"customMethods": `not-json`}, + supportedTypes: "alipay,wxpay,ldc", + wantErr: "customMethods must be a JSON array", + }, + { + name: "missing upstream type", + config: map[string]string{"customMethods": `[{"type":"ldc","displayName":"LDC"}]`}, + supportedTypes: "alipay,wxpay,ldc", + wantErr: "customMethods upstreamType is required", + }, + { + name: "duplicate custom type", + config: map[string]string{"customMethods": `[{"type":"ldc","upstreamType":"epay"},{"type":"ldc","upstreamType":"epay2"}]`}, + supportedTypes: "alipay,wxpay,ldc", + wantErr: "duplicate customMethods type", + }, + { + name: "custom type must already be lowercase", + config: map[string]string{"customMethods": `[{"type":"LDC","upstreamType":"epay"}]`}, + supportedTypes: "alipay,wxpay,ldc", + wantErr: "customMethods type may only contain lowercase letters", + }, + { + name: "upstream type must already be lowercase", + config: map[string]string{"customMethods": `[{"type":"ldc","upstreamType":"ALIPAY"}]`}, + supportedTypes: "alipay,wxpay,ldc", + wantErr: "customMethods upstreamType may only contain lowercase letters", + }, + { + name: "custom type uses alipay prefix", + config: map[string]string{"customMethods": `[{"type":"alipay_hk","upstreamType":"hkpay"}]`}, + supportedTypes: "alipay,wxpay,alipay_hk", + wantErr: "customMethods type cannot start with alipay or wxpay", + }, + { + name: "custom type uses wxpay prefix", + config: map[string]string{"customMethods": `[{"type":"wxpay_usdt","upstreamType":"usdt"}]`}, + supportedTypes: "alipay,wxpay,wxpay_usdt", + wantErr: "customMethods type cannot start with alipay or wxpay", + }, + { + name: "supported custom type missing mapping", + config: map[string]string{"customMethods": `[{"type":"ldc","upstreamType":"epay"}]`}, + supportedTypes: "alipay,wxpay,ldc,usdt_trc20", + wantErr: "supported EasyPay custom type usdt_trc20 has no customMethods mapping", + }, + { + name: "supported custom type must already be lowercase", + config: map[string]string{"customMethods": `[{"type":"ldc","upstreamType":"epay"}]`}, + supportedTypes: "alipay,wxpay,LDC", + wantErr: "supported EasyPay custom type LDC may only contain lowercase letters", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := validateEasyPayCustomMethods(tc.config, tc.supportedTypes) + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErr) + }) + } +} + func TestIsSensitiveProviderConfigField(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index 0050013645..52a7ddc67e 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -116,6 +116,7 @@ type UpdatePaymentConfigRequest struct { // MethodLimits holds per-payment-type limits. type MethodLimits struct { PaymentType string `json:"payment_type"` + DisplayName string `json:"display_name,omitempty"` Currency string `json:"currency"` FeeRate float64 `json:"fee_rate"` DailyLimit float64 `json:"daily_limit"` diff --git a/backend/internal/service/payment_config_service_test.go b/backend/internal/service/payment_config_service_test.go index f04f4697b1..bfc69d1705 100644 --- a/backend/internal/service/payment_config_service_test.go +++ b/backend/internal/service/payment_config_service_test.go @@ -187,6 +187,23 @@ func TestParsePaymentConfig(t *testing.T) { } }) + t.Run("custom enabled types are preserved", func(t *testing.T) { + t.Parallel() + vals := map[string]string{ + SettingEnabledPaymentTypes: "alipay,ldc,usdt_trc20", + } + cfg := svc.parsePaymentConfig(vals) + want := []string{"alipay", "ldc", "usdt_trc20"} + if len(cfg.EnabledTypes) != len(want) { + t.Fatalf("EnabledTypes len = %d, want %d (%v)", len(cfg.EnabledTypes), len(want), cfg.EnabledTypes) + } + for i := range want { + if cfg.EnabledTypes[i] != want[i] { + t.Fatalf("EnabledTypes[%d] = %q, want %q (full=%v)", i, cfg.EnabledTypes[i], want[i], cfg.EnabledTypes) + } + } + }) + t.Run("empty enabled types string", func(t *testing.T) { t.Parallel() vals := map[string]string{ diff --git a/backend/internal/service/payment_resume_service_test.go b/backend/internal/service/payment_resume_service_test.go index 7e0adc2de8..17b637fa23 100644 --- a/backend/internal/service/payment_resume_service_test.go +++ b/backend/internal/service/payment_resume_service_test.go @@ -26,9 +26,10 @@ func TestNormalizeVisibleMethods(t *testing.T) { " wxpay_direct ", "wxpay", "stripe", + "ldc", }) - want := []string{"alipay", "wxpay", "stripe"} + want := []string{"alipay", "wxpay", "stripe", "ldc"} if len(got) != len(want) { t.Fatalf("NormalizeVisibleMethods len = %d, want %d (%v)", len(got), len(want), got) } @@ -39,6 +40,21 @@ func TestNormalizeVisibleMethods(t *testing.T) { } } +func TestEnabledVisibleMethodsForEasyPayIncludesCustomSupportedTypes(t *testing.T) { + t.Parallel() + + got := enabledVisibleMethodsForProvider(payment.TypeEasyPay, "alipay,ldc,usdt_trc20") + want := []string{"alipay", "ldc", "usdt_trc20"} + if len(got) != len(want) { + t.Fatalf("enabledVisibleMethodsForProvider len = %d, want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("enabledVisibleMethodsForProvider[%d] = %q, want %q (full=%v)", i, got[i], want[i], got) + } + } +} + func TestNormalizePaymentSource(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/payment_visible_method_instances.go b/backend/internal/service/payment_visible_method_instances.go index 899bd7a020..97b3b1ef66 100644 --- a/backend/internal/service/payment_visible_method_instances.go +++ b/backend/internal/service/payment_visible_method_instances.go @@ -16,8 +16,7 @@ func enabledVisibleMethodsForProvider(providerKey, supportedTypes string) []stri methodSet := make(map[string]struct{}, 2) addMethod := func(method string) { method = NormalizeVisibleMethod(method) - switch method { - case payment.TypeAlipay, payment.TypeWxpay: + if method != "" { methodSet[method] = struct{}{} } } @@ -55,6 +54,14 @@ func enabledVisibleMethodsForProvider(providerKey, supportedTypes string) []stri for _, method := range []string{payment.TypeAlipay, payment.TypeWxpay} { if _, ok := methodSet[method]; ok { methods = append(methods, method) + delete(methodSet, method) + } + } + for _, supportedType := range splitTypes(supportedTypes) { + method := NormalizeVisibleMethod(supportedType) + if _, ok := methodSet[method]; ok { + methods = append(methods, method) + delete(methodSet, method) } } return methods @@ -215,7 +222,7 @@ func (s *PaymentConfigService) resolveEnabledVisibleMethodInstance( } method = NormalizeVisibleMethod(method) - if method != payment.TypeAlipay && method != payment.TypeWxpay { + if method == "" { return nil, nil } diff --git a/frontend/src/assets/icons/payment.svg b/frontend/src/assets/icons/payment.svg new file mode 100644 index 0000000000..c78bea4cf7 --- /dev/null +++ b/frontend/src/assets/icons/payment.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index 31f6e3bd26..d094f5366d 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -107,7 +107,7 @@ describe('BulkEditAccountModal', () => { expect(mappingTab).toBeTruthy() await mappingTab!.trigger('click') - expect(wrapper.text()).toContain('3.1-Flash-Image透传') + expect(wrapper.text()).toContain('3.1-Flash-Image passthrough') expect(wrapper.text()).toContain('3-Pro-Image→3.1') expect(wrapper.text()).not.toContain('GPT-5.3 Codex Spark') }) diff --git a/frontend/src/components/payment/PaymentMethodSelector.vue b/frontend/src/components/payment/PaymentMethodSelector.vue index d84a3e154d..2c02340ed4 100644 --- a/frontend/src/components/payment/PaymentMethodSelector.vue +++ b/frontend/src/components/payment/PaymentMethodSelector.vue @@ -20,9 +20,9 @@ @click="method.available && emit('select', method.type)" > - + - {{ t(`payment.methods.${method.type}`) }} + {{ methodLabel(method) }} import { computed } from 'vue' import { useI18n } from 'vue-i18n' -import { METHOD_ORDER } from './providerConfig' +import { METHOD_ORDER, isBuiltInAlipayMethod, isBuiltInWxpayMethod } from './providerConfig' import alipayIcon from '@/assets/icons/alipay.svg' import wxpayIcon from '@/assets/icons/wxpay.svg' import stripeIcon from '@/assets/icons/stripe.svg' import airwallexIcon from '@/assets/icons/airwallex.svg' +import paymentIcon from '@/assets/icons/payment.svg' export interface PaymentMethodOption { type: string + display_name?: string fee_rate: number available: boolean } @@ -67,6 +69,7 @@ const METHOD_ICONS: Record = { wxpay: wxpayIcon, stripe: stripeIcon, airwallex: airwallexIcon, + credit_card: paymentIcon, } const sortedMethods = computed(() => { @@ -79,15 +82,19 @@ const sortedMethods = computed(() => { }) function methodIcon(type: string): string { - if (type.includes('alipay')) return METHOD_ICONS.alipay - if (type.includes('wxpay')) return METHOD_ICONS.wxpay + if (isBuiltInAlipayMethod(type)) return METHOD_ICONS.alipay + if (isBuiltInWxpayMethod(type)) return METHOD_ICONS.wxpay if (type === 'airwallex') return METHOD_ICONS.airwallex - return METHOD_ICONS[type] || alipayIcon + return METHOD_ICONS[type] || paymentIcon +} + +function methodLabel(method: PaymentMethodOption): string { + return method.display_name || t(`payment.methods.${method.type}`, method.type) } function methodSelectedClass(type: string): string { - if (type.includes('alipay')) return 'border-[#02A9F1] bg-blue-50 text-gray-900 shadow-sm dark:bg-blue-950 dark:text-gray-100' - if (type.includes('wxpay')) return 'border-[#09BB07] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100' + if (isBuiltInAlipayMethod(type)) return 'border-[#02A9F1] bg-blue-50 text-gray-900 shadow-sm dark:bg-blue-950 dark:text-gray-100' + if (isBuiltInWxpayMethod(type)) return 'border-[#09BB07] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100' if (type === 'stripe') return 'border-[#676BE5] bg-indigo-50 text-gray-900 shadow-sm dark:bg-indigo-950 dark:text-gray-100' if (type === 'airwallex') return 'border-[#FF6B3D] bg-orange-50 text-gray-900 shadow-sm dark:border-[#FF8E3C] dark:bg-orange-950 dark:text-gray-100' return 'border-primary-500 bg-primary-50 text-gray-900 shadow-sm dark:bg-primary-950 dark:text-gray-100' diff --git a/frontend/src/components/payment/PaymentProviderDialog.vue b/frontend/src/components/payment/PaymentProviderDialog.vue index c8ebadd517..838a33dd9a 100644 --- a/frontend/src/components/payment/PaymentProviderDialog.vue +++ b/frontend/src/components/payment/PaymentProviderDialog.vue @@ -70,6 +70,49 @@ +
+
+
+
+ {{ t('admin.settings.payment.easypayCustomMethods') }} +
+

+ {{ t('admin.settings.payment.easypayCustomMethodsHint') }} +

+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
@@ -270,7 +313,7 @@ import Select from '@/components/common/Select.vue' import type { SelectOption } from '@/components/common/Select.vue' import ToggleSwitch from './ToggleSwitch.vue' import type { ProviderInstance } from '@/types/payment' -import type { TypeOption } from './providerConfig' +import type { EasyPayCustomMethod, TypeOption } from './providerConfig' import { PROVIDER_CONFIG_FIELDS, PROVIDER_SUPPORTED_TYPES, @@ -282,6 +325,8 @@ import { STRIPE_SDK_API_VERSION, getAvailableTypes, extractBaseUrl, + parseEasyPayCustomMethods, + serializeEasyPayCustomMethods, } from './providerConfig' /** Default payment_mode per provider key — "" means "no preference, use @@ -365,6 +410,7 @@ const notifyBaseUrl = ref('') const returnBaseUrl = ref('') const limitsExpanded = ref(false) const visibleFields = reactive>({}) +const easyPayCustomMethods = reactive([]) // --- Computed --- const defaultBaseUrl = typeof window !== 'undefined' ? window.location.origin : '' @@ -404,6 +450,16 @@ const paymentModeOptions = computed(() => { const availableTypes = computed(() => { const base = getAvailableTypes(form.provider_key, props.allPaymentTypes, props.redirectLabel) + if (form.provider_key === 'easypay') { + for (const method of normalizedEasyPayCustomMethods()) { + if (!base.some(opt => opt.value === method.type)) { + base.push({ + value: method.type, + label: method.displayName || method.type, + }) + } + } + } // Resolve i18n labels for types not in allPaymentTypes (e.g. card, link inside stripe) return base.map(opt => opt.label === opt.value @@ -510,6 +566,28 @@ function toggleType(type: string) { } } +function normalizedEasyPayCustomMethods(): EasyPayCustomMethod[] { + return easyPayCustomMethods + .map(method => ({ + type: normalizeEasyPayCustomMethodCode(method.type), + upstreamType: normalizeEasyPayCustomMethodCode(method.upstreamType), + displayName: method.displayName.trim(), + })) + .filter(method => method.type || method.upstreamType || method.displayName) +} + +function normalizeEasyPayCustomMethodCode(value: string): string { + return value.trim().toLowerCase() +} + +function addEasyPayCustomMethod() { + easyPayCustomMethods.push({ type: '', upstreamType: '', displayName: '' }) +} + +function removeEasyPayCustomMethod(index: number) { + easyPayCustomMethods.splice(index, 1) +} + function onKeyChange() { form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[form.provider_key] || [])] form.payment_mode = defaultPaymentMode(form.provider_key) @@ -524,6 +602,7 @@ function clearConfig() { notifyBaseUrl.value = '' returnBaseUrl.value = '' limitsExpanded.value = false + easyPayCustomMethods.splice(0, easyPayCustomMethods.length) } function applyDefaults() { @@ -581,6 +660,14 @@ function handleSave() { emitValidationError(t('admin.settings.payment.validationNameRequired')) return } + if (form.provider_key === 'easypay') { + const validationError = validateEasyPayCustomMethods() + if (validationError) { + emitValidationError(validationError) + return + } + syncEasyPayCustomMethods() + } // Validate required config fields — all non-optional fields must be filled. // In edit mode, sensitive fields may be left blank to preserve the stored // value (backend merges blanks by preserving the existing secret). @@ -610,6 +697,9 @@ function handleSave() { } filteredConfig[k] = v } + if (form.provider_key === 'easypay') { + filteredConfig.customMethods = serializeEasyPayCustomMethods(normalizedEasyPayCustomMethods()) + } // Inject computed callback URLs (each URL = independent base + fixed path) // If base URL is empty, auto-fill with current domain @@ -636,6 +726,56 @@ function handleSave() { }) } +function syncEasyPayCustomMethods(): string[] { + if (form.provider_key !== 'easypay') return [] + const baseTypes = new Set(PROVIDER_SUPPORTED_TYPES.easypay || []) + const customTypes: string[] = [] + const seen = new Set() + for (const method of normalizedEasyPayCustomMethods()) { + if (!method.type || !method.upstreamType) continue + if (seen.has(method.type)) continue + seen.add(method.type) + customTypes.push(method.type) + } + form.supported_types = form.supported_types + .map(type => normalizeEasyPayCustomMethodCode(type)) + .filter(type => baseTypes.has(type) || customTypes.includes(type)) + for (const customType of customTypes) { + if (!form.supported_types.includes(customType)) { + form.supported_types.push(customType) + } + } + return customTypes +} + +function validateEasyPayCustomMethods(): string | null { + const seen = new Set() + for (const method of normalizedEasyPayCustomMethods()) { + const hasAnyValue = Boolean(method.type || method.upstreamType || method.displayName) + if (!hasAnyValue) continue + if (!method.type || !method.upstreamType) { + return t('admin.settings.payment.validationEasyPayCustomMethodRequired') + } + if (!/^[a-z0-9_-]+$/.test(method.type)) { + return t('admin.settings.payment.validationEasyPayCustomMethodTypeInvalid') + } + if (!/^[a-z0-9_-]+$/.test(method.upstreamType)) { + return t('admin.settings.payment.validationEasyPayCustomMethodUpstreamTypeInvalid') + } + if ((PROVIDER_SUPPORTED_TYPES.easypay || []).includes(method.type)) { + return t('admin.settings.payment.validationEasyPayCustomMethodReserved') + } + if (method.type.startsWith('alipay') || method.type.startsWith('wxpay')) { + return t('admin.settings.payment.validationEasyPayCustomMethodPrefixReserved') + } + if (seen.has(method.type)) { + return t('admin.settings.payment.validationEasyPayCustomMethodDuplicate') + } + seen.add(method.type) + } + return null +} + function emitValidationError(msg: string) { // Use a custom event or inject appStore — for now use window alert fallback // The parent handles this via the save event validation @@ -677,6 +817,10 @@ function loadProvider(provider: ProviderInstance) { for (const [k, v] of Object.entries(provider.config)) { // Skip notifyUrl/returnUrl — they are derived from callbackBaseUrl if (k === 'notifyUrl' || k === 'returnUrl') continue + if (k === 'customMethods' && provider.provider_key === 'easypay') { + easyPayCustomMethods.push(...parseEasyPayCustomMethods(v)) + continue + } config[k] = v } // Extract base URLs from existing callback URLs diff --git a/frontend/src/components/payment/PaymentQRDialog.vue b/frontend/src/components/payment/PaymentQRDialog.vue index f6278e93e0..7dff831a6a 100644 --- a/frontend/src/components/payment/PaymentQRDialog.vue +++ b/frontend/src/components/payment/PaymentQRDialog.vue @@ -79,7 +79,7 @@ import { usePaymentStore } from '@/stores/payment' import { useAppStore } from '@/stores' import { paymentAPI } from '@/api/payment' import { extractI18nErrorMessage } from '@/utils/apiError' -import { getPaymentPopupFeatures } from '@/components/payment/providerConfig' +import { getPaymentPopupFeatures, isBuiltInAlipayMethod, isBuiltInWxpayMethod } from '@/components/payment/providerConfig' import type { PaymentOrder } from '@/types/payment' import { currencySymbol } from '@/components/payment/currency' import QRCode from 'qrcode' @@ -122,8 +122,8 @@ let lastVerifyAt = 0 const VERIFY_RETRY_INTERVAL_MS = 15000 const VERIFY_RETRY_MAX_ATTEMPTS = 6 -const isAlipay = computed(() => props.paymentType.includes('alipay')) -const isWxpay = computed(() => props.paymentType.includes('wxpay')) +const isAlipay = computed(() => isBuiltInAlipayMethod(props.paymentType)) +const isWxpay = computed(() => isBuiltInWxpayMethod(props.paymentType)) const dialogTitle = computed(() => { if (success.value) return t('payment.result.success') diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue index d77db58a2f..c7232fd640 100644 --- a/frontend/src/components/payment/PaymentStatusPanel.vue +++ b/frontend/src/components/payment/PaymentStatusPanel.vue @@ -79,7 +79,7 @@
- +
@@ -128,13 +128,14 @@ import { usePaymentStore } from '@/stores/payment' import { useAppStore } from '@/stores' import { paymentAPI } from '@/api/payment' import { extractI18nErrorMessage } from '@/utils/apiError' -import { getPaymentPopupFeatures } from '@/components/payment/providerConfig' +import { getPaymentPopupFeatures, isBuiltInAlipayMethod, isBuiltInWxpayMethod } from '@/components/payment/providerConfig' import { currencySymbol, formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency' import type { PaymentOrder } from '@/types/payment' import Icon from '@/components/icons/Icon.vue' import QRCode from 'qrcode' import alipayIcon from '@/assets/icons/alipay.svg' import wxpayIcon from '@/assets/icons/wxpay.svg' +import paymentIcon from '@/assets/icons/payment.svg' const props = defineProps<{ orderId: number @@ -182,8 +183,8 @@ let lastVerifyAt = 0 const VERIFY_RETRY_INTERVAL_MS = 15000 const VERIFY_RETRY_MAX_ATTEMPTS = 6 -const isAlipay = computed(() => props.paymentType.includes('alipay')) -const isWxpay = computed(() => props.paymentType.includes('wxpay')) +const isAlipay = computed(() => isBuiltInAlipayMethod(props.paymentType)) +const isWxpay = computed(() => isBuiltInWxpayMethod(props.paymentType)) const qrBorderClass = computed(() => { if (isAlipay.value) return 'border-[#00AEEF] bg-blue-50 dark:border-[#00AEEF]/70 dark:bg-blue-950/20' @@ -197,6 +198,12 @@ const qrLogoBgClass = computed(() => { return 'bg-gray-400' }) +const qrLogoIcon = computed(() => { + if (isAlipay.value) return alipayIcon + if (isWxpay.value) return wxpayIcon + return paymentIcon +}) + const scanTitle = computed(() => { if (isAlipay.value) return t('payment.qr.scanAlipay') if (isWxpay.value) return t('payment.qr.scanWxpay') diff --git a/frontend/src/components/payment/__tests__/PaymentMethodSelector.spec.ts b/frontend/src/components/payment/__tests__/PaymentMethodSelector.spec.ts new file mode 100644 index 0000000000..e481325fe7 --- /dev/null +++ b/frontend/src/components/payment/__tests__/PaymentMethodSelector.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, fallback?: string) => fallback ?? key, + }), +})) + +describe('PaymentMethodSelector', () => { + it('shows the configured display name for custom EasyPay methods', () => { + const wrapper = mount(PaymentMethodSelector, { + props: { + selected: 'ldc', + methods: [{ type: 'ldc', display_name: 'LDC Pay', fee_rate: 0, available: true }], + }, + }) + + expect(wrapper.text()).toContain('LDC Pay') + expect(wrapper.text()).not.toContain('ldc') + expect(wrapper.text()).not.toContain('payment.methods.ldc') + }) + + it('uses the generic selected style for custom methods that contain built-in names', () => { + const wrapper = mount(PaymentMethodSelector, { + props: { + selected: 'card_alipay', + methods: [{ type: 'card_alipay', display_name: 'Card Pay', fee_rate: 0, available: true }], + }, + }) + + const button = wrapper.get('button') + expect(button.classes()).toContain('border-primary-500') + expect(button.classes()).not.toContain('border-[#02A9F1]') + }) +}) diff --git a/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts b/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts index 099152d8a3..a84ff4cbda 100644 --- a/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts +++ b/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts @@ -7,6 +7,12 @@ import type { ProviderInstance } from '@/types/payment' const messages: Record = { 'admin.settings.payment.providerConfig': 'Credentials', + 'admin.settings.payment.easypayCustomMethods': 'Custom EasyPay methods', + 'admin.settings.payment.easypayCustomMethodsHint': 'Add provider-specific EasyPay type values.', + 'admin.settings.payment.addCustomMethod': 'Add method', + 'admin.settings.payment.customMethodType': 'Payment type', + 'admin.settings.payment.customMethodUpstreamType': 'Upstream type', + 'admin.settings.payment.customMethodDisplayName': 'Display name', 'admin.settings.payment.paymentGuideTrigger': 'View payment guide', 'admin.settings.payment.alipayGuideSummary': 'Desktop prefers QR precreate and falls back to cashier; mobile prefers WAP checkout.', 'admin.settings.payment.wxpayGuideSummary': 'Desktop prefers Native QR; mobile routes to JSAPI or H5 based on browser context.', @@ -53,12 +59,14 @@ function mountDialog(options: { editing?: ProviderInstance | null } = {}) { saving: false, editing: options.editing ?? null, allKeyOptions: [ + { value: 'easypay', label: 'EasyPay' }, { value: 'alipay', label: 'Alipay' }, { value: 'wxpay', label: 'WeChat Pay' }, { value: 'stripe', label: 'Stripe' }, { value: 'airwallex', label: 'Airwallex' }, ], enabledKeyOptions: [ + { value: 'easypay', label: 'EasyPay' }, { value: 'alipay', label: 'Alipay' }, { value: 'wxpay', label: 'WeChat Pay' }, { value: 'airwallex', label: 'Airwallex' }, @@ -156,4 +164,85 @@ describe('PaymentProviderDialog payment guide', () => { const payload = wrapper.emitted('save')?.[0]?.[0] as { config: Record } expect(payload.config.accountId).toBe('') }) + + it('serializes EasyPay custom methods and adds them to supported_types', async () => { + const provider = providerFactory({ + provider_key: 'easypay', + name: 'EasyPay', + config: { + pid: 'pid-1', + apiBase: 'https://pay.example.com', + notifyUrl: 'https://example.com/api/v1/payment/webhook/easypay', + returnUrl: 'https://example.com/payment/result', + }, + supported_types: ['alipay', 'wxpay'], + payment_mode: 'qrcode', + }) + const wrapper = mountDialog({ editing: provider }) + + ;(wrapper.vm as unknown as { loadProvider: (provider: ProviderInstance) => void }).loadProvider(provider) + await nextTick() + + await wrapper.find('button.btn-sm').trigger('click') + await nextTick() + + const inputs = wrapper.findAll('input[type="text"]') + const customTypeInputs = inputs.filter(input => (input.element as HTMLInputElement).placeholder === 'credit_card') + const ldcTypeInput = customTypeInputs[0] + const upstreamTypeInput = customTypeInputs[1] + const displayNameInput = inputs.find(input => (input.element as HTMLInputElement).placeholder === '信用卡') + if (!ldcTypeInput || !upstreamTypeInput || !displayNameInput) { + throw new Error('custom method inputs not found') + } + + await ldcTypeInput.setValue('ldc') + await upstreamTypeInput.setValue('epay') + await displayNameInput.setValue('LDC') + await wrapper.find('form').trigger('submit.prevent') + + const payload = wrapper.emitted('save')?.[0]?.[0] as { + config: Record + supported_types: string[] + } + expect(payload.config.customMethods).toBe('[{"type":"ldc","upstreamType":"epay","displayName":"LDC"}]') + expect(payload.supported_types).toEqual(['alipay', 'wxpay', 'ldc']) + }) + + it('rejects custom EasyPay method types with built-in payment prefixes', async () => { + const provider = providerFactory({ + provider_key: 'easypay', + name: 'EasyPay', + config: { + pid: 'pid-1', + apiBase: 'https://pay.example.com', + notifyUrl: 'https://example.com/api/v1/payment/webhook/easypay', + returnUrl: 'https://example.com/payment/result', + }, + supported_types: ['alipay', 'wxpay'], + payment_mode: 'qrcode', + }) + const wrapper = mountDialog({ editing: provider }) + + ;(wrapper.vm as unknown as { loadProvider: (provider: ProviderInstance) => void }).loadProvider(provider) + await nextTick() + + await wrapper.find('button.btn-sm').trigger('click') + await nextTick() + + const inputs = wrapper.findAll('input[type="text"]') + const customTypeInputs = inputs.filter(input => (input.element as HTMLInputElement).placeholder === 'credit_card') + const typeInput = customTypeInputs[0] + const upstreamTypeInput = customTypeInputs[1] + const displayNameInput = inputs.find(input => (input.element as HTMLInputElement).placeholder === '信用卡') + if (!typeInput || !upstreamTypeInput || !displayNameInput) { + throw new Error('custom method inputs not found') + } + + await typeInput.setValue('alipay_hk') + await upstreamTypeInput.setValue('hkpay') + await displayNameInput.setValue('Hong Kong Alipay') + await wrapper.find('form').trigger('submit.prevent') + + expect(wrapper.emitted('save')).toBeUndefined() + }) }) diff --git a/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts b/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts index 7e39247831..d5919867c5 100644 --- a/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts +++ b/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts @@ -132,6 +132,28 @@ describe('PaymentStatusPanel', () => { openSpy.mockRestore() }) + it('uses generic QR copy for custom methods that contain built-in names', async () => { + const wrapper = mount(PaymentStatusPanel, { + props: { + orderId: 42, + qrCode: 'https://pay.example.com/qr/42', + expiresAt: '2099-01-01T12:30:00Z', + paymentType: 'card_alipay', + orderType: 'balance', + }, + global: { + stubs: { + Icon: true, + }, + }, + }) + + await flushPromises() + + expect(wrapper.text()).toContain('payment.qr.scanToPay') + expect(wrapper.text()).not.toContain('payment.qr.scanAlipay') + }) + it('actively verifies a stuck pending order and settles it when upstream confirms payment', async () => { pollOrderStatus.mockResolvedValue(orderFactory('PENDING')) verifyOrder.mockResolvedValue({ diff --git a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts index 7eda7a0df4..85e79de6bf 100644 --- a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts +++ b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts @@ -59,6 +59,18 @@ describe('getVisibleMethods', () => { expect(visible.alipay.single_min).toBe(2) expect(visible.wxpay.fee_rate).toBe(1.2) }) + + it('keeps custom EasyPay methods as visible methods', () => { + const visible = getVisibleMethods({ + ldc: methodLimit({ single_min: 3 }), + usdt_trc20: methodLimit({ fee_rate: 1 }), + }) + + expect(visible).toEqual({ + ldc: methodLimit({ single_min: 3 }), + usdt_trc20: methodLimit({ fee_rate: 1 }), + }) + }) }) describe('decidePaymentLaunch', () => { diff --git a/frontend/src/components/payment/__tests__/providerConfig.spec.ts b/frontend/src/components/payment/__tests__/providerConfig.spec.ts index bafc7cd754..267693b5cb 100644 --- a/frontend/src/components/payment/__tests__/providerConfig.spec.ts +++ b/frontend/src/components/payment/__tests__/providerConfig.spec.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest' -import { PAYMENT_CURRENCY_OPTIONS, PROVIDER_CONFIG_FIELDS } from '@/components/payment/providerConfig' +import { + PAYMENT_CURRENCY_OPTIONS, + PROVIDER_CONFIG_FIELDS, + isBuiltInAlipayMethod, + isBuiltInWxpayMethod, + parseEasyPayCustomMethods, + serializeEasyPayCustomMethods, +} from '@/components/payment/providerConfig' function findField(providerKey: string, key: string) { const fields = PROVIDER_CONFIG_FIELDS[providerKey] || [] @@ -50,3 +57,39 @@ describe('PROVIDER_CONFIG_FIELDS.stripe', () => { expect(currency?.options).toBe(PAYMENT_CURRENCY_OPTIONS) }) }) + +describe('EasyPay custom methods config', () => { + it('parses customMethods from the JSON string stored in provider config', () => { + expect(parseEasyPayCustomMethods( + '[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":"USDT-TRC20"}]', + )).toEqual([ + { type: 'ldc', upstreamType: 'epay', displayName: 'LDC' }, + { type: 'usdt_trc20', upstreamType: 'usdt', displayName: 'USDT-TRC20' }, + ]) + }) + + it('serializes non-empty custom methods into the config string format', () => { + expect(serializeEasyPayCustomMethods([ + { type: 'ldc', upstreamType: 'epay', displayName: 'LDC' }, + { type: ' ', upstreamType: 'ignored', displayName: 'Ignored' }, + { type: 'usdt_trc20', upstreamType: 'usdt', displayName: '' }, + ])).toBe('[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":""}]') + }) + + it('returns an empty string for invalid or empty custom methods', () => { + expect(parseEasyPayCustomMethods('not-json')).toEqual([]) + expect(serializeEasyPayCustomMethods([{ type: '', upstreamType: 'epay', displayName: 'LDC' }])).toBe('') + }) +}) + +describe('built-in payment method helpers', () => { + it('only treats exact built-in aliases as Alipay or WeChat Pay', () => { + expect(isBuiltInAlipayMethod('alipay')).toBe(true) + expect(isBuiltInAlipayMethod('alipay_direct')).toBe(true) + expect(isBuiltInAlipayMethod('card_alipay')).toBe(false) + + expect(isBuiltInWxpayMethod('wxpay')).toBe(true) + expect(isBuiltInWxpayMethod('wxpay_direct')).toBe(true) + expect(isBuiltInWxpayMethod('card_wxpay')).toBe(false) + }) +}) diff --git a/frontend/src/components/payment/paymentFlow.ts b/frontend/src/components/payment/paymentFlow.ts index ab5acf26db..a8176f7472 100644 --- a/frontend/src/components/payment/paymentFlow.ts +++ b/frontend/src/components/payment/paymentFlow.ts @@ -99,7 +99,7 @@ export function getVisibleMethods(methods: Record): Record< const visible: Record = {} Object.entries(methods).forEach(([type, limit]) => { - const normalized = normalizeVisibleMethod(type) + const normalized = normalizeVisibleMethod(type) || type.trim() if (!normalized) return const isCanonical = type === normalized diff --git a/frontend/src/components/payment/providerConfig.ts b/frontend/src/components/payment/providerConfig.ts index 2b612b4302..395c32725f 100644 --- a/frontend/src/components/payment/providerConfig.ts +++ b/frontend/src/components/payment/providerConfig.ts @@ -21,6 +21,12 @@ export interface TypeOption { [key: string]: unknown } +export interface EasyPayCustomMethod { + type: string + upstreamType: string + displayName: string +} + /** Callback URL paths for a provider. */ export interface CallbackPaths { notifyUrl?: string @@ -44,6 +50,14 @@ 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 function isBuiltInAlipayMethod(type: string): boolean { + return type === 'alipay' || type === 'alipay_direct' +} + +export function isBuiltInWxpayMethod(type: string): boolean { + return type === 'wxpay' || type === 'wxpay_direct' +} + /** Payment mode constants */ export const PAYMENT_MODE_QRCODE = 'qrcode' export const PAYMENT_MODE_POPUP = 'popup' @@ -171,6 +185,34 @@ export function getAvailableTypes( return types.map(t => resolveTypeLabel(t, providerKey, allTypes, redirectLabel)) } +export function parseEasyPayCustomMethods(raw: string | undefined): EasyPayCustomMethod[] { + if (!raw || !raw.trim()) return [] + try { + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed + .map(item => ({ + type: String(item?.type || '').trim(), + upstreamType: String(item?.upstreamType || '').trim(), + displayName: String(item?.displayName || '').trim(), + })) + .filter(item => item.type && item.upstreamType) + } catch { + return [] + } +} + +export function serializeEasyPayCustomMethods(methods: EasyPayCustomMethod[]): string { + const clean = methods + .map(method => ({ + type: method.type.trim(), + upstreamType: method.upstreamType.trim(), + displayName: method.displayName.trim(), + })) + .filter(method => method.type && method.upstreamType) + return clean.length ? JSON.stringify(clean) : '' +} + /** Extract base URL from a full callback URL by removing the known path suffix. */ export function extractBaseUrl(fullUrl: string, path: string): string { if (!fullUrl) return '' diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 5808e4a9df..3d7ee87b5c 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -6232,6 +6232,12 @@ export default { validationNameRequired: 'Provider name is required', validationTypesRequired: 'Please select at least one supported payment type', validationFieldRequired: '{field} is required', + validationEasyPayCustomMethodRequired: 'Each custom EasyPay method requires both a payment type and an upstream type', + validationEasyPayCustomMethodTypeInvalid: 'Custom EasyPay payment types may only contain lowercase letters, digits, underscores, and hyphens', + validationEasyPayCustomMethodUpstreamTypeInvalid: 'EasyPay upstream types may only contain lowercase letters, digits, underscores, and hyphens', + validationEasyPayCustomMethodReserved: 'Custom EasyPay payment types cannot use built-in alipay or wxpay', + validationEasyPayCustomMethodPrefixReserved: 'Custom EasyPay payment types cannot start with alipay or wxpay', + validationEasyPayCustomMethodDuplicate: 'Custom EasyPay payment types must be unique', field_apiBase: 'API Base URL', field_notifyUrl: 'Notify URL', field_returnUrl: 'Return URL', @@ -6261,6 +6267,12 @@ export default { field_cid: 'Channel ID', field_cidAlipay: 'Alipay Channel ID', field_cidWxpay: 'WeChat Channel ID', + easypayCustomMethods: 'Custom EasyPay methods', + easypayCustomMethodsHint: 'Add provider-specific methods supported by this EasyPay endpoint. The payment type is stored on Sub2API orders; the upstream type is sent as EasyPay type.', + addCustomMethod: 'Add method', + customMethodType: 'Payment type', + customMethodUpstreamType: 'Upstream type', + customMethodDisplayName: 'Display name', stripeWebhookHint: 'Configure the following URL as a Webhook endpoint in Stripe Dashboard:', stripeWebhookApiVersionHint: 'Set this Webhook endpoint API version to match the integrated Stripe SDK. Recommended: {version}. A mismatch can cause webhook parsing errors.', airwallexWebhookHint: 'Configure the following URL as a Webhook endpoint in Airwallex. Select at least Payment Intent -> Succeeded (payment_intent.succeeded), preferably also Payment Intent -> Cancelled (payment_intent.cancelled). Use the account default or latest stable API version.', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 5dd37a987b..bd49ba0ccf 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -6387,6 +6387,12 @@ export default { validationNameRequired: '服务商名称不能为空', validationTypesRequired: '请至少选择一种支持的支付方式', validationFieldRequired: '{field} 不能为空', + validationEasyPayCustomMethodRequired: '每个易支付自定义方式都必须填写支付方式和上游 type', + validationEasyPayCustomMethodTypeInvalid: '易支付自定义支付方式只能包含小写字母、数字、下划线和短横线', + validationEasyPayCustomMethodUpstreamTypeInvalid: '易支付上游 type 只能包含小写字母、数字、下划线和短横线', + validationEasyPayCustomMethodReserved: '易支付自定义支付方式不能使用内置的 alipay 或 wxpay', + validationEasyPayCustomMethodPrefixReserved: '易支付自定义支付方式不能以 alipay 或 wxpay 开头', + validationEasyPayCustomMethodDuplicate: '易支付自定义支付方式不能重复', field_apiBase: 'API 基础地址', field_notifyUrl: '异步通知地址', field_returnUrl: '同步跳转地址', @@ -6416,6 +6422,12 @@ export default { field_cid: '支付渠道 ID', field_cidAlipay: '支付宝渠道 ID', field_cidWxpay: '微信渠道 ID', + easypayCustomMethods: '易支付自定义支付方式', + easypayCustomMethodsHint: '添加当前易支付服务商额外支持的支付方式。支付方式会记录到 Sub2API 订单中,上游 type 会作为易支付 type 参数提交。', + addCustomMethod: '添加方式', + customMethodType: '支付方式', + customMethodUpstreamType: '上游 type', + customMethodDisplayName: '显示名称', stripeWebhookHint: '请在 Stripe Dashboard 中将以下地址配置为 Webhook 端点:', stripeWebhookApiVersionHint: 'Webhook 端点的 API 版本请与当前集成的 Stripe SDK 对齐,建议选择 {version};版本不一致可能导致回调事件解析失败。', airwallexWebhookHint: '请在 Airwallex 后台将以下地址配置为 Webhook 端点;事件至少选择 Payment Intent -> Succeeded(payment_intent.succeeded),建议同时选择 Payment Intent -> Cancelled(payment_intent.cancelled);API version 选择账户默认或最新稳定版本。', diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index 98dab93e8c..303ad9961f 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -43,6 +43,7 @@ export interface PaymentConfig { export interface MethodLimit { currency?: string + display_name?: string daily_limit: number daily_used: number daily_remaining: number diff --git a/frontend/src/views/user/PaymentQRCodeView.vue b/frontend/src/views/user/PaymentQRCodeView.vue index f844858daf..5df67d0fe9 100644 --- a/frontend/src/views/user/PaymentQRCodeView.vue +++ b/frontend/src/views/user/PaymentQRCodeView.vue @@ -41,6 +41,7 @@ import { usePaymentStore } from '@/stores/payment' import { paymentAPI } from '@/api/payment' import { extractI18nErrorMessage } from '@/utils/apiError' import { useAppStore } from '@/stores' +import { isBuiltInAlipayMethod, isBuiltInWxpayMethod } from '@/components/payment/providerConfig' import QRCode from 'qrcode' import alipayIcon from '@/assets/icons/alipay.svg' import wxpayIcon from '@/assets/icons/wxpay.svg' @@ -69,8 +70,8 @@ const countdownDisplay = computed(() => { return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0') }) -const isAlipay = computed(() => paymentType.value.includes('alipay')) -const isWxpay = computed(() => paymentType.value.includes('wxpay')) +const isAlipay = computed(() => isBuiltInAlipayMethod(paymentType.value)) +const isWxpay = computed(() => isBuiltInWxpayMethod(paymentType.value)) const scanTitle = computed(() => { if (isAlipay.value) return t('payment.qr.scanAlipay') diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 3ae2f76ea7..6d1d2fd31e 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -267,7 +267,7 @@ import type { SubscriptionPlan, CheckoutInfoResponse, CreateOrderResult, OrderTy import AppLayout from '@/components/layout/AppLayout.vue' import AmountInput from '@/components/payment/AmountInput.vue' import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue' -import { METHOD_ORDER, getPaymentPopupFeatures } from '@/components/payment/providerConfig' +import { METHOD_ORDER, getPaymentPopupFeatures, isBuiltInAlipayMethod, isBuiltInWxpayMethod } from '@/components/payment/providerConfig' import { PAYMENT_RECOVERY_STORAGE_KEY, buildCreateOrderPayload, @@ -603,6 +603,7 @@ const methodOptions = computed(() => const ml = visibleMethods.value[type] return { type, + display_name: ml?.display_name, fee_rate: ml?.fee_rate ?? 0, available: ml?.available !== false && amountFitsMethod(validAmount.value, type), } @@ -672,6 +673,7 @@ const subMethodOptions = computed(() => { const currency = normalizePaymentCurrency(ml?.currency) return { type, + display_name: ml?.display_name, fee_rate: ml?.fee_rate ?? 0, available: ml?.available !== false && amountFitsMethod(subscriptionTotalAmountForCurrency(price, currency), type), } @@ -695,8 +697,8 @@ watch(() => [validAmount.value, selectedMethod.value] as const, ([amt, method]) const paymentButtonClass = computed(() => { const m = selectedMethod.value if (!m) return 'btn-primary' - if (m.includes('alipay')) return 'btn-alipay' - if (m.includes('wxpay')) return 'btn-wxpay' + if (isBuiltInAlipayMethod(m)) return 'btn-alipay' + if (isBuiltInWxpayMethod(m)) return 'btn-wxpay' if (m === 'stripe') return 'btn-stripe' if (m === 'airwallex') return 'btn-airwallex' return 'btn-primary' @@ -1117,6 +1119,7 @@ onMounted(async () => { paymentState.value = restored paymentPhase.value = 'paying' const restoredMethod = normalizeVisibleMethod(restored.paymentType) + || (visibleMethods.value[restored.paymentType] ? restored.paymentType : '') if (restoredMethod) { selectedMethod.value = restoredMethod } diff --git a/frontend/src/views/user/__tests__/PaymentView.spec.ts b/frontend/src/views/user/__tests__/PaymentView.spec.ts index 7591db37a0..aa7c401349 100644 --- a/frontend/src/views/user/__tests__/PaymentView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentView.spec.ts @@ -326,6 +326,88 @@ describe('PaymentView subscription confirmation amounts', () => { }) }) +describe('PaymentView payment recovery', () => { + beforeEach(() => { + vi.useRealTimers() + routeState.path = '/purchase' + routeState.query = {} + routerReplace.mockReset().mockResolvedValue(undefined) + routerPush.mockReset().mockResolvedValue(undefined) + routerResolve.mockClear() + createOrder.mockReset() + refreshUser.mockReset() + fetchActiveSubscriptions.mockReset().mockResolvedValue(undefined) + showError.mockReset() + showInfo.mockReset() + showWarning.mockReset() + bridgeInvoke.mockReset() + window.localStorage.clear() + ;(window as Window & { WeixinJSBridge?: { invoke: typeof bridgeInvoke } }).WeixinJSBridge = undefined + }) + + it('restores a custom EasyPay method as the selected payment method', async () => { + getCheckoutInfo.mockResolvedValue(checkoutInfoFixture({ + methods: { + wxpay: checkoutInfoFixture().data.methods.wxpay, + ldc: { + daily_limit: 0, + daily_used: 0, + daily_remaining: 0, + single_min: 0, + single_max: 0, + fee_rate: 0, + available: true, + display_name: 'LDC Pay', + }, + }, + })) + window.localStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify({ + orderId: 888, + amount: 66, + qrCode: 'ldc-qr', + expiresAt: '2099-01-01T00:10:00.000Z', + paymentType: 'ldc', + payUrl: 'https://pay.example.com/ldc', + outTradeNo: 'sub2_ldc_888', + clientSecret: '', + intentId: '', + currency: '', + countryCode: '', + paymentEnv: '', + payAmount: 66, + orderType: 'balance', + paymentMode: 'popup', + resumeToken: '', + createdAt: Date.now(), + })) + + const wrapper = shallowMount(PaymentView, { + global: { + stubs: { + AppLayout: { + template: '
', + }, + PaymentStatusPanel: { + template: '