feat(payment): 充值优惠阶梯支持折扣模式,快捷金额角标改为促销价签

- 新增全局模式开关 RECHARGE_BONUS_MODE(bonus / discount),接入支付配置、后台设置接口与 checkout-info
- 折扣模式:到账不变,实付基数按百分比打折(按币种精度取整),手续费 / 每日限额 / 渠道选实例沿用折后基数;
  百分比须 < 100,运行时 ≥ 100 按无优惠处理
- 充值页角标改为单行红色价签(赠金「+20%」/ 折扣「20% OFF」),第二行按模式显示到账或折后实付;
  摘要卡折扣模式显示优惠行,渠道限额校验改用折后实付
- 后台「充值优惠阶梯」拆为独立卡片:赠金 / 折扣切换、紧凑档位表格、区间预览、活动文案
- 补前后端单测与 zh/en 文案
This commit is contained in:
feeeei
2026-10-02 00:25:41 +08:00
parent 1b1039f4fd
commit 1aa34d478d
24 changed files with 780 additions and 259 deletions
@@ -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,
@@ -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))
@@ -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 ||
+1
View File
@@ -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"`
@@ -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"`
@@ -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": "",
@@ -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)
}
+13 -12
View File
@@ -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
@@ -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
@@ -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}))
})
}
+2
View File
@@ -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;
@@ -1,110 +1,177 @@
<template>
<div data-testid="recharge-bonus-tier-editor">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<label class="input-label">
{{ t('admin.settings.payment.rechargeBonus.label') }}
</label>
<p class="mt-0.5 text-xs text-gray-400">
{{ t('admin.settings.payment.rechargeBonus.hint') }}
</p>
<div class="card" data-testid="recharge-bonus-tier-editor">
<!-- Header:与服务商管理卡片同款 -->
<div class="border-b border-gray-100 px-4 py-3 dark:border-dark-700">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<h2 class="text-base font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.payment.rechargeBonus.label') }}
</h2>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.payment.rechargeBonus.hint') }}
</p>
</div>
<button
type="button"
class="btn btn-primary btn-sm shrink-0"
:disabled="tiers.length >= MAX_RECHARGE_BONUS_TIERS"
data-testid="recharge-bonus-tier-add"
@click="addTier"
>
<Icon name="plus" size="sm" class="mr-1" />
{{ t('admin.settings.payment.rechargeBonus.addTier') }}
</button>
</div>
<button
type="button"
class="btn btn-secondary btn-sm shrink-0"
:disabled="tiers.length >= MAX_RECHARGE_BONUS_TIERS"
data-testid="recharge-bonus-tier-add"
@click="addTier"
>
<Icon name="plus" size="sm" class="mr-1" />
{{ t('admin.settings.payment.rechargeBonus.addTier') }}
</button>
</div>
<div
v-if="tiers.length === 0"
class="mt-3 rounded-lg border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-dark-600 dark:text-gray-400"
>
{{ t('admin.settings.payment.rechargeBonus.empty') }}
</div>
<div class="space-y-4 p-4">
<!-- 模式:整条阶梯共用 -->
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.payment.rechargeBonus.modeLabel') }}
</span>
<div
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-dark-600 dark:bg-dark-800"
role="group"
>
<button
v-for="option in modeOptions"
:key="option.value"
type="button"
:class="[
'rounded-md px-3 py-1 text-sm font-medium transition-colors',
currentMode === option.value
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
]"
:aria-pressed="currentMode === option.value"
:data-testid="`recharge-bonus-mode-${option.value}`"
@click="setMode(option.value)"
>
{{ option.label }}
</button>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500">
{{ currentMode === 'discount'
? t('admin.settings.payment.rechargeBonus.modeDiscountHint')
: t('admin.settings.payment.rechargeBonus.modeBonusHint') }}
</span>
</div>
<div v-else class="mt-3 space-y-2">
<!-- 档位列表:紧凑表格 -->
<div
v-for="(tier, index) in tiers"
:key="index"
class="rounded-lg border border-gray-200 px-4 py-3 dark:border-dark-600"
data-testid="recharge-bonus-tier-row"
v-if="tiers.length === 0"
class="rounded-xl border border-dashed border-gray-300 px-4 py-3 text-sm text-gray-500 dark:border-dark-600 dark:text-gray-400"
>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ t('admin.settings.payment.rechargeBonus.minAmountLabel') }}
{{ t('admin.settings.payment.rechargeBonus.empty') }}
</div>
<div v-else class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-600">
<div
class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2rem] items-center gap-3 bg-gray-50 px-3 py-2 text-xs font-medium text-gray-500 dark:bg-dark-800 dark:text-gray-400"
>
<span>{{ t('admin.settings.payment.rechargeBonus.minAmountLabel') }}</span>
<span>
{{ currentMode === 'discount'
? t('admin.settings.payment.rechargeBonus.percentLabelDiscount')
: t('admin.settings.payment.rechargeBonus.percentLabel') }}
</span>
<input
:value="tier.min_amount ?? ''"
type="number"
step="0.01"
min="0"
class="input w-32"
placeholder="100"
data-testid="recharge-bonus-tier-min-input"
@input="onMinAmountInput(index, $event)"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ t('admin.settings.payment.rechargeBonus.percentLabel') }}
</span>
<div class="relative">
<span></span>
</div>
<div class="divide-y divide-gray-100 dark:divide-dark-700">
<div
v-for="(tier, index) in tiers"
:key="index"
class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2rem] items-center gap-3 px-3 py-2"
data-testid="recharge-bonus-tier-row"
>
<input
:value="tier.bonus_percent ?? ''"
:value="tier.min_amount ?? ''"
type="number"
step="0.01"
min="0"
:max="MAX_RECHARGE_BONUS_PERCENT"
class="input w-28 pr-8"
placeholder="20"
data-testid="recharge-bonus-tier-percent-input"
@input="onPercentInput(index, $event)"
class="input py-2"
placeholder="100"
data-testid="recharge-bonus-tier-min-input"
@input="onMinAmountInput(index, $event)"
/>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400">%</span>
<div class="relative">
<input
:value="tier.bonus_percent ?? ''"
type="number"
step="0.01"
min="0"
:max="currentMode === 'discount' ? 99.99 : MAX_RECHARGE_BONUS_PERCENT"
:class="['input py-2', currentMode === 'discount' ? 'pr-16' : 'pr-8']"
placeholder="20"
data-testid="recharge-bonus-tier-percent-input"
@input="onPercentInput(index, $event)"
/>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-xs font-medium"
:class="currentMode === 'discount' ? 'text-red-600 dark:text-red-400' : 'text-gray-400'"
>{{ currentMode === 'discount' ? '% OFF' : '%' }}</span>
</div>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-dark-700 dark:hover:text-red-400"
:title="t('admin.settings.payment.rechargeBonus.removeTier')"
data-testid="recharge-bonus-tier-remove"
@click="removeTier(index)"
>
<Icon name="x" size="sm" />
</button>
<p
v-if="rowError(index)"
class="col-span-3 -mt-1 text-xs text-red-600 dark:text-red-400"
data-testid="recharge-bonus-tier-error"
>
{{ t(`admin.settings.payment.rechargeBonus.${rowError(index)}`) }}
</p>
<p
v-else-if="rowIncomplete(index)"
class="col-span-3 -mt-1 text-xs text-gray-400 dark:text-gray-500"
data-testid="recharge-bonus-tier-incomplete"
>
{{ t('admin.settings.payment.rechargeBonus.incompleteRow') }}
</p>
</div>
<button
type="button"
class="ml-auto rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-dark-700 dark:hover:text-red-400"
:title="t('admin.settings.payment.rechargeBonus.removeTier')"
data-testid="recharge-bonus-tier-remove"
@click="removeTier(index)"
>
<Icon name="x" size="sm" />
</button>
</div>
<p
v-if="rowError(index)"
class="mt-1 text-xs text-red-600 dark:text-red-400"
data-testid="recharge-bonus-tier-error"
>
{{ t(`admin.settings.payment.rechargeBonus.${rowError(index)}`) }}
</p>
<p
v-else-if="rowIncomplete(index)"
class="mt-1 text-xs text-gray-400 dark:text-gray-500"
data-testid="recharge-bonus-tier-incomplete"
>
{{ t('admin.settings.payment.rechargeBonus.incompleteRow') }}
</div>
<!-- 区间预览:单行内联 -->
<p
v-if="intervals.length > 0"
class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500 dark:text-gray-400"
data-testid="recharge-bonus-tier-preview"
>
<span class="font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.payment.rechargeBonus.previewTitle') }}
</span>
<span
v-for="(interval, index) in intervals"
:key="index"
class="rounded-md bg-gray-100 px-2 py-0.5 dark:bg-dark-700"
>{{ intervalLabel(interval) }}</span>
</p>
<!-- 活动文案 -->
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
<label class="input-label">
{{ t('admin.settings.payment.rechargeBonus.noticeLabel') }}
</label>
<textarea
:value="notice ?? ''"
rows="3"
class="input"
:placeholder="t('admin.settings.payment.rechargeBonus.noticePlaceholder')"
data-testid="recharge-bonus-notice-input"
@input="emit('update:notice', ($event.target as HTMLTextAreaElement).value)"
></textarea>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.payment.rechargeBonus.noticeHint') }}
</p>
</div>
</div>
<div
v-if="intervals.length > 0"
class="mt-3 rounded-lg bg-gray-50 px-4 py-3 text-xs text-gray-600 dark:bg-dark-700/60 dark:text-gray-300"
data-testid="recharge-bonus-tier-preview"
>
<p class="font-medium text-gray-700 dark:text-gray-200">
{{ t('admin.settings.payment.rechargeBonus.previewTitle') }}
</p>
<ul class="mt-1 space-y-0.5">
<li v-for="(interval, index) in intervals" :key="index">{{ intervalLabel(interval) }}</li>
</ul>
</div>
</div>
</template>
@@ -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<RechargeBonusMode>(() => 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 })
}
</script>
+57 -14
View File
@@ -5,7 +5,7 @@
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('payment.quickAmounts') }}
</label>
<div class="grid grid-cols-3 gap-3 pt-1">
<div class="grid grid-cols-3 gap-x-4 gap-y-4 pt-2">
<button
v-for="amt in filteredAmounts"
:key="amt"
@@ -14,27 +14,36 @@
'relative rounded-lg border-2 px-3 py-3 text-center font-medium transition-colors',
modelValue === amt
? 'border-primary-500 bg-primary-50 text-primary-700 dark:border-primary-400 dark:bg-primary-900/40 dark:text-primary-300'
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-200 dark:hover:border-dark-500',
: quoteFor(amt).percent > 0
? 'border-red-200 bg-white text-gray-700 hover:border-red-300 dark:border-red-500/40 dark:bg-dark-800 dark:text-gray-200 dark:hover:border-red-400/60'
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-200 dark:hover:border-dark-500',
]"
:data-testid="`quick-amount-${amt}`"
@click="selectAmount(amt)"
>
<!-- 赠送角标:仅命中档位的金额显示 -->
<!-- 促销价签(单行):仅命中档位的金额显示;红底白字、内圈点线、右侧圆孔、整体旋转 -->
<span
v-if="quoteFor(amt).bonus > 0"
class="absolute -right-2 -top-2.5 rounded-full bg-amber-500 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-white shadow-sm dark:bg-amber-400 dark:text-amber-950"
v-if="quoteFor(amt).percent > 0"
class="pointer-events-none absolute -right-2 -top-3 z-10 rotate-12"
data-testid="quick-amount-bonus-badge"
>+{{ formatRechargeBonusNumber(quoteFor(amt).percent) }}%</span>
>
<span
class="relative flex items-center gap-1 whitespace-nowrap rounded bg-red-600 py-0.5 pl-1.5 pr-1 text-[11px] font-extrabold leading-tight tracking-tight text-white shadow-md ring-2 ring-white before:pointer-events-none before:absolute before:inset-[2px] before:rounded-sm before:border before:border-dotted before:border-white/70 dark:bg-red-500 dark:ring-dark-800"
>
<span>{{ badgeText(amt) }}</span>
<span class="h-1 w-1 shrink-0 rounded-full bg-white"></span>
</span>
</span>
<span class="block">{{ amt }}</span>
<!-- 配置了赠送阶梯时,所有按钮都显示到账金额,保持高度一致 -->
<!-- 配置了优惠阶梯时,所有按钮都显示第二行,保持高度一致:赠金显示到账 USD,折扣显示折后实付 -->
<span
v-if="showCredited"
v-if="showSecondLine"
:class="[
'mt-0.5 block text-[11px] font-normal leading-tight',
quoteFor(amt).bonus > 0 ? 'text-amber-600 dark:text-amber-300' : 'text-gray-400 dark:text-gray-500',
quoteFor(amt).percent > 0 ? 'text-red-600 dark:text-red-300' : 'text-gray-400 dark:text-gray-500',
]"
data-testid="quick-amount-credited"
>{{ t('payment.rechargeBonus.creditedShort', { amount: '$' + quoteFor(amt).credited.toFixed(2) }) }}</span>
>{{ secondLine(amt) }}</span>
</button>
</div>
</div>
@@ -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(() => {
@@ -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')
})
})
+12 -3
View File
@@ -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.',
+2
View File
@@ -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',
+13 -4
View File
@@ -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,展示在充值页金额选择区顶部;留空则不展示。',
+2
View File
@@ -317,8 +317,10 @@ export default {
creditedBalance: '到账余额',
rechargeBonus: {
creditedShort: '到账 {amount}',
payShort: '实付 {amount}',
amountLabel: '赠送额度',
amountLabelWithPercent: '赠送额度 (+{percent}%)',
discountLabelWithPercent: '优惠 ({percent}% OFF)',
},
quickAmounts: '快捷金额',
customAmount: '自定义金额',
+2
View File
@@ -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
@@ -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 },
+55 -17
View File
@@ -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[] = []
+16 -21
View File
@@ -8369,27 +8369,6 @@
</div>
</div>
</div>
<!-- Row 3b: 充值赠送阶梯 + 活动文案 -->
<RechargeBonusTierEditor
v-model="form.payment_recharge_bonus_tiers"
/>
<div>
<label class="input-label">{{
t("admin.settings.payment.rechargeBonus.noticeLabel")
}}</label>
<textarea
v-model="form.payment_recharge_bonus_notice"
rows="3"
class="input"
:placeholder="
t('admin.settings.payment.rechargeBonus.noticePlaceholder')
"
data-testid="recharge-bonus-notice-input"
></textarea>
<p class="mt-0.5 text-xs text-gray-400">
{{ t("admin.settings.payment.rechargeBonus.noticeHint") }}
</p>
</div>
<!-- Row 4: Enabled payment types (provider badges like sub2apipay) -->
<div>
<label class="input-label">{{
@@ -8469,6 +8448,14 @@
</div>
</div>
<!-- 充值优惠阶梯(独立卡片,与服务商管理同级) -->
<RechargeBonusTierEditor
v-if="form.payment_enabled"
v-model="form.payment_recharge_bonus_tiers"
v-model:mode="form.payment_recharge_bonus_mode"
v-model:notice="form.payment_recharge_bonus_notice"
/>
<!-- Provider Management -->
<PaymentProviderList
v-if="form.payment_enabled"
@@ -9046,8 +9033,10 @@ import BackupSettings from "@/views/admin/BackupView.vue";
import EmailTemplateEditor from "@/views/admin/settings/EmailTemplateEditor.vue";
import RechargeBonusTierEditor from "@/components/admin/settings/RechargeBonusTierEditor.vue";
import {
normalizeRechargeBonusMode,
normalizeRechargeBonusTiers,
sanitizeRechargeBonusTiersForSubmit,
type RechargeBonusMode,
type RechargeBonusTierDraft,
} from "@/utils/rechargeBonus";
import OpenAIFastPolicyUserSelector from "@/views/admin/settings/OpenAIFastPolicyUserSelector.vue";
@@ -9768,6 +9757,7 @@ type SettingsForm = Omit<
openai_advanced_scheduler_weight_session_sticky: string;
// 充值赠送阶梯编辑态:允许留空的行,提交时清洗为 RechargeBonusTier[]
payment_recharge_bonus_tiers: RechargeBonusTierDraft[];
payment_recharge_bonus_mode: RechargeBonusMode;
payment_recharge_bonus_notice: string;
// 系统全局平台限额 map;form 内始终归一化为全 4 平台对象(模板非空绑定依赖此不变量)
default_platform_quotas: DefaultPlatformQuotasMap;
@@ -9834,6 +9824,7 @@ const form = reactive<SettingsForm>({
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,
@@ -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: "**新活动**",
}),
);
+40 -23
View File
@@ -63,7 +63,9 @@
:min="globalMinAmount"
:max="globalMaxAmount"
:bonus-tiers="rechargeBonusTiers"
:bonus-mode="rechargeBonusMode"
:multiplier="balanceRechargeMultiplier"
:currency="selectedCurrency"
/>
<p v-if="amountError" class="mt-2 text-xs text-amber-600 dark:text-amber-300">{{ amountError }}</p>
</div>
@@ -78,23 +80,27 @@
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.paymentAmount') }}</span>
<span class="text-gray-900 dark:text-white">{{ formatSelectedPaymentAmount(validAmount) }}</span>
<span :class="discountAmount > 0 ? 'text-gray-400 line-through dark:text-gray-500' : 'text-gray-900 dark:text-white'">{{ formatSelectedPaymentAmount(validAmount) }}</span>
</div>
<div v-if="discountAmount > 0" class="flex justify-between" data-testid="recharge-discount-row">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.rechargeBonus.discountLabelWithPercent', { percent: formatRechargeBonusNumber(bonusQuote.percent) }) }}</span>
<span class="font-medium text-red-600 dark:text-red-400">-{{ formatSelectedPaymentAmount(discountAmount) }}</span>
</div>
<div v-if="feeRate > 0" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
<span class="text-gray-900 dark:text-white">{{ formatSelectedPaymentAmount(feeAmount) }}</span>
</div>
<div v-if="feeRate > 0" class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
<div v-if="showActualPay" class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">{{ formatSelectedPaymentAmount(totalAmount) }}</span>
</div>
<div v-if="bonusQuote.bonus > 0" class="flex justify-between" :class="{ 'border-t border-gray-200 pt-2 dark:border-dark-600': feeRate <= 0 }" data-testid="recharge-bonus-row">
<div v-if="showBonusRow" class="flex justify-between" :class="{ 'border-t border-gray-200 pt-2 dark:border-dark-600': !showActualPay }" data-testid="recharge-bonus-row">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.rechargeBonus.amountLabelWithPercent', { percent: formatRechargeBonusNumber(bonusQuote.percent) }) }}</span>
<span class="font-medium text-amber-600 dark:text-amber-400">+${{ bonusQuote.bonus.toFixed(2) }}</span>
<span class="font-medium text-red-600 dark:text-red-400">+${{ bonusQuote.bonus.toFixed(2) }}</span>
</div>
<div v-if="showCreditedBalance" class="flex justify-between" :class="{ 'border-t border-gray-200 pt-2 dark:border-dark-600': feeRate <= 0 && bonusQuote.bonus <= 0 }" data-testid="recharge-credited-row">
<div v-if="showCreditedBalance" class="flex justify-between" :class="{ 'border-t border-gray-200 pt-2 dark:border-dark-600': !showActualPay && !showBonusRow }" data-testid="recharge-credited-row">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.creditedBalance') }}</span>
<span :class="bonusQuote.bonus > 0 ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-900 dark:text-white'">${{ creditedAmount.toFixed(2) }}</span>
<span :class="bonusQuote.percent > 0 ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-900 dark:text-white'">${{ creditedAmount.toFixed(2) }}</span>
</div>
<p v-if="balanceRechargeMultiplier !== 1" class="border-t border-gray-200 pt-2 text-xs text-gray-500 dark:border-dark-600 dark:text-gray-400">
{{ 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<CheckoutInfoResponse>({
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<PaymentMethodOption[]>(() =>
enabledMethods.value.map((type) => {
const ml = visibleMethods.value[type]
@@ -664,41 +680,42 @@ const methodOptions = computed<PaymentMethodOption[]>(() =>
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