mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #3749 from moonfunjohn/codex/easypay-custom-methods
EasyPay custom visible payment methods
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Payment">
|
||||
<path d="M512 64c247.424 0 448 200.576 448 448S759.424 960 512 960 64 759.424 64 512 264.576 64 512 64Z" fill="#4F46E5"/>
|
||||
<path d="M307 329c0-39.765 32.235-72 72-72h274c39.765 0 72 32.235 72 72v36H371c-35.346 0-64 28.654-64 64V329Z" fill="#C7D2FE"/>
|
||||
<path d="M260 413c0-35.346 28.654-64 64-64h392c35.346 0 64 28.654 64 64v258c0 35.346-28.654 64-64 64H324c-35.346 0-64-28.654-64-64V413Z" fill="#FFFFFF"/>
|
||||
<path d="M636 481c0-30.928 25.072-56 56-56h88v214h-88c-30.928 0-56-25.072-56-56V481Z" fill="#EEF2FF"/>
|
||||
<path d="M708 494c30.928 0 56 25.072 56 56s-25.072 56-56 56-56-25.072-56-56 25.072-56 56-56Z" fill="#FBBF24"/>
|
||||
<path d="M348 457c0-17.673 14.327-32 32-32h172c17.673 0 32 14.327 32 32s-14.327 32-32 32H380c-17.673 0-32-14.327-32-32Z" fill="#4F46E5"/>
|
||||
<path d="M348 557c0-15.464 12.536-28 28-28h152c15.464 0 28 12.536 28 28s-12.536 28-28 28H376c-15.464 0-28-12.536-28-28Z" fill="#A5B4FC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1012 B |
@@ -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')
|
||||
})
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
@click="method.available && emit('select', method.type)"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<img :src="methodIcon(method.type)" :alt="t(`payment.methods.${method.type}`)" class="h-7 w-7 object-contain" />
|
||||
<img :src="methodIcon(method.type)" :alt="methodLabel(method)" class="h-7 w-7 object-contain" />
|
||||
<span class="flex flex-col items-start leading-none">
|
||||
<span class="text-base font-semibold">{{ t(`payment.methods.${method.type}`) }}</span>
|
||||
<span class="text-base font-semibold">{{ methodLabel(method) }}</span>
|
||||
<span
|
||||
v-if="method.fee_rate > 0"
|
||||
class="text-[10px] tracking-wide text-gray-500 dark:text-dark-400"
|
||||
@@ -39,14 +39,16 @@
|
||||
<script setup lang="ts">
|
||||
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<string, string> = {
|
||||
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'
|
||||
|
||||
@@ -70,6 +70,49 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.provider_key === 'easypay'" class="space-y-3 rounded-lg border border-gray-100 p-3 dark:border-dark-700">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h5 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.payment.easypayCustomMethods') }}
|
||||
</h5>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.payment.easypayCustomMethodsHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="addEasyPayCustomMethod">
|
||||
{{ t('admin.settings.payment.addCustomMethod') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="easyPayCustomMethods.length" class="space-y-2">
|
||||
<div
|
||||
v-for="(method, index) in easyPayCustomMethods"
|
||||
:key="index"
|
||||
class="grid grid-cols-[1fr_1fr_1fr_auto] items-end gap-2"
|
||||
>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.customMethodType') }}</label>
|
||||
<input v-model="method.type" type="text" class="input mt-0.5" placeholder="credit_card" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.customMethodUpstreamType') }}</label>
|
||||
<input v-model="method.upstreamType" type="text" class="input mt-0.5" placeholder="credit_card" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.customMethodDisplayName') }}</label>
|
||||
<input v-model="method.displayName" type="text" class="input mt-0.5" placeholder="信用卡" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-red-200 px-2.5 py-2 text-xs font-medium text-red-600 transition-colors hover:bg-red-50 dark:border-red-800/60 dark:text-red-300 dark:hover:bg-red-900/20"
|
||||
@click="removeEasyPayCustomMethod(index)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Config fields -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-700">
|
||||
@@ -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<Record<string, boolean>>({})
|
||||
const easyPayCustomMethods = reactive<EasyPayCustomMethod[]>([])
|
||||
|
||||
// --- 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<string>()
|
||||
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<string>()
|
||||
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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
<!-- Brand logo overlay -->
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span :class="['rounded-full p-2 shadow ring-2 ring-white', qrLogoBgClass]">
|
||||
<img :src="isAlipay ? alipayIcon : wxpayIcon" alt="" class="h-5 w-5 brightness-0 invert" />
|
||||
<img :src="qrLogoIcon" alt="" class="h-5 w-5 brightness-0 invert" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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')
|
||||
|
||||
@@ -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]')
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,12 @@ import type { ProviderInstance } from '@/types/payment'
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
'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<string, string> }
|
||||
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<string, string>
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -99,7 +99,7 @@ export function getVisibleMethods(methods: Record<string, MethodLimit>): Record<
|
||||
const visible: Record<string, MethodLimit> = {}
|
||||
|
||||
Object.entries(methods).forEach(([type, limit]) => {
|
||||
const normalized = normalizeVisibleMethod(type)
|
||||
const normalized = normalizeVisibleMethod(type) || type.trim()
|
||||
if (!normalized) return
|
||||
|
||||
const isCanonical = type === normalized
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 选择账户默认或最新稳定版本。',
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface PaymentConfig {
|
||||
|
||||
export interface MethodLimit {
|
||||
currency?: string
|
||||
display_name?: string
|
||||
daily_limit: number
|
||||
daily_used: number
|
||||
daily_remaining: number
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<PaymentMethodOption[]>(() =>
|
||||
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<PaymentMethodOption[]>(() => {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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: '<div><slot /></div>',
|
||||
},
|
||||
PaymentStatusPanel: {
|
||||
template: '<button data-test="payment-done" @click="$emit(\'done\')" />',
|
||||
},
|
||||
PaymentMethodSelector: {
|
||||
props: ['selected'],
|
||||
template: '<div data-test="method-selector">{{ selected }}</div>',
|
||||
},
|
||||
Teleport: true,
|
||||
Transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-test="payment-done"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-test="method-selector"]').text()).toBe('ldc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PaymentView WeChat JSAPI flow', () => {
|
||||
beforeEach(() => {
|
||||
routeState.path = '/purchase'
|
||||
|
||||
Reference in New Issue
Block a user