Merge pull request #3744 from Wei-Shaw/fix/subscription-cny-optin-rate

fix(payment): 订阅 CNY 换算改为独立汇率配置的显式 opt-in(含 #3738)
This commit is contained in:
Wesley Liddick
2026-07-06 14:58:20 +08:00
committed by GitHub
18 changed files with 229 additions and 63 deletions
@@ -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 ||
+1
View File
@@ -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"`
@@ -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"`
@@ -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": "",
@@ -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))).
@@ -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 ""
@@ -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) {
+25 -3
View File
@@ -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 {
@@ -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)
}
}
+2
View File
@@ -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
+2
View File
@@ -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;
+4
View File
@@ -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}',
+4
View File
@@ -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} 元',
+3
View File
@@ -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
+31
View File
@@ -6480,6 +6480,34 @@
}}
</p>
</div>
<div>
<label class="input-label">{{
t("admin.settings.payment.subscriptionUsdToCnyRate")
}}</label>
<input
:value="form.payment_subscription_usd_to_cny_rate || ''"
@input="
form.payment_subscription_usd_to_cny_rate =
parseFloat(
($event.target as HTMLInputElement).value,
) || 0
"
type="number"
step="0.01"
min="0"
class="input"
:placeholder="
t(
'admin.settings.payment.subscriptionUsdToCnyRateDisabled',
)
"
/>
<p class="mt-0.5 text-xs text-gray-400">
{{
t("admin.settings.payment.subscriptionUsdToCnyRateHint")
}}
</p>
</div>
<div>
<label class="input-label">{{
t("admin.settings.payment.rechargeFeeRate")
@@ -8037,6 +8065,7 @@ const form = reactive<SettingsForm>({
payment_order_timeout_minutes: 30,
payment_balance_disabled: false,
payment_balance_recharge_multiplier: 1,
payment_subscription_usd_to_cny_rate: 0,
payment_recharge_fee_rate: 0,
payment_enabled_types: [],
payment_help_image_url: "",
@@ -9538,6 +9567,8 @@ async function saveSettings() {
payment_balance_disabled: form.payment_balance_disabled,
payment_balance_recharge_multiplier:
Number(form.payment_balance_recharge_multiplier) || 1,
payment_subscription_usd_to_cny_rate:
Number(form.payment_subscription_usd_to_cny_rate) || 0,
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,
@@ -412,6 +412,7 @@ const baseSettingsResponse = {
payment_enabled_types: [],
payment_balance_disabled: false,
payment_balance_recharge_multiplier: 1,
payment_subscription_usd_to_cny_rate: 0,
payment_recharge_fee_rate: 0,
payment_load_balance_strategy: "round-robin",
payment_product_name_prefix: "",
+16 -5
View File
@@ -283,7 +283,7 @@ import { platformAccentBarClass, platformBadgeLightClass, platformBadgeClass, pl
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
import Icon from '@/components/icons/Icon.vue'
import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import { DEFAULT_PAYMENT_CURRENCY, formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
import { buildPaymentErrorToastMessage, describePaymentScenarioError } from './paymentUx'
import { hasWechatResumeQuery, parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume'
@@ -494,7 +494,7 @@ function onPaymentSettled() {
// All checkout data from single API call
const checkout = ref<CheckoutInfoResponse>({
methods: {}, global_min: 0, global_max: 0,
plans: [], balance_disabled: false, balance_recharge_multiplier: 1, recharge_fee_rate: 0, help_text: '', help_image_url: '', stripe_publishable_key: '',
plans: [], balance_disabled: false, balance_recharge_multiplier: 1, subscription_usd_to_cny_rate: 0, recharge_fee_rate: 0, help_text: '', help_image_url: '', stripe_publishable_key: '',
})
const tabs = computed(() => {
@@ -511,6 +511,11 @@ const balanceRechargeMultiplier = computed(() => {
const multiplier = checkout.value.balance_recharge_multiplier
return Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 1
})
// 订阅 CNY 换算汇率(1 USD = X CNY)。0 = 未配置,订阅保持 price 直付(与后端 opt-in 条件严格镜像)。
const subscriptionUsdToCnyRate = computed(() => {
const rate = checkout.value.subscription_usd_to_cny_rate
return Number.isFinite(rate) && rate > 0 ? rate : 0
})
const creditedAmount = computed(() => Math.round((validAmount.value * balanceRechargeMultiplier.value) * 100) / 100)
// Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+
@@ -579,12 +584,18 @@ function ceilPaymentAmount(value: number, currency: string): number {
return Math.ceil(value * factor) / factor
}
function subscriptionPaymentAmountForCurrency(value: number, currency: string): number {
const rate = subscriptionUsdToCnyRate.value
if (rate <= 0 || currency !== DEFAULT_PAYMENT_CURRENCY) return roundPaymentAmount(value, currency)
return roundPaymentAmount(value * rate, currency)
}
function formatSelectedPaymentAmount(value: number): string {
return formatPaymentAmount(value, selectedCurrency.value, localeCode.value)
}
function formatSelectedSubscriptionPaymentAmount(value: number): string {
return formatSelectedPaymentAmount(roundPaymentAmount(value, selectedCurrency.value))
return formatSelectedPaymentAmount(subscriptionPaymentAmountForCurrency(value, selectedCurrency.value))
}
const methodOptions = computed<PaymentMethodOption[]>(() =>
@@ -633,7 +644,7 @@ const canSubmit = computed(() =>
const subPaymentAmount = computed(() => {
const price = selectedPlan.value?.price ?? 0
return roundPaymentAmount(price, selectedCurrency.value)
return subscriptionPaymentAmountForCurrency(price, selectedCurrency.value)
})
const subFeeAmount = computed(() => {
@@ -647,7 +658,7 @@ const subTotalAmount = computed(() => {
})
function subscriptionTotalAmountForCurrency(value: number, currency: string): number {
const paymentAmount = roundPaymentAmount(value, currency)
const paymentAmount = subscriptionPaymentAmountForCurrency(value, currency)
if (feeRate.value <= 0 || paymentAmount <= 0) return paymentAmount
const fee = ceilPaymentAmount((paymentAmount * feeRate.value) / 100, currency)
return roundPaymentAmount(paymentAmount + fee, currency)
@@ -105,6 +105,7 @@ function checkoutInfoFixture(overrides: Partial<CheckoutInfoResponse> = {}) {
plans: [],
balance_disabled: false,
balance_recharge_multiplier: 1,
subscription_usd_to_cny_rate: 0,
recharge_fee_rate: 0,
help_text: '',
help_image_url: '',
@@ -236,35 +237,39 @@ async function mountSubscriptionConfirm(options: Parameters<typeof checkoutInfoW
}
describe('PaymentView subscription confirmation amounts', () => {
it('keeps subscription plan price independent from balance recharge multiplier', async () => {
it('shows converted CNY pay amount using the subscription rate, not the balance multiplier', async () => {
const wrapper = await mountSubscriptionConfirm({
checkout: {
balance_recharge_multiplier: 4,
balance_recharge_multiplier: 0.14,
subscription_usd_to_cny_rate: 7.15,
},
method: {
currency: 'CNY',
},
plan: {
price: 200,
original_price: 300,
price: 9.99,
original_price: 12.99,
},
})
const text = wrapper.text()
const planPrice = formatPaymentAmount(200, 'CNY')
const originalPrice = formatPaymentAmount(300, 'CNY')
const convertedByRechargeMultiplier = formatPaymentAmount(50, 'CNY')
const convertedPrice = formatPaymentAmount(71.43, 'CNY')
const convertedOriginalPrice = formatPaymentAmount(92.88, 'CNY')
expect(text).toContain(planPrice)
expect(text).toContain(originalPrice)
expect(text).not.toContain(convertedByRechargeMultiplier)
expect(wrapper.findAll('button').some(button => button.text().includes(planPrice))).toBe(true)
expect(text).toContain(convertedPrice)
expect(text).toContain(convertedOriginalPrice)
expect(text).not.toContain(formatPaymentAmount(9.99, 'CNY'))
// 换算必须使用订阅汇率(×7.15),而不是余额倍率(÷0.14 = 71.36)
expect(text).not.toContain(formatPaymentAmount(71.36, 'CNY'))
expect(wrapper.findAll('button').some(button => button.text().includes(convertedPrice))).toBe(true)
})
it('keeps plan price when multiplier is not configured or payment currency is not CNY', async () => {
it('keeps plan price when the subscription rate is not configured or payment currency is not CNY', async () => {
// opt-in 回归锁:即使余额倍率已配置,未配置订阅汇率时 CNY 订阅仍按 price 直付
const cnyWrapper = await mountSubscriptionConfirm({
checkout: {
balance_recharge_multiplier: 0,
balance_recharge_multiplier: 0.14,
subscription_usd_to_cny_rate: 0,
},
method: {
currency: 'CNY',
@@ -276,10 +281,11 @@ describe('PaymentView subscription confirmation amounts', () => {
expect(cnyWrapper.text()).toContain(formatPaymentAmount(7.99, 'CNY'))
expect(cnyWrapper.text()).not.toContain(formatPaymentAmount(57.07, 'CNY'))
expect(cnyWrapper.text()).not.toContain(formatPaymentAmount(57.13, 'CNY'))
const usdWrapper = await mountSubscriptionConfirm({
checkout: {
balance_recharge_multiplier: 0.14,
subscription_usd_to_cny_rate: 7.15,
},
method: {
currency: 'USD',
@@ -294,26 +300,26 @@ describe('PaymentView subscription confirmation amounts', () => {
expect(usdWrapper.text()).toContain(formatPaymentAmount(9.99, 'USD'))
})
it('adds fee rate to the direct subscription plan price to match backend pay_amount', async () => {
it('adds fee rate after CNY rate conversion to match backend pay_amount', async () => {
const wrapper = await mountSubscriptionConfirm({
checkout: {
balance_recharge_multiplier: 4,
subscription_usd_to_cny_rate: 7.15,
recharge_fee_rate: 2.5,
},
method: {
currency: 'CNY',
},
plan: {
price: 7.99,
price: 9.99,
},
})
const text = wrapper.text()
const price = formatPaymentAmount(7.99, 'CNY')
const fee = formatPaymentAmount(0.20, 'CNY')
const total = formatPaymentAmount(8.19, 'CNY')
const convertedPrice = formatPaymentAmount(71.43, 'CNY')
const fee = formatPaymentAmount(1.79, 'CNY')
const total = formatPaymentAmount(73.22, 'CNY')
expect(text).toContain(price)
expect(text).toContain(convertedPrice)
expect(text).toContain(fee)
expect(text).toContain(total)
expect(wrapper.findAll('button').some(button => button.text().includes(total))).toBe(true)