feat: drive visible payment methods from enabled providers
This commit is contained in:
@@ -20,18 +20,7 @@ func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) (*M
|
||||
return nil, fmt.Errorf("query provider instances: %w", err)
|
||||
}
|
||||
typeInstances := pcGroupByPaymentType(instances)
|
||||
if s.settingRepo != nil {
|
||||
vals, err := s.settingRepo.GetMultiple(ctx, []string{
|
||||
SettingPaymentVisibleMethodAlipayEnabled,
|
||||
SettingPaymentVisibleMethodAlipaySource,
|
||||
SettingPaymentVisibleMethodWxpayEnabled,
|
||||
SettingPaymentVisibleMethodWxpaySource,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query visible method settings: %w", err)
|
||||
}
|
||||
typeInstances = pcApplyVisibleMethodRouting(typeInstances, vals, buildVisibleMethodSourceAvailability(instances))
|
||||
}
|
||||
typeInstances = pcApplyEnabledVisibleMethodInstances(typeInstances, instances)
|
||||
resp := &MethodLimitsResponse{
|
||||
Methods: make(map[string]MethodLimits, len(typeInstances)),
|
||||
}
|
||||
@@ -43,6 +32,27 @@ func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) (*M
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func pcApplyEnabledVisibleMethodInstances(typeInstances map[string][]*dbent.PaymentProviderInstance, instances []*dbent.PaymentProviderInstance) map[string][]*dbent.PaymentProviderInstance {
|
||||
if len(typeInstances) == 0 {
|
||||
return typeInstances
|
||||
}
|
||||
|
||||
filtered := make(map[string][]*dbent.PaymentProviderInstance, len(typeInstances))
|
||||
for paymentType, groupedInstances := range typeInstances {
|
||||
filtered[paymentType] = groupedInstances
|
||||
}
|
||||
|
||||
for _, method := range []string{payment.TypeAlipay, payment.TypeWxpay} {
|
||||
matching := filterEnabledVisibleMethodInstances(instances, method)
|
||||
if len(matching) != 1 {
|
||||
delete(filtered, method)
|
||||
continue
|
||||
}
|
||||
filtered[method] = []*dbent.PaymentProviderInstance{matching[0]}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func pcApplyVisibleMethodRouting(typeInstances map[string][]*dbent.PaymentProviderInstance, vals map[string]string, available map[string]bool) map[string][]*dbent.PaymentProviderInstance {
|
||||
if len(typeInstances) == 0 {
|
||||
return typeInstances
|
||||
|
||||
@@ -301,7 +301,7 @@ func TestPcInstanceTypeLimits(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetAvailableMethodLimitsRespectsVisibleMethodRouting(t *testing.T) {
|
||||
func TestGetAvailableMethodLimitsHidesConflictingVisibleMethodProviders(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := newPaymentConfigServiceTestClient(t)
|
||||
|
||||
@@ -341,14 +341,6 @@ func TestGetAvailableMethodLimitsRespectsVisibleMethodRouting(t *testing.T) {
|
||||
|
||||
svc := &PaymentConfigService{
|
||||
entClient: client,
|
||||
settingRepo: &paymentConfigSettingRepoStub{
|
||||
values: map[string]string{
|
||||
SettingPaymentVisibleMethodAlipayEnabled: "true",
|
||||
SettingPaymentVisibleMethodAlipaySource: VisibleMethodSourceEasyPayAlipay,
|
||||
SettingPaymentVisibleMethodWxpayEnabled: "false",
|
||||
SettingPaymentVisibleMethodWxpaySource: VisibleMethodSourceOfficialWechat,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := svc.GetAvailableMethodLimits(ctx)
|
||||
@@ -356,17 +348,18 @@ func TestGetAvailableMethodLimitsRespectsVisibleMethodRouting(t *testing.T) {
|
||||
t.Fatalf("GetAvailableMethodLimits returned error: %v", err)
|
||||
}
|
||||
|
||||
alipayLimits, ok := resp.Methods[payment.TypeAlipay]
|
||||
if _, ok := resp.Methods[payment.TypeAlipay]; ok {
|
||||
t.Fatalf("alipay should be hidden when multiple enabled providers claim it, got %v", resp.Methods[payment.TypeAlipay])
|
||||
}
|
||||
|
||||
wxpayLimits, ok := resp.Methods[payment.TypeWxpay]
|
||||
if !ok {
|
||||
t.Fatalf("expected visible alipay limits, got %v", resp.Methods)
|
||||
t.Fatalf("expected wxpay limits to remain visible, got %v", resp.Methods)
|
||||
}
|
||||
if alipayLimits.SingleMin != 20 || alipayLimits.SingleMax != 200 {
|
||||
t.Fatalf("alipay limits = %+v, want easypay-only min=20 max=200", alipayLimits)
|
||||
if wxpayLimits.SingleMin != 30 || wxpayLimits.SingleMax != 300 {
|
||||
t.Fatalf("wxpay limits = %+v, want official-only min=30 max=300", wxpayLimits)
|
||||
}
|
||||
if _, ok := resp.Methods[payment.TypeWxpay]; ok {
|
||||
t.Fatalf("wxpay should be hidden when visible method is disabled, got %v", resp.Methods[payment.TypeWxpay])
|
||||
}
|
||||
if resp.GlobalMin != 20 || resp.GlobalMax != 200 {
|
||||
t.Fatalf("global range = (%v, %v), want (20, 200)", resp.GlobalMin, resp.GlobalMax)
|
||||
if resp.GlobalMin != 30 || resp.GlobalMax != 300 {
|
||||
t.Fatalf("global range = (%v, %v), want (30, 300)", resp.GlobalMin, resp.GlobalMax)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,9 @@ func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req C
|
||||
if err := validateProviderRequest(req.ProviderKey, req.Name, typesStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateVisibleMethodEnablementConflicts(ctx, 0, req.ProviderKey, typesStr, req.Enabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enc, err := s.encryptConfig(req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -136,6 +139,21 @@ func validateProviderRequest(providerKey, name, supportedTypes string) error {
|
||||
// NOTE: This function exceeds 30 lines due to per-field nil-check patch update
|
||||
// boilerplate and pending-order safety checks.
|
||||
func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id int64, req UpdateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
|
||||
current, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nextEnabled := current.Enabled
|
||||
if req.Enabled != nil {
|
||||
nextEnabled = *req.Enabled
|
||||
}
|
||||
nextSupportedTypes := current.SupportedTypes
|
||||
if req.SupportedTypes != nil {
|
||||
nextSupportedTypes = joinTypes(req.SupportedTypes)
|
||||
}
|
||||
if err := s.validateVisibleMethodEnablementConflicts(ctx, id, current.ProviderKey, nextSupportedTypes, nextEnabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Config != nil {
|
||||
hasSensitive := false
|
||||
for k := range req.Config {
|
||||
@@ -188,11 +206,7 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
|
||||
}
|
||||
if count > 0 {
|
||||
// Load current instance to compare types
|
||||
inst, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load provider instance: %w", err)
|
||||
}
|
||||
oldTypes := strings.Split(inst.SupportedTypes, ",")
|
||||
oldTypes := strings.Split(current.SupportedTypes, ",")
|
||||
newTypes := req.SupportedTypes
|
||||
for _, ot := range oldTypes {
|
||||
ot = strings.TrimSpace(ot)
|
||||
@@ -237,10 +251,7 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
|
||||
if req.RefundEnabled != nil {
|
||||
refundEnabled = *req.RefundEnabled
|
||||
} else {
|
||||
inst, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
|
||||
if err == nil {
|
||||
refundEnabled = inst.RefundEnabled
|
||||
}
|
||||
refundEnabled = current.RefundEnabled
|
||||
}
|
||||
if refundEnabled {
|
||||
u.SetAllowUserRefund(true)
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -185,3 +187,104 @@ func TestJoinTypes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderInstanceRejectsConflictingVisibleMethodEnablement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
client := newPaymentConfigServiceTestClient(t)
|
||||
svc := &PaymentConfigService{
|
||||
entClient: client,
|
||||
encryptionKey: []byte("0123456789abcdef0123456789abcdef"),
|
||||
}
|
||||
|
||||
_, err := svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{
|
||||
ProviderKey: "easypay",
|
||||
Name: "EasyPay Alipay",
|
||||
Config: map[string]string{"pid": "1001"},
|
||||
SupportedTypes: []string{"alipay"},
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{
|
||||
ProviderKey: "alipay",
|
||||
Name: "Official Alipay",
|
||||
Config: map[string]string{"appId": "app-1"},
|
||||
SupportedTypes: []string{"alipay"},
|
||||
Enabled: true,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "PAYMENT_PROVIDER_CONFLICT", infraerrors.Reason(err))
|
||||
}
|
||||
|
||||
func TestUpdateProviderInstanceRejectsEnablingConflictingVisibleMethodProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
client := newPaymentConfigServiceTestClient(t)
|
||||
svc := &PaymentConfigService{
|
||||
entClient: client,
|
||||
encryptionKey: []byte("0123456789abcdef0123456789abcdef"),
|
||||
}
|
||||
|
||||
existing, err := svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{
|
||||
ProviderKey: "easypay",
|
||||
Name: "EasyPay WeChat",
|
||||
Config: map[string]string{"pid": "2001"},
|
||||
SupportedTypes: []string{"wxpay"},
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, existing)
|
||||
|
||||
candidate, err := svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{
|
||||
ProviderKey: "wxpay",
|
||||
Name: "Official WeChat",
|
||||
Config: map[string]string{"appId": "wx-app"},
|
||||
SupportedTypes: []string{"wxpay"},
|
||||
Enabled: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.UpdateProviderInstance(ctx, candidate.ID, UpdateProviderInstanceRequest{
|
||||
Enabled: boolPtrValue(true),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "PAYMENT_PROVIDER_CONFLICT", infraerrors.Reason(err))
|
||||
}
|
||||
|
||||
func TestUpdateProviderInstancePersistsEnabledAndSupportedTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
client := newPaymentConfigServiceTestClient(t)
|
||||
svc := &PaymentConfigService{
|
||||
entClient: client,
|
||||
encryptionKey: []byte("0123456789abcdef0123456789abcdef"),
|
||||
}
|
||||
|
||||
instance, err := svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{
|
||||
ProviderKey: "easypay",
|
||||
Name: "EasyPay",
|
||||
Config: map[string]string{"pid": "3001"},
|
||||
SupportedTypes: []string{"alipay"},
|
||||
Enabled: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.UpdateProviderInstance(ctx, instance.ID, UpdateProviderInstanceRequest{
|
||||
Enabled: boolPtrValue(true),
|
||||
SupportedTypes: []string{"alipay", "wxpay"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
saved, err := client.PaymentProviderInstance.Get(ctx, instance.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, saved.Enabled)
|
||||
require.Equal(t, "alipay,wxpay", saved.SupportedTypes)
|
||||
}
|
||||
|
||||
func boolPtrValue(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
@@ -209,17 +209,6 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo
|
||||
return nil, fmt.Errorf("get payment config settings: %w", err)
|
||||
}
|
||||
cfg := s.parsePaymentConfig(vals)
|
||||
if s.entClient != nil {
|
||||
instances, err := s.entClient.PaymentProviderInstance.Query().
|
||||
Where(paymentproviderinstance.EnabledEQ(true)).
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list enabled provider instances: %w", err)
|
||||
}
|
||||
cfg.EnabledTypes = applyVisibleMethodRoutingToEnabledTypes(cfg.EnabledTypes, vals, buildVisibleMethodSourceAvailability(instances))
|
||||
} else {
|
||||
cfg.EnabledTypes = applyVisibleMethodRoutingToEnabledTypes(cfg.EnabledTypes, vals, nil)
|
||||
}
|
||||
// Load Stripe publishable key from the first enabled Stripe provider instance
|
||||
cfg.StripePublishableKey = s.getStripePublishableKey(ctx)
|
||||
return cfg, nil
|
||||
@@ -305,25 +294,25 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda
|
||||
}
|
||||
}
|
||||
m := map[string]string{
|
||||
SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled),
|
||||
SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount),
|
||||
SettingMaxRechargeAmount: formatPositiveFloat(req.MaxAmount),
|
||||
SettingDailyRechargeLimit: formatPositiveFloat(req.DailyLimit),
|
||||
SettingOrderTimeoutMinutes: formatPositiveInt(req.OrderTimeoutMin),
|
||||
SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders),
|
||||
SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled),
|
||||
SettingBalanceRechargeMult: formatPositiveFloat(req.BalanceRechargeMultiplier),
|
||||
SettingRechargeFeeRate: formatNonNegativeFloat(req.RechargeFeeRate),
|
||||
SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy),
|
||||
SettingProductNamePrefix: derefStr(req.ProductNamePrefix),
|
||||
SettingProductNameSuffix: derefStr(req.ProductNameSuffix),
|
||||
SettingHelpImageURL: derefStr(req.HelpImageURL),
|
||||
SettingHelpText: derefStr(req.HelpText),
|
||||
SettingCancelRateLimitOn: formatBoolOrEmpty(req.CancelRateLimitEnabled),
|
||||
SettingCancelRateLimitMax: formatPositiveInt(req.CancelRateLimitMax),
|
||||
SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow),
|
||||
SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit),
|
||||
SettingCancelWindowMode: derefStr(req.CancelRateLimitMode),
|
||||
SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled),
|
||||
SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount),
|
||||
SettingMaxRechargeAmount: formatPositiveFloat(req.MaxAmount),
|
||||
SettingDailyRechargeLimit: formatPositiveFloat(req.DailyLimit),
|
||||
SettingOrderTimeoutMinutes: formatPositiveInt(req.OrderTimeoutMin),
|
||||
SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders),
|
||||
SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled),
|
||||
SettingBalanceRechargeMult: formatPositiveFloat(req.BalanceRechargeMultiplier),
|
||||
SettingRechargeFeeRate: formatNonNegativeFloat(req.RechargeFeeRate),
|
||||
SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy),
|
||||
SettingProductNamePrefix: derefStr(req.ProductNamePrefix),
|
||||
SettingProductNameSuffix: derefStr(req.ProductNameSuffix),
|
||||
SettingHelpImageURL: derefStr(req.HelpImageURL),
|
||||
SettingHelpText: derefStr(req.HelpText),
|
||||
SettingCancelRateLimitOn: formatBoolOrEmpty(req.CancelRateLimitEnabled),
|
||||
SettingCancelRateLimitMax: formatPositiveInt(req.CancelRateLimitMax),
|
||||
SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow),
|
||||
SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit),
|
||||
SettingCancelWindowMode: derefStr(req.CancelRateLimitMode),
|
||||
SettingPaymentVisibleMethodAlipaySource: derefStr(req.VisibleMethodAlipaySource),
|
||||
SettingPaymentVisibleMethodWxpaySource: derefStr(req.VisibleMethodWxpaySource),
|
||||
SettingPaymentVisibleMethodAlipayEnabled: formatBoolOrEmpty(req.VisibleMethodAlipayEnabled),
|
||||
|
||||
@@ -3,6 +3,8 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
@@ -302,7 +304,7 @@ func TestBuildVisibleMethodSourceAvailability(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPaymentConfigAppliesVisibleMethodRouting(t *testing.T) {
|
||||
func TestGetPaymentConfigKeepsStoredEnabledTypes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := newPaymentConfigServiceTestClient(t)
|
||||
|
||||
@@ -321,11 +323,7 @@ func TestGetPaymentConfigAppliesVisibleMethodRouting(t *testing.T) {
|
||||
entClient: client,
|
||||
settingRepo: &paymentConfigSettingRepoStub{
|
||||
values: map[string]string{
|
||||
SettingEnabledPaymentTypes: "alipay,wxpay,stripe",
|
||||
SettingPaymentVisibleMethodAlipayEnabled: "true",
|
||||
SettingPaymentVisibleMethodAlipaySource: "easypay",
|
||||
SettingPaymentVisibleMethodWxpayEnabled: "true",
|
||||
SettingPaymentVisibleMethodWxpaySource: "wxpay",
|
||||
SettingEnabledPaymentTypes: "alipay,wxpay,stripe",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -335,7 +333,7 @@ func TestGetPaymentConfigAppliesVisibleMethodRouting(t *testing.T) {
|
||||
t.Fatalf("GetPaymentConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
want := []string{payment.TypeAlipay, payment.TypeStripe}
|
||||
want := []string{payment.TypeAlipay, payment.TypeWxpay, payment.TypeStripe}
|
||||
if len(cfg.EnabledTypes) != len(want) {
|
||||
t.Fatalf("EnabledTypes len = %d, want %d (%v)", len(cfg.EnabledTypes), len(want), cfg.EnabledTypes)
|
||||
}
|
||||
@@ -349,7 +347,11 @@ func TestGetPaymentConfigAppliesVisibleMethodRouting(t *testing.T) {
|
||||
func newPaymentConfigServiceTestClient(t *testing.T) *dbent.Client {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", "file:payment_config_service?mode=memory&cache=shared")
|
||||
dbName := fmt.Sprintf(
|
||||
"file:%s?mode=memory&cache=shared",
|
||||
strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()),
|
||||
)
|
||||
db, err := sql.Open("sqlite", dbName)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
@@ -326,20 +326,17 @@ func requestNeedsWeChatJSAPICompatibility(req CreateOrderRequest) bool {
|
||||
}
|
||||
|
||||
func (s *PaymentService) usesOfficialWxpayVisibleMethod(ctx context.Context) bool {
|
||||
if s == nil || s.configService == nil || s.configService.settingRepo == nil {
|
||||
if s == nil || s.configService == nil {
|
||||
return false
|
||||
}
|
||||
vals, err := s.configService.settingRepo.GetMultiple(ctx, []string{
|
||||
SettingPaymentVisibleMethodWxpayEnabled,
|
||||
SettingPaymentVisibleMethodWxpaySource,
|
||||
})
|
||||
inst, err := s.configService.resolveEnabledVisibleMethodInstance(ctx, payment.TypeWxpay)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if vals[SettingPaymentVisibleMethodWxpayEnabled] != "true" {
|
||||
if inst == nil {
|
||||
return false
|
||||
}
|
||||
return NormalizeVisibleMethodSource(payment.TypeWxpay, vals[SettingPaymentVisibleMethodWxpaySource]) == VisibleMethodSourceOfficialWechat
|
||||
return inst.ProviderKey == payment.TypeWxpay
|
||||
}
|
||||
|
||||
func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.PaymentOrder, req CreateOrderRequest, cfg *PaymentConfig, limitAmount float64, payAmountStr string, payAmount float64, plan *dbent.SubscriptionPlan, sel *payment.InstanceSelection) (*CreateOrderResponse, error) {
|
||||
|
||||
@@ -2,115 +2,32 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
)
|
||||
|
||||
const jsapiTestEncryptionKey = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
func TestSelectCreateOrderInstancePrefersJSAPICompatibleWxpayInstance(t *testing.T) {
|
||||
func TestUsesOfficialWxpayVisibleMethodDerivesFromEnabledProviderInstance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := newPaymentConfigServiceTestClient(t)
|
||||
|
||||
compatibleConfig := mustEncryptJSAPITestConfig(t, map[string]string{
|
||||
"appId": "wx-merchant-app",
|
||||
"mpAppId": "wx-mp-app",
|
||||
"mchId": "mch-compatible",
|
||||
"privateKey": "private-key",
|
||||
"apiV3Key": jsapiTestEncryptionKey,
|
||||
"publicKey": "public-key",
|
||||
"publicKeyId": "key-compatible",
|
||||
"certSerial": "serial-compatible",
|
||||
})
|
||||
incompatibleConfig := mustEncryptJSAPITestConfig(t, map[string]string{
|
||||
"appId": "wx-merchant-other",
|
||||
"mpAppId": "wx-mp-other",
|
||||
"mchId": "mch-incompatible",
|
||||
"privateKey": "private-key",
|
||||
"apiV3Key": jsapiTestEncryptionKey,
|
||||
"publicKey": "public-key",
|
||||
"publicKeyId": "key-incompatible",
|
||||
"certSerial": "serial-incompatible",
|
||||
})
|
||||
|
||||
compatible, err := client.PaymentProviderInstance.Create().
|
||||
_, err := client.PaymentProviderInstance.Create().
|
||||
SetProviderKey(payment.TypeWxpay).
|
||||
SetName("wxpay-compatible").
|
||||
SetConfig(compatibleConfig).
|
||||
SetName("Official WeChat").
|
||||
SetConfig("{}").
|
||||
SetSupportedTypes("wxpay").
|
||||
SetEnabled(true).
|
||||
SetSortOrder(1).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("create compatible wxpay instance: %v", err)
|
||||
}
|
||||
_, err = client.PaymentProviderInstance.Create().
|
||||
SetProviderKey(payment.TypeWxpay).
|
||||
SetName("wxpay-incompatible").
|
||||
SetConfig(incompatibleConfig).
|
||||
SetSupportedTypes("wxpay").
|
||||
SetEnabled(true).
|
||||
SetSortOrder(2).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("create incompatible wxpay instance: %v", err)
|
||||
t.Fatalf("create official wxpay instance: %v", err)
|
||||
}
|
||||
|
||||
configService := &PaymentConfigService{
|
||||
entClient: client,
|
||||
settingRepo: &paymentConfigSettingRepoStub{values: map[string]string{
|
||||
SettingPaymentVisibleMethodWxpayEnabled: "true",
|
||||
SettingPaymentVisibleMethodWxpaySource: VisibleMethodSourceOfficialWechat,
|
||||
SettingKeyWeChatConnectEnabled: "true",
|
||||
SettingKeyWeChatConnectAppID: "wx-mp-app",
|
||||
SettingKeyWeChatConnectAppSecret: "wechat-secret",
|
||||
SettingKeyWeChatConnectMode: "mp",
|
||||
SettingKeyWeChatConnectScopes: "snsapi_base",
|
||||
SettingKeyWeChatConnectRedirectURL: "https://api.example.com/api/v1/auth/oauth/wechat/callback",
|
||||
SettingKeyWeChatConnectFrontendRedirectURL: "/auth/wechat/callback",
|
||||
}},
|
||||
encryptionKey: []byte(jsapiTestEncryptionKey),
|
||||
}
|
||||
loadBalancer := newVisibleMethodLoadBalancer(
|
||||
payment.NewDefaultLoadBalancer(client, []byte(jsapiTestEncryptionKey)),
|
||||
configService,
|
||||
)
|
||||
svc := &PaymentService{
|
||||
entClient: client,
|
||||
loadBalancer: loadBalancer,
|
||||
configService: configService,
|
||||
configService: &PaymentConfigService{entClient: client},
|
||||
}
|
||||
|
||||
sel, err := svc.selectCreateOrderInstance(ctx, CreateOrderRequest{
|
||||
PaymentType: payment.TypeWxpay,
|
||||
OpenID: "openid-123",
|
||||
IsWeChatBrowser: true,
|
||||
}, &PaymentConfig{LoadBalanceStrategy: string(payment.StrategyRoundRobin)}, 12.5)
|
||||
if err != nil {
|
||||
t.Fatalf("selectCreateOrderInstance returned error: %v", err)
|
||||
}
|
||||
if sel == nil {
|
||||
t.Fatal("expected selected instance, got nil")
|
||||
}
|
||||
expectedInstanceID := fmt.Sprintf("%d", compatible.ID)
|
||||
if sel.InstanceID != expectedInstanceID {
|
||||
t.Fatalf("selected instance id = %q, want %q", sel.InstanceID, expectedInstanceID)
|
||||
if !svc.usesOfficialWxpayVisibleMethod(ctx) {
|
||||
t.Fatal("expected official wxpay visible method to be detected from enabled provider instance")
|
||||
}
|
||||
}
|
||||
|
||||
func mustEncryptJSAPITestConfig(t *testing.T, config map[string]string) string {
|
||||
t.Helper()
|
||||
|
||||
data, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
encrypted, err := payment.Encrypt(string(data), []byte(jsapiTestEncryptionKey))
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt config: %v", err)
|
||||
}
|
||||
return encrypted
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ const (
|
||||
paymentResumeNotConfiguredCode = "PAYMENT_RESUME_NOT_CONFIGURED"
|
||||
paymentResumeNotConfiguredMessage = "payment resume tokens require a configured signing key"
|
||||
|
||||
paymentResumeTokenTTL = 24 * time.Hour
|
||||
wechatPaymentResumeTokenTTL = 15 * time.Minute
|
||||
paymentResumeTokenTTL = 24 * time.Hour
|
||||
wechatPaymentResumeTokenTTL = 15 * time.Minute
|
||||
)
|
||||
|
||||
type ResumeTokenClaims struct {
|
||||
@@ -163,7 +163,7 @@ func VisibleMethodProviderKeyForSource(method, source string) (string, bool) {
|
||||
}
|
||||
|
||||
func newVisibleMethodLoadBalancer(inner payment.LoadBalancer, configService *PaymentConfigService) payment.LoadBalancer {
|
||||
if inner == nil || configService == nil || configService.settingRepo == nil {
|
||||
if inner == nil || configService == nil || configService.entClient == nil {
|
||||
return inner
|
||||
}
|
||||
return &visibleMethodLoadBalancer{inner: inner, configService: configService}
|
||||
@@ -179,21 +179,14 @@ func (lb *visibleMethodLoadBalancer) SelectInstance(ctx context.Context, provide
|
||||
return lb.inner.SelectInstance(ctx, providerKey, paymentType, strategy, orderAmount)
|
||||
}
|
||||
|
||||
enabledKey := visibleMethodEnabledSettingKey(visibleMethod)
|
||||
sourceKey := visibleMethodSourceSettingKey(visibleMethod)
|
||||
vals, err := lb.configService.settingRepo.GetMultiple(ctx, []string{enabledKey, sourceKey})
|
||||
inst, err := lb.configService.resolveEnabledVisibleMethodInstance(ctx, visibleMethod)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load visible method routing for %s: %w", visibleMethod, err)
|
||||
return nil, err
|
||||
}
|
||||
if vals[enabledKey] != "true" {
|
||||
return nil, fmt.Errorf("visible payment method %s is disabled", visibleMethod)
|
||||
if inst == nil {
|
||||
return nil, fmt.Errorf("visible payment method %s has no enabled provider instance", visibleMethod)
|
||||
}
|
||||
|
||||
targetProviderKey, ok := VisibleMethodProviderKeyForSource(visibleMethod, vals[sourceKey])
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("visible payment method %s has no valid source", visibleMethod)
|
||||
}
|
||||
return lb.inner.SelectInstance(ctx, targetProviderKey, paymentType, strategy, orderAmount)
|
||||
return lb.inner.SelectInstance(ctx, inst.ProviderKey, paymentType, strategy, orderAmount)
|
||||
}
|
||||
|
||||
func visibleMethodEnabledSettingKey(method string) string {
|
||||
|
||||
@@ -344,21 +344,30 @@ func TestVisibleMethodProviderKeyForSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleMethodLoadBalancerUsesConfiguredSource(t *testing.T) {
|
||||
func TestVisibleMethodLoadBalancerUsesEnabledProviderInstance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
client := newPaymentConfigServiceTestClient(t)
|
||||
_, err := client.PaymentProviderInstance.Create().
|
||||
SetProviderKey(payment.TypeAlipay).
|
||||
SetName("Official Alipay").
|
||||
SetConfig("{}").
|
||||
SetSupportedTypes("alipay").
|
||||
SetEnabled(true).
|
||||
SetSortOrder(1).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("create alipay provider: %v", err)
|
||||
}
|
||||
|
||||
inner := &captureLoadBalancer{}
|
||||
configService := &PaymentConfigService{
|
||||
settingRepo: &paymentSettingRepoStub{
|
||||
values: map[string]string{
|
||||
SettingPaymentVisibleMethodAlipayEnabled: "true",
|
||||
SettingPaymentVisibleMethodAlipaySource: VisibleMethodSourceOfficialAlipay,
|
||||
},
|
||||
},
|
||||
entClient: client,
|
||||
}
|
||||
lb := newVisibleMethodLoadBalancer(inner, configService)
|
||||
|
||||
_, err := lb.SelectInstance(context.Background(), "", payment.TypeAlipay, payment.StrategyRoundRobin, 12.5)
|
||||
_, err = lb.SelectInstance(ctx, "", payment.TypeAlipay, payment.StrategyRoundRobin, 12.5)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectInstance returned error: %v", err)
|
||||
}
|
||||
@@ -367,47 +376,20 @@ func TestVisibleMethodLoadBalancerUsesConfiguredSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleMethodLoadBalancerRejectsDisabledVisibleMethod(t *testing.T) {
|
||||
func TestVisibleMethodLoadBalancerRejectsMissingEnabledVisibleMethodProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inner := &captureLoadBalancer{}
|
||||
configService := &PaymentConfigService{
|
||||
settingRepo: &paymentSettingRepoStub{
|
||||
values: map[string]string{
|
||||
SettingPaymentVisibleMethodWxpayEnabled: "false",
|
||||
SettingPaymentVisibleMethodWxpaySource: VisibleMethodSourceOfficialWechat,
|
||||
},
|
||||
},
|
||||
entClient: newPaymentConfigServiceTestClient(t),
|
||||
}
|
||||
lb := newVisibleMethodLoadBalancer(inner, configService)
|
||||
|
||||
if _, err := lb.SelectInstance(context.Background(), "", payment.TypeWxpay, payment.StrategyRoundRobin, 9.9); err == nil {
|
||||
t.Fatal("SelectInstance should reject disabled visible method")
|
||||
t.Fatal("SelectInstance should reject when no enabled provider instance exists")
|
||||
}
|
||||
}
|
||||
|
||||
type paymentSettingRepoStub struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (s *paymentSettingRepoStub) Get(context.Context, string) (*Setting, error) { return nil, nil }
|
||||
func (s *paymentSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
|
||||
return s.values[key], nil
|
||||
}
|
||||
func (s *paymentSettingRepoStub) Set(context.Context, string, string) error { return nil }
|
||||
func (s *paymentSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
|
||||
out := make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
out[key] = s.values[key]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (s *paymentSettingRepoStub) SetMultiple(context.Context, map[string]string) error { return nil }
|
||||
func (s *paymentSettingRepoStub) GetAll(context.Context) (map[string]string, error) {
|
||||
return s.values, nil
|
||||
}
|
||||
func (s *paymentSettingRepoStub) Delete(context.Context, string) error { return nil }
|
||||
|
||||
type captureLoadBalancer struct {
|
||||
lastProviderKey string
|
||||
lastPaymentType string
|
||||
|
||||
166
backend/internal/service/payment_visible_method_instances.go
Normal file
166
backend/internal/service/payment_visible_method_instances.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
)
|
||||
|
||||
func enabledVisibleMethodsForProvider(providerKey, supportedTypes string) []string {
|
||||
methodSet := make(map[string]struct{}, 2)
|
||||
addMethod := func(method string) {
|
||||
method = NormalizeVisibleMethod(method)
|
||||
switch method {
|
||||
case payment.TypeAlipay, payment.TypeWxpay:
|
||||
methodSet[method] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(providerKey) {
|
||||
case payment.TypeAlipay:
|
||||
if strings.TrimSpace(supportedTypes) == "" {
|
||||
addMethod(payment.TypeAlipay)
|
||||
break
|
||||
}
|
||||
for _, supportedType := range splitTypes(supportedTypes) {
|
||||
if NormalizeVisibleMethod(supportedType) == payment.TypeAlipay {
|
||||
addMethod(payment.TypeAlipay)
|
||||
break
|
||||
}
|
||||
}
|
||||
case payment.TypeWxpay:
|
||||
if strings.TrimSpace(supportedTypes) == "" {
|
||||
addMethod(payment.TypeWxpay)
|
||||
break
|
||||
}
|
||||
for _, supportedType := range splitTypes(supportedTypes) {
|
||||
if NormalizeVisibleMethod(supportedType) == payment.TypeWxpay {
|
||||
addMethod(payment.TypeWxpay)
|
||||
break
|
||||
}
|
||||
}
|
||||
case payment.TypeEasyPay:
|
||||
for _, supportedType := range splitTypes(supportedTypes) {
|
||||
addMethod(supportedType)
|
||||
}
|
||||
}
|
||||
|
||||
methods := make([]string, 0, len(methodSet))
|
||||
for _, method := range []string{payment.TypeAlipay, payment.TypeWxpay} {
|
||||
if _, ok := methodSet[method]; ok {
|
||||
methods = append(methods, method)
|
||||
}
|
||||
}
|
||||
return methods
|
||||
}
|
||||
|
||||
func providerSupportsVisibleMethod(inst *dbent.PaymentProviderInstance, method string) bool {
|
||||
if inst == nil || !inst.Enabled {
|
||||
return false
|
||||
}
|
||||
method = NormalizeVisibleMethod(method)
|
||||
for _, candidate := range enabledVisibleMethodsForProvider(inst.ProviderKey, inst.SupportedTypes) {
|
||||
if candidate == method {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func filterEnabledVisibleMethodInstances(instances []*dbent.PaymentProviderInstance, method string) []*dbent.PaymentProviderInstance {
|
||||
filtered := make([]*dbent.PaymentProviderInstance, 0, len(instances))
|
||||
for _, inst := range instances {
|
||||
if providerSupportsVisibleMethod(inst, method) {
|
||||
filtered = append(filtered, inst)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func buildPaymentProviderConflictError(method string, conflicting *dbent.PaymentProviderInstance) error {
|
||||
metadata := map[string]string{
|
||||
"payment_method": NormalizeVisibleMethod(method),
|
||||
}
|
||||
if conflicting != nil {
|
||||
metadata["conflicting_provider_id"] = fmt.Sprintf("%d", conflicting.ID)
|
||||
metadata["conflicting_provider_key"] = conflicting.ProviderKey
|
||||
metadata["conflicting_provider_name"] = conflicting.Name
|
||||
}
|
||||
return infraerrors.Conflict(
|
||||
"PAYMENT_PROVIDER_CONFLICT",
|
||||
fmt.Sprintf("%s payment already has an enabled provider instance", NormalizeVisibleMethod(method)),
|
||||
).WithMetadata(metadata)
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) validateVisibleMethodEnablementConflicts(
|
||||
ctx context.Context,
|
||||
excludeID int64,
|
||||
providerKey string,
|
||||
supportedTypes string,
|
||||
enabled bool,
|
||||
) error {
|
||||
if s == nil || s.entClient == nil || !enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
claimedMethods := enabledVisibleMethodsForProvider(providerKey, supportedTypes)
|
||||
if len(claimedMethods) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
query := s.entClient.PaymentProviderInstance.Query().
|
||||
Where(paymentproviderinstance.EnabledEQ(true))
|
||||
if excludeID > 0 {
|
||||
query = query.Where(paymentproviderinstance.IDNEQ(excludeID))
|
||||
}
|
||||
instances, err := query.All(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query enabled payment providers: %w", err)
|
||||
}
|
||||
|
||||
for _, method := range claimedMethods {
|
||||
for _, inst := range instances {
|
||||
if providerSupportsVisibleMethod(inst, method) {
|
||||
return buildPaymentProviderConflictError(method, inst)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) resolveEnabledVisibleMethodInstance(
|
||||
ctx context.Context,
|
||||
method string,
|
||||
) (*dbent.PaymentProviderInstance, error) {
|
||||
if s == nil || s.entClient == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
method = NormalizeVisibleMethod(method)
|
||||
if method != payment.TypeAlipay && method != payment.TypeWxpay {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
instances, err := s.entClient.PaymentProviderInstance.Query().
|
||||
Where(paymentproviderinstance.EnabledEQ(true)).
|
||||
Order(paymentproviderinstance.BySortOrder()).
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query enabled payment providers: %w", err)
|
||||
}
|
||||
|
||||
matching := filterEnabledVisibleMethodInstances(instances, method)
|
||||
switch len(matching) {
|
||||
case 0:
|
||||
return nil, nil
|
||||
case 1:
|
||||
return matching[0], nil
|
||||
default:
|
||||
return nil, buildPaymentProviderConflictError(method, matching[0])
|
||||
}
|
||||
}
|
||||
@@ -4757,6 +4757,7 @@ export default {
|
||||
supportedTypesHint: 'Comma-separated, e.g. alipay,wxpay',
|
||||
refundEnabled: 'Allow Refund',
|
||||
allowUserRefund: 'Allow User Refund',
|
||||
enableConflict: '{method} already has an enabled provider instance: {provider}. Disable the existing instance before switching.',
|
||||
},
|
||||
balanceNotify: {
|
||||
title: 'Balance Low Notification',
|
||||
@@ -5612,6 +5613,7 @@ export default {
|
||||
alipayMobileUnavailable: 'This page could not hand off to Alipay.',
|
||||
alipayMobileOpenHint: 'Allow the current page to open the Alipay app, or retry from the system browser.',
|
||||
PENDING_ORDERS: 'This provider has pending orders. Please wait for them to complete before making changes.',
|
||||
PAYMENT_PROVIDER_CONFLICT: 'Another enabled provider instance is already serving this payment method. Disable it before continuing.',
|
||||
},
|
||||
stripePay: 'Pay Now',
|
||||
stripeSuccessProcessing: 'Payment successful, processing your order...',
|
||||
|
||||
@@ -4921,6 +4921,7 @@ export default {
|
||||
supportedTypesHint: '逗号分隔,如 alipay,wxpay',
|
||||
refundEnabled: '允许退款',
|
||||
allowUserRefund: '允许用户退款',
|
||||
enableConflict: '{method} 已有启用中的服务商实例:{provider}。请先停用现有实例后再启用或切换。',
|
||||
},
|
||||
balanceNotify: {
|
||||
title: '余额不足提醒',
|
||||
@@ -5800,6 +5801,7 @@ export default {
|
||||
alipayMobileUnavailable: '当前页面未成功跳转到支付宝。',
|
||||
alipayMobileOpenHint: '请允许当前页面打开支付宝 App,或改用系统浏览器重新发起支付。',
|
||||
PENDING_ORDERS: '该服务商有未完成的订单,请等待订单完成后再操作',
|
||||
PAYMENT_PROVIDER_CONFLICT: '该支付方式已有其他启用中的服务商实例,请先停用后再继续。',
|
||||
},
|
||||
stripePay: '立即支付',
|
||||
stripeSuccessProcessing: '支付成功,正在处理订单...',
|
||||
|
||||
@@ -4160,73 +4160,6 @@
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<div
|
||||
v-for="visibleMethod in paymentVisibleMethodCards"
|
||||
:key="visibleMethod.key"
|
||||
class="rounded-lg border border-gray-200 p-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label
|
||||
class="font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
{{
|
||||
t("admin.settings.paymentVisibleMethods.methodLabel", {
|
||||
title: visibleMethod.title,
|
||||
})
|
||||
}}
|
||||
</label>
|
||||
<p
|
||||
class="mt-1 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{
|
||||
t("admin.settings.paymentVisibleMethods.methodHint")
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
:model-value="
|
||||
getPaymentVisibleMethodEnabled(visibleMethod.key)
|
||||
"
|
||||
@update:model-value="
|
||||
setPaymentVisibleMethodEnabled(
|
||||
visibleMethod.key,
|
||||
$event,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="input-label">
|
||||
{{ t("admin.settings.paymentVisibleMethods.sourceLabel") }}
|
||||
</label>
|
||||
<Select
|
||||
:model-value="
|
||||
getPaymentVisibleMethodSource(visibleMethod.key)
|
||||
"
|
||||
:options="
|
||||
getPaymentVisibleMethodSourceSelectOptions(
|
||||
visibleMethod.key,
|
||||
)
|
||||
"
|
||||
@update:model-value="
|
||||
setPaymentVisibleMethodSource(
|
||||
visibleMethod.key,
|
||||
$event,
|
||||
)
|
||||
"
|
||||
:placeholder="visibleMethod.key"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-400">
|
||||
{{
|
||||
t("admin.settings.paymentVisibleMethods.sourceHint")
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 5: Help image + text -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
@@ -4742,15 +4675,12 @@ import {
|
||||
buildAuthSourceDefaultsState,
|
||||
defaultWeChatConnectScopesForMode,
|
||||
deriveWeChatConnectStoredMode,
|
||||
getPaymentVisibleMethodSourceOptions,
|
||||
normalizePaymentVisibleMethodSource,
|
||||
normalizeDefaultSubscriptionSettings,
|
||||
resolveWeChatConnectModeCapabilities,
|
||||
} from "@/api/admin/settings";
|
||||
import type {
|
||||
AuthSourceDefaultsState,
|
||||
AuthSourceType,
|
||||
PaymentVisibleMethod,
|
||||
SystemSettings,
|
||||
UpdateSettingsRequest,
|
||||
DefaultSubscriptionSetting,
|
||||
@@ -4777,6 +4707,7 @@ import { useClipboard } from "@/composables/useClipboard";
|
||||
import { extractApiErrorMessage } from "@/utils/apiError";
|
||||
import { useAppStore } from "@/stores";
|
||||
import { useAdminSettingsStore } from "@/stores/adminSettings";
|
||||
import { normalizeVisibleMethod } from "@/components/payment/paymentFlow";
|
||||
import {
|
||||
isRegistrationEmailSuffixDomainValid,
|
||||
normalizeRegistrationEmailSuffixDomain,
|
||||
@@ -4788,10 +4719,6 @@ const { t, locale } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
const adminSettingsStore = useAdminSettingsStore();
|
||||
|
||||
function localText(zh: string, en: string): string {
|
||||
return locale.value.startsWith("zh") ? zh : en;
|
||||
}
|
||||
|
||||
type SettingsTab =
|
||||
| "general"
|
||||
| "security"
|
||||
@@ -4908,10 +4835,6 @@ type SettingsForm = Omit<
|
||||
wechat_connect_mobile_enabled: boolean;
|
||||
oidc_connect_client_secret: string;
|
||||
force_email_on_third_party_signup: boolean;
|
||||
payment_visible_method_alipay_source: string;
|
||||
payment_visible_method_wxpay_source: string;
|
||||
payment_visible_method_alipay_enabled: boolean;
|
||||
payment_visible_method_wxpay_enabled: boolean;
|
||||
openai_advanced_scheduler_enabled: boolean;
|
||||
};
|
||||
|
||||
@@ -4957,10 +4880,6 @@ const form = reactive<SettingsForm>({
|
||||
payment_cancel_rate_limit_window: 1,
|
||||
payment_cancel_rate_limit_unit: "day",
|
||||
payment_cancel_rate_limit_window_mode: "rolling",
|
||||
payment_visible_method_alipay_source: "",
|
||||
payment_visible_method_wxpay_source: "",
|
||||
payment_visible_method_alipay_enabled: false,
|
||||
payment_visible_method_wxpay_enabled: false,
|
||||
table_default_page_size: tablePageSizeDefault,
|
||||
table_page_size_options: [10, 20, 50, 100],
|
||||
custom_menu_items: [] as Array<{
|
||||
@@ -5099,86 +5018,6 @@ const authSourceDefaultsMeta = computed(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
const paymentVisibleMethodCards = computed(() => [
|
||||
{
|
||||
key: "alipay" as const,
|
||||
title: t("payment.methods.alipay"),
|
||||
enabledField: "payment_visible_method_alipay_enabled" as const,
|
||||
sourceField: "payment_visible_method_alipay_source" as const,
|
||||
},
|
||||
{
|
||||
key: "wxpay" as const,
|
||||
title: t("payment.methods.wxpay"),
|
||||
enabledField: "payment_visible_method_wxpay_enabled" as const,
|
||||
sourceField: "payment_visible_method_wxpay_source" as const,
|
||||
},
|
||||
]);
|
||||
|
||||
function getPaymentVisibleMethodEnabled(method: "alipay" | "wxpay"): boolean {
|
||||
return method === "alipay"
|
||||
? form.payment_visible_method_alipay_enabled
|
||||
: form.payment_visible_method_wxpay_enabled;
|
||||
}
|
||||
|
||||
function setPaymentVisibleMethodEnabled(
|
||||
method: "alipay" | "wxpay",
|
||||
enabled: boolean,
|
||||
) {
|
||||
if (method === "alipay") {
|
||||
form.payment_visible_method_alipay_enabled = enabled;
|
||||
return;
|
||||
}
|
||||
form.payment_visible_method_wxpay_enabled = enabled;
|
||||
}
|
||||
|
||||
function getPaymentVisibleMethodSource(method: "alipay" | "wxpay"): string {
|
||||
return method === "alipay"
|
||||
? form.payment_visible_method_alipay_source
|
||||
: form.payment_visible_method_wxpay_source;
|
||||
}
|
||||
|
||||
function getPaymentVisibleMethodSourceSelectOptions(
|
||||
method: PaymentVisibleMethod,
|
||||
) {
|
||||
return getPaymentVisibleMethodSourceOptions(method).map((option) => ({
|
||||
value: option.value,
|
||||
label: localText(option.labelZh, option.labelEn),
|
||||
}));
|
||||
}
|
||||
|
||||
function setPaymentVisibleMethodSource(
|
||||
method: "alipay" | "wxpay",
|
||||
source: string | number | boolean | null,
|
||||
) {
|
||||
const normalized = normalizePaymentVisibleMethodSource(method, source);
|
||||
if (method === "alipay") {
|
||||
form.payment_visible_method_alipay_source = normalized;
|
||||
return;
|
||||
}
|
||||
form.payment_visible_method_wxpay_source = normalized;
|
||||
}
|
||||
|
||||
function validatePaymentVisibleMethodSelections(): boolean {
|
||||
for (const visibleMethod of paymentVisibleMethodCards.value) {
|
||||
if (!getPaymentVisibleMethodEnabled(visibleMethod.key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (getPaymentVisibleMethodSource(visibleMethod.key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
appStore.showError(
|
||||
t("admin.settings.paymentVisibleMethods.sourceRequiredError", {
|
||||
title: visibleMethod.title,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Proxies for web search emulation ProxySelector
|
||||
const webSearchProxies = ref<Proxy[]>([]);
|
||||
|
||||
@@ -5660,16 +5499,6 @@ async function loadSettings() {
|
||||
form.default_subscriptions = normalizeDefaultSubscriptionSettings(
|
||||
settings.default_subscriptions,
|
||||
);
|
||||
form.payment_visible_method_alipay_source =
|
||||
normalizePaymentVisibleMethodSource(
|
||||
"alipay",
|
||||
settings.payment_visible_method_alipay_source,
|
||||
);
|
||||
form.payment_visible_method_wxpay_source =
|
||||
normalizePaymentVisibleMethodSource(
|
||||
"wxpay",
|
||||
settings.payment_visible_method_wxpay_source,
|
||||
);
|
||||
registrationEmailSuffixWhitelistTags.value =
|
||||
normalizeRegistrationEmailSuffixDomains(
|
||||
settings.registration_email_suffix_whitelist,
|
||||
@@ -5873,7 +5702,6 @@ async function saveSettings() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate URL fields — novalidate disables browser-native checks, so we validate here
|
||||
const isValidHttpUrl = (url: string): boolean => {
|
||||
if (!url) return true;
|
||||
@@ -6028,18 +5856,6 @@ async function saveSettings() {
|
||||
payment_cancel_rate_limit_unit: form.payment_cancel_rate_limit_unit,
|
||||
payment_cancel_rate_limit_window_mode:
|
||||
form.payment_cancel_rate_limit_window_mode,
|
||||
payment_visible_method_alipay_source: normalizePaymentVisibleMethodSource(
|
||||
"alipay",
|
||||
form.payment_visible_method_alipay_source,
|
||||
),
|
||||
payment_visible_method_wxpay_source: normalizePaymentVisibleMethodSource(
|
||||
"wxpay",
|
||||
form.payment_visible_method_wxpay_source,
|
||||
),
|
||||
payment_visible_method_alipay_enabled:
|
||||
form.payment_visible_method_alipay_enabled,
|
||||
payment_visible_method_wxpay_enabled:
|
||||
form.payment_visible_method_wxpay_enabled,
|
||||
openai_advanced_scheduler_enabled: form.openai_advanced_scheduler_enabled,
|
||||
// Balance & quota notification
|
||||
balance_low_notify_enabled: form.balance_low_notify_enabled,
|
||||
@@ -6062,16 +5878,6 @@ async function saveSettings() {
|
||||
}
|
||||
}
|
||||
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(updated));
|
||||
form.payment_visible_method_alipay_source =
|
||||
normalizePaymentVisibleMethodSource(
|
||||
"alipay",
|
||||
updated.payment_visible_method_alipay_source,
|
||||
);
|
||||
form.payment_visible_method_wxpay_source =
|
||||
normalizePaymentVisibleMethodSource(
|
||||
"wxpay",
|
||||
updated.payment_visible_method_wxpay_source,
|
||||
);
|
||||
registrationEmailSuffixWhitelistTags.value =
|
||||
normalizeRegistrationEmailSuffixDomains(
|
||||
updated.registration_email_suffix_whitelist,
|
||||
@@ -6588,8 +6394,98 @@ const cancelRateLimitModeOptions = computed(() => [
|
||||
|
||||
const paymentErrorMap = computed(() => ({
|
||||
PENDING_ORDERS: t("payment.errors.PENDING_ORDERS"),
|
||||
PAYMENT_PROVIDER_CONFLICT: t("payment.errors.PAYMENT_PROVIDER_CONFLICT"),
|
||||
}));
|
||||
|
||||
type ProviderEnablementCandidate = Pick<
|
||||
ProviderInstance,
|
||||
"id" | "provider_key" | "supported_types" | "enabled" | "name"
|
||||
>;
|
||||
|
||||
function getProviderVisibleMethods(
|
||||
provider: ProviderEnablementCandidate,
|
||||
): Array<"alipay" | "wxpay"> {
|
||||
if (!provider.enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const supportedTypes = Array.isArray(provider.supported_types)
|
||||
? provider.supported_types
|
||||
: [];
|
||||
const methods = new Set<"alipay" | "wxpay">();
|
||||
const addMethod = (type: string) => {
|
||||
const method = normalizeVisibleMethod(type);
|
||||
if (method === "alipay" || method === "wxpay") {
|
||||
methods.add(method);
|
||||
}
|
||||
};
|
||||
|
||||
if (provider.provider_key === "alipay") {
|
||||
if (supportedTypes.length === 0) {
|
||||
methods.add("alipay");
|
||||
} else {
|
||||
supportedTypes.forEach((type) => {
|
||||
if (normalizeVisibleMethod(type) === "alipay") {
|
||||
methods.add("alipay");
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (provider.provider_key === "wxpay") {
|
||||
if (supportedTypes.length === 0) {
|
||||
methods.add("wxpay");
|
||||
} else {
|
||||
supportedTypes.forEach((type) => {
|
||||
if (normalizeVisibleMethod(type) === "wxpay") {
|
||||
methods.add("wxpay");
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (provider.provider_key === "easypay") {
|
||||
supportedTypes.forEach(addMethod);
|
||||
}
|
||||
|
||||
return Array.from(methods);
|
||||
}
|
||||
|
||||
function findProviderEnablementConflict(
|
||||
candidate: ProviderEnablementCandidate,
|
||||
): { method: "alipay" | "wxpay"; conflicting: ProviderInstance } | null {
|
||||
const claimedMethods = getProviderVisibleMethods(candidate);
|
||||
if (claimedMethods.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const other of providers.value) {
|
||||
if (other.id === candidate.id || !other.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const otherMethods = getProviderVisibleMethods(other);
|
||||
const matchedMethod = claimedMethods.find((method) =>
|
||||
otherMethods.includes(method),
|
||||
);
|
||||
if (matchedMethod) {
|
||||
return {
|
||||
method: matchedMethod,
|
||||
conflicting: other,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function showProviderEnablementConflict(
|
||||
conflict: { method: "alipay" | "wxpay"; conflicting: ProviderInstance },
|
||||
) {
|
||||
appStore.showError(
|
||||
t("admin.settings.payment.enableConflict", {
|
||||
method: t(`payment.methods.${conflict.method}`),
|
||||
provider: conflict.conflicting.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
providersLoading.value = true;
|
||||
try {
|
||||
@@ -6619,6 +6515,21 @@ function openEditProvider(provider: ProviderInstance) {
|
||||
async function handleSaveProvider(payload: Partial<ProviderInstance>) {
|
||||
providerSaving.value = true;
|
||||
try {
|
||||
const candidate: ProviderEnablementCandidate = {
|
||||
id: editingProvider.value?.id ?? 0,
|
||||
provider_key:
|
||||
payload.provider_key ?? editingProvider.value?.provider_key ?? "",
|
||||
supported_types:
|
||||
payload.supported_types ?? editingProvider.value?.supported_types ?? [],
|
||||
enabled: payload.enabled ?? editingProvider.value?.enabled ?? false,
|
||||
name: payload.name ?? editingProvider.value?.name ?? "",
|
||||
};
|
||||
const conflict = findProviderEnablementConflict(candidate);
|
||||
if (conflict) {
|
||||
showProviderEnablementConflict(conflict);
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingProvider.value) {
|
||||
await adminAPI.payment.updateProvider(editingProvider.value.id, payload);
|
||||
} else {
|
||||
@@ -6647,6 +6558,20 @@ async function handleToggleField(
|
||||
else if (field === "refund_enabled") newValue = !provider.refund_enabled;
|
||||
else newValue = !provider.allow_user_refund;
|
||||
|
||||
if (field === "enabled" && newValue) {
|
||||
const conflict = findProviderEnablementConflict({
|
||||
id: provider.id,
|
||||
provider_key: provider.provider_key,
|
||||
supported_types: provider.supported_types,
|
||||
enabled: true,
|
||||
name: provider.name,
|
||||
});
|
||||
if (conflict) {
|
||||
showProviderEnablementConflict(conflict);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const payload: Record<string, boolean> = { [field]: newValue };
|
||||
// Cascade: turning off refund_enabled also turns off allow_user_refund
|
||||
if (field === "refund_enabled" && !newValue) {
|
||||
@@ -6654,13 +6579,7 @@ async function handleToggleField(
|
||||
}
|
||||
try {
|
||||
await adminAPI.payment.updateProvider(provider.id, payload);
|
||||
if (field === "enabled") provider.enabled = newValue;
|
||||
else if (field === "refund_enabled") {
|
||||
provider.refund_enabled = newValue;
|
||||
if (!newValue) provider.allow_user_refund = false;
|
||||
} else {
|
||||
provider.allow_user_refund = newValue;
|
||||
}
|
||||
await loadProviders();
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(
|
||||
extractApiErrorMessage(err, t("common.error"), paymentErrorMap.value),
|
||||
@@ -6672,11 +6591,22 @@ async function handleToggleType(provider: ProviderInstance, type: string) {
|
||||
const updated = provider.supported_types.includes(type)
|
||||
? provider.supported_types.filter((t) => t !== type)
|
||||
: [...provider.supported_types, type];
|
||||
const conflict = findProviderEnablementConflict({
|
||||
id: provider.id,
|
||||
provider_key: provider.provider_key,
|
||||
supported_types: updated,
|
||||
enabled: provider.enabled,
|
||||
name: provider.name,
|
||||
});
|
||||
if (conflict) {
|
||||
showProviderEnablementConflict(conflict);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await adminAPI.payment.updateProvider(provider.id, {
|
||||
supported_types: updated,
|
||||
} as any);
|
||||
provider.supported_types = updated;
|
||||
await loadProviders();
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(
|
||||
extractApiErrorMessage(err, t("common.error"), paymentErrorMap.value),
|
||||
@@ -6700,11 +6630,7 @@ async function handleReorderProviders(
|
||||
} as Partial<ProviderInstance>),
|
||||
),
|
||||
);
|
||||
// Update local state to match new order
|
||||
for (const u of updates) {
|
||||
const p = providers.value.find((p) => p.id === u.id);
|
||||
if (p) p.sort_order = u.sort_order;
|
||||
}
|
||||
await loadProviders();
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t("common.error")));
|
||||
loadProviders();
|
||||
|
||||
@@ -17,6 +17,9 @@ const {
|
||||
getGroups,
|
||||
listProxies,
|
||||
getProviders,
|
||||
updateProvider,
|
||||
createProvider,
|
||||
deleteProvider,
|
||||
fetchPublicSettings,
|
||||
adminSettingsFetch,
|
||||
showError,
|
||||
@@ -34,6 +37,9 @@ const {
|
||||
getGroups: vi.fn(),
|
||||
listProxies: vi.fn(),
|
||||
getProviders: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
createProvider: vi.fn(),
|
||||
deleteProvider: vi.fn(),
|
||||
fetchPublicSettings: vi.fn(),
|
||||
adminSettingsFetch: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
@@ -61,6 +67,9 @@ vi.mock("@/api", () => ({
|
||||
},
|
||||
payment: {
|
||||
getProviders,
|
||||
updateProvider,
|
||||
createProvider,
|
||||
deleteProvider,
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -413,6 +422,9 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
getGroups.mockReset();
|
||||
listProxies.mockReset();
|
||||
getProviders.mockReset();
|
||||
updateProvider.mockReset();
|
||||
createProvider.mockReset();
|
||||
deleteProvider.mockReset();
|
||||
fetchPublicSettings.mockReset();
|
||||
adminSettingsFetch.mockReset();
|
||||
showError.mockReset();
|
||||
@@ -467,98 +479,93 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
adminSettingsFetch.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("loads canonical source options and normalizes existing values", async () => {
|
||||
it("does not render legacy visible payment method controls", async () => {
|
||||
const wrapper = mountView();
|
||||
|
||||
await flushPromises();
|
||||
await openPaymentTab(wrapper);
|
||||
|
||||
const paymentSourceSelects = wrapper
|
||||
.findAll("select.select-stub")
|
||||
.filter((node) =>
|
||||
["alipay", "wxpay"].includes(node.attributes("data-placeholder")),
|
||||
);
|
||||
|
||||
expect(paymentSourceSelects).toHaveLength(2);
|
||||
|
||||
const alipaySelect = paymentSourceSelects.find(
|
||||
(node) => node.attributes("data-placeholder") === "alipay",
|
||||
);
|
||||
const wxpaySelect = paymentSourceSelects.find(
|
||||
(node) => node.attributes("data-placeholder") === "wxpay",
|
||||
);
|
||||
|
||||
expect(alipaySelect?.element.value).toBe("official_alipay");
|
||||
expect(
|
||||
alipaySelect?.findAll("option").map((option) => option.element.value),
|
||||
).toEqual(["", "official_alipay", "easypay_alipay"]);
|
||||
|
||||
expect(wxpaySelect?.element.value).toBe("");
|
||||
expect(
|
||||
wxpaySelect?.findAll("option").map((option) => option.element.value),
|
||||
).toEqual(["", "official_wxpay", "easypay_wxpay"]);
|
||||
expect(wrapper.text()).not.toContain("可见方式");
|
||||
expect(wrapper.text()).not.toContain("支付来源");
|
||||
});
|
||||
|
||||
it("saves canonical source keys selected from the dropdowns", async () => {
|
||||
it("does not submit legacy visible payment method settings", async () => {
|
||||
const wrapper = mountView();
|
||||
|
||||
await flushPromises();
|
||||
await openPaymentTab(wrapper);
|
||||
|
||||
const paymentSourceSelects = wrapper
|
||||
.findAll("select.select-stub")
|
||||
.filter((node) =>
|
||||
["alipay", "wxpay"].includes(node.attributes("data-placeholder")),
|
||||
);
|
||||
|
||||
const alipaySelect = paymentSourceSelects.find(
|
||||
(node) => node.attributes("data-placeholder") === "alipay",
|
||||
);
|
||||
const wxpaySelect = paymentSourceSelects.find(
|
||||
(node) => node.attributes("data-placeholder") === "wxpay",
|
||||
);
|
||||
|
||||
await alipaySelect?.setValue("easypay_alipay");
|
||||
await wxpaySelect?.setValue("official_wxpay");
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledTimes(1);
|
||||
expect(updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payment_visible_method_alipay_source: "easypay_alipay",
|
||||
payment_visible_method_wxpay_source: "official_wxpay",
|
||||
payment_visible_method_alipay_enabled: true,
|
||||
payment_visible_method_wxpay_enabled: true,
|
||||
}),
|
||||
);
|
||||
const payload = updateSettings.mock.calls[0]?.[0];
|
||||
expect(payload).not.toHaveProperty("payment_visible_method_alipay_source");
|
||||
expect(payload).not.toHaveProperty("payment_visible_method_wxpay_source");
|
||||
expect(payload).not.toHaveProperty("payment_visible_method_alipay_enabled");
|
||||
expect(payload).not.toHaveProperty("payment_visible_method_wxpay_enabled");
|
||||
});
|
||||
|
||||
it("blocks saving when a visible payment method is enabled without a source", async () => {
|
||||
const wrapper = mountView();
|
||||
it("updates provider enablement immediately and reloads providers", async () => {
|
||||
const provider = {
|
||||
id: 7,
|
||||
provider_key: "alipay",
|
||||
name: "Official Alipay",
|
||||
config: {},
|
||||
supported_types: ["alipay"],
|
||||
enabled: false,
|
||||
payment_mode: "",
|
||||
refund_enabled: false,
|
||||
allow_user_refund: false,
|
||||
limits: "",
|
||||
sort_order: 0,
|
||||
};
|
||||
getProviders.mockReset();
|
||||
getProviders
|
||||
.mockResolvedValueOnce({ data: [provider] })
|
||||
.mockResolvedValueOnce({ data: [{ ...provider, enabled: true }] });
|
||||
updateProvider.mockResolvedValue({ data: { ...provider, enabled: true } });
|
||||
|
||||
const PaymentProviderListStub = defineComponent({
|
||||
emits: ["toggleField"],
|
||||
setup(_, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
class: "provider-toggle-stub",
|
||||
onClick: () => emit("toggleField", provider, "enabled"),
|
||||
},
|
||||
"toggle provider",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mount(SettingsView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: AppLayoutStub,
|
||||
Select: SelectStub,
|
||||
Toggle: ToggleStub,
|
||||
Icon: true,
|
||||
ConfirmDialog: true,
|
||||
PaymentProviderList: PaymentProviderListStub,
|
||||
PaymentProviderDialog: true,
|
||||
GroupBadge: true,
|
||||
GroupOptionItem: true,
|
||||
ProxySelector: true,
|
||||
ImageUpload: true,
|
||||
BackupSettings: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await openPaymentTab(wrapper);
|
||||
|
||||
const paymentSourceSelects = wrapper
|
||||
.findAll("select.select-stub")
|
||||
.filter((node) =>
|
||||
["alipay", "wxpay"].includes(node.attributes("data-placeholder")),
|
||||
);
|
||||
|
||||
const alipaySelect = paymentSourceSelects.find(
|
||||
(node) => node.attributes("data-placeholder") === "alipay",
|
||||
);
|
||||
|
||||
await alipaySelect?.setValue("");
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await wrapper.get(".provider-toggle-stub").trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(updateSettings).not.toHaveBeenCalled();
|
||||
expect(showError).toHaveBeenCalled();
|
||||
expect(String(showError.mock.calls.at(-1)?.[0] ?? "")).toContain(
|
||||
"支付来源",
|
||||
);
|
||||
expect(updateProvider).toHaveBeenCalledWith(7, { enabled: true });
|
||||
expect(getProviders).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("renders advanced scheduler copy as local experimental gateway policy", async () => {
|
||||
@@ -588,6 +595,9 @@ describe("admin SettingsView wechat connect controls", () => {
|
||||
getGroups.mockReset();
|
||||
listProxies.mockReset();
|
||||
getProviders.mockReset();
|
||||
updateProvider.mockReset();
|
||||
createProvider.mockReset();
|
||||
deleteProvider.mockReset();
|
||||
fetchPublicSettings.mockReset();
|
||||
adminSettingsFetch.mockReset();
|
||||
showError.mockReset();
|
||||
|
||||
Reference in New Issue
Block a user