diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 624fddb1e6..529e46c575 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -311,6 +311,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { PaymentEnabledTypes: paymentCfg.EnabledTypes, PaymentBalanceDisabled: paymentCfg.BalanceDisabled, PaymentBalanceRechargeMultiplier: paymentCfg.BalanceRechargeMultiplier, + PaymentSubscriptionUSDToCNYRate: paymentCfg.SubscriptionUSDToCNYRate, PaymentRechargeFeeRate: paymentCfg.RechargeFeeRate, PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy, PaymentProductNamePrefix: paymentCfg.ProductNamePrefix, @@ -672,6 +673,7 @@ type UpdateSettingsRequest struct { PaymentEnabledTypes []string `json:"payment_enabled_types"` PaymentBalanceDisabled *bool `json:"payment_balance_disabled"` PaymentBalanceRechargeMultiplier *float64 `json:"payment_balance_recharge_multiplier"` + PaymentSubscriptionUSDToCNYRate *float64 `json:"payment_subscription_usd_to_cny_rate"` PaymentRechargeFeeRate *float64 `json:"payment_recharge_fee_rate"` PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"` PaymentProductNamePrefix *string `json:"payment_product_name_prefix"` @@ -2015,6 +2017,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { EnabledTypes: req.PaymentEnabledTypes, BalanceDisabled: req.PaymentBalanceDisabled, BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier, + SubscriptionUSDToCNYRate: req.PaymentSubscriptionUSDToCNYRate, RechargeFeeRate: req.PaymentRechargeFeeRate, LoadBalanceStrategy: req.PaymentLoadBalanceStrat, ProductNamePrefix: req.PaymentProductNamePrefix, @@ -2258,6 +2261,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { PaymentEnabledTypes: updatedPaymentCfg.EnabledTypes, PaymentBalanceDisabled: updatedPaymentCfg.BalanceDisabled, PaymentBalanceRechargeMultiplier: updatedPaymentCfg.BalanceRechargeMultiplier, + PaymentSubscriptionUSDToCNYRate: updatedPaymentCfg.SubscriptionUSDToCNYRate, PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate, PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy, PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix, @@ -2316,7 +2320,8 @@ 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.PaymentBalanceRechargeMultiplier != nil || req.PaymentSubscriptionUSDToCNYRate != 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 9a20191a0c..99fba54980 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -242,6 +242,7 @@ type SystemSettings struct { PaymentEnabledTypes []string `json:"payment_enabled_types"` PaymentBalanceDisabled bool `json:"payment_balance_disabled"` PaymentBalanceRechargeMultiplier float64 `json:"payment_balance_recharge_multiplier"` + PaymentSubscriptionUSDToCNYRate float64 `json:"payment_subscription_usd_to_cny_rate"` PaymentRechargeFeeRate float64 `json:"payment_recharge_fee_rate"` PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"` PaymentProductNamePrefix string `json:"payment_product_name_prefix"` diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 7cdf73cd3d..a267d73724 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -150,6 +150,7 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) { Plans: planList, BalanceDisabled: cfg.BalanceDisabled, BalanceRechargeMultiplier: cfg.BalanceRechargeMultiplier, + SubscriptionUSDToCNYRate: cfg.SubscriptionUSDToCNYRate, RechargeFeeRate: cfg.RechargeFeeRate, HelpText: cfg.HelpText, HelpImageURL: cfg.HelpImageURL, @@ -165,6 +166,7 @@ type checkoutInfoResponse struct { Plans []checkoutPlan `json:"plans"` BalanceDisabled bool `json:"balance_disabled"` BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"` + SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"` RechargeFeeRate float64 `json:"recharge_fee_rate"` HelpText string `json:"help_text"` HelpImageURL string `json:"help_image_url"` diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 9b3f2dcdd1..2432d570cb 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -899,6 +899,7 @@ func TestAPIContracts(t *testing.T) { "payment_max_pending_orders": 0, "payment_balance_disabled": false, "payment_balance_recharge_multiplier": 0, + "payment_subscription_usd_to_cny_rate": 0, "payment_recharge_fee_rate": 0, "payment_load_balance_strategy": "", "payment_product_name_prefix": "", @@ -1169,6 +1170,7 @@ func TestAPIContracts(t *testing.T) { "payment_enabled_types": null, "payment_balance_disabled": false, "payment_balance_recharge_multiplier": 0, + "payment_subscription_usd_to_cny_rate": 0, "payment_recharge_fee_rate": 0, "payment_load_balance_strategy": "", "payment_product_name_prefix": "", diff --git a/backend/internal/service/payment_amounts.go b/backend/internal/service/payment_amounts.go index a7f620d33e..2fd00c5957 100644 --- a/backend/internal/service/payment_amounts.go +++ b/backend/internal/service/payment_amounts.go @@ -16,6 +16,15 @@ func normalizeBalanceRechargeMultiplier(multiplier float64) float64 { return multiplier } +// normalizeSubscriptionUSDToCNYRate 将非法值归一为 0(换算关闭)。 +// 与余额倍率不同,0 是合法状态:表示订阅保持 price 直付的存量行为。 +func normalizeSubscriptionUSDToCNYRate(rate float64) float64 { + if math.IsNaN(rate) || math.IsInf(rate, 0) || rate < 0 { + return 0 + } + return rate +} + func calculateCreditedBalance(paymentAmount, multiplier float64) float64 { return decimal.NewFromFloat(paymentAmount). Mul(decimal.NewFromFloat(normalizeBalanceRechargeMultiplier(multiplier))). diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index 022b1b0156..0050013645 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -24,17 +24,20 @@ 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" - SettingHelpText = "PAYMENT_HELP_TEXT" - SettingCancelRateLimitOn = "CANCEL_RATE_LIMIT_ENABLED" - SettingCancelRateLimitMax = "CANCEL_RATE_LIMIT_MAX" - SettingCancelWindowSize = "CANCEL_RATE_LIMIT_WINDOW" - SettingCancelWindowUnit = "CANCEL_RATE_LIMIT_UNIT" - SettingCancelWindowMode = "CANCEL_RATE_LIMIT_WINDOW_MODE" - SettingAlipayForceQRCode = "ALIPAY_FORCE_QRCODE" + // SettingSubscriptionUSDToCNYRate 是订阅 CNY 换算汇率(1 USD = X CNY)。 + // 0/未配置 = 关闭换算(订阅按 price 数值直付),显式配置后 CNY 通道订阅按 price × rate 收款。 + SettingSubscriptionUSDToCNYRate = "SUBSCRIPTION_USD_TO_CNY_RATE" + SettingRechargeFeeRate = "RECHARGE_FEE_RATE" + SettingProductNamePrefix = "PRODUCT_NAME_PREFIX" + SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX" + SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL" + SettingHelpText = "PAYMENT_HELP_TEXT" + SettingCancelRateLimitOn = "CANCEL_RATE_LIMIT_ENABLED" + SettingCancelRateLimitMax = "CANCEL_RATE_LIMIT_MAX" + SettingCancelWindowSize = "CANCEL_RATE_LIMIT_WINDOW" + SettingCancelWindowUnit = "CANCEL_RATE_LIMIT_UNIT" + SettingCancelWindowMode = "CANCEL_RATE_LIMIT_WINDOW_MODE" + SettingAlipayForceQRCode = "ALIPAY_FORCE_QRCODE" ) // Default values for payment configuration settings. @@ -54,13 +57,15 @@ 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"` - HelpImageURL string `json:"help_image_url"` - HelpText string `json:"help_text"` - StripePublishableKey string `json:"stripe_publishable_key,omitempty"` + // SubscriptionUSDToCNYRate 为 0 时订阅换算关闭(兼容存量行为)。 + SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"` + 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"` @@ -84,6 +89,7 @@ type UpdatePaymentConfigRequest struct { EnabledTypes []string `json:"enabled_payment_types"` BalanceDisabled *bool `json:"balance_disabled"` BalanceRechargeMultiplier *float64 `json:"balance_recharge_multiplier"` + SubscriptionUSDToCNYRate *float64 `json:"subscription_usd_to_cny_rate"` RechargeFeeRate *float64 `json:"recharge_fee_rate"` LoadBalanceStrategy *string `json:"load_balance_strategy"` ProductNamePrefix *string `json:"product_name_prefix"` @@ -204,7 +210,7 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo keys := []string{ SettingPaymentEnabled, SettingMinRechargeAmount, SettingMaxRechargeAmount, SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders, - SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingBalanceRechargeMult, SettingRechargeFeeRate, SettingLoadBalanceStrategy, + SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingBalanceRechargeMult, SettingSubscriptionUSDToCNYRate, SettingRechargeFeeRate, SettingLoadBalanceStrategy, SettingProductNamePrefix, SettingProductNameSuffix, SettingHelpImageURL, SettingHelpText, SettingCancelRateLimitOn, SettingCancelRateLimitMax, @@ -233,6 +239,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)), + SubscriptionUSDToCNYRate: normalizeSubscriptionUSDToCNYRate(pcParseFloat(vals[SettingSubscriptionUSDToCNYRate], 0)), RechargeFeeRate: pcParseFloat(vals[SettingRechargeFeeRate], 0), LoadBalanceStrategy: vals[SettingLoadBalanceStrategy], ProductNamePrefix: vals[SettingProductNamePrefix], @@ -294,6 +301,12 @@ 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.SubscriptionUSDToCNYRate != nil { + v := *req.SubscriptionUSDToCNYRate + if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 { + return infraerrors.BadRequest("INVALID_SUBSCRIPTION_USD_TO_CNY_RATE", "subscription USD to CNY rate must be 0 (disabled) or a positive number") + } + } if req.RechargeFeeRate != nil { v := *req.RechargeFeeRate if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > 100 { @@ -313,6 +326,7 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders), SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled), SettingBalanceRechargeMult: formatPositiveFloat(req.BalanceRechargeMultiplier), + SettingSubscriptionUSDToCNYRate: formatPositiveFloatExact(req.SubscriptionUSDToCNYRate), SettingRechargeFeeRate: formatNonNegativeFloat(req.RechargeFeeRate), SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy), SettingProductNamePrefix: derefStr(req.ProductNamePrefix), @@ -352,6 +366,14 @@ func formatPositiveFloat(v *float64) string { return strconv.FormatFloat(*v, 'f', 2, 64) } +// formatPositiveFloatExact 保留完整精度,用于汇率等对小数位敏感的配置。 +func formatPositiveFloatExact(v *float64) string { + if v == nil || *v <= 0 { + return "" // empty → parsePaymentConfig 视为未配置(换算关闭) + } + return strconv.FormatFloat(*v, 'f', -1, 64) +} + func formatNonNegativeFloat(v *float64) string { if v == nil || *v < 0 { return "" diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go index b46d6a1fc8..a8c78d713c 100644 --- a/backend/internal/service/payment_fulfillment_test.go +++ b/backend/internal/service/payment_fulfillment_test.go @@ -602,8 +602,8 @@ func TestExecuteSubscriptionFulfillmentAppliesAffiliateRebate(t *testing.T) { SetUserID(user.ID). SetUserEmail(user.Email). SetUserName(user.Username). - SetAmount(120). - SetPayAmount(120). + SetAmount(9.99). + SetPayAmount(71.36). SetFeeRate(0). SetRechargeCode("PAY-SUB-AFFILIATE"). SetOutTradeNo("sub2_subscription_affiliate"). @@ -636,7 +636,7 @@ func TestExecuteSubscriptionFulfillmentAppliesAffiliateRebate(t *testing.T) { } settingSvc := NewSettingService(&paymentFulfillmentSettingRepoStub{values: map[string]string{ SettingKeyAffiliateEnabled: "true", - SettingKeyAffiliateRebateRate: "20", + SettingKeyAffiliateRebateRate: "15", SettingKeyAffiliateRebateFreezeHours: "0", }}, nil) subRepo := newSubscriptionUserSubRepoStub() @@ -659,7 +659,7 @@ func TestExecuteSubscriptionFulfillmentAppliesAffiliateRebate(t *testing.T) { require.Len(t, affiliateRepo.accrueCalls, 1) require.Equal(t, inviterID, affiliateRepo.accrueCalls[0].inviterID) require.Equal(t, user.ID, affiliateRepo.accrueCalls[0].inviteeUserID) - require.Equal(t, 24.0, affiliateRepo.accrueCalls[0].amount) + require.InDelta(t, 1.4985, affiliateRepo.accrueCalls[0].amount, 0.00000001) require.NotNil(t, affiliateRepo.accrueCalls[0].sourceOrderID) require.Equal(t, order.ID, *affiliateRepo.accrueCalls[0].sourceOrderID) require.Equal(t, 1, subRepo.createCalls) @@ -668,8 +668,8 @@ func TestExecuteSubscriptionFulfillmentAppliesAffiliateRebate(t *testing.T) { Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("AFFILIATE_REBATE_APPLIED")). Only(ctx) require.NoError(t, err) - require.Contains(t, applied.Detail, `"baseAmount":120`) - require.Contains(t, applied.Detail, `"rebateAmount":24`) + require.Contains(t, applied.Detail, `"baseAmount":9.99`) + require.Contains(t, applied.Detail, `"rebateAmount":1.4985`) } func TestExecuteSubscriptionFulfillmentDoesNotDuplicateWorkAfterLegacySuccessAudit(t *testing.T) { diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index 154159b932..04feb8002a 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -16,6 +16,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/payment" "github.com/Wei-Shaw/sub2api/internal/payment/provider" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/shopspring/decimal" ) // --- Order Creation --- @@ -67,8 +68,7 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest return nil, err } } - // 订阅套餐 price 是直付价,余额充值倍率只影响余额充值到账,不参与订阅 pay_amount 计算。 - payAmountStr, payAmount, err := calculateCreateOrderPayAmount(limitAmount, feeRate, methodCurrency) + payAmountStr, payAmount, err := calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, methodCurrency, req.OrderType, cfg.SubscriptionUSDToCNYRate) if err != nil { return nil, err } @@ -84,7 +84,7 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest selectedCurrency = paymentProviderConfigCurrency(sel.ProviderKey, sel.Config) } if selectedCurrency != methodCurrency { - payAmountStr, payAmount, err = calculateCreateOrderPayAmount(limitAmount, feeRate, selectedCurrency) + payAmountStr, payAmount, err = calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, selectedCurrency, req.OrderType, cfg.SubscriptionUSDToCNYRate) if err != nil { return nil, err } @@ -630,6 +630,28 @@ func calculateCreateOrderPayAmount(limitAmount, feeRate float64, currency string return payAmountStr, payAmount, nil } +func calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate float64, currency, orderType string, usdToCnyRate float64) (string, float64, error) { + paymentAmount := limitAmount + if orderType == payment.OrderTypeSubscription { + paymentAmount = calculateSubscriptionGatewayBaseAmount(limitAmount, usdToCnyRate, currency) + } + return calculateCreateOrderPayAmount(paymentAmount, feeRate, currency) +} + +// calculateSubscriptionGatewayBaseAmount 计算订阅订单的网关扣款基数。 +// 换算是显式 opt-in:仅当管理员配置了订阅汇率(rate > 0,1 USD = rate CNY) +// 且网关币种为 CNY 时,按 price × rate 换算;未配置时保持 price 直付的存量行为。 +func calculateSubscriptionGatewayBaseAmount(amount, usdToCnyRate float64, currency string) float64 { + rate := normalizeSubscriptionUSDToCNYRate(usdToCnyRate) + if rate <= 0 || currency != payment.DefaultPaymentCurrency { + return amount + } + return decimal.NewFromFloat(amount). + Mul(decimal.NewFromFloat(rate)). + Round(int32(payment.CurrencyMaxFractionDigits(currency))). + InexactFloat64() +} + func validateCreateOrderAmountCurrency(amount float64, currency string) error { amountStr := strconv.FormatFloat(amount, 'f', -1, 64) if _, err := payment.AmountToMinorUnit(amountStr, currency); err != nil { diff --git a/backend/internal/service/payment_order_result_test.go b/backend/internal/service/payment_order_result_test.go index 14192bd6cd..ac439ee6f2 100644 --- a/backend/internal/service/payment_order_result_test.go +++ b/backend/internal/service/payment_order_result_test.go @@ -161,27 +161,66 @@ func TestCalculateCreateOrderPayAmountUsesCurrencyPrecision(t *testing.T) { } } -func TestCalculateCreateOrderPayAmountForSubscriptionKeepsDirectPrice(t *testing.T) { +func TestCalculateCreateOrderPayAmountForSubscriptionConvertsCNYPriceWhenRateConfigured(t *testing.T) { t.Parallel() - amountStr, amount, err := calculateCreateOrderPayAmount(5, 0, "CNY") + amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "CNY", payment.OrderTypeSubscription, 7.15) if err != nil { t.Fatalf("unexpected error: %v", err) } - if amountStr != "5.00" || amount != 5 { - t.Fatalf("subscription CNY pay amount = (%q, %v), want (5.00, 5)", amountStr, amount) + if amountStr != "71.43" || amount != 71.43 { + t.Fatalf("subscription CNY pay amount = (%q, %v), want (71.43, 71.43)", amountStr, amount) } } -func TestCalculateCreateOrderPayAmountForSubscriptionAppliesFeeToDirectPrice(t *testing.T) { +func TestCalculateCreateOrderPayAmountForSubscriptionAppliesFeeAfterCNYConversion(t *testing.T) { t.Parallel() - amountStr, amount, err := calculateCreateOrderPayAmount(5, 2.5, "CNY") + amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 2.5, "CNY", payment.OrderTypeSubscription, 7.15) if err != nil { t.Fatalf("unexpected error: %v", err) } - if amountStr != "5.13" || amount != 5.13 { - t.Fatalf("subscription CNY pay amount with fee = (%q, %v), want (5.13, 5.13)", amountStr, amount) + if amountStr != "73.22" || amount != 73.22 { + t.Fatalf("subscription CNY pay amount with fee = (%q, %v), want (73.22, 73.22)", amountStr, amount) + } +} + +func TestCalculateCreateOrderPayAmountForSubscriptionKeepsNonCNYPrice(t *testing.T) { + t.Parallel() + + amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "USD", payment.OrderTypeSubscription, 7.15) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if amountStr != "9.99" || amount != 9.99 { + t.Fatalf("subscription USD pay amount = (%q, %v), want (9.99, 9.99)", amountStr, amount) + } +} + +// 换算是 opt-in:未配置汇率(rate=0)时,CNY 订阅保持 price 直付的存量行为。 +// 该测试锁住存量部署升级后行为不变的兼容承诺。 +func TestCalculateCreateOrderPayAmountForSubscriptionKeepsDirectPriceWhenRateDisabled(t *testing.T) { + t.Parallel() + + amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(9.99, 0, "CNY", payment.OrderTypeSubscription, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if amountStr != "9.99" || amount != 9.99 { + t.Fatalf("subscription CNY pay amount without rate = (%q, %v), want (9.99, 9.99)", amountStr, amount) + } +} + +// 汇率只作用于订阅订单,余额充值订单不受影响。 +func TestCalculateCreateOrderPayAmountForBalanceIgnoresSubscriptionRate(t *testing.T) { + t.Parallel() + + amountStr, amount, err := calculateCreateOrderPayAmountForOrderType(50, 0, "CNY", payment.OrderTypeBalance, 7.15) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if amountStr != "50.00" || amount != 50 { + t.Fatalf("balance CNY pay amount = (%q, %v), want (50.00, 50)", amountStr, amount) } } diff --git a/frontend/src/api/admin/payment.ts b/frontend/src/api/admin/payment.ts index 49efcc355d..9bab627218 100644 --- a/frontend/src/api/admin/payment.ts +++ b/frontend/src/api/admin/payment.ts @@ -24,6 +24,7 @@ export interface AdminPaymentConfig { enabled_payment_types: string[] balance_disabled: boolean balance_recharge_multiplier: number + subscription_usd_to_cny_rate: number load_balance_strategy: string product_name_prefix: string product_name_suffix: string @@ -42,6 +43,7 @@ export interface UpdatePaymentConfigRequest { enabled_payment_types?: string[] balance_disabled?: boolean balance_recharge_multiplier?: number + subscription_usd_to_cny_rate?: 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 457775870d..f5da990930 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -589,6 +589,7 @@ export interface SystemSettings { payment_enabled_types: string[]; payment_balance_disabled: boolean; payment_balance_recharge_multiplier: number; + payment_subscription_usd_to_cny_rate: number; payment_recharge_fee_rate: number; payment_load_balance_strategy: string; payment_product_name_prefix: string; @@ -860,6 +861,7 @@ export interface UpdateSettingsRequest { payment_enabled_types?: string[]; payment_balance_disabled?: boolean; payment_balance_recharge_multiplier?: number; + payment_subscription_usd_to_cny_rate?: number; payment_recharge_fee_rate?: number; payment_load_balance_strategy?: string; payment_product_name_prefix?: string; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 259801d7ad..5808e4a9df 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -6183,6 +6183,10 @@ export default { balanceRechargeMultiplier: 'Balance Recharge Multiplier', balanceRechargeMultiplierHint: 'How many USD balance the user receives for each 1 CNY paid', balanceRechargePreview: 'Preview: 1 CNY = {usd} USD', + subscriptionUsdToCnyRate: 'Subscription USD to CNY Rate', + subscriptionUsdToCnyRateHint: + 'CNY charged per 1 USD of plan price on CNY channels (e.g. 7.15). 0 or empty = disabled, plan price is charged as-is. When enabled, all plan prices must be set in USD', + subscriptionUsdToCnyRateDisabled: 'Disabled (price charged as-is)', rechargeFeeRate: 'Recharge Fee Rate', rechargeFeeRateHint: 'Percentage of service fee charged on top of recharge amount, 0 means no fee', rechargeFeePreview: 'Preview: Recharge 100, fee {fee}', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 2e8d9f83fc..5dd37a987b 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -6338,6 +6338,10 @@ export default { balanceRechargeMultiplier: '余额充值倍率', balanceRechargeMultiplierHint: '用户每支付 1 CNY 可获得多少 USD 余额', balanceRechargePreview: '预览:1 CNY = {usd} USD', + subscriptionUsdToCnyRate: '订阅 CNY 换算汇率', + subscriptionUsdToCnyRateHint: + 'CNY 支付通道下,套餐每 1 USD 价格收取多少 CNY(如 7.15)。0 或留空 = 不换算,订阅按 price 数值直接收款。启用后所有套餐 price 必须按 USD 定价', + subscriptionUsdToCnyRateDisabled: '未启用(按 price 直付)', rechargeFeeRate: '充值手续费率', rechargeFeeRateHint: '用户充值时额外收取的手续费百分比,0 表示不收取手续费', rechargeFeePreview: '预览:充值 100 元,手续费 {fee} 元', diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index a02ef1b78f..98dab93e8c 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -34,6 +34,7 @@ export interface PaymentConfig { order_timeout_minutes: number balance_disabled: boolean balance_recharge_multiplier: number + subscription_usd_to_cny_rate: number enabled_payment_types: PaymentType[] help_image_url: string help_text: string @@ -66,6 +67,8 @@ export interface CheckoutInfoResponse { plans: SubscriptionPlan[] balance_disabled: boolean balance_recharge_multiplier: number + /** Subscription CNY conversion rate (1 USD = X CNY); 0 = disabled, plan price is charged as-is */ + subscription_usd_to_cny_rate: number recharge_fee_rate: number help_text: string help_image_url: string diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 78f642d761..c1072cddbb 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -6480,6 +6480,34 @@ }}
++ {{ + t("admin.settings.payment.subscriptionUsdToCnyRateHint") + }} +
+