From 98140f6cac5b40c80e8fca22df4a6e52eaf30dc4 Mon Sep 17 00:00:00 2001
From: erio
Date: Wed, 15 Apr 2026 00:41:33 +0800
Subject: [PATCH] feat(payment): add recharge fee rate setting and fix provider
card UI
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add recharge_fee_rate system setting (percentage fee on top of recharge amount)
- Full backend chain: config constant, PaymentConfig struct, update validation,
read/write persistence, DTO, handler GET/PUT responses
- Frontend: settings input with preview, i18n (zh/en), API types
- Fix provider card toggle layout: labels above switches to save width
- Fix Chinese translation: "EasyPay" → "易支付" in provider description
---
.../internal/handler/admin/setting_handler.go | 6 +++++-
backend/internal/handler/dto/settings.go | 1 +
backend/internal/handler/payment_handler.go | 2 ++
.../service/payment_config_service.go | 19 ++++++++++++++++++-
frontend/src/api/admin/settings.ts | 2 ++
.../src/components/payment/ToggleSwitch.vue | 4 ++--
frontend/src/i18n/locales/en.ts | 3 +++
frontend/src/i18n/locales/zh.ts | 5 ++++-
frontend/src/views/admin/SettingsView.vue | 9 ++++++++-
9 files changed, 45 insertions(+), 6 deletions(-)
diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index d0134953..b2e400b3 100644
--- a/backend/internal/handler/admin/setting_handler.go
+++ b/backend/internal/handler/admin/setting_handler.go
@@ -189,6 +189,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PaymentEnabledTypes: paymentCfg.EnabledTypes,
PaymentBalanceDisabled: paymentCfg.BalanceDisabled,
PaymentBalanceRechargeMultiplier: paymentCfg.BalanceRechargeMultiplier,
+ PaymentRechargeFeeRate: paymentCfg.RechargeFeeRate,
PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy,
PaymentProductNamePrefix: paymentCfg.ProductNamePrefix,
PaymentProductNameSuffix: paymentCfg.ProductNameSuffix,
@@ -327,6 +328,7 @@ type UpdateSettingsRequest struct {
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"`
@@ -945,6 +947,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EnabledTypes: req.PaymentEnabledTypes,
BalanceDisabled: req.PaymentBalanceDisabled,
BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier,
+ RechargeFeeRate: req.PaymentRechargeFeeRate,
LoadBalanceStrategy: req.PaymentLoadBalanceStrat,
ProductNamePrefix: req.PaymentProductNamePrefix,
ProductNameSuffix: req.PaymentProductNameSuffix,
@@ -1086,6 +1089,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PaymentEnabledTypes: updatedPaymentCfg.EnabledTypes,
PaymentBalanceDisabled: updatedPaymentCfg.BalanceDisabled,
PaymentBalanceRechargeMultiplier: updatedPaymentCfg.BalanceRechargeMultiplier,
+ PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate,
PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy,
PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix,
PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix,
@@ -1105,7 +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.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 2f97b3e0..c41eb7f6 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -137,6 +137,7 @@ type SystemSettings struct {
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"`
diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go
index 973ba4a5..1ddb8ae2 100644
--- a/backend/internal/handler/payment_handler.go
+++ b/backend/internal/handler/payment_handler.go
@@ -132,6 +132,7 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) {
Plans: planList,
BalanceDisabled: cfg.BalanceDisabled,
BalanceRechargeMultiplier: cfg.BalanceRechargeMultiplier,
+ RechargeFeeRate: cfg.RechargeFeeRate,
HelpText: cfg.HelpText,
HelpImageURL: cfg.HelpImageURL,
StripePublishableKey: cfg.StripePublishableKey,
@@ -145,6 +146,7 @@ type checkoutInfoResponse struct {
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"`
diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go
index 409625ed..2f040292 100644
--- a/backend/internal/service/payment_config_service.go
+++ b/backend/internal/service/payment_config_service.go
@@ -24,6 +24,7 @@ const (
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"
@@ -52,6 +53,7 @@ type PaymentConfig struct {
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"`
@@ -78,6 +80,7 @@ type UpdatePaymentConfigRequest struct {
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"`
@@ -188,7 +191,7 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo
keys := []string{
SettingPaymentEnabled, SettingMinRechargeAmount, SettingMaxRechargeAmount,
SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders,
- SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingBalanceRechargeMult, SettingLoadBalanceStrategy,
+ SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingBalanceRechargeMult, SettingRechargeFeeRate, SettingLoadBalanceStrategy,
SettingProductNamePrefix, SettingProductNameSuffix,
SettingHelpImageURL, SettingHelpText,
SettingCancelRateLimitOn, SettingCancelRateLimitMax,
@@ -214,6 +217,7 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme
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],
@@ -267,6 +271,11 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda
return infraerrors.BadRequest("INVALID_BALANCE_RECHARGE_MULTIPLIER", "balance recharge multiplier must be greater than 0")
}
}
+ if req.RechargeFeeRate != nil {
+ if math.IsNaN(*req.RechargeFeeRate) || math.IsInf(*req.RechargeFeeRate, 0) || *req.RechargeFeeRate < 0 {
+ return infraerrors.BadRequest("INVALID_RECHARGE_FEE_RATE", "recharge fee rate must be >= 0")
+ }
+ }
m := map[string]string{
SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled),
SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount),
@@ -276,6 +285,7 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda
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),
@@ -309,6 +319,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/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts
index c0cf16ee..1e4a3053 100644
--- a/frontend/src/api/admin/settings.ts
+++ b/frontend/src/api/admin/settings.ts
@@ -126,6 +126,7 @@ export interface SystemSettings {
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
@@ -233,6 +234,7 @@ export interface UpdateSettingsRequest {
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/payment/ToggleSwitch.vue b/frontend/src/components/payment/ToggleSwitch.vue
index e4f77514..c74a86cd 100644
--- a/frontend/src/components/payment/ToggleSwitch.vue
+++ b/frontend/src/components/payment/ToggleSwitch.vue
@@ -1,6 +1,6 @@
-
{{ t('admin.settings.payment.balanceRechargePreview', { usd: (Number(form.payment_balance_recharge_multiplier) || 1).toFixed(2) }) }}
+
+
{{ t('admin.settings.payment.rechargeFeeRate') }}
+
+
{{ t('admin.settings.payment.rechargeFeeRateHint') }}
+
{{ t('admin.settings.payment.rechargeFeePreview', { fee: (Number(form.payment_recharge_fee_rate) || 0).toFixed(2) }) }}
+
@@ -2974,7 +2980,7 @@ const form = reactive({
home_content: '',
backend_mode_enabled: false,
hide_ccs_import_button: false,
- payment_enabled: false, payment_min_amount: 1, payment_max_amount: 10000, payment_daily_limit: 50000, payment_max_pending_orders: 3, payment_order_timeout_minutes: 30, payment_balance_disabled: false, payment_balance_recharge_multiplier: 1, payment_enabled_types: [], payment_help_image_url: '', payment_help_text: '', payment_product_name_prefix: '', payment_product_name_suffix: '', payment_load_balance_strategy: 'round-robin', payment_cancel_rate_limit_enabled: false, payment_cancel_rate_limit_max: 10, payment_cancel_rate_limit_window: 1, payment_cancel_rate_limit_unit: 'day', payment_cancel_rate_limit_window_mode: 'rolling',
+ payment_enabled: false, payment_min_amount: 1, payment_max_amount: 10000, payment_daily_limit: 50000, payment_max_pending_orders: 3, payment_order_timeout_minutes: 30, payment_balance_disabled: false, payment_balance_recharge_multiplier: 1, payment_recharge_fee_rate: 0, payment_enabled_types: [], payment_help_image_url: '', payment_help_text: '', payment_product_name_prefix: '', payment_product_name_suffix: '', payment_load_balance_strategy: 'round-robin', payment_cancel_rate_limit_enabled: false, payment_cancel_rate_limit_max: 10, payment_cancel_rate_limit_window: 1, payment_cancel_rate_limit_unit: 'day', payment_cancel_rate_limit_window_mode: 'rolling',
table_default_page_size: tablePageSizeDefault,
table_page_size_options: [10, 20, 50, 100],
custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>,
@@ -3634,6 +3640,7 @@ async function saveSettings() {
payment_order_timeout_minutes: Number(form.payment_order_timeout_minutes) || 0,
payment_balance_disabled: form.payment_balance_disabled,
payment_balance_recharge_multiplier: Number(form.payment_balance_recharge_multiplier) || 1,
+ payment_recharge_fee_rate: Number(form.payment_recharge_fee_rate) || 0,
payment_enabled_types: form.payment_enabled_types,
payment_load_balance_strategy: form.payment_load_balance_strategy,
payment_product_name_prefix: form.payment_product_name_prefix,