mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #3744 from Wei-Shaw/fix/subscription-cny-optin-rate
fix(payment): 订阅 CNY 换算改为独立汇率配置的显式 opt-in(含 #3738)
This commit is contained in:
@@ -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 ||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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} 元',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user