diff --git a/Antigravity-Manager b/Antigravity-Manager new file mode 160000 index 00000000..a9d96bd5 --- /dev/null +++ b/Antigravity-Manager @@ -0,0 +1 @@ +Subproject commit a9d96bd54978c22d3033830debfe77aeeeee2500 diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 29c97b4b..bec0f126 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -188,6 +188,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { PaymentMaxPendingOrders: paymentCfg.MaxPendingOrders, PaymentEnabledTypes: paymentCfg.EnabledTypes, PaymentBalanceDisabled: paymentCfg.BalanceDisabled, + PaymentBalanceRechargeMultiplier: paymentCfg.BalanceRechargeMultiplier, + PaymentRechargeFeeRate: paymentCfg.RechargeFeeRate, PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy, PaymentProductNamePrefix: paymentCfg.ProductNamePrefix, PaymentProductNameSuffix: paymentCfg.ProductNameSuffix, @@ -317,19 +319,21 @@ type UpdateSettingsRequest struct { AccountQuotaNotifyEmails *[]dto.NotifyEmailEntry `json:"account_quota_notify_emails"` // Payment configuration (integrated into settings, full replace) - PaymentEnabled *bool `json:"payment_enabled"` - PaymentMinAmount *float64 `json:"payment_min_amount"` - PaymentMaxAmount *float64 `json:"payment_max_amount"` - PaymentDailyLimit *float64 `json:"payment_daily_limit"` - PaymentOrderTimeoutMin *int `json:"payment_order_timeout_minutes"` - PaymentMaxPendingOrders *int `json:"payment_max_pending_orders"` - PaymentEnabledTypes []string `json:"payment_enabled_types"` - PaymentBalanceDisabled *bool `json:"payment_balance_disabled"` - PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"` - PaymentProductNamePrefix *string `json:"payment_product_name_prefix"` - PaymentProductNameSuffix *string `json:"payment_product_name_suffix"` - PaymentHelpImageURL *string `json:"payment_help_image_url"` - PaymentHelpText *string `json:"payment_help_text"` + PaymentEnabled *bool `json:"payment_enabled"` + PaymentMinAmount *float64 `json:"payment_min_amount"` + PaymentMaxAmount *float64 `json:"payment_max_amount"` + PaymentDailyLimit *float64 `json:"payment_daily_limit"` + PaymentOrderTimeoutMin *int `json:"payment_order_timeout_minutes"` + PaymentMaxPendingOrders *int `json:"payment_max_pending_orders"` + PaymentEnabledTypes []string `json:"payment_enabled_types"` + PaymentBalanceDisabled *bool `json:"payment_balance_disabled"` + PaymentBalanceRechargeMultiplier *float64 `json:"payment_balance_recharge_multiplier"` + PaymentRechargeFeeRate *float64 `json:"payment_recharge_fee_rate"` + PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"` + PaymentProductNamePrefix *string `json:"payment_product_name_prefix"` + PaymentProductNameSuffix *string `json:"payment_product_name_suffix"` + PaymentHelpImageURL *string `json:"payment_help_image_url"` + PaymentHelpText *string `json:"payment_help_text"` // Cancel rate limit PaymentCancelRateLimitEnabled *bool `json:"payment_cancel_rate_limit_enabled"` @@ -934,24 +938,26 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { // Skip if no payment fields were provided (prevents accidental wipe). if h.paymentConfigService != nil && hasPaymentFields(req) { paymentReq := service.UpdatePaymentConfigRequest{ - Enabled: req.PaymentEnabled, - MinAmount: req.PaymentMinAmount, - MaxAmount: req.PaymentMaxAmount, - DailyLimit: req.PaymentDailyLimit, - OrderTimeoutMin: req.PaymentOrderTimeoutMin, - MaxPendingOrders: req.PaymentMaxPendingOrders, - EnabledTypes: req.PaymentEnabledTypes, - BalanceDisabled: req.PaymentBalanceDisabled, - LoadBalanceStrategy: req.PaymentLoadBalanceStrat, - ProductNamePrefix: req.PaymentProductNamePrefix, - ProductNameSuffix: req.PaymentProductNameSuffix, - HelpImageURL: req.PaymentHelpImageURL, - HelpText: req.PaymentHelpText, - CancelRateLimitEnabled: req.PaymentCancelRateLimitEnabled, - CancelRateLimitMax: req.PaymentCancelRateLimitMax, - CancelRateLimitWindow: req.PaymentCancelRateLimitWindow, - CancelRateLimitUnit: req.PaymentCancelRateLimitUnit, - CancelRateLimitMode: req.PaymentCancelRateLimitMode, + Enabled: req.PaymentEnabled, + MinAmount: req.PaymentMinAmount, + MaxAmount: req.PaymentMaxAmount, + DailyLimit: req.PaymentDailyLimit, + OrderTimeoutMin: req.PaymentOrderTimeoutMin, + MaxPendingOrders: req.PaymentMaxPendingOrders, + EnabledTypes: req.PaymentEnabledTypes, + BalanceDisabled: req.PaymentBalanceDisabled, + BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier, + RechargeFeeRate: req.PaymentRechargeFeeRate, + LoadBalanceStrategy: req.PaymentLoadBalanceStrat, + ProductNamePrefix: req.PaymentProductNamePrefix, + ProductNameSuffix: req.PaymentProductNameSuffix, + HelpImageURL: req.PaymentHelpImageURL, + HelpText: req.PaymentHelpText, + CancelRateLimitEnabled: req.PaymentCancelRateLimitEnabled, + CancelRateLimitMax: req.PaymentCancelRateLimitMax, + CancelRateLimitWindow: req.PaymentCancelRateLimitWindow, + CancelRateLimitUnit: req.PaymentCancelRateLimitUnit, + CancelRateLimitMode: req.PaymentCancelRateLimitMode, } if err := h.paymentConfigService.UpdatePaymentConfig(c.Request.Context(), paymentReq); err != nil { response.ErrorFrom(c, err) @@ -1082,6 +1088,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { PaymentMaxPendingOrders: updatedPaymentCfg.MaxPendingOrders, PaymentEnabledTypes: updatedPaymentCfg.EnabledTypes, PaymentBalanceDisabled: updatedPaymentCfg.BalanceDisabled, + PaymentBalanceRechargeMultiplier: updatedPaymentCfg.BalanceRechargeMultiplier, + PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate, PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy, PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix, PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix, @@ -1101,6 +1109,7 @@ func hasPaymentFields(req UpdateSettingsRequest) bool { req.PaymentMaxAmount != nil || req.PaymentDailyLimit != nil || req.PaymentOrderTimeoutMin != nil || req.PaymentMaxPendingOrders != nil || req.PaymentEnabledTypes != nil || req.PaymentBalanceDisabled != nil || + req.PaymentBalanceRechargeMultiplier != nil || req.PaymentRechargeFeeRate != nil || req.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil || req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil || req.PaymentHelpText != nil || req.PaymentCancelRateLimitEnabled != nil || diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index ef285a44..3659e79b 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -128,19 +128,21 @@ type SystemSettings struct { WebSearchEmulationEnabled bool `json:"web_search_emulation_enabled"` // Payment configuration - PaymentEnabled bool `json:"payment_enabled"` - PaymentMinAmount float64 `json:"payment_min_amount"` - PaymentMaxAmount float64 `json:"payment_max_amount"` - PaymentDailyLimit float64 `json:"payment_daily_limit"` - PaymentOrderTimeoutMin int `json:"payment_order_timeout_minutes"` - PaymentMaxPendingOrders int `json:"payment_max_pending_orders"` - PaymentEnabledTypes []string `json:"payment_enabled_types"` - PaymentBalanceDisabled bool `json:"payment_balance_disabled"` - PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"` - PaymentProductNamePrefix string `json:"payment_product_name_prefix"` - PaymentProductNameSuffix string `json:"payment_product_name_suffix"` - PaymentHelpImageURL string `json:"payment_help_image_url"` - PaymentHelpText string `json:"payment_help_text"` + PaymentEnabled bool `json:"payment_enabled"` + PaymentMinAmount float64 `json:"payment_min_amount"` + PaymentMaxAmount float64 `json:"payment_max_amount"` + PaymentDailyLimit float64 `json:"payment_daily_limit"` + PaymentOrderTimeoutMin int `json:"payment_order_timeout_minutes"` + PaymentMaxPendingOrders int `json:"payment_max_pending_orders"` + PaymentEnabledTypes []string `json:"payment_enabled_types"` + PaymentBalanceDisabled bool `json:"payment_balance_disabled"` + PaymentBalanceRechargeMultiplier float64 `json:"payment_balance_recharge_multiplier"` + PaymentRechargeFeeRate float64 `json:"payment_recharge_fee_rate"` + PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"` + PaymentProductNamePrefix string `json:"payment_product_name_prefix"` + PaymentProductNameSuffix string `json:"payment_product_name_suffix"` + PaymentHelpImageURL string `json:"payment_help_image_url"` + PaymentHelpText string `json:"payment_help_text"` // Cancel rate limit PaymentCancelRateLimitEnabled bool `json:"payment_cancel_rate_limit_enabled"` diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 5fde86fa..1ddb8ae2 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -126,26 +126,30 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) { } response.Success(c, checkoutInfoResponse{ - Methods: limitsResp.Methods, - GlobalMin: limitsResp.GlobalMin, - GlobalMax: limitsResp.GlobalMax, - Plans: planList, - BalanceDisabled: cfg.BalanceDisabled, - HelpText: cfg.HelpText, - HelpImageURL: cfg.HelpImageURL, - StripePublishableKey: cfg.StripePublishableKey, + Methods: limitsResp.Methods, + GlobalMin: limitsResp.GlobalMin, + GlobalMax: limitsResp.GlobalMax, + Plans: planList, + BalanceDisabled: cfg.BalanceDisabled, + BalanceRechargeMultiplier: cfg.BalanceRechargeMultiplier, + RechargeFeeRate: cfg.RechargeFeeRate, + HelpText: cfg.HelpText, + HelpImageURL: cfg.HelpImageURL, + StripePublishableKey: cfg.StripePublishableKey, }) } type checkoutInfoResponse struct { - Methods map[string]service.MethodLimits `json:"methods"` - GlobalMin float64 `json:"global_min"` - GlobalMax float64 `json:"global_max"` - Plans []checkoutPlan `json:"plans"` - BalanceDisabled bool `json:"balance_disabled"` - HelpText string `json:"help_text"` - HelpImageURL string `json:"help_image_url"` - StripePublishableKey string `json:"stripe_publishable_key"` + Methods map[string]service.MethodLimits `json:"methods"` + GlobalMin float64 `json:"global_min"` + GlobalMax float64 `json:"global_max"` + Plans []checkoutPlan `json:"plans"` + BalanceDisabled bool `json:"balance_disabled"` + BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"` + RechargeFeeRate float64 `json:"recharge_fee_rate"` + HelpText string `json:"help_text"` + HelpImageURL string `json:"help_image_url"` + StripePublishableKey string `json:"stripe_publishable_key"` } type checkoutPlan struct { @@ -381,6 +385,7 @@ type PublicOrderResult struct { Amount float64 `json:"amount"` PayAmount float64 `json:"pay_amount"` PaymentType string `json:"payment_type"` + OrderType string `json:"order_type"` Status string `json:"status"` } @@ -404,6 +409,7 @@ func (h *PaymentHandler) VerifyOrderPublic(c *gin.Context) { Amount: order.Amount, PayAmount: order.PayAmount, PaymentType: order.PaymentType, + OrderType: order.OrderType, Status: order.Status, }) } diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 44c3f0e4..b686b986 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -601,6 +601,8 @@ func TestAPIContracts(t *testing.T) { "payment_order_timeout_minutes": 0, "payment_max_pending_orders": 0, "payment_balance_disabled": false, + "payment_balance_recharge_multiplier": 0, + "payment_recharge_fee_rate": 0, "payment_load_balance_strategy": "", "payment_product_name_prefix": "", "payment_product_name_suffix": "", diff --git a/backend/internal/service/payment_amounts.go b/backend/internal/service/payment_amounts.go new file mode 100644 index 00000000..cc01f6ad --- /dev/null +++ b/backend/internal/service/payment_amounts.go @@ -0,0 +1,37 @@ +package service + +import ( + "math" + + "github.com/shopspring/decimal" +) + +const defaultBalanceRechargeMultiplier = 1.0 + +func normalizeBalanceRechargeMultiplier(multiplier float64) float64 { + if math.IsNaN(multiplier) || math.IsInf(multiplier, 0) || multiplier <= 0 { + return defaultBalanceRechargeMultiplier + } + return multiplier +} + +func calculateCreditedBalance(paymentAmount, multiplier float64) float64 { + return decimal.NewFromFloat(paymentAmount). + Mul(decimal.NewFromFloat(normalizeBalanceRechargeMultiplier(multiplier))). + Round(2). + InexactFloat64() +} + +func calculateGatewayRefundAmount(orderAmount, payAmount, refundAmount float64) float64 { + if orderAmount <= 0 || payAmount <= 0 || refundAmount <= 0 { + return 0 + } + if math.Abs(refundAmount-orderAmount) <= amountToleranceCNY { + return decimal.NewFromFloat(payAmount).Round(2).InexactFloat64() + } + return decimal.NewFromFloat(payAmount). + Mul(decimal.NewFromFloat(refundAmount)). + Div(decimal.NewFromFloat(orderAmount)). + Round(2). + InexactFloat64() +} diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index cce31f4d..59764b29 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -3,12 +3,14 @@ package service import ( "context" "fmt" + "math" "strconv" "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" ) const ( @@ -21,6 +23,8 @@ const ( SettingEnabledPaymentTypes = "ENABLED_PAYMENT_TYPES" SettingLoadBalanceStrategy = "LOAD_BALANCE_STRATEGY" SettingBalancePayDisabled = "BALANCE_PAYMENT_DISABLED" + SettingBalanceRechargeMult = "BALANCE_RECHARGE_MULTIPLIER" + SettingRechargeFeeRate = "RECHARGE_FEE_RATE" SettingProductNamePrefix = "PRODUCT_NAME_PREFIX" SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX" SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL" @@ -40,20 +44,22 @@ const ( // PaymentConfig holds the payment system configuration. type PaymentConfig struct { - Enabled bool `json:"enabled"` - MinAmount float64 `json:"min_amount"` - MaxAmount float64 `json:"max_amount"` - DailyLimit float64 `json:"daily_limit"` - OrderTimeoutMin int `json:"order_timeout_minutes"` - MaxPendingOrders int `json:"max_pending_orders"` - EnabledTypes []string `json:"enabled_payment_types"` - BalanceDisabled bool `json:"balance_disabled"` - LoadBalanceStrategy string `json:"load_balance_strategy"` - ProductNamePrefix string `json:"product_name_prefix"` - ProductNameSuffix string `json:"product_name_suffix"` - HelpImageURL string `json:"help_image_url"` - HelpText string `json:"help_text"` - StripePublishableKey string `json:"stripe_publishable_key,omitempty"` + Enabled bool `json:"enabled"` + MinAmount float64 `json:"min_amount"` + MaxAmount float64 `json:"max_amount"` + DailyLimit float64 `json:"daily_limit"` + OrderTimeoutMin int `json:"order_timeout_minutes"` + MaxPendingOrders int `json:"max_pending_orders"` + EnabledTypes []string `json:"enabled_payment_types"` + BalanceDisabled bool `json:"balance_disabled"` + BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"` + RechargeFeeRate float64 `json:"recharge_fee_rate"` + LoadBalanceStrategy string `json:"load_balance_strategy"` + ProductNamePrefix string `json:"product_name_prefix"` + ProductNameSuffix string `json:"product_name_suffix"` + HelpImageURL string `json:"help_image_url"` + HelpText string `json:"help_text"` + StripePublishableKey string `json:"stripe_publishable_key,omitempty"` // Cancel rate limit settings CancelRateLimitEnabled bool `json:"cancel_rate_limit_enabled"` @@ -65,19 +71,21 @@ type PaymentConfig struct { // UpdatePaymentConfigRequest contains fields to update payment configuration. type UpdatePaymentConfigRequest struct { - Enabled *bool `json:"enabled"` - MinAmount *float64 `json:"min_amount"` - MaxAmount *float64 `json:"max_amount"` - DailyLimit *float64 `json:"daily_limit"` - OrderTimeoutMin *int `json:"order_timeout_minutes"` - MaxPendingOrders *int `json:"max_pending_orders"` - EnabledTypes []string `json:"enabled_payment_types"` - BalanceDisabled *bool `json:"balance_disabled"` - LoadBalanceStrategy *string `json:"load_balance_strategy"` - ProductNamePrefix *string `json:"product_name_prefix"` - ProductNameSuffix *string `json:"product_name_suffix"` - HelpImageURL *string `json:"help_image_url"` - HelpText *string `json:"help_text"` + Enabled *bool `json:"enabled"` + MinAmount *float64 `json:"min_amount"` + MaxAmount *float64 `json:"max_amount"` + DailyLimit *float64 `json:"daily_limit"` + OrderTimeoutMin *int `json:"order_timeout_minutes"` + MaxPendingOrders *int `json:"max_pending_orders"` + EnabledTypes []string `json:"enabled_payment_types"` + BalanceDisabled *bool `json:"balance_disabled"` + BalanceRechargeMultiplier *float64 `json:"balance_recharge_multiplier"` + RechargeFeeRate *float64 `json:"recharge_fee_rate"` + LoadBalanceStrategy *string `json:"load_balance_strategy"` + ProductNamePrefix *string `json:"product_name_prefix"` + ProductNameSuffix *string `json:"product_name_suffix"` + HelpImageURL *string `json:"help_image_url"` + HelpText *string `json:"help_text"` // Cancel rate limit settings CancelRateLimitEnabled *bool `json:"cancel_rate_limit_enabled"` @@ -183,7 +191,7 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo keys := []string{ SettingPaymentEnabled, SettingMinRechargeAmount, SettingMaxRechargeAmount, SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders, - SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingLoadBalanceStrategy, + SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingBalanceRechargeMult, SettingRechargeFeeRate, SettingLoadBalanceStrategy, SettingProductNamePrefix, SettingProductNameSuffix, SettingHelpImageURL, SettingHelpText, SettingCancelRateLimitOn, SettingCancelRateLimitMax, @@ -201,18 +209,20 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *PaymentConfig { cfg := &PaymentConfig{ - Enabled: vals[SettingPaymentEnabled] == "true", - MinAmount: pcParseFloat(vals[SettingMinRechargeAmount], 1), - MaxAmount: pcParseFloat(vals[SettingMaxRechargeAmount], 0), - DailyLimit: pcParseFloat(vals[SettingDailyRechargeLimit], 0), - OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], defaultOrderTimeoutMin), - MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], defaultMaxPendingOrders), - BalanceDisabled: vals[SettingBalancePayDisabled] == "true", - LoadBalanceStrategy: vals[SettingLoadBalanceStrategy], - ProductNamePrefix: vals[SettingProductNamePrefix], - ProductNameSuffix: vals[SettingProductNameSuffix], - HelpImageURL: vals[SettingHelpImageURL], - HelpText: vals[SettingHelpText], + Enabled: vals[SettingPaymentEnabled] == "true", + MinAmount: pcParseFloat(vals[SettingMinRechargeAmount], 1), + MaxAmount: pcParseFloat(vals[SettingMaxRechargeAmount], 0), + DailyLimit: pcParseFloat(vals[SettingDailyRechargeLimit], 0), + OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], defaultOrderTimeoutMin), + MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], defaultMaxPendingOrders), + BalanceDisabled: vals[SettingBalancePayDisabled] == "true", + BalanceRechargeMultiplier: normalizeBalanceRechargeMultiplier(pcParseFloat(vals[SettingBalanceRechargeMult], defaultBalanceRechargeMultiplier)), + RechargeFeeRate: pcParseFloat(vals[SettingRechargeFeeRate], 0), + LoadBalanceStrategy: vals[SettingLoadBalanceStrategy], + ProductNamePrefix: vals[SettingProductNamePrefix], + ProductNameSuffix: vals[SettingProductNameSuffix], + HelpImageURL: vals[SettingHelpImageURL], + HelpText: vals[SettingHelpText], CancelRateLimitEnabled: vals[SettingCancelRateLimitOn] == "true", CancelRateLimitMax: pcParseInt(vals[SettingCancelRateLimitMax], 10), @@ -256,6 +266,21 @@ func (s *PaymentConfigService) getStripePublishableKey(ctx context.Context) stri // nil-check before serialisation — this is inherent to patch-style update patterns // and cannot be meaningfully decomposed without introducing unnecessary abstraction. func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req UpdatePaymentConfigRequest) error { + if req.BalanceRechargeMultiplier != nil { + if math.IsNaN(*req.BalanceRechargeMultiplier) || math.IsInf(*req.BalanceRechargeMultiplier, 0) || *req.BalanceRechargeMultiplier <= 0 { + return infraerrors.BadRequest("INVALID_BALANCE_RECHARGE_MULTIPLIER", "balance recharge multiplier must be greater than 0") + } + } + if req.RechargeFeeRate != nil { + v := *req.RechargeFeeRate + if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > 100 { + return infraerrors.BadRequest("INVALID_RECHARGE_FEE_RATE", "recharge fee rate must be between 0 and 100") + } + // Enforce max 2 decimal places + if math.Round(v*100) != v*100 { + return infraerrors.BadRequest("INVALID_RECHARGE_FEE_RATE", "recharge fee rate allows at most 2 decimal places") + } + } m := map[string]string{ SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled), SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount), @@ -264,6 +289,8 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda 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), @@ -297,6 +324,13 @@ func formatPositiveFloat(v *float64) string { return strconv.FormatFloat(*v, 'f', 2, 64) } +func formatNonNegativeFloat(v *float64) string { + if v == nil || *v < 0 { + return "" + } + return strconv.FormatFloat(*v, 'f', 2, 64) +} + func formatPositiveInt(v *int) string { if v == nil || *v <= 0 { return "" diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index de41d742..44818b37 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -216,7 +216,11 @@ func (s *PaymentService) markCompleted(ctx context.Context, o *dbent.PaymentOrde if err != nil { return fmt.Errorf("mark completed: %w", err) } - s.writeAuditLog(ctx, o.ID, auditAction, "system", map[string]any{"rechargeCode": o.RechargeCode, "amount": o.Amount}) + s.writeAuditLog(ctx, o.ID, auditAction, "system", map[string]any{ + "rechargeCode": o.RechargeCode, + "creditedAmount": o.Amount, + "payAmount": o.PayAmount, + }) return nil } diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index ff4dfaa8..128416e4 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -43,18 +43,22 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest if user.Status != payment.EntityStatusActive { return nil, infraerrors.Forbidden("USER_INACTIVE", "user account is disabled") } - amount := req.Amount + orderAmount := req.Amount + limitAmount := req.Amount if plan != nil { - amount = plan.Price + orderAmount = plan.Price + limitAmount = plan.Price + } else if req.OrderType == payment.OrderTypeBalance { + orderAmount = calculateCreditedBalance(req.Amount, cfg.BalanceRechargeMultiplier) } - feeRate := s.getFeeRate(req.PaymentType) - payAmountStr := payment.CalculatePayAmount(amount, feeRate) + feeRate := cfg.RechargeFeeRate + payAmountStr := payment.CalculatePayAmount(limitAmount, feeRate) payAmount, _ := strconv.ParseFloat(payAmountStr, 64) - order, err := s.createOrderInTx(ctx, req, user, plan, cfg, amount, feeRate, payAmount) + order, err := s.createOrderInTx(ctx, req, user, plan, cfg, orderAmount, limitAmount, feeRate, payAmount) if err != nil { return nil, err } - resp, err := s.invokeProvider(ctx, order, req, cfg, payAmountStr, payAmount, plan) + resp, err := s.invokeProvider(ctx, order, req, cfg, limitAmount, payAmountStr, payAmount, plan) if err != nil { _, _ = s.entClient.PaymentOrder.UpdateOneID(order.ID). SetStatus(OrderStatusFailed). @@ -99,7 +103,7 @@ func (s *PaymentService) validateSubOrder(ctx context.Context, req CreateOrderRe return plan, nil } -func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderRequest, user *User, plan *dbent.SubscriptionPlan, cfg *PaymentConfig, amount, feeRate, payAmount float64) (*dbent.PaymentOrder, error) { +func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderRequest, user *User, plan *dbent.SubscriptionPlan, cfg *PaymentConfig, orderAmount, limitAmount, feeRate, payAmount float64) (*dbent.PaymentOrder, error) { tx, err := s.entClient.Tx(ctx) if err != nil { return nil, fmt.Errorf("begin transaction: %w", err) @@ -108,7 +112,7 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq if err := s.checkPendingLimit(ctx, tx, req.UserID, cfg.MaxPendingOrders); err != nil { return nil, err } - if err := s.checkDailyLimit(ctx, tx, req.UserID, amount, cfg.DailyLimit); err != nil { + if err := s.checkDailyLimit(ctx, tx, req.UserID, limitAmount, cfg.DailyLimit); err != nil { return nil, err } tm := cfg.OrderTimeoutMin @@ -121,7 +125,7 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq SetUserEmail(user.Email). SetUserName(user.Username). SetNillableUserNotes(psNilIfEmpty(user.Notes)). - SetAmount(amount). + SetAmount(orderAmount). SetPayAmount(payAmount). SetFeeRate(feeRate). SetRechargeCode(""). @@ -180,6 +184,10 @@ func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, user } var used float64 for _, o := range orders { + if o.OrderType == payment.OrderTypeBalance { + used += o.PayAmount + continue + } used += o.Amount } if used+amount > limit { @@ -188,7 +196,7 @@ func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, user return nil } -func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.PaymentOrder, req CreateOrderRequest, cfg *PaymentConfig, payAmountStr string, payAmount float64, plan *dbent.SubscriptionPlan) (*CreateOrderResponse, error) { +func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.PaymentOrder, req CreateOrderRequest, cfg *PaymentConfig, limitAmount float64, payAmountStr string, payAmount float64, plan *dbent.SubscriptionPlan) (*CreateOrderResponse, error) { // Select an instance across all providers that support the requested payment type. // This enables cross-provider load balancing (e.g. EasyPay + Alipay direct for "alipay"). sel, err := s.loadBalancer.SelectInstance(ctx, "", req.PaymentType, payment.Strategy(cfg.LoadBalanceStrategy), payAmount) @@ -202,7 +210,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen if err != nil { return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "payment method is temporarily unavailable") } - subject := s.buildPaymentSubject(plan, payAmountStr, cfg) + subject := s.buildPaymentSubject(plan, limitAmount, cfg) outTradeNo := order.OutTradeNo pr, err := prov.CreatePayment(ctx, payment.CreatePaymentRequest{OrderID: outTradeNo, Amount: payAmountStr, PaymentType: req.PaymentType, Subject: subject, ClientIP: req.ClientIP, IsMobile: req.IsMobile, InstanceSubMethods: sel.SupportedTypes}) if err != nil { @@ -213,23 +221,30 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen if err != nil { return nil, fmt.Errorf("update order with payment details: %w", err) } - s.writeAuditLog(ctx, order.ID, "ORDER_CREATED", fmt.Sprintf("user:%d", req.UserID), map[string]any{"amount": req.Amount, "paymentType": req.PaymentType, "orderType": req.OrderType}) + s.writeAuditLog(ctx, order.ID, "ORDER_CREATED", fmt.Sprintf("user:%d", req.UserID), map[string]any{ + "paymentAmount": req.Amount, + "creditedAmount": order.Amount, + "payAmount": order.PayAmount, + "paymentType": req.PaymentType, + "orderType": req.OrderType, + }) return &CreateOrderResponse{OrderID: order.ID, Amount: order.Amount, PayAmount: payAmount, FeeRate: order.FeeRate, Status: OrderStatusPending, PaymentType: req.PaymentType, PayURL: pr.PayURL, QRCode: pr.QRCode, ClientSecret: pr.ClientSecret, ExpiresAt: order.ExpiresAt, PaymentMode: sel.PaymentMode}, nil } -func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, payAmountStr string, cfg *PaymentConfig) string { +func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, limitAmount float64, cfg *PaymentConfig) string { if plan != nil { if plan.ProductName != "" { return plan.ProductName } return "Sub2API Subscription " + plan.Name } + amountStr := strconv.FormatFloat(limitAmount, 'f', 2, 64) pf := strings.TrimSpace(cfg.ProductNamePrefix) sf := strings.TrimSpace(cfg.ProductNameSuffix) if pf != "" || sf != "" { - return strings.TrimSpace(pf + " " + payAmountStr + " " + sf) + return strings.TrimSpace(pf + " " + amountStr + " " + sf) } - return "Sub2API " + payAmountStr + " CNY" + return "Sub2API " + amountStr + " CNY" } // --- Order Queries --- diff --git a/backend/internal/service/payment_refund.go b/backend/internal/service/payment_refund.go index 99468433..c5bda763 100644 --- a/backend/internal/service/payment_refund.go +++ b/backend/internal/service/payment_refund.go @@ -113,11 +113,7 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float if amt-o.Amount > amountToleranceCNY { return nil, nil, infraerrors.BadRequest("REFUND_AMOUNT_EXCEEDED", "refund amount exceeds recharge") } - // Full refund: use actual pay_amount for gateway (includes fees) - ga := amt - if math.Abs(amt-o.Amount) <= amountToleranceCNY { - ga = o.PayAmount - } + ga := calculateGatewayRefundAmount(o.Amount, o.PayAmount, amt) rr := strings.TrimSpace(reason) if rr == "" && o.RefundRequestReason != nil { rr = *o.RefundRequestReason diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index 6d8b185e..6fc23f97 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -288,8 +288,6 @@ func psComputeValidityDays(days int, unit string) int { } } -func (s *PaymentService) getFeeRate(_ string) float64 { return 0 } - func psStartOfDayUTC(t time.Time) time.Time { y, m, d := t.UTC().Date() return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) diff --git a/frontend/src/api/admin/payment.ts b/frontend/src/api/admin/payment.ts index 946c91b0..3daf56b2 100644 --- a/frontend/src/api/admin/payment.ts +++ b/frontend/src/api/admin/payment.ts @@ -23,6 +23,7 @@ export interface AdminPaymentConfig { max_pending_orders: number enabled_payment_types: string[] balance_disabled: boolean + balance_recharge_multiplier: number load_balance_strategy: string product_name_prefix: string product_name_suffix: string @@ -40,6 +41,7 @@ export interface UpdatePaymentConfigRequest { max_pending_orders?: number enabled_payment_types?: string[] balance_disabled?: boolean + balance_recharge_multiplier?: number load_balance_strategy?: string product_name_prefix?: string product_name_suffix?: string diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index aa1d0f82..1e4a3053 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -125,6 +125,8 @@ export interface SystemSettings { payment_max_pending_orders: number payment_enabled_types: string[] payment_balance_disabled: boolean + payment_balance_recharge_multiplier: number + payment_recharge_fee_rate: number payment_load_balance_strategy: string payment_product_name_prefix: string payment_product_name_suffix: string @@ -231,6 +233,8 @@ export interface UpdateSettingsRequest { payment_max_pending_orders?: number payment_enabled_types?: string[] payment_balance_disabled?: boolean + payment_balance_recharge_multiplier?: number + payment_recharge_fee_rate?: number payment_load_balance_strategy?: string payment_product_name_prefix?: string payment_product_name_suffix?: string diff --git a/frontend/src/components/admin/payment/AdminOrderDetail.vue b/frontend/src/components/admin/payment/AdminOrderDetail.vue index de4b00b5..9ab1ba95 100644 --- a/frontend/src/components/admin/payment/AdminOrderDetail.vue +++ b/frontend/src/components/admin/payment/AdminOrderDetail.vue @@ -18,12 +18,20 @@
-

{{ t('payment.orders.amount') }}

-

${{ order.amount.toFixed(2) }}

+

{{ t('payment.orders.baseAmount') }}

+

¥{{ baseAmount.toFixed(2) }}

+
+
+

{{ t('payment.orders.fee') }} ({{ order.fee_rate }}%)

+

¥{{ feeAmount.toFixed(2) }}

{{ t('payment.orders.payAmount') }}

-

${{ order.pay_amount.toFixed(2) }}

+

¥{{ order.pay_amount.toFixed(2) }}

+
+
+

{{ t('payment.orders.creditedAmount') }}

+

{{ order.order_type === 'balance' ? '$' : '¥' }}{{ order.amount.toFixed(2) }}

{{ t('payment.orders.paymentMethod') }}

@@ -31,10 +39,6 @@ {{ t('payment.methods.' + order.payment_type, order.payment_type) }}

-
-

{{ t('payment.admin.feeRate') }}

-

{{ (order.fee_rate * 100).toFixed(1) }}%

-

{{ t('payment.admin.orderType') }}

@@ -73,7 +77,7 @@

{{ t('payment.admin.refundAmount') }}: - ${{ order.refund_amount.toFixed(2) }} + {{ order.order_type === 'balance' ? '$' : '¥' }}{{ order.refund_amount.toFixed(2) }}
{{ t('payment.admin.refundReason') }}: @@ -110,6 +114,7 @@