diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index fb05421a8e..cca3b17f84 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -363,6 +363,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { PaymentSubscriptionUSDToCNYRate: paymentCfg.SubscriptionUSDToCNYRate, PaymentRechargeFeeRate: paymentCfg.RechargeFeeRate, PaymentRechargeBonusTiers: rechargeBonusTiersToDTO(paymentCfg.RechargeBonusTiers), + PaymentRechargeBonusMode: rechargeBonusModeToDTO(paymentCfg.RechargeBonusMode), PaymentRechargeBonusNotice: paymentCfg.RechargeBonusNotice, PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy, PaymentProductNamePrefix: paymentCfg.ProductNamePrefix, diff --git a/backend/internal/handler/admin/setting_handler_recharge_bonus.go b/backend/internal/handler/admin/setting_handler_recharge_bonus.go index d03b2e7e39..334aedcef2 100644 --- a/backend/internal/handler/admin/setting_handler_recharge_bonus.go +++ b/backend/internal/handler/admin/setting_handler_recharge_bonus.go @@ -17,6 +17,12 @@ func rechargeBonusTiersFromDTO(items *[]dto.RechargeBonusTier) *[]service.Rechar return &out } +// rechargeBonusModeToDTO 输出已归一化的模式(空/非法按 bonus)。 +func rechargeBonusModeToDTO(mode string) string { + normalized, _ := service.NormalizeRechargeBonusMode(mode) + return normalized +} + // rechargeBonusTiersToDTO 始终返回非 nil 切片,空配置输出 []。 func rechargeBonusTiersToDTO(items []service.RechargeBonusTier) []dto.RechargeBonusTier { out := make([]dto.RechargeBonusTier, 0, len(items)) diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index ba27da96c6..0947c43a58 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -314,6 +314,7 @@ type UpdateSettingsRequest struct { PaymentRechargeFeeRate *float64 `json:"payment_recharge_fee_rate"` // nil 表示不更新;空数组表示清空阶梯 PaymentRechargeBonusTiers *[]dto.RechargeBonusTier `json:"payment_recharge_bonus_tiers"` + PaymentRechargeBonusMode *string `json:"payment_recharge_bonus_mode"` PaymentRechargeBonusNotice *string `json:"payment_recharge_bonus_notice"` PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"` PaymentProductNamePrefix *string `json:"payment_product_name_prefix"` @@ -2132,6 +2133,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { SubscriptionUSDToCNYRate: req.PaymentSubscriptionUSDToCNYRate, RechargeFeeRate: req.PaymentRechargeFeeRate, RechargeBonusTiers: rechargeBonusTiersFromDTO(req.PaymentRechargeBonusTiers), + RechargeBonusMode: req.PaymentRechargeBonusMode, RechargeBonusNotice: req.PaymentRechargeBonusNotice, LoadBalanceStrategy: req.PaymentLoadBalanceStrat, ProductNamePrefix: req.PaymentProductNamePrefix, @@ -2413,6 +2415,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { PaymentSubscriptionUSDToCNYRate: updatedPaymentCfg.SubscriptionUSDToCNYRate, PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate, PaymentRechargeBonusTiers: rechargeBonusTiersToDTO(updatedPaymentCfg.RechargeBonusTiers), + PaymentRechargeBonusMode: rechargeBonusModeToDTO(updatedPaymentCfg.RechargeBonusMode), PaymentRechargeBonusNotice: updatedPaymentCfg.RechargeBonusNotice, PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy, PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix, @@ -2490,7 +2493,7 @@ func hasPaymentFields(req UpdateSettingsRequest) bool { req.PaymentEnabledTypes != nil || req.PaymentBalanceDisabled != nil || req.PaymentBalanceRechargeMultiplier != nil || req.PaymentSubscriptionUSDToCNYRate != nil || req.PaymentRechargeFeeRate != nil || - req.PaymentRechargeBonusTiers != nil || req.PaymentRechargeBonusNotice != nil || + req.PaymentRechargeBonusTiers != nil || req.PaymentRechargeBonusMode != nil || req.PaymentRechargeBonusNotice != 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 aa1477a1be..96dcc82ec6 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -281,6 +281,7 @@ type SystemSettings struct { PaymentRechargeFeeRate float64 `json:"payment_recharge_fee_rate"` // 充值赠送阶梯与活动文案 PaymentRechargeBonusTiers []RechargeBonusTier `json:"payment_recharge_bonus_tiers"` + PaymentRechargeBonusMode string `json:"payment_recharge_bonus_mode"` PaymentRechargeBonusNotice string `json:"payment_recharge_bonus_notice"` 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 3371fac39f..24e318cc5a 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) { SubscriptionUSDToCNYRate: cfg.SubscriptionUSDToCNYRate, RechargeFeeRate: cfg.RechargeFeeRate, RechargeBonusTiers: cfg.RechargeBonusTiers, + RechargeBonusMode: cfg.RechargeBonusMode, RechargeBonusNotice: cfg.RechargeBonusNotice, HelpText: cfg.HelpText, HelpImageURL: cfg.HelpImageURL, @@ -169,6 +170,7 @@ type checkoutInfoResponse struct { SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"` RechargeFeeRate float64 `json:"recharge_fee_rate"` RechargeBonusTiers []service.RechargeBonusTier `json:"recharge_bonus_tiers"` + RechargeBonusMode string `json:"recharge_bonus_mode"` RechargeBonusNotice string `json:"recharge_bonus_notice"` 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 f420e4d2b5..d002e32f1c 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -972,6 +972,7 @@ func TestAPIContracts(t *testing.T) { "payment_subscription_usd_to_cny_rate": 0, "payment_recharge_fee_rate": 0, "payment_recharge_bonus_tiers": [], + "payment_recharge_bonus_mode": "bonus", "payment_recharge_bonus_notice": "", "payment_load_balance_strategy": "", "payment_product_name_prefix": "", @@ -1294,6 +1295,7 @@ func TestAPIContracts(t *testing.T) { "payment_subscription_usd_to_cny_rate": 0, "payment_recharge_fee_rate": 0, "payment_recharge_bonus_tiers": [], + "payment_recharge_bonus_mode": "bonus", "payment_recharge_bonus_notice": "", "payment_load_balance_strategy": "", "payment_product_name_prefix": "", diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index ab28c3efa0..ad5583da27 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -62,8 +62,10 @@ type PaymentConfig struct { // SubscriptionUSDToCNYRate 为 0 时订阅换算关闭(兼容存量行为)。 SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"` RechargeFeeRate float64 `json:"recharge_fee_rate"` - // RechargeBonusTiers 余额充值赠送阶梯(按 MinAmount 升序);空表示不赠送。 + // RechargeBonusTiers 余额充值优惠阶梯(按 MinAmount 升序);空表示无优惠。 RechargeBonusTiers []RechargeBonusTier `json:"recharge_bonus_tiers"` + // RechargeBonusMode 阶梯模式:bonus(赠金)/ discount(折扣),已归一化。 + RechargeBonusMode string `json:"recharge_bonus_mode"` // RechargeBonusNotice 充值页展示的 Markdown 活动文案;空表示不展示。 RechargeBonusNotice string `json:"recharge_bonus_notice"` LoadBalanceStrategy string `json:"load_balance_strategy"` @@ -101,6 +103,7 @@ type UpdatePaymentConfigRequest struct { RechargeFeeRate *float64 `json:"recharge_fee_rate"` // RechargeBonusTiers nil 表示不更新;空切片表示清空阶梯。 RechargeBonusTiers *[]RechargeBonusTier `json:"recharge_bonus_tiers"` + RechargeBonusMode *string `json:"recharge_bonus_mode"` RechargeBonusNotice *string `json:"recharge_bonus_notice"` LoadBalanceStrategy *string `json:"load_balance_strategy"` ProductNamePrefix *string `json:"product_name_prefix"` @@ -227,7 +230,7 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo SettingPaymentEnabled, SettingMinRechargeAmount, SettingMaxRechargeAmount, SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders, SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingBalanceRechargeMult, SettingSubscriptionUSDToCNYRate, SettingRechargeFeeRate, SettingLoadBalanceStrategy, - SettingRechargeBonusTiers, SettingRechargeBonusNotice, + SettingRechargeBonusTiers, SettingRechargeBonusMode, SettingRechargeBonusNotice, SettingProductNamePrefix, SettingProductNameSuffix, SettingHelpImageURL, SettingHelpText, SettingCancelRateLimitOn, SettingCancelRateLimitMax, @@ -275,6 +278,7 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme AlipayForceQRCode: vals[SettingAlipayForceQRCode] == "true", AlipayMobilePrecreateDeepLink: vals[SettingAlipayMobilePrecreateDeepLink] == "true", } + cfg.RechargeBonusMode, _ = NormalizeRechargeBonusMode(vals[SettingRechargeBonusMode]) cfg.AlipayMobilePrecreateDeepLink = pcEnvBoolOverride( SettingAlipayMobilePrecreateDeepLink, cfg.AlipayMobilePrecreateDeepLink, @@ -353,17 +357,9 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda return infraerrors.BadRequest("INVALID_RECHARGE_FEE_RATE", "recharge fee rate allows at most 2 decimal places") } } - rechargeBonusTiersValue := "" - if req.RechargeBonusTiers != nil { - normalized, err := NormalizeRechargeBonusTiers(*req.RechargeBonusTiers) - if err != nil { - return infraerrors.BadRequest("INVALID_RECHARGE_BONUS_TIERS", err.Error()) - } - encoded, err := encodeRechargeBonusTiers(normalized) - if err != nil { - return err - } - rechargeBonusTiersValue = encoded + rechargeBonusTiersValue, rechargeBonusModeValue, err := s.resolveRechargeBonusUpdate(ctx, req) + if err != nil { + return err } if req.RechargeBonusNotice != nil { if err := validateRechargeBonusNotice(*req.RechargeBonusNotice); err != nil { @@ -407,6 +403,9 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda if req.RechargeBonusTiers != nil { m[SettingRechargeBonusTiers] = rechargeBonusTiersValue } + if req.RechargeBonusMode != nil { + m[SettingRechargeBonusMode] = rechargeBonusModeValue + } if req.RechargeBonusNotice != nil { m[SettingRechargeBonusNotice] = strings.TrimSpace(*req.RechargeBonusNotice) } diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index be28657015..becefc6a1a 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -53,18 +53,6 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest if s.notificationEmailService != nil { s.notificationEmailService.RememberRecipientLocale(ctx, req.UserID, user.Email, req.Locale) } - orderAmount := req.Amount - limitAmount := req.Amount - bonusAmount := 0.0 - if plan != nil { - orderAmount = plan.Price - limitAmount = plan.Price - } else if req.OrderType == payment.OrderTypeBalance { - // 到账 = 基数(支付金额 × 倍率)+ 赠送(按支付金额命中阶梯,按基数计百分比) - baseCredited := calculateCreditedBalance(req.Amount, cfg.BalanceRechargeMultiplier) - bonusAmount, _ = resolveRechargeBonus(cfg.RechargeBonusTiers, req.Amount, baseCredited) - orderAmount = addRechargeBonus(baseCredited, bonusAmount) - } feeRate := cfg.RechargeFeeRate methodCurrency := payment.DefaultPaymentCurrency if s.configService != nil { @@ -73,6 +61,19 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest return nil, err } } + orderAmount := req.Amount + limitAmount := req.Amount + bonusAmount := 0.0 + if plan != nil { + orderAmount = plan.Price + limitAmount = plan.Price + } else if req.OrderType == payment.OrderTypeBalance { + // 阈值按支付金额命中。赠金模式:到账 = 基数 + 赠送;折扣模式:到账 = 基数,实付基数按折扣减少。 + quote := quoteRechargeBonus(cfg, req.Amount, methodCurrency) + limitAmount = quote.PayBase + bonusAmount = quote.Bonus + orderAmount = quote.Credited + } payAmountStr, payAmount, err := calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, methodCurrency, req.OrderType, cfg.SubscriptionUSDToCNYRate) if err != nil { return nil, err diff --git a/backend/internal/service/payment_recharge_bonus.go b/backend/internal/service/payment_recharge_bonus.go index a3598bc6a7..8d6c1525aa 100644 --- a/backend/internal/service/payment_recharge_bonus.go +++ b/backend/internal/service/payment_recharge_bonus.go @@ -1,6 +1,7 @@ package service import ( + "context" "encoding/json" "fmt" "log/slog" @@ -11,17 +12,29 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/internal/payment" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/shopspring/decimal" ) -// 充值赠送阶梯:余额充值订单按用户输入的支付金额命中阶梯(取不超过该金额的最大 MinAmount), -// 在到账基数(输入金额 × 充值倍率)之上额外赠送 BonusPercent% 的 USD 余额。 -// 订阅订单不参与。赠送在下单时按当时配置计算并落到 payment_orders.bonus_amount,后续改配置不影响已建订单。 +// 充值优惠阶梯:余额充值订单按用户输入的支付金额命中阶梯(取不超过该金额的最大 MinAmount), +// 整条阶梯只有一种模式(RECHARGE_BONUS_MODE): +// - bonus(赠金):实付不变,在到账基数(输入 × 充值倍率)之上额外赠送 BonusPercent% 的 USD 余额; +// - discount(折扣):到账不变(输入 × 倍率),实付基数按 BonusPercent% 打折。 +// +// 两种模式落库形态相同:amount 为到账总额,bonus_amount 为其中「免费」的 USD 部分,pay_amount 为实收。 +// 订阅订单不参与。优惠在下单时按当时配置计算并落库,后续改配置不影响已建订单。 const ( - // SettingRechargeBonusTiers 存 JSON 数组(RechargeBonusTier 列表),空/缺失表示未启用赠送。 + // SettingRechargeBonusTiers 存 JSON 数组(RechargeBonusTier 列表),空/缺失表示未启用优惠。 SettingRechargeBonusTiers = "RECHARGE_BONUS_TIERS" // SettingRechargeBonusNotice 充值页金额卡顶部展示的 Markdown 活动文案,空表示不展示。 SettingRechargeBonusNotice = "RECHARGE_BONUS_NOTICE" + // SettingRechargeBonusMode 阶梯模式:bonus / discount;空/非法按 bonus 解析(兼容早期配置)。 + SettingRechargeBonusMode = "RECHARGE_BONUS_MODE" +) + +const ( + RechargeBonusModeBonus = "bonus" + RechargeBonusModeDiscount = "discount" ) const ( @@ -31,12 +44,38 @@ const ( rechargeBonusAmountEpsilon = 1e-9 ) -// RechargeBonusTier 一个赠送档位:支付金额 ≥ MinAmount 时赠送到账基数的 BonusPercent%。 +// RechargeBonusTier 一个优惠档位:支付金额 ≥ MinAmount 时按 BonusPercent% 赠送(bonus)或打折(discount)。 type RechargeBonusTier struct { MinAmount float64 `json:"min_amount"` BonusPercent float64 `json:"bonus_percent"` } +// NormalizeRechargeBonusMode 归一化模式;空按 bonus。第二个返回值表示输入是否合法。 +func NormalizeRechargeBonusMode(raw string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", RechargeBonusModeBonus: + return RechargeBonusModeBonus, true + case RechargeBonusModeDiscount: + return RechargeBonusModeDiscount, true + default: + return RechargeBonusModeBonus, false + } +} + +// ValidateRechargeBonusTiersForMode 折扣模式下百分比必须 < 100,否则实付为 0 或负数。 +func ValidateRechargeBonusTiersForMode(mode string, tiers []RechargeBonusTier) error { + if mode != RechargeBonusModeDiscount { + return nil + } + for _, tier := range tiers { + if tier.BonusPercent >= 100 { + return fmt.Errorf("discount percent must be less than 100 (tier with min amount %s)", + decimal.NewFromFloat(tier.MinAmount).Round(2).String()) + } + } + return nil +} + func rechargeBonusValueValid(v float64, max float64) bool { if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > max { return false @@ -128,6 +167,51 @@ func validateRechargeBonusNotice(notice string) error { return nil } +// resolveRechargeBonusUpdate 校验并归一化阶梯/模式更新。任一字段缺省时读取现值做交叉校验 +// (折扣模式下所有档位百分比必须 < 100)。返回值仅在对应请求字段非 nil 时有意义。 +func (s *PaymentConfigService) resolveRechargeBonusUpdate(ctx context.Context, req UpdatePaymentConfigRequest) (tiersValue string, modeValue string, err error) { + if req.RechargeBonusTiers == nil && req.RechargeBonusMode == nil { + return "", "", nil + } + stored := map[string]string{} + if (req.RechargeBonusTiers == nil || req.RechargeBonusMode == nil) && s != nil && s.settingRepo != nil { + stored, err = s.settingRepo.GetMultiple(ctx, []string{SettingRechargeBonusTiers, SettingRechargeBonusMode}) + if err != nil { + return "", "", fmt.Errorf("get recharge bonus settings: %w", err) + } + } + + var tiers []RechargeBonusTier + if req.RechargeBonusTiers != nil { + tiers, err = NormalizeRechargeBonusTiers(*req.RechargeBonusTiers) + if err != nil { + return "", "", infraerrors.BadRequest("INVALID_RECHARGE_BONUS_TIERS", err.Error()) + } + } else { + tiers = parseRechargeBonusTiers(stored[SettingRechargeBonusTiers]) + } + + var mode string + if req.RechargeBonusMode != nil { + normalized, ok := NormalizeRechargeBonusMode(*req.RechargeBonusMode) + if !ok { + return "", "", infraerrors.BadRequest("INVALID_RECHARGE_BONUS_MODE", "recharge bonus mode must be bonus or discount") + } + mode = normalized + } else { + mode, _ = NormalizeRechargeBonusMode(stored[SettingRechargeBonusMode]) + } + + if err := ValidateRechargeBonusTiersForMode(mode, tiers); err != nil { + return "", "", infraerrors.BadRequest("INVALID_RECHARGE_BONUS_TIERS", err.Error()) + } + tiersValue, err = encodeRechargeBonusTiers(tiers) + if err != nil { + return "", "", err + } + return tiersValue, mode, nil +} + // matchRechargeBonusTier 返回不超过 paymentAmount 的最大档位;tiers 需已按 MinAmount 升序。 func matchRechargeBonusTier(tiers []RechargeBonusTier, paymentAmount float64) (RechargeBonusTier, bool) { if math.IsNaN(paymentAmount) || math.IsInf(paymentAmount, 0) || paymentAmount <= 0 { @@ -157,19 +241,6 @@ func calculateRechargeBonus(baseCredited, bonusPercent float64) float64 { InexactFloat64() } -// resolveRechargeBonus 按支付金额命中阶梯并计算赠送;未命中或赠送为 0 时返回 (0, 0)。 -func resolveRechargeBonus(tiers []RechargeBonusTier, paymentAmount, baseCredited float64) (bonus float64, percent float64) { - tier, ok := matchRechargeBonusTier(tiers, paymentAmount) - if !ok { - return 0, 0 - } - bonus = calculateRechargeBonus(baseCredited, tier.BonusPercent) - if bonus <= 0 { - return 0, 0 - } - return bonus, tier.BonusPercent -} - // addRechargeBonus 到账总额 = 基数 + 赠送,两位小数。 func addRechargeBonus(baseCredited, bonus float64) float64 { return decimal.NewFromFloat(baseCredited). @@ -178,7 +249,76 @@ func addRechargeBonus(baseCredited, bonus float64) float64 { InexactFloat64() } -// paymentOrderAmountWithoutBonus 订单到账金额剔除赠送后的实充部分(USD),用于推广返利基数。 +// calculateDiscountedPayBase 折扣模式实付基数 = 支付金额 × (1 − 百分比),按币种精度四舍五入。 +func calculateDiscountedPayBase(paymentAmount, discountPercent float64, currency string) float64 { + digits := int32(payment.CurrencyMaxFractionDigits(currency)) + return decimal.NewFromFloat(paymentAmount). + Mul(decimal.NewFromInt(100).Sub(decimal.NewFromFloat(discountPercent))). + Div(decimal.NewFromInt(100)). + Round(digits). + InexactFloat64() +} + +// rechargeBonusQuote 一笔余额充值的报价结果。 +type rechargeBonusQuote struct { + // PayBase 网关收款基数(支付币种,不含手续费);赠金模式等于支付金额,折扣模式为折后金额。 + PayBase float64 + // Credited 到账总额(USD),含 Bonus。 + Credited float64 + // Bonus 免费额度(USD):赠金模式为额外赠送,折扣模式为未付费却到账的部分。 + Bonus float64 + // Percent 命中档位的百分比;未命中或未产生优惠时为 0。 + Percent float64 +} + +// quoteRechargeBonus 按配置模式报价。currency 用于折扣模式实付基数的精度。 +// 未配置阶梯、未命中、或折扣百分比 ≥ 100(非法历史数据,fail-safe)时按无优惠处理。 +func quoteRechargeBonus(cfg *PaymentConfig, paymentAmount float64, currency string) rechargeBonusQuote { + multiplier := defaultBalanceRechargeMultiplier + var tiers []RechargeBonusTier + mode := RechargeBonusModeBonus + if cfg != nil { + multiplier = cfg.BalanceRechargeMultiplier + tiers = cfg.RechargeBonusTiers + mode, _ = NormalizeRechargeBonusMode(cfg.RechargeBonusMode) + } + base := calculateCreditedBalance(paymentAmount, multiplier) + quote := rechargeBonusQuote{PayBase: paymentAmount, Credited: base} + + tier, ok := matchRechargeBonusTier(tiers, paymentAmount) + if !ok || tier.BonusPercent <= 0 { + return quote + } + switch mode { + case RechargeBonusModeDiscount: + if tier.BonusPercent >= 100 { + return quote + } + payBase := calculateDiscountedPayBase(paymentAmount, tier.BonusPercent, currency) + if payBase <= 0 || payBase >= paymentAmount { + return quote + } + paidCredit := calculateCreditedBalance(payBase, multiplier) + bonus := decimal.NewFromFloat(base).Sub(decimal.NewFromFloat(paidCredit)).Round(2).InexactFloat64() + if bonus < 0 { + bonus = 0 + } + quote.PayBase = payBase + quote.Bonus = bonus + quote.Percent = tier.BonusPercent + default: + bonus := calculateRechargeBonus(base, tier.BonusPercent) + if bonus <= 0 { + return quote + } + quote.Bonus = bonus + quote.Credited = addRechargeBonus(base, bonus) + quote.Percent = tier.BonusPercent + } + return quote +} + +// paymentOrderAmountWithoutBonus 订单到账金额剔除免费额度后的实付部分(USD),用于推广返利基数。 func paymentOrderAmountWithoutBonus(o *dbent.PaymentOrder) float64 { if o == nil { return 0 diff --git a/backend/internal/service/payment_recharge_bonus_test.go b/backend/internal/service/payment_recharge_bonus_test.go index d0b459e4c2..00af53bfe6 100644 --- a/backend/internal/service/payment_recharge_bonus_test.go +++ b/backend/internal/service/payment_recharge_bonus_test.go @@ -133,38 +133,23 @@ func TestMatchRechargeBonusTier(t *testing.T) { }) } -func TestResolveRechargeBonus(t *testing.T) { - tiers := []RechargeBonusTier{{MinAmount: 100, BonusPercent: 20}, {MinAmount: 500, BonusPercent: 30}} +func TestCalculateRechargeBonusRounding(t *testing.T) { + require.Equal(t, 20.0, calculateRechargeBonus(100, 20)) + require.Equal(t, 120.0, addRechargeBonus(100, 20)) + // 33.33 * 15% = 4.9995 → 5.00 + require.Equal(t, 5.0, calculateRechargeBonus(33.33, 15)) + require.Zero(t, calculateRechargeBonus(100, 0)) + require.Zero(t, calculateRechargeBonus(0, 20)) - t.Run("bonus is percent of credited base rounded to cents", func(t *testing.T) { - bonus, pct := resolveRechargeBonus(tiers, 100, 100) - require.Equal(t, 20.0, bonus) - require.Equal(t, 20.0, pct) - require.Equal(t, 120.0, addRechargeBonus(100, bonus)) - - // 33.33 * 15% = 4.9995 → 5.00 - bonus, _ = resolveRechargeBonus([]RechargeBonusTier{{MinAmount: 1, BonusPercent: 15}}, 33.33, 33.33) - require.Equal(t, 5.0, bonus) - }) - - t.Run("threshold uses payment amount while bonus uses credited base", func(t *testing.T) { - // 1000 CNY paid, multiplier 0.14 → base 140 USD; tier matched by 1000 (30%) - base := calculateCreditedBalance(1000, 0.14) - bonus, pct := resolveRechargeBonus(tiers, 1000, base) - require.Equal(t, 30.0, pct) - require.Equal(t, 42.0, bonus) - require.Equal(t, 182.0, addRechargeBonus(base, bonus)) - }) - - t.Run("no match or zero percent yields zero", func(t *testing.T) { - bonus, pct := resolveRechargeBonus(tiers, 50, 50) - require.Zero(t, bonus) - require.Zero(t, pct) - - bonus, pct = resolveRechargeBonus([]RechargeBonusTier{{MinAmount: 10, BonusPercent: 0}}, 50, 50) - require.Zero(t, bonus) - require.Zero(t, pct) - }) + // 阈值按支付金额命中,赠送按到账基数计算:1000 CNY × 0.14 = 140 USD,命中 1000 档 30% → 42 + cfg := &PaymentConfig{ + BalanceRechargeMultiplier: 0.14, + RechargeBonusTiers: []RechargeBonusTier{{MinAmount: 100, BonusPercent: 20}, {MinAmount: 500, BonusPercent: 30}}, + } + require.Equal(t, rechargeBonusQuote{PayBase: 1000, Credited: 182, Bonus: 42, Percent: 30}, quoteRechargeBonus(cfg, 1000, "CNY")) + // 命中 0% 档位视为无优惠 + cfg.RechargeBonusTiers = []RechargeBonusTier{{MinAmount: 10, BonusPercent: 0}} + require.Equal(t, rechargeBonusQuote{PayBase: 50, Credited: 7}, quoteRechargeBonus(cfg, 50, "CNY")) } func TestParsePaymentConfigRechargeBonus(t *testing.T) { @@ -249,3 +234,116 @@ func TestAffiliateRebateBaseAmountExcludesRechargeBonus(t *testing.T) { OrderType: payment.OrderTypeBalance, Amount: 10, BonusAmount: 30, })) } + +func TestNormalizeRechargeBonusMode(t *testing.T) { + for raw, want := range map[string]string{"": RechargeBonusModeBonus, "bonus": RechargeBonusModeBonus, " Discount ": RechargeBonusModeDiscount} { + mode, ok := NormalizeRechargeBonusMode(raw) + require.True(t, ok, raw) + require.Equal(t, want, mode, raw) + } + mode, ok := NormalizeRechargeBonusMode("cashback") + require.False(t, ok) + require.Equal(t, RechargeBonusModeBonus, mode) +} + +func TestValidateRechargeBonusTiersForMode(t *testing.T) { + tiers := []RechargeBonusTier{{MinAmount: 100, BonusPercent: 20}, {MinAmount: 500, BonusPercent: 100}} + require.NoError(t, ValidateRechargeBonusTiersForMode(RechargeBonusModeBonus, tiers)) + require.Error(t, ValidateRechargeBonusTiersForMode(RechargeBonusModeDiscount, tiers)) + require.NoError(t, ValidateRechargeBonusTiersForMode(RechargeBonusModeDiscount, tiers[:1])) +} + +func TestQuoteRechargeBonus(t *testing.T) { + tiers := []RechargeBonusTier{{MinAmount: 100, BonusPercent: 20}, {MinAmount: 500, BonusPercent: 50}} + + t.Run("bonus mode keeps pay base and inflates credit", func(t *testing.T) { + cfg := &PaymentConfig{BalanceRechargeMultiplier: 1, RechargeBonusTiers: tiers, RechargeBonusMode: RechargeBonusModeBonus} + q := quoteRechargeBonus(cfg, 100, "USD") + require.Equal(t, rechargeBonusQuote{PayBase: 100, Credited: 120, Bonus: 20, Percent: 20}, q) + + // 未命中:无优惠 + q = quoteRechargeBonus(cfg, 50, "USD") + require.Equal(t, rechargeBonusQuote{PayBase: 50, Credited: 50}, q) + }) + + t.Run("discount mode keeps credit and reduces pay base", func(t *testing.T) { + cfg := &PaymentConfig{BalanceRechargeMultiplier: 1, RechargeBonusTiers: tiers, RechargeBonusMode: RechargeBonusModeDiscount} + q := quoteRechargeBonus(cfg, 500, "USD") + require.Equal(t, rechargeBonusQuote{PayBase: 250, Credited: 500, Bonus: 250, Percent: 50}, q) + + // 倍率 0.14:1000 CNY 到账 140 USD;20% off 实付 800 CNY,免费部分 = 140 − 112 = 28 USD + cfg.BalanceRechargeMultiplier = 0.14 + q = quoteRechargeBonus(cfg, 1000, "CNY") + require.Equal(t, rechargeBonusQuote{PayBase: 500, Credited: 140, Bonus: 70, Percent: 50}, q) + q = quoteRechargeBonus(cfg, 200, "CNY") + require.Equal(t, rechargeBonusQuote{PayBase: 160, Credited: 28, Bonus: 5.6, Percent: 20}, q) + }) + + t.Run("discount rounds pay base to currency precision", func(t *testing.T) { + cfg := &PaymentConfig{BalanceRechargeMultiplier: 1, RechargeBonusTiers: []RechargeBonusTier{{MinAmount: 1, BonusPercent: 15}}, RechargeBonusMode: RechargeBonusModeDiscount} + require.Equal(t, 85.85, quoteRechargeBonus(cfg, 101, "USD").PayBase) + require.Equal(t, 86.0, quoteRechargeBonus(cfg, 101, "JPY").PayBase) + }) + + t.Run("discount percent at or above 100 is ignored fail-safe", func(t *testing.T) { + cfg := &PaymentConfig{BalanceRechargeMultiplier: 1, RechargeBonusTiers: []RechargeBonusTier{{MinAmount: 1, BonusPercent: 100}}, RechargeBonusMode: RechargeBonusModeDiscount} + require.Equal(t, rechargeBonusQuote{PayBase: 100, Credited: 100}, quoteRechargeBonus(cfg, 100, "USD")) + }) + + t.Run("nil config and empty tiers yield plain conversion", func(t *testing.T) { + require.Equal(t, rechargeBonusQuote{PayBase: 100, Credited: 100}, quoteRechargeBonus(nil, 100, "USD")) + require.Equal(t, rechargeBonusQuote{PayBase: 100, Credited: 14}, quoteRechargeBonus(&PaymentConfig{BalanceRechargeMultiplier: 0.14}, 100, "CNY")) + }) +} + +func TestParsePaymentConfigRechargeBonusMode(t *testing.T) { + svc := &PaymentConfigService{} + require.Equal(t, RechargeBonusModeBonus, svc.parsePaymentConfig(map[string]string{}).RechargeBonusMode) + require.Equal(t, RechargeBonusModeDiscount, svc.parsePaymentConfig(map[string]string{SettingRechargeBonusMode: "discount"}).RechargeBonusMode) + require.Equal(t, RechargeBonusModeBonus, svc.parsePaymentConfig(map[string]string{SettingRechargeBonusMode: "junk"}).RechargeBonusMode) +} + +func TestUpdatePaymentConfigRechargeBonusMode(t *testing.T) { + ctx := context.Background() + + t.Run("persists discount mode with valid tiers", func(t *testing.T) { + repo := &paymentConfigSettingRepoStub{values: map[string]string{}} + svc := &PaymentConfigService{settingRepo: repo} + mode := "discount" + tiers := []RechargeBonusTier{{MinAmount: 100, BonusPercent: 20}} + require.NoError(t, svc.UpdatePaymentConfig(ctx, UpdatePaymentConfigRequest{RechargeBonusTiers: &tiers, RechargeBonusMode: &mode})) + require.Equal(t, "discount", repo.updates[SettingRechargeBonusMode]) + cfg, err := svc.GetPaymentConfig(ctx) + require.NoError(t, err) + require.Equal(t, RechargeBonusModeDiscount, cfg.RechargeBonusMode) + }) + + t.Run("rejects unknown mode", func(t *testing.T) { + repo := &paymentConfigSettingRepoStub{values: map[string]string{}} + svc := &PaymentConfigService{settingRepo: repo} + mode := "cashback" + require.Error(t, svc.UpdatePaymentConfig(ctx, UpdatePaymentConfigRequest{RechargeBonusMode: &mode})) + require.Nil(t, repo.updates) + }) + + t.Run("switching to discount with stored tiers at 100 percent is rejected", func(t *testing.T) { + repo := &paymentConfigSettingRepoStub{values: map[string]string{ + SettingRechargeBonusTiers: `[{"min_amount":100,"bonus_percent":100}]`, + }} + svc := &PaymentConfigService{settingRepo: repo} + mode := "discount" + require.Error(t, svc.UpdatePaymentConfig(ctx, UpdatePaymentConfigRequest{RechargeBonusMode: &mode})) + require.Nil(t, repo.updates) + }) + + t.Run("saving tiers at 100 percent while stored mode is discount is rejected", func(t *testing.T) { + repo := &paymentConfigSettingRepoStub{values: map[string]string{SettingRechargeBonusMode: "discount"}} + svc := &PaymentConfigService{settingRepo: repo} + tiers := []RechargeBonusTier{{MinAmount: 100, BonusPercent: 100}} + require.Error(t, svc.UpdatePaymentConfig(ctx, UpdatePaymentConfigRequest{RechargeBonusTiers: &tiers})) + require.Nil(t, repo.updates) + // 同样的档位在赠金模式下合法 + repo.values[SettingRechargeBonusMode] = "bonus" + require.NoError(t, svc.UpdatePaymentConfig(ctx, UpdatePaymentConfigRequest{RechargeBonusTiers: &tiers})) + }) +} diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 2c57aec3b2..66546fac75 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -672,6 +672,7 @@ export interface SystemSettings { payment_subscription_usd_to_cny_rate: number; payment_recharge_fee_rate: number; payment_recharge_bonus_tiers?: RechargeBonusTier[]; + payment_recharge_bonus_mode?: string; payment_recharge_bonus_notice?: string; payment_load_balance_strategy: string; payment_product_name_prefix: string; @@ -995,6 +996,7 @@ export interface UpdateSettingsRequest { payment_subscription_usd_to_cny_rate?: number; payment_recharge_fee_rate?: number; payment_recharge_bonus_tiers?: RechargeBonusTier[]; + payment_recharge_bonus_mode?: string; payment_recharge_bonus_notice?: string; payment_load_balance_strategy?: string; payment_product_name_prefix?: string; diff --git a/frontend/src/components/admin/settings/RechargeBonusTierEditor.vue b/frontend/src/components/admin/settings/RechargeBonusTierEditor.vue index aaf641b68d..6ef92a9a44 100644 --- a/frontend/src/components/admin/settings/RechargeBonusTierEditor.vue +++ b/frontend/src/components/admin/settings/RechargeBonusTierEditor.vue @@ -1,110 +1,177 @@ @@ -120,9 +187,11 @@ import { formatRechargeBonusNumber, isDuplicateRechargeBonusMinAmount, isRechargeBonusMinAmountValid, - isRechargeBonusPercentValid, + isRechargeBonusPercentValidForMode, + normalizeRechargeBonusMode, sanitizeRechargeBonusTiersForSubmit, type RechargeBonusInterval, + type RechargeBonusMode, type RechargeBonusTierDraft, } from '@/utils/rechargeBonus' @@ -130,16 +199,31 @@ const { t } = useI18n() const props = defineProps<{ modelValue?: RechargeBonusTierDraft[] | null + mode?: RechargeBonusMode | string | null + notice?: string | null }>() const emit = defineEmits<{ (e: 'update:modelValue', value: RechargeBonusTierDraft[]): void + (e: 'update:mode', value: RechargeBonusMode): void + (e: 'update:notice', value: string): void }>() const tiers = computed(() => props.modelValue ?? []) +const currentMode = computed(() => normalizeRechargeBonusMode(props.mode)) + +const modeOptions = computed<{ value: RechargeBonusMode; label: string }[]>(() => [ + { value: 'bonus', label: t('admin.settings.payment.rechargeBonus.modeBonus') }, + { value: 'discount', label: t('admin.settings.payment.rechargeBonus.modeDiscount') }, +]) const intervals = computed(() => describeRechargeBonusIntervals(sanitizeRechargeBonusTiersForSubmit(tiers.value))) +function setMode(mode: RechargeBonusMode) { + if (mode === currentMode.value) return + emit('update:mode', mode) +} + function cloneTiers(): RechargeBonusTierDraft[] { return tiers.value.map((tier) => ({ min_amount: tier.min_amount, bonus_percent: tier.bonus_percent })) } @@ -174,13 +258,17 @@ function onPercentInput(index: number, event: Event) { emit('update:modelValue', next) } -type RowError = '' | 'invalidMinAmount' | 'invalidPercent' | 'duplicateMinAmount' +type RowError = '' | 'invalidMinAmount' | 'invalidPercent' | 'invalidDiscountPercent' | 'duplicateMinAmount' function rowError(index: number): RowError { const tier = tiers.value[index] if (!tier) return '' if (tier.min_amount !== null && !isRechargeBonusMinAmountValid(tier.min_amount)) return 'invalidMinAmount' - if (tier.bonus_percent !== null && !isRechargeBonusPercentValid(tier.bonus_percent)) return 'invalidPercent' + if (tier.bonus_percent !== null && !isRechargeBonusPercentValidForMode(tier.bonus_percent, currentMode.value)) { + return currentMode.value === 'discount' && isRechargeBonusPercentValidForMode(tier.bonus_percent, 'bonus') + ? 'invalidDiscountPercent' + : 'invalidPercent' + } if (isDuplicateRechargeBonusMinAmount(tiers.value, index)) return 'duplicateMinAmount' return '' } @@ -193,14 +281,17 @@ function rowIncomplete(index: number): boolean { function intervalLabel(interval: RechargeBonusInterval): string { const from = formatRechargeBonusNumber(interval.from) const percent = formatRechargeBonusNumber(interval.percent) + const discount = currentMode.value === 'discount' if (interval.to === null) { - return interval.percent > 0 - ? t('admin.settings.payment.rechargeBonus.previewOpen', { from, percent }) - : t('admin.settings.payment.rechargeBonus.previewOpenNone', { from }) + if (interval.percent <= 0) return t('admin.settings.payment.rechargeBonus.previewOpenNone', { from }) + return discount + ? t('admin.settings.payment.rechargeBonus.previewOpenDiscount', { from, percent }) + : t('admin.settings.payment.rechargeBonus.previewOpen', { from, percent }) } const to = formatRechargeBonusNumber(interval.to) - return interval.percent > 0 - ? t('admin.settings.payment.rechargeBonus.previewRange', { from, to, percent }) - : t('admin.settings.payment.rechargeBonus.previewRangeNone', { from, to }) + if (interval.percent <= 0) return t('admin.settings.payment.rechargeBonus.previewRangeNone', { from, to }) + return discount + ? t('admin.settings.payment.rechargeBonus.previewRangeDiscount', { from, to, percent }) + : t('admin.settings.payment.rechargeBonus.previewRange', { from, to, percent }) } diff --git a/frontend/src/components/payment/AmountInput.vue b/frontend/src/components/payment/AmountInput.vue index 601193ad4a..a16029e94b 100644 --- a/frontend/src/components/payment/AmountInput.vue +++ b/frontend/src/components/payment/AmountInput.vue @@ -5,7 +5,7 @@ -
+
@@ -65,23 +74,30 @@ import { ref, computed, watch } from 'vue' import { useI18n } from 'vue-i18n' import type { RechargeBonusTier } from '@/types/payment' -import { formatRechargeBonusNumber, quoteRechargeBonus } from '@/utils/rechargeBonus' +import { formatRechargeBonusNumber, quoteRechargeBonus, type RechargeBonusMode } from '@/utils/rechargeBonus' +import { formatPaymentAmount } from './currency' const props = withDefaults(defineProps<{ amounts?: number[] modelValue: number | null min?: number max?: number - /** 充值赠送阶梯(按 min_amount 升序);为空时不显示角标与到账行 */ + /** 充值优惠阶梯(按 min_amount 升序);为空时不显示价签与第二行 */ bonusTiers?: RechargeBonusTier[] + /** 阶梯模式:bonus 赠金 / discount 折扣 */ + bonusMode?: RechargeBonusMode /** 充值倍率(1 支付币种 = multiplier USD),用于计算到账金额 */ multiplier?: number + /** 支付币种(折扣模式第二行实付金额的币种与精度) */ + currency?: string }>(), { amounts: () => [10, 20, 50, 100, 200, 500, 1000, 2000, 5000], min: 0, max: 0, bonusTiers: () => [], + bonusMode: 'bonus', multiplier: 1, + currency: undefined, }) const emit = defineEmits<{ @@ -97,10 +113,37 @@ const filteredAmounts = computed(() => props.amounts.filter((a) => (props.min <= 0 || a >= props.min) && (props.max <= 0 || a <= props.max)) ) -const showCredited = computed(() => props.bonusTiers.length > 0) +const showSecondLine = computed(() => props.bonusTiers.length > 0) + +function currencyDigits(): number { + if (!props.currency) return 2 + try { + return new Intl.NumberFormat(undefined, { style: 'currency', currency: props.currency }).resolvedOptions().maximumFractionDigits ?? 2 + } catch { + return 2 + } +} function quoteFor(amt: number) { - return quoteRechargeBonus(props.bonusTiers, amt, props.multiplier) + return quoteRechargeBonus(props.bonusTiers, amt, { + multiplier: props.multiplier, + mode: props.bonusMode, + currencyDigits: currencyDigits(), + }) +} + +// 价签文案:赠金「+20%」,折扣「20% OFF」 +function badgeText(amt: number): string { + const percent = formatRechargeBonusNumber(quoteFor(amt).percent) + return props.bonusMode === 'discount' ? `${percent}% OFF` : `+${percent}%` +} + +function secondLine(amt: number): string { + const quote = quoteFor(amt) + if (props.bonusMode === 'discount') { + return t('payment.rechargeBonus.payShort', { amount: formatPaymentAmount(quote.payBase, props.currency) }) + } + return t('payment.rechargeBonus.creditedShort', { amount: '$' + quote.credited.toFixed(2) }) } const placeholderText = computed(() => { diff --git a/frontend/src/components/payment/__tests__/AmountInput.spec.ts b/frontend/src/components/payment/__tests__/AmountInput.spec.ts index ec569499a2..44e6144fd1 100644 --- a/frontend/src/components/payment/__tests__/AmountInput.spec.ts +++ b/frontend/src/components/payment/__tests__/AmountInput.spec.ts @@ -47,13 +47,13 @@ describe('recharge bonus hints on quick amounts', () => { { min_amount: 500, bonus_percent: 30 }, ] - it('renders no badge or credited line when no tiers are configured', () => { + it('renders no badge or second line when no tiers are configured', () => { const wrapper = mount(AmountInput, { props: { modelValue: null, amounts: [50, 100, 500] } }) expect(wrapper.find('[data-testid="quick-amount-bonus-badge"]').exists()).toBe(false) expect(wrapper.find('[data-testid="quick-amount-credited"]').exists()).toBe(false) }) - it('shows a badge only on amounts that hit a tier and credited totals on every button', () => { + it('bonus mode: price tag only on amounts that hit a tier, credited totals on every button', () => { const wrapper = mount(AmountInput, { props: { modelValue: null, amounts: [50, 100, 500], bonusTiers: tiers } }) const below = wrapper.get('[data-testid="quick-amount-50"]') const first = wrapper.get('[data-testid="quick-amount-100"]') @@ -67,7 +67,7 @@ describe('recharge bonus hints on quick amounts', () => { expect(second.get('[data-testid="quick-amount-credited"]').text()).toContain('$650.00') }) - it('matches tiers by the entered amount but credits by the multiplier', () => { + it('bonus mode: matches tiers by the entered amount but credits by the multiplier', () => { const wrapper = mount(AmountInput, { props: { modelValue: null, amounts: [1000], bonusTiers: tiers, multiplier: 0.14 }, }) @@ -76,4 +76,17 @@ describe('recharge bonus hints on quick amounts', () => { // 1000 × 0.14 = 140 base, +30% = 182 expect(button.get('[data-testid="quick-amount-credited"]').text()).toContain('$182.00') }) + + it('discount mode: tag reads N% OFF and the second line shows the discounted payment', () => { + const wrapper = mount(AmountInput, { + props: { modelValue: null, amounts: [50, 500], bonusTiers: tiers, bonusMode: 'discount', currency: 'USD' }, + }) + const below = wrapper.get('[data-testid="quick-amount-50"]') + const hit = wrapper.get('[data-testid="quick-amount-500"]') + expect(below.find('[data-testid="quick-amount-bonus-badge"]').exists()).toBe(false) + expect(below.get('[data-testid="quick-amount-credited"]').text()).toContain('50.00') + expect(hit.get('[data-testid="quick-amount-bonus-badge"]').text()).toBe('30% OFF') + // 500 × (1 − 30%) = 350 + expect(hit.get('[data-testid="quick-amount-credited"]').text()).toContain('350.00') + }) }) diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index cc09fb78c4..a3075fc601 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -748,21 +748,30 @@ export default { rechargeFeeRateHint: 'Percentage of service fee charged on top of recharge amount, 0 means no fee', rechargeFeePreview: 'Preview: Recharge 100, fee {fee}', rechargeBonus: { - label: 'Recharge Bonus Tiers', - hint: 'Balance top-ups match a tier by the amount the user enters (highest threshold not above it) and receive that percentage on top of the credited base. Leave empty for no bonus. Subscriptions are not affected.', + label: 'Recharge Promotion Tiers', + hint: 'Balance top-ups match a tier by the amount the user enters (highest threshold not above it). Leave empty for no promotion. Subscriptions are not affected.', + modeLabel: 'Promotion type', + modeBonus: 'Bonus', + modeDiscount: 'Discount OFF', + modeBonusHint: 'Bonus: the payment stays the same and the credited base (amount × multiplier) gets the percentage added on top.', + modeDiscountHint: 'Discount: the credit stays the same (amount × multiplier) and the payment is reduced by the percentage; must be below 100.', addTier: 'Add Tier', - empty: 'No bonus tiers configured; top-ups receive no bonus.', + empty: 'No promotion tiers configured; top-ups are credited at face value.', minAmountLabel: 'Amount ≥', percentLabel: 'Bonus', + percentLabelDiscount: 'Discount', removeTier: 'Remove tier', invalidMinAmount: 'Amount must be a number ≥ 0 with at most 2 decimals', invalidPercent: 'Percent must be between 0 and 1000 with at most 2 decimals', + invalidDiscountPercent: 'Discount percent must be below 100', duplicateMinAmount: 'This threshold already exists', incompleteRow: 'Fill in both amount and percent for this tier to take effect', previewTitle: 'Range preview', previewRange: '{from} ~ {to}: {percent}% bonus', previewRangeNone: '{from} ~ {to}: no bonus', + previewRangeDiscount: '{from} ~ {to}: {percent}% OFF', previewOpen: '≥ {from}: {percent}% bonus', + previewOpenDiscount: '≥ {from}: {percent}% OFF', previewOpenNone: '≥ {from}: no bonus', noticeLabel: 'Recharge Bonus Notice', noticeHint: 'Markdown supported. Shown at the top of the amount picker on the recharge page; leave empty to hide.', diff --git a/frontend/src/i18n/locales/en/misc.ts b/frontend/src/i18n/locales/en/misc.ts index 27aaa9b314..0888751a00 100644 --- a/frontend/src/i18n/locales/en/misc.ts +++ b/frontend/src/i18n/locales/en/misc.ts @@ -293,8 +293,10 @@ export default { creditedBalance: 'Credited Balance', rechargeBonus: { creditedShort: 'Get {amount}', + payShort: 'Pay {amount}', amountLabel: 'Bonus', amountLabelWithPercent: 'Bonus (+{percent}%)', + discountLabelWithPercent: 'Discount ({percent}% OFF)', }, quickAmounts: 'Quick Amounts', customAmount: 'Custom Amount', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 71ea637aae..14317bff89 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -743,21 +743,30 @@ export default { rechargeFeeRateHint: '用户充值时额外收取的手续费百分比,0 表示不收取手续费', rechargeFeePreview: '预览:充值 100 元,手续费 {fee} 元', rechargeBonus: { - label: '充值赠送阶梯', - hint: '余额充值按用户输入的支付金额命中档位(取不超过该金额的最大档),在到账基数上额外赠送对应百分比;不配置则不赠送。订阅订单不参与。', + label: '充值优惠阶梯', + hint: '余额充值按用户输入的金额命中档位(取不超过该金额的最大档);不配置则无优惠。订阅订单不参与。', + modeLabel: '优惠方式', + modeBonus: '赠金', + modeDiscount: '折扣 OFF', + modeBonusHint: '赠金:实付不变,在到账基数(输入金额 × 充值倍率)之上额外赠送对应百分比的余额。', + modeDiscountHint: '折扣:到账不变(输入金额 × 充值倍率),实付金额按对应百分比打折;百分比必须小于 100。', addTier: '添加档位', - empty: '尚未配置赠送档位,充值不赠送额度。', - minAmountLabel: '支付金额 ≥', + empty: '尚未配置优惠档位,充值按原价到账。', + minAmountLabel: '充值金额 ≥', percentLabel: '赠送', + percentLabelDiscount: '优惠', removeTier: '删除档位', invalidMinAmount: '金额需为 ≥ 0 且最多两位小数的数字', invalidPercent: '百分比需在 0 ~ 1000 之间,最多两位小数', + invalidDiscountPercent: '折扣模式下百分比必须小于 100', duplicateMinAmount: '该金额档位已存在', incompleteRow: '金额与百分比都填写后该档位才会生效', previewTitle: '区间预览', previewRange: '{from} ~ {to}:赠送 {percent}%', previewRangeNone: '{from} ~ {to}:不赠送', + previewRangeDiscount: '{from} ~ {to}:{percent}% OFF', previewOpen: '≥ {from}:赠送 {percent}%', + previewOpenDiscount: '≥ {from}:{percent}% OFF', previewOpenNone: '≥ {from}:不赠送', noticeLabel: '充值赠送活动文案', noticeHint: '支持 Markdown,展示在充值页金额选择区顶部;留空则不展示。', diff --git a/frontend/src/i18n/locales/zh/misc.ts b/frontend/src/i18n/locales/zh/misc.ts index ee37c5a483..2c61524731 100644 --- a/frontend/src/i18n/locales/zh/misc.ts +++ b/frontend/src/i18n/locales/zh/misc.ts @@ -317,8 +317,10 @@ export default { creditedBalance: '到账余额', rechargeBonus: { creditedShort: '到账 {amount}', + payShort: '实付 {amount}', amountLabel: '赠送额度', amountLabelWithPercent: '赠送额度 (+{percent}%)', + discountLabelWithPercent: '优惠 ({percent}% OFF)', }, quickAmounts: '快捷金额', customAmount: '自定义金额', diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index 68b12d648b..6a41030988 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -79,6 +79,8 @@ export interface CheckoutInfoResponse { recharge_fee_rate: number /** 充值赠送阶梯(按 min_amount 升序);缺失/空数组 = 不赠送 */ recharge_bonus_tiers?: RechargeBonusTier[] + /** 阶梯模式:bonus 赠金 / discount 折扣;缺失按 bonus */ + recharge_bonus_mode?: string /** 充值页金额区顶部的 Markdown 活动文案;空 = 不展示 */ recharge_bonus_notice?: string help_text: string diff --git a/frontend/src/utils/__tests__/rechargeBonus.spec.ts b/frontend/src/utils/__tests__/rechargeBonus.spec.ts index a9d5c9e082..25561b50ec 100644 --- a/frontend/src/utils/__tests__/rechargeBonus.spec.ts +++ b/frontend/src/utils/__tests__/rechargeBonus.spec.ts @@ -5,7 +5,9 @@ import { describeRechargeBonusIntervals, formatRechargeBonusNumber, isDuplicateRechargeBonusMinAmount, + isRechargeBonusPercentValidForMode, matchRechargeBonusTier, + normalizeRechargeBonusMode, normalizeRechargeBonusTiers, quoteRechargeBonus, sanitizeRechargeBonusTiersForSubmit, @@ -90,6 +92,34 @@ describe('rechargeBonus helpers', () => { expect(quoteRechargeBonus([], 100)).toMatchObject({ percent: 0, bonus: 0, credited: 100 }) }) + it('quotes discount mode: credit stays, pay base shrinks, free part recorded as bonus', () => { + expect(quoteRechargeBonus(tiers, 500, { mode: 'discount' })).toMatchObject({ + mode: 'discount', percent: 30, payBase: 350, base: 500, bonus: 150, credited: 500, + }) + // 倍率 0.14:1000 CNY 到账 140 USD,35% off 实付 650 CNY,免费部分 140 − 91 = 49 USD + expect(quoteRechargeBonus(tiers, 1000, { mode: 'discount', multiplier: 0.14 })).toMatchObject({ + percent: 35, payBase: 650, base: 140, bonus: 49, credited: 140, + }) + // 币种精度:JPY 无小数 + expect(quoteRechargeBonus([{ min_amount: 1, bonus_percent: 15 }], 101, { mode: 'discount' }).payBase).toBe(85.85) + expect(quoteRechargeBonus([{ min_amount: 1, bonus_percent: 15 }], 101, { mode: 'discount', currencyDigits: 0 }).payBase).toBe(86) + // 折扣 ≥ 100% 视为无优惠(fail-safe) + expect(quoteRechargeBonus([{ min_amount: 1, bonus_percent: 100 }], 100, { mode: 'discount' })).toMatchObject({ percent: 0, payBase: 100, credited: 100 }) + // 未命中 + expect(quoteRechargeBonus(tiers, 50, { mode: 'discount' })).toMatchObject({ percent: 0, payBase: 50, credited: 50, bonus: 0 }) + }) + + it('normalizes mode and validates percent per mode', () => { + expect(normalizeRechargeBonusMode('discount')).toBe('discount') + expect(normalizeRechargeBonusMode(' Discount ')).toBe('discount') + expect(normalizeRechargeBonusMode('bonus')).toBe('bonus') + expect(normalizeRechargeBonusMode(undefined)).toBe('bonus') + expect(normalizeRechargeBonusMode('junk')).toBe('bonus') + expect(isRechargeBonusPercentValidForMode(100, 'bonus')).toBe(true) + expect(isRechargeBonusPercentValidForMode(100, 'discount')).toBe(false) + expect(isRechargeBonusPercentValidForMode(99.99, 'discount')).toBe(true) + }) + it('describes intervals with an implicit no-bonus head segment', () => { expect(describeRechargeBonusIntervals(tiers)).toEqual([ { from: 0, to: 100, percent: 0 }, diff --git a/frontend/src/utils/rechargeBonus.ts b/frontend/src/utils/rechargeBonus.ts index c268cbc136..25f1b97fb6 100644 --- a/frontend/src/utils/rechargeBonus.ts +++ b/frontend/src/utils/rechargeBonus.ts @@ -2,6 +2,8 @@ import type { RechargeBonusTier } from '@/types/payment' export type { RechargeBonusTier } +export type RechargeBonusMode = 'bonus' | 'discount' + export const MAX_RECHARGE_BONUS_TIERS = 20 export const MAX_RECHARGE_BONUS_PERCENT = 1000 @@ -21,26 +23,34 @@ export interface RechargeBonusInterval { } export interface RechargeBonusQuote { - /** 命中档位的百分比;未命中或赠送为 0 时为 0 */ + mode: RechargeBonusMode + /** 命中档位的百分比;未命中或未产生优惠时为 0 */ percent: number - /** 到账基数(支付金额 × 倍率,USD) */ + /** 网关收款基数(支付币种,不含手续费);赠金模式 = 输入金额,折扣模式 = 折后金额 */ + payBase: number + /** 到账基数(输入金额 × 倍率,USD),不含赠送 */ base: number - /** 赠送额度(USD) */ + /** 免费额度(USD):赠金模式为额外赠送,折扣模式为未付费却到账的部分 */ bonus: number - /** 到账总额 = base + bonus */ + /** 到账总额(USD) */ credited: number tier: RechargeBonusTier | null } -export function roundRechargeAmount(value: number): number { +export function roundRechargeAmount(value: number, digits = 2): number { if (!Number.isFinite(value)) return 0 - return Math.round((value + Number.EPSILON) * 100) / 100 + const factor = 10 ** digits + return Math.round((value + Number.EPSILON) * factor) / factor } function hasAtMostTwoDecimals(value: number): boolean { return Math.abs(roundRechargeAmount(value) - value) < AMOUNT_EPSILON } +export function normalizeRechargeBonusMode(raw: unknown): RechargeBonusMode { + return String(raw ?? '').trim().toLowerCase() === 'discount' ? 'discount' : 'bonus' +} + export function isRechargeBonusMinAmountValid(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) && value >= 0 && hasAtMostTwoDecimals(value) } @@ -55,6 +65,12 @@ export function isRechargeBonusPercentValid(value: unknown): value is number { ) } +// 折扣模式下百分比必须 < 100,否则实付为 0 或负数;与后端 ValidateRechargeBonusTiersForMode 一致。 +export function isRechargeBonusPercentValidForMode(value: unknown, mode: RechargeBonusMode): value is number { + if (!isRechargeBonusPercentValid(value)) return false + return mode !== 'discount' || value < 100 +} + function minAmountKey(value: number): string { return roundRechargeAmount(value).toFixed(2) } @@ -128,27 +144,49 @@ export function calculateRechargeBonus(baseCredited: number, percent: number): n return roundRechargeAmount((baseCredited * percent) / 100) } -// 充值页报价:阈值按支付金额比较,赠送按到账基数(支付金额 × 倍率)计算。 +export interface RechargeBonusQuoteOptions { + multiplier?: number + mode?: RechargeBonusMode + /** 支付币种小数位,折扣模式实付基数按此精度四舍五入 */ + currencyDigits?: number +} + +// 充值页报价:阈值按支付金额比较;赠金模式按到账基数加赠送,折扣模式按百分比减实付。 +// 与后端 quoteRechargeBonus 一致(含折扣 ≥ 100% 的 fail-safe)。 export function quoteRechargeBonus( tiers: RechargeBonusTier[], paymentAmount: number, - multiplier = 1, + options: RechargeBonusQuoteOptions | number = {}, ): RechargeBonusQuote { + const opts: RechargeBonusQuoteOptions = typeof options === 'number' ? { multiplier: options } : options + const mode = opts.mode ?? 'bonus' const amount = Number.isFinite(paymentAmount) && paymentAmount > 0 ? paymentAmount : 0 - const rate = Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 1 + const rate = typeof opts.multiplier === 'number' && Number.isFinite(opts.multiplier) && opts.multiplier > 0 ? opts.multiplier : 1 + const digits = typeof opts.currencyDigits === 'number' && Number.isInteger(opts.currencyDigits) && opts.currencyDigits >= 0 ? opts.currencyDigits : 2 const base = roundRechargeAmount(amount * rate) + const quote: RechargeBonusQuote = { mode, percent: 0, payBase: amount, base, bonus: 0, credited: base, tier: null } const tier = matchRechargeBonusTier(tiers, amount) - const bonus = tier ? calculateRechargeBonus(base, tier.bonus_percent) : 0 - return { - percent: bonus > 0 && tier ? tier.bonus_percent : 0, - base, - bonus, - credited: roundRechargeAmount(base + bonus), - tier, + if (!tier || tier.bonus_percent <= 0) return quote + quote.tier = tier + if (mode === 'discount') { + if (tier.bonus_percent >= 100) return quote + const payBase = roundRechargeAmount((amount * (100 - tier.bonus_percent)) / 100, digits) + if (payBase <= 0 || payBase >= amount) return quote + const paidCredit = roundRechargeAmount(payBase * rate) + quote.payBase = payBase + quote.bonus = Math.max(0, roundRechargeAmount(base - paidCredit)) + quote.percent = tier.bonus_percent + return quote } + const bonus = calculateRechargeBonus(base, tier.bonus_percent) + if (bonus <= 0) return quote + quote.bonus = bonus + quote.credited = roundRechargeAmount(base + bonus) + quote.percent = tier.bonus_percent + return quote } -// 区间预览:把升序阈值列表展开为 [from, to) 区间;首档阈值 > 0 时补一段「不赠送」。 +// 区间预览:把升序阈值列表展开为 [from, to) 区间;首档阈值 > 0 时补一段「无优惠」。 export function describeRechargeBonusIntervals(tiers: RechargeBonusTier[]): RechargeBonusInterval[] { const sorted = [...tiers].sort((a, b) => a.min_amount - b.min_amount) const out: RechargeBonusInterval[] = [] diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index d1cce74c28..793867a3e2 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -8369,27 +8369,6 @@ - - -
- - -

- {{ t("admin.settings.payment.rechargeBonus.noticeHint") }} -

-
+ + + ({ payment_subscription_usd_to_cny_rate: 0, payment_recharge_fee_rate: 0, payment_recharge_bonus_tiers: [], + payment_recharge_bonus_mode: "bonus", payment_recharge_bonus_notice: "", payment_enabled_types: [], payment_help_image_url: "", @@ -11108,6 +11099,9 @@ async function loadSettings() { form.payment_recharge_bonus_tiers = normalizeRechargeBonusTiers( settings.payment_recharge_bonus_tiers, ); + form.payment_recharge_bonus_mode = normalizeRechargeBonusMode( + settings.payment_recharge_bonus_mode, + ); form.payment_recharge_bonus_notice = settings.payment_recharge_bonus_notice || ""; form.default_platform_quotas = normalizePlatformQuotasMap(settings.default_platform_quotas); form.account_scheduling_thresholds = normalizeAccountSchedulingThresholdsMap( @@ -11719,6 +11713,7 @@ async function saveSettings() { payment_recharge_bonus_tiers: sanitizeRechargeBonusTiersForSubmit( form.payment_recharge_bonus_tiers, ), + payment_recharge_bonus_mode: form.payment_recharge_bonus_mode, payment_recharge_bonus_notice: form.payment_recharge_bonus_notice, payment_enabled_types: form.payment_enabled_types, payment_load_balance_strategy: form.payment_load_balance_strategy, diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 398a9edd80..415dab9ab1 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -489,6 +489,7 @@ const baseSettingsResponse = { payment_subscription_usd_to_cny_rate: 0, payment_recharge_fee_rate: 0, payment_recharge_bonus_tiers: [], + payment_recharge_bonus_mode: "bonus", payment_recharge_bonus_notice: "", payment_load_balance_strategy: "round-robin", payment_product_name_prefix: "", @@ -1102,6 +1103,19 @@ describe("admin SettingsView payment visible method controls", () => { rows = wrapper.findAll('[data-testid="recharge-bonus-tier-row"]'); expect(rows[2]!.find('[data-testid="recharge-bonus-tier-error"]').exists()).toBe(false); + // 切到折扣模式:百分比 ≥ 100 行内报错,改回 < 100 后消失 + await wrapper.get('[data-testid="recharge-bonus-mode-discount"]').trigger("click"); + rows = wrapper.findAll('[data-testid="recharge-bonus-tier-row"]'); + await rows[2]!.get('[data-testid="recharge-bonus-tier-percent-input"]').setValue("100"); + rows = wrapper.findAll('[data-testid="recharge-bonus-tier-row"]'); + expect(rows[2]!.get('[data-testid="recharge-bonus-tier-error"]').text()).toContain("invalidDiscountPercent"); + await rows[2]!.get('[data-testid="recharge-bonus-tier-percent-input"]').setValue("25"); + rows = wrapper.findAll('[data-testid="recharge-bonus-tier-row"]'); + expect(rows[2]!.find('[data-testid="recharge-bonus-tier-error"]').exists()).toBe(false); + expect(wrapper.get('[data-testid="recharge-bonus-tier-preview"]').text()).toContain( + "admin.settings.payment.rechargeBonus.previewRangeDiscount", + ); + const notice = wrapper.get('[data-testid="recharge-bonus-notice-input"]'); expect((notice.element as HTMLTextAreaElement).value).toBe("满 100 送 20%"); await notice.setValue("**新活动**"); @@ -1116,6 +1130,7 @@ describe("admin SettingsView payment visible method controls", () => { { min_amount: 500, bonus_percent: 30 }, { min_amount: 1000, bonus_percent: 25 }, ], + payment_recharge_bonus_mode: "discount", payment_recharge_bonus_notice: "**新活动**", }), ); diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 2924be0593..4727e1602b 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -63,7 +63,9 @@ :min="globalMinAmount" :max="globalMaxAmount" :bonus-tiers="rechargeBonusTiers" + :bonus-mode="rechargeBonusMode" :multiplier="balanceRechargeMultiplier" + :currency="selectedCurrency" />

{{ amountError }}

@@ -78,23 +80,27 @@
{{ t('payment.paymentAmount') }} - {{ formatSelectedPaymentAmount(validAmount) }} + {{ formatSelectedPaymentAmount(validAmount) }} +
+
+ {{ t('payment.rechargeBonus.discountLabelWithPercent', { percent: formatRechargeBonusNumber(bonusQuote.percent) }) }} + -{{ formatSelectedPaymentAmount(discountAmount) }}
{{ t('payment.fee') }} ({{ feeRate }}%) {{ formatSelectedPaymentAmount(feeAmount) }}
-
+
{{ t('payment.actualPay') }} {{ formatSelectedPaymentAmount(totalAmount) }}
-
+
{{ t('payment.rechargeBonus.amountLabelWithPercent', { percent: formatRechargeBonusNumber(bonusQuote.percent) }) }} - +${{ bonusQuote.bonus.toFixed(2) }} + +${{ bonusQuote.bonus.toFixed(2) }}
-
+
{{ t('payment.creditedBalance') }} - ${{ creditedAmount.toFixed(2) }} + ${{ creditedAmount.toFixed(2) }}

{{ t('payment.rechargeRatePreview', { currency: selectedCurrency, usd: balanceRechargeMultiplier.toFixed(2) }) }} @@ -289,7 +295,7 @@ import { extractApiErrorMessage, extractI18nErrorMessage } from '@/utils/apiErro import { isMobileDevice } from '@/utils/device' import { hasPeakRate, formatPeakRateWindow, serverTimezoneLabel, type PeakRateFields } from '@/utils/peak-rate' import type { SubscriptionPlan, CheckoutInfoResponse, CreateOrderResult, OrderType } from '@/types/payment' -import { formatRechargeBonusNumber, normalizeRechargeBonusTiers, quoteRechargeBonus } from '@/utils/rechargeBonus' +import { formatRechargeBonusNumber, normalizeRechargeBonusMode, normalizeRechargeBonusTiers, quoteRechargeBonus } from '@/utils/rechargeBonus' import AppLayout from '@/components/layout/AppLayout.vue' import AmountInput from '@/components/payment/AmountInput.vue' import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue' @@ -525,7 +531,7 @@ function onPaymentSettled() { const checkout = ref({ methods: {}, global_min: 0, global_max: 0, 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: '', - recharge_bonus_tiers: [], recharge_bonus_notice: '', + recharge_bonus_tiers: [], recharge_bonus_mode: 'bonus', recharge_bonus_notice: '', }) const renderedHelpText = computed(() => DOMPurify.sanitize( @@ -571,11 +577,8 @@ const subscriptionUsdToCnyRate = computed(() => { const rate = checkout.value.subscription_usd_to_cny_rate return Number.isFinite(rate) && rate > 0 ? rate : 0 }) -// 充值赠送阶梯:阈值按输入金额命中,赠送按到账基数(输入 × 倍率)计算;与后端 resolveRechargeBonus 一致 const rechargeBonusTiers = computed(() => normalizeRechargeBonusTiers(checkout.value.recharge_bonus_tiers)) -const bonusQuote = computed(() => quoteRechargeBonus(rechargeBonusTiers.value, validAmount.value, balanceRechargeMultiplier.value)) -const creditedAmount = computed(() => bonusQuote.value.credited) -const showCreditedBalance = computed(() => balanceRechargeMultiplier.value !== 1 || bonusQuote.value.bonus > 0) +const rechargeBonusMode = computed(() => normalizeRechargeBonusMode(checkout.value.recharge_bonus_mode)) // Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+ const planGridClass = computed(() => { @@ -657,6 +660,19 @@ function formatSelectedSubscriptionPaymentAmount(value: number): string { return formatSelectedPaymentAmount(subscriptionPaymentAmountForCurrency(value, selectedCurrency.value)) } +// 充值优惠:阈值按输入金额命中;赠金模式按到账基数(输入 × 倍率)加赠送,折扣模式按百分比减实付。 +// 与后端 quoteRechargeBonus 一致;渠道限额、手续费、实付都按折后基数(payBaseAmount)计算,提交仍发送输入金额。 +const bonusQuote = computed(() => quoteRechargeBonus(rechargeBonusTiers.value, validAmount.value, { + multiplier: balanceRechargeMultiplier.value, + mode: rechargeBonusMode.value, + currencyDigits: currencyFractionDigits(selectedCurrency.value), +})) +const payBaseAmount = computed(() => bonusQuote.value.payBase) +const discountAmount = computed(() => roundPaymentAmount(validAmount.value - payBaseAmount.value, selectedCurrency.value)) +const creditedAmount = computed(() => bonusQuote.value.credited) +const showBonusRow = computed(() => bonusQuote.value.mode !== 'discount' && bonusQuote.value.bonus > 0) +const showCreditedBalance = computed(() => balanceRechargeMultiplier.value !== 1 || bonusQuote.value.percent > 0) + const methodOptions = computed(() => enabledMethods.value.map((type) => { const ml = visibleMethods.value[type] @@ -664,41 +680,42 @@ const methodOptions = computed(() => type, display_name: ml?.display_name, fee_rate: ml?.fee_rate ?? 0, - available: ml?.available !== false && amountFitsMethod(validAmount.value, type), + available: ml?.available !== false && amountFitsMethod(payBaseAmount.value, type), } }) ) const feeRate = computed(() => checkout.value?.recharge_fee_rate ?? 0) const feeAmount = computed(() => - feeRate.value > 0 && validAmount.value > 0 - ? Math.ceil(((validAmount.value * feeRate.value) / 100) * 100) / 100 + feeRate.value > 0 && payBaseAmount.value > 0 + ? Math.ceil(((payBaseAmount.value * feeRate.value) / 100) * 100) / 100 : 0 ) const totalAmount = computed(() => - feeRate.value > 0 && validAmount.value > 0 - ? Math.round((validAmount.value + feeAmount.value) * 100) / 100 - : validAmount.value + feeRate.value > 0 && payBaseAmount.value > 0 + ? Math.round((payBaseAmount.value + feeAmount.value) * 100) / 100 + : payBaseAmount.value ) +const showActualPay = computed(() => feeRate.value > 0 || discountAmount.value > 0) const amountError = computed(() => { if (validAmount.value <= 0) return '' // No method can handle this amount - if (!enabledMethods.value.some((m) => amountFitsMethod(validAmount.value, m))) { + if (!enabledMethods.value.some((m) => amountFitsMethod(payBaseAmount.value, m))) { return t('payment.amountNoMethod') } // Selected method can't handle this amount (but others can) const ml = selectedLimit.value if (ml) { - if (ml.single_min > 0 && validAmount.value < ml.single_min) return t('payment.amountTooLow', { min: formatSelectedPaymentAmount(ml.single_min) }) - if (ml.single_max > 0 && validAmount.value > ml.single_max) return t('payment.amountTooHigh', { max: formatSelectedPaymentAmount(ml.single_max) }) + if (ml.single_min > 0 && payBaseAmount.value < ml.single_min) return t('payment.amountTooLow', { min: formatSelectedPaymentAmount(ml.single_min) }) + if (ml.single_max > 0 && payBaseAmount.value > ml.single_max) return t('payment.amountTooHigh', { max: formatSelectedPaymentAmount(ml.single_max) }) } return '' }) const canSubmit = computed(() => validAmount.value > 0 - && amountFitsMethod(validAmount.value, selectedMethod.value) + && amountFitsMethod(payBaseAmount.value, selectedMethod.value) && selectedLimit.value?.available !== false ) @@ -746,7 +763,7 @@ const canSubmitSubscription = computed(() => ) // Auto-switch to first available method when current selection can't handle the amount -watch(() => [validAmount.value, selectedMethod.value] as const, ([amt, method]) => { +watch(() => [payBaseAmount.value, selectedMethod.value] as const, ([amt, method]) => { if (amt <= 0 || amountFitsMethod(amt, method)) return const available = enabledMethods.value.find((m) => amountFitsMethod(amt, m)) if (available) selectedMethod.value = available