From 1b1039f4fd3ffbf169967cf633bec7d8efc41b1c Mon Sep 17 00:00:00 2001 From: feeeei Date: Thu, 1 Oct 2026 23:05:15 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(payment):=20=E5=85=85=E5=80=BC?= =?UTF-8?q?=E8=B5=A0=E9=80=81=E9=98=B6=E6=A2=AF=EF=BC=88=E6=8C=89=E9=87=91?= =?UTF-8?q?=E9=A2=9D=E6=A1=A3=E4=BD=8D=E9=A2=9D=E5=A4=96=E8=B5=A0=E9=80=81?= =?UTF-8?q?=E5=88=B0=E8=B4=A6=E9=A2=9D=E5=BA=A6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后台支付设置新增「充值赠送阶梯」(满 X 送 Y%,RECHARGE_BONUS_TIERS)与 Markdown 活动文案 (RECHARGE_BONUS_NOTICE):阈值按用户输入的支付金额命中(取不超过金额的最大档), 赠送按到账基数(输入 × 充值倍率)计算;严格校验写入、宽松解析读取,订阅订单不参与 - 下单时按当时配置计算赠送并落库:payment_orders 新增 bonus_amount 列(迁移 241), amount 仍为到账总额,到账/兑换码/退款逻辑不变;推广返利基数改为 amount - bonus_amount - checkout-info 下发 recharge_bonus_tiers / recharge_bonus_notice;创建订单与订单查询响应带 bonus_amount - 充值页:快捷金额按钮右上角「+N%」角标 + 第二行到账金额,金额卡顶部渲染活动文案, 订单摘要新增赠送额度行并在有赠送时始终显示到账余额;订单列表/详情/支付结果/成功面板显示含赠送 - 后台新增 RechargeBonusTierEditor(行内校验重复/非法阈值,自动排序并显示区间预览) - 补后端单测、契约测试与前端 vitest,zh/en 文案 --- backend/ent/migrate/schema.go | 19 +- backend/ent/mutation.go | 89 ++++++- backend/ent/paymentorder.go | 13 +- backend/ent/paymentorder/paymentorder.go | 10 + backend/ent/paymentorder/where.go | 45 ++++ backend/ent/paymentorder_create.go | 85 ++++++ backend/ent/paymentorder_update.go | 54 ++++ backend/ent/runtime/runtime.go | 34 +-- backend/ent/schema/payment_order.go | 4 + .../internal/handler/admin/payment_handler.go | 2 + .../internal/handler/admin/setting_handler.go | 2 + .../admin/setting_handler_recharge_bonus.go | 27 ++ .../handler/admin/setting_handler_update.go | 18 +- .../handler/dto/recharge_bonus_tiers.go | 7 + backend/internal/handler/dto/settings.go | 13 +- backend/internal/handler/payment_handler.go | 8 + backend/internal/server/api_contract_test.go | 4 + .../service/payment_config_service.go | 55 +++- .../internal/service/payment_fulfillment.go | 5 +- backend/internal/service/payment_order.go | 13 +- .../payment_order_provider_snapshot_test.go | 1 + .../service/payment_recharge_bonus.go | 197 ++++++++++++++ .../service/payment_recharge_bonus_test.go | 251 ++++++++++++++++++ backend/internal/service/payment_service.go | 1 + .../241_add_payment_order_bonus_amount.sql | 3 + frontend/src/api/admin/settings.ts | 5 + .../admin/payment/AdminOrderDetail.vue | 6 +- .../admin/payment/AdminOrderTable.vue | 5 +- .../settings/RechargeBonusTierEditor.vue | 206 ++++++++++++++ .../src/components/payment/AmountInput.vue | 36 ++- .../src/components/payment/OrderTable.vue | 5 +- .../components/payment/PaymentStatusPanel.vue | 7 +- .../payment/__tests__/AmountInput.spec.ts | 44 ++- .../src/i18n/locales/en/admin/settings.ts | 21 ++ frontend/src/i18n/locales/en/misc.ts | 7 + .../src/i18n/locales/zh/admin/settings.ts | 21 ++ frontend/src/i18n/locales/zh/misc.ts | 7 + frontend/src/types/payment.ts | 13 + .../src/utils/__tests__/rechargeBonus.spec.ts | 111 ++++++++ frontend/src/utils/rechargeBonus.ts | 169 ++++++++++++ frontend/src/views/admin/SettingsView.vue | 40 +++ .../admin/__tests__/SettingsView.spec.ts | 82 ++++++ frontend/src/views/user/PaymentResultView.vue | 12 +- frontend/src/views/user/PaymentView.vue | 32 ++- 44 files changed, 1726 insertions(+), 63 deletions(-) create mode 100644 backend/internal/handler/admin/setting_handler_recharge_bonus.go create mode 100644 backend/internal/handler/dto/recharge_bonus_tiers.go create mode 100644 backend/internal/service/payment_recharge_bonus.go create mode 100644 backend/internal/service/payment_recharge_bonus_test.go create mode 100644 backend/migrations/241_add_payment_order_bonus_amount.sql create mode 100644 frontend/src/components/admin/settings/RechargeBonusTierEditor.vue create mode 100644 frontend/src/utils/__tests__/rechargeBonus.spec.ts create mode 100644 frontend/src/utils/rechargeBonus.ts diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index ceefcc684f..17ee59bdca 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -1128,6 +1128,7 @@ var ( {Name: "amount", Type: field.TypeFloat64, SchemaType: map[string]string{"postgres": "decimal(20,2)"}}, {Name: "pay_amount", Type: field.TypeFloat64, SchemaType: map[string]string{"postgres": "decimal(20,2)"}}, {Name: "fee_rate", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(10,4)"}}, + {Name: "bonus_amount", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,2)"}}, {Name: "recharge_code", Type: field.TypeString, Size: 64}, {Name: "out_trade_no", Type: field.TypeString, Size: 64, Default: ""}, {Name: "payment_type", Type: field.TypeString, Size: 30}, @@ -1170,7 +1171,7 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "payment_orders_users_payment_orders", - Columns: []*schema.Column{PaymentOrdersColumns[39]}, + Columns: []*schema.Column{PaymentOrdersColumns[40]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.NoAction, }, @@ -1179,7 +1180,7 @@ var ( { Name: "paymentorder_out_trade_no", Unique: true, - Columns: []*schema.Column{PaymentOrdersColumns[8]}, + Columns: []*schema.Column{PaymentOrdersColumns[9]}, Annotation: &entsql.IndexAnnotation{ Where: "out_trade_no <> ''", }, @@ -1187,37 +1188,37 @@ var ( { Name: "paymentorder_user_id", Unique: false, - Columns: []*schema.Column{PaymentOrdersColumns[39]}, + Columns: []*schema.Column{PaymentOrdersColumns[40]}, }, { Name: "paymentorder_status", Unique: false, - Columns: []*schema.Column{PaymentOrdersColumns[21]}, + Columns: []*schema.Column{PaymentOrdersColumns[22]}, }, { Name: "paymentorder_expires_at", Unique: false, - Columns: []*schema.Column{PaymentOrdersColumns[29]}, + Columns: []*schema.Column{PaymentOrdersColumns[30]}, }, { Name: "paymentorder_created_at", Unique: false, - Columns: []*schema.Column{PaymentOrdersColumns[37]}, + Columns: []*schema.Column{PaymentOrdersColumns[38]}, }, { Name: "paymentorder_paid_at", Unique: false, - Columns: []*schema.Column{PaymentOrdersColumns[30]}, + Columns: []*schema.Column{PaymentOrdersColumns[31]}, }, { Name: "paymentorder_payment_type_paid_at", Unique: false, - Columns: []*schema.Column{PaymentOrdersColumns[9], PaymentOrdersColumns[30]}, + Columns: []*schema.Column{PaymentOrdersColumns[10], PaymentOrdersColumns[31]}, }, { Name: "paymentorder_order_type", Unique: false, - Columns: []*schema.Column{PaymentOrdersColumns[14]}, + Columns: []*schema.Column{PaymentOrdersColumns[15]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 3367fab567..38fd383f76 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -30113,6 +30113,8 @@ type PaymentOrderMutation struct { addpay_amount *float64 fee_rate *float64 addfee_rate *float64 + bonus_amount *float64 + addbonus_amount *float64 recharge_code *string out_trade_no *string payment_type *string @@ -30580,6 +30582,62 @@ func (m *PaymentOrderMutation) ResetFeeRate() { m.addfee_rate = nil } +// SetBonusAmount sets the "bonus_amount" field. +func (m *PaymentOrderMutation) SetBonusAmount(f float64) { + m.bonus_amount = &f + m.addbonus_amount = nil +} + +// BonusAmount returns the value of the "bonus_amount" field in the mutation. +func (m *PaymentOrderMutation) BonusAmount() (r float64, exists bool) { + v := m.bonus_amount + if v == nil { + return + } + return *v, true +} + +// OldBonusAmount returns the old "bonus_amount" field's value of the PaymentOrder entity. +// If the PaymentOrder object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *PaymentOrderMutation) OldBonusAmount(ctx context.Context) (v float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldBonusAmount is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldBonusAmount requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldBonusAmount: %w", err) + } + return oldValue.BonusAmount, nil +} + +// AddBonusAmount adds f to the "bonus_amount" field. +func (m *PaymentOrderMutation) AddBonusAmount(f float64) { + if m.addbonus_amount != nil { + *m.addbonus_amount += f + } else { + m.addbonus_amount = &f + } +} + +// AddedBonusAmount returns the value that was added to the "bonus_amount" field in this mutation. +func (m *PaymentOrderMutation) AddedBonusAmount() (r float64, exists bool) { + v := m.addbonus_amount + if v == nil { + return + } + return *v, true +} + +// ResetBonusAmount resets all changes to the "bonus_amount" field. +func (m *PaymentOrderMutation) ResetBonusAmount() { + m.bonus_amount = nil + m.addbonus_amount = nil +} + // SetRechargeCode sets the "recharge_code" field. func (m *PaymentOrderMutation) SetRechargeCode(s string) { m.recharge_code = &s @@ -32123,7 +32181,7 @@ func (m *PaymentOrderMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *PaymentOrderMutation) Fields() []string { - fields := make([]string, 0, 39) + fields := make([]string, 0, 40) if m.user != nil { fields = append(fields, paymentorder.FieldUserID) } @@ -32145,6 +32203,9 @@ func (m *PaymentOrderMutation) Fields() []string { if m.fee_rate != nil { fields = append(fields, paymentorder.FieldFeeRate) } + if m.bonus_amount != nil { + fields = append(fields, paymentorder.FieldBonusAmount) + } if m.recharge_code != nil { fields = append(fields, paymentorder.FieldRechargeCode) } @@ -32263,6 +32324,8 @@ func (m *PaymentOrderMutation) Field(name string) (ent.Value, bool) { return m.PayAmount() case paymentorder.FieldFeeRate: return m.FeeRate() + case paymentorder.FieldBonusAmount: + return m.BonusAmount() case paymentorder.FieldRechargeCode: return m.RechargeCode() case paymentorder.FieldOutTradeNo: @@ -32350,6 +32413,8 @@ func (m *PaymentOrderMutation) OldField(ctx context.Context, name string) (ent.V return m.OldPayAmount(ctx) case paymentorder.FieldFeeRate: return m.OldFeeRate(ctx) + case paymentorder.FieldBonusAmount: + return m.OldBonusAmount(ctx) case paymentorder.FieldRechargeCode: return m.OldRechargeCode(ctx) case paymentorder.FieldOutTradeNo: @@ -32472,6 +32537,13 @@ func (m *PaymentOrderMutation) SetField(name string, value ent.Value) error { } m.SetFeeRate(v) return nil + case paymentorder.FieldBonusAmount: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetBonusAmount(v) + return nil case paymentorder.FieldRechargeCode: v, ok := value.(string) if !ok { @@ -32713,6 +32785,9 @@ func (m *PaymentOrderMutation) AddedFields() []string { if m.addfee_rate != nil { fields = append(fields, paymentorder.FieldFeeRate) } + if m.addbonus_amount != nil { + fields = append(fields, paymentorder.FieldBonusAmount) + } if m.addplan_id != nil { fields = append(fields, paymentorder.FieldPlanID) } @@ -32739,6 +32814,8 @@ func (m *PaymentOrderMutation) AddedField(name string) (ent.Value, bool) { return m.AddedPayAmount() case paymentorder.FieldFeeRate: return m.AddedFeeRate() + case paymentorder.FieldBonusAmount: + return m.AddedBonusAmount() case paymentorder.FieldPlanID: return m.AddedPlanID() case paymentorder.FieldSubscriptionGroupID: @@ -32777,6 +32854,13 @@ func (m *PaymentOrderMutation) AddField(name string, value ent.Value) error { } m.AddFeeRate(v) return nil + case paymentorder.FieldBonusAmount: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddBonusAmount(v) + return nil case paymentorder.FieldPlanID: v, ok := value.(int64) if !ok { @@ -32976,6 +33060,9 @@ func (m *PaymentOrderMutation) ResetField(name string) error { case paymentorder.FieldFeeRate: m.ResetFeeRate() return nil + case paymentorder.FieldBonusAmount: + m.ResetBonusAmount() + return nil case paymentorder.FieldRechargeCode: m.ResetRechargeCode() return nil diff --git a/backend/ent/paymentorder.go b/backend/ent/paymentorder.go index b131b8c880..db56994990 100644 --- a/backend/ent/paymentorder.go +++ b/backend/ent/paymentorder.go @@ -33,6 +33,8 @@ type PaymentOrder struct { PayAmount float64 `json:"pay_amount,omitempty"` // FeeRate holds the value of the "fee_rate" field. FeeRate float64 `json:"fee_rate,omitempty"` + // BonusAmount holds the value of the "bonus_amount" field. + BonusAmount float64 `json:"bonus_amount,omitempty"` // RechargeCode holds the value of the "recharge_code" field. RechargeCode string `json:"recharge_code,omitempty"` // OutTradeNo holds the value of the "out_trade_no" field. @@ -132,7 +134,7 @@ func (*PaymentOrder) scanValues(columns []string) ([]any, error) { values[i] = new([]byte) case paymentorder.FieldForceRefund: values[i] = new(sql.NullBool) - case paymentorder.FieldAmount, paymentorder.FieldPayAmount, paymentorder.FieldFeeRate, paymentorder.FieldRefundAmount: + case paymentorder.FieldAmount, paymentorder.FieldPayAmount, paymentorder.FieldFeeRate, paymentorder.FieldBonusAmount, paymentorder.FieldRefundAmount: values[i] = new(sql.NullFloat64) case paymentorder.FieldID, paymentorder.FieldUserID, paymentorder.FieldPlanID, paymentorder.FieldSubscriptionGroupID, paymentorder.FieldSubscriptionDays: values[i] = new(sql.NullInt64) @@ -204,6 +206,12 @@ func (_m *PaymentOrder) assignValues(columns []string, values []any) error { } else if value.Valid { _m.FeeRate = value.Float64 } + case paymentorder.FieldBonusAmount: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field bonus_amount", values[i]) + } else if value.Valid { + _m.BonusAmount = value.Float64 + } case paymentorder.FieldRechargeCode: if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field recharge_code", values[i]) @@ -480,6 +488,9 @@ func (_m *PaymentOrder) String() string { builder.WriteString("fee_rate=") builder.WriteString(fmt.Sprintf("%v", _m.FeeRate)) builder.WriteString(", ") + builder.WriteString("bonus_amount=") + builder.WriteString(fmt.Sprintf("%v", _m.BonusAmount)) + builder.WriteString(", ") builder.WriteString("recharge_code=") builder.WriteString(_m.RechargeCode) builder.WriteString(", ") diff --git a/backend/ent/paymentorder/paymentorder.go b/backend/ent/paymentorder/paymentorder.go index 6288379434..391389b159 100644 --- a/backend/ent/paymentorder/paymentorder.go +++ b/backend/ent/paymentorder/paymentorder.go @@ -28,6 +28,8 @@ const ( FieldPayAmount = "pay_amount" // FieldFeeRate holds the string denoting the fee_rate field in the database. FieldFeeRate = "fee_rate" + // FieldBonusAmount holds the string denoting the bonus_amount field in the database. + FieldBonusAmount = "bonus_amount" // FieldRechargeCode holds the string denoting the recharge_code field in the database. FieldRechargeCode = "recharge_code" // FieldOutTradeNo holds the string denoting the out_trade_no field in the database. @@ -115,6 +117,7 @@ var Columns = []string{ FieldAmount, FieldPayAmount, FieldFeeRate, + FieldBonusAmount, FieldRechargeCode, FieldOutTradeNo, FieldPaymentType, @@ -166,6 +169,8 @@ var ( UserNameValidator func(string) error // DefaultFeeRate holds the default value on creation for the "fee_rate" field. DefaultFeeRate float64 + // DefaultBonusAmount holds the default value on creation for the "bonus_amount" field. + DefaultBonusAmount float64 // RechargeCodeValidator is a validator for the "recharge_code" field. It is called by the builders before save. RechargeCodeValidator func(string) error // DefaultOutTradeNo holds the default value on creation for the "out_trade_no" field. @@ -249,6 +254,11 @@ func ByFeeRate(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldFeeRate, opts...).ToFunc() } +// ByBonusAmount orders the results by the bonus_amount field. +func ByBonusAmount(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldBonusAmount, opts...).ToFunc() +} + // ByRechargeCode orders the results by the recharge_code field. func ByRechargeCode(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldRechargeCode, opts...).ToFunc() diff --git a/backend/ent/paymentorder/where.go b/backend/ent/paymentorder/where.go index e96bf51ebd..33e5267791 100644 --- a/backend/ent/paymentorder/where.go +++ b/backend/ent/paymentorder/where.go @@ -90,6 +90,11 @@ func FeeRate(v float64) predicate.PaymentOrder { return predicate.PaymentOrder(sql.FieldEQ(FieldFeeRate, v)) } +// BonusAmount applies equality check predicate on the "bonus_amount" field. It's identical to BonusAmountEQ. +func BonusAmount(v float64) predicate.PaymentOrder { + return predicate.PaymentOrder(sql.FieldEQ(FieldBonusAmount, v)) +} + // RechargeCode applies equality check predicate on the "recharge_code" field. It's identical to RechargeCodeEQ. func RechargeCode(v string) predicate.PaymentOrder { return predicate.PaymentOrder(sql.FieldEQ(FieldRechargeCode, v)) @@ -590,6 +595,46 @@ func FeeRateLTE(v float64) predicate.PaymentOrder { return predicate.PaymentOrder(sql.FieldLTE(FieldFeeRate, v)) } +// BonusAmountEQ applies the EQ predicate on the "bonus_amount" field. +func BonusAmountEQ(v float64) predicate.PaymentOrder { + return predicate.PaymentOrder(sql.FieldEQ(FieldBonusAmount, v)) +} + +// BonusAmountNEQ applies the NEQ predicate on the "bonus_amount" field. +func BonusAmountNEQ(v float64) predicate.PaymentOrder { + return predicate.PaymentOrder(sql.FieldNEQ(FieldBonusAmount, v)) +} + +// BonusAmountIn applies the In predicate on the "bonus_amount" field. +func BonusAmountIn(vs ...float64) predicate.PaymentOrder { + return predicate.PaymentOrder(sql.FieldIn(FieldBonusAmount, vs...)) +} + +// BonusAmountNotIn applies the NotIn predicate on the "bonus_amount" field. +func BonusAmountNotIn(vs ...float64) predicate.PaymentOrder { + return predicate.PaymentOrder(sql.FieldNotIn(FieldBonusAmount, vs...)) +} + +// BonusAmountGT applies the GT predicate on the "bonus_amount" field. +func BonusAmountGT(v float64) predicate.PaymentOrder { + return predicate.PaymentOrder(sql.FieldGT(FieldBonusAmount, v)) +} + +// BonusAmountGTE applies the GTE predicate on the "bonus_amount" field. +func BonusAmountGTE(v float64) predicate.PaymentOrder { + return predicate.PaymentOrder(sql.FieldGTE(FieldBonusAmount, v)) +} + +// BonusAmountLT applies the LT predicate on the "bonus_amount" field. +func BonusAmountLT(v float64) predicate.PaymentOrder { + return predicate.PaymentOrder(sql.FieldLT(FieldBonusAmount, v)) +} + +// BonusAmountLTE applies the LTE predicate on the "bonus_amount" field. +func BonusAmountLTE(v float64) predicate.PaymentOrder { + return predicate.PaymentOrder(sql.FieldLTE(FieldBonusAmount, v)) +} + // RechargeCodeEQ applies the EQ predicate on the "recharge_code" field. func RechargeCodeEQ(v string) predicate.PaymentOrder { return predicate.PaymentOrder(sql.FieldEQ(FieldRechargeCode, v)) diff --git a/backend/ent/paymentorder_create.go b/backend/ent/paymentorder_create.go index 3ee24f8e91..a8693d0914 100644 --- a/backend/ent/paymentorder_create.go +++ b/backend/ent/paymentorder_create.go @@ -81,6 +81,20 @@ func (_c *PaymentOrderCreate) SetNillableFeeRate(v *float64) *PaymentOrderCreate return _c } +// SetBonusAmount sets the "bonus_amount" field. +func (_c *PaymentOrderCreate) SetBonusAmount(v float64) *PaymentOrderCreate { + _c.mutation.SetBonusAmount(v) + return _c +} + +// SetNillableBonusAmount sets the "bonus_amount" field if the given value is not nil. +func (_c *PaymentOrderCreate) SetNillableBonusAmount(v *float64) *PaymentOrderCreate { + if v != nil { + _c.SetBonusAmount(*v) + } + return _c +} + // SetRechargeCode sets the "recharge_code" field. func (_c *PaymentOrderCreate) SetRechargeCode(v string) *PaymentOrderCreate { _c.mutation.SetRechargeCode(v) @@ -517,6 +531,10 @@ func (_c *PaymentOrderCreate) defaults() { v := paymentorder.DefaultFeeRate _c.mutation.SetFeeRate(v) } + if _, ok := _c.mutation.BonusAmount(); !ok { + v := paymentorder.DefaultBonusAmount + _c.mutation.SetBonusAmount(v) + } if _, ok := _c.mutation.OutTradeNo(); !ok { v := paymentorder.DefaultOutTradeNo _c.mutation.SetOutTradeNo(v) @@ -577,6 +595,9 @@ func (_c *PaymentOrderCreate) check() error { if _, ok := _c.mutation.FeeRate(); !ok { return &ValidationError{Name: "fee_rate", err: errors.New(`ent: missing required field "PaymentOrder.fee_rate"`)} } + if _, ok := _c.mutation.BonusAmount(); !ok { + return &ValidationError{Name: "bonus_amount", err: errors.New(`ent: missing required field "PaymentOrder.bonus_amount"`)} + } if _, ok := _c.mutation.RechargeCode(); !ok { return &ValidationError{Name: "recharge_code", err: errors.New(`ent: missing required field "PaymentOrder.recharge_code"`)} } @@ -725,6 +746,10 @@ func (_c *PaymentOrderCreate) createSpec() (*PaymentOrder, *sqlgraph.CreateSpec) _spec.SetField(paymentorder.FieldFeeRate, field.TypeFloat64, value) _node.FeeRate = value } + if value, ok := _c.mutation.BonusAmount(); ok { + _spec.SetField(paymentorder.FieldBonusAmount, field.TypeFloat64, value) + _node.BonusAmount = value + } if value, ok := _c.mutation.RechargeCode(); ok { _spec.SetField(paymentorder.FieldRechargeCode, field.TypeString, value) _node.RechargeCode = value @@ -1030,6 +1055,24 @@ func (u *PaymentOrderUpsert) AddFeeRate(v float64) *PaymentOrderUpsert { return u } +// SetBonusAmount sets the "bonus_amount" field. +func (u *PaymentOrderUpsert) SetBonusAmount(v float64) *PaymentOrderUpsert { + u.Set(paymentorder.FieldBonusAmount, v) + return u +} + +// UpdateBonusAmount sets the "bonus_amount" field to the value that was provided on create. +func (u *PaymentOrderUpsert) UpdateBonusAmount() *PaymentOrderUpsert { + u.SetExcluded(paymentorder.FieldBonusAmount) + return u +} + +// AddBonusAmount adds v to the "bonus_amount" field. +func (u *PaymentOrderUpsert) AddBonusAmount(v float64) *PaymentOrderUpsert { + u.Add(paymentorder.FieldBonusAmount, v) + return u +} + // SetRechargeCode sets the "recharge_code" field. func (u *PaymentOrderUpsert) SetRechargeCode(v string) *PaymentOrderUpsert { u.Set(paymentorder.FieldRechargeCode, v) @@ -1711,6 +1754,27 @@ func (u *PaymentOrderUpsertOne) UpdateFeeRate() *PaymentOrderUpsertOne { }) } +// SetBonusAmount sets the "bonus_amount" field. +func (u *PaymentOrderUpsertOne) SetBonusAmount(v float64) *PaymentOrderUpsertOne { + return u.Update(func(s *PaymentOrderUpsert) { + s.SetBonusAmount(v) + }) +} + +// AddBonusAmount adds v to the "bonus_amount" field. +func (u *PaymentOrderUpsertOne) AddBonusAmount(v float64) *PaymentOrderUpsertOne { + return u.Update(func(s *PaymentOrderUpsert) { + s.AddBonusAmount(v) + }) +} + +// UpdateBonusAmount sets the "bonus_amount" field to the value that was provided on create. +func (u *PaymentOrderUpsertOne) UpdateBonusAmount() *PaymentOrderUpsertOne { + return u.Update(func(s *PaymentOrderUpsert) { + s.UpdateBonusAmount() + }) +} + // SetRechargeCode sets the "recharge_code" field. func (u *PaymentOrderUpsertOne) SetRechargeCode(v string) *PaymentOrderUpsertOne { return u.Update(func(s *PaymentOrderUpsert) { @@ -2643,6 +2707,27 @@ func (u *PaymentOrderUpsertBulk) UpdateFeeRate() *PaymentOrderUpsertBulk { }) } +// SetBonusAmount sets the "bonus_amount" field. +func (u *PaymentOrderUpsertBulk) SetBonusAmount(v float64) *PaymentOrderUpsertBulk { + return u.Update(func(s *PaymentOrderUpsert) { + s.SetBonusAmount(v) + }) +} + +// AddBonusAmount adds v to the "bonus_amount" field. +func (u *PaymentOrderUpsertBulk) AddBonusAmount(v float64) *PaymentOrderUpsertBulk { + return u.Update(func(s *PaymentOrderUpsert) { + s.AddBonusAmount(v) + }) +} + +// UpdateBonusAmount sets the "bonus_amount" field to the value that was provided on create. +func (u *PaymentOrderUpsertBulk) UpdateBonusAmount() *PaymentOrderUpsertBulk { + return u.Update(func(s *PaymentOrderUpsert) { + s.UpdateBonusAmount() + }) +} + // SetRechargeCode sets the "recharge_code" field. func (u *PaymentOrderUpsertBulk) SetRechargeCode(v string) *PaymentOrderUpsertBulk { return u.Update(func(s *PaymentOrderUpsert) { diff --git a/backend/ent/paymentorder_update.go b/backend/ent/paymentorder_update.go index 378e0dad2f..3ab893cbe6 100644 --- a/backend/ent/paymentorder_update.go +++ b/backend/ent/paymentorder_update.go @@ -154,6 +154,27 @@ func (_u *PaymentOrderUpdate) AddFeeRate(v float64) *PaymentOrderUpdate { return _u } +// SetBonusAmount sets the "bonus_amount" field. +func (_u *PaymentOrderUpdate) SetBonusAmount(v float64) *PaymentOrderUpdate { + _u.mutation.ResetBonusAmount() + _u.mutation.SetBonusAmount(v) + return _u +} + +// SetNillableBonusAmount sets the "bonus_amount" field if the given value is not nil. +func (_u *PaymentOrderUpdate) SetNillableBonusAmount(v *float64) *PaymentOrderUpdate { + if v != nil { + _u.SetBonusAmount(*v) + } + return _u +} + +// AddBonusAmount adds value to the "bonus_amount" field. +func (_u *PaymentOrderUpdate) AddBonusAmount(v float64) *PaymentOrderUpdate { + _u.mutation.AddBonusAmount(v) + return _u +} + // SetRechargeCode sets the "recharge_code" field. func (_u *PaymentOrderUpdate) SetRechargeCode(v string) *PaymentOrderUpdate { _u.mutation.SetRechargeCode(v) @@ -881,6 +902,12 @@ func (_u *PaymentOrderUpdate) sqlSave(ctx context.Context) (_node int, err error if value, ok := _u.mutation.AddedFeeRate(); ok { _spec.AddField(paymentorder.FieldFeeRate, field.TypeFloat64, value) } + if value, ok := _u.mutation.BonusAmount(); ok { + _spec.SetField(paymentorder.FieldBonusAmount, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedBonusAmount(); ok { + _spec.AddField(paymentorder.FieldBonusAmount, field.TypeFloat64, value) + } if value, ok := _u.mutation.RechargeCode(); ok { _spec.SetField(paymentorder.FieldRechargeCode, field.TypeString, value) } @@ -1217,6 +1244,27 @@ func (_u *PaymentOrderUpdateOne) AddFeeRate(v float64) *PaymentOrderUpdateOne { return _u } +// SetBonusAmount sets the "bonus_amount" field. +func (_u *PaymentOrderUpdateOne) SetBonusAmount(v float64) *PaymentOrderUpdateOne { + _u.mutation.ResetBonusAmount() + _u.mutation.SetBonusAmount(v) + return _u +} + +// SetNillableBonusAmount sets the "bonus_amount" field if the given value is not nil. +func (_u *PaymentOrderUpdateOne) SetNillableBonusAmount(v *float64) *PaymentOrderUpdateOne { + if v != nil { + _u.SetBonusAmount(*v) + } + return _u +} + +// AddBonusAmount adds value to the "bonus_amount" field. +func (_u *PaymentOrderUpdateOne) AddBonusAmount(v float64) *PaymentOrderUpdateOne { + _u.mutation.AddBonusAmount(v) + return _u +} + // SetRechargeCode sets the "recharge_code" field. func (_u *PaymentOrderUpdateOne) SetRechargeCode(v string) *PaymentOrderUpdateOne { _u.mutation.SetRechargeCode(v) @@ -1974,6 +2022,12 @@ func (_u *PaymentOrderUpdateOne) sqlSave(ctx context.Context) (_node *PaymentOrd if value, ok := _u.mutation.AddedFeeRate(); ok { _spec.AddField(paymentorder.FieldFeeRate, field.TypeFloat64, value) } + if value, ok := _u.mutation.BonusAmount(); ok { + _spec.SetField(paymentorder.FieldBonusAmount, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedBonusAmount(); ok { + _spec.AddField(paymentorder.FieldBonusAmount, field.TypeFloat64, value) + } if value, ok := _u.mutation.RechargeCode(); ok { _spec.SetField(paymentorder.FieldRechargeCode, field.TypeString, value) } diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 5a718fabca..375e749c73 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1323,70 +1323,74 @@ func init() { paymentorderDescFeeRate := paymentorderFields[6].Descriptor() // paymentorder.DefaultFeeRate holds the default value on creation for the fee_rate field. paymentorder.DefaultFeeRate = paymentorderDescFeeRate.Default.(float64) + // paymentorderDescBonusAmount is the schema descriptor for bonus_amount field. + paymentorderDescBonusAmount := paymentorderFields[7].Descriptor() + // paymentorder.DefaultBonusAmount holds the default value on creation for the bonus_amount field. + paymentorder.DefaultBonusAmount = paymentorderDescBonusAmount.Default.(float64) // paymentorderDescRechargeCode is the schema descriptor for recharge_code field. - paymentorderDescRechargeCode := paymentorderFields[7].Descriptor() + paymentorderDescRechargeCode := paymentorderFields[8].Descriptor() // paymentorder.RechargeCodeValidator is a validator for the "recharge_code" field. It is called by the builders before save. paymentorder.RechargeCodeValidator = paymentorderDescRechargeCode.Validators[0].(func(string) error) // paymentorderDescOutTradeNo is the schema descriptor for out_trade_no field. - paymentorderDescOutTradeNo := paymentorderFields[8].Descriptor() + paymentorderDescOutTradeNo := paymentorderFields[9].Descriptor() // paymentorder.DefaultOutTradeNo holds the default value on creation for the out_trade_no field. paymentorder.DefaultOutTradeNo = paymentorderDescOutTradeNo.Default.(string) // paymentorder.OutTradeNoValidator is a validator for the "out_trade_no" field. It is called by the builders before save. paymentorder.OutTradeNoValidator = paymentorderDescOutTradeNo.Validators[0].(func(string) error) // paymentorderDescPaymentType is the schema descriptor for payment_type field. - paymentorderDescPaymentType := paymentorderFields[9].Descriptor() + paymentorderDescPaymentType := paymentorderFields[10].Descriptor() // paymentorder.PaymentTypeValidator is a validator for the "payment_type" field. It is called by the builders before save. paymentorder.PaymentTypeValidator = paymentorderDescPaymentType.Validators[0].(func(string) error) // paymentorderDescPaymentTradeNo is the schema descriptor for payment_trade_no field. - paymentorderDescPaymentTradeNo := paymentorderFields[10].Descriptor() + paymentorderDescPaymentTradeNo := paymentorderFields[11].Descriptor() // paymentorder.PaymentTradeNoValidator is a validator for the "payment_trade_no" field. It is called by the builders before save. paymentorder.PaymentTradeNoValidator = paymentorderDescPaymentTradeNo.Validators[0].(func(string) error) // paymentorderDescOrderType is the schema descriptor for order_type field. - paymentorderDescOrderType := paymentorderFields[14].Descriptor() + paymentorderDescOrderType := paymentorderFields[15].Descriptor() // paymentorder.DefaultOrderType holds the default value on creation for the order_type field. paymentorder.DefaultOrderType = paymentorderDescOrderType.Default.(string) // paymentorder.OrderTypeValidator is a validator for the "order_type" field. It is called by the builders before save. paymentorder.OrderTypeValidator = paymentorderDescOrderType.Validators[0].(func(string) error) // paymentorderDescProviderInstanceID is the schema descriptor for provider_instance_id field. - paymentorderDescProviderInstanceID := paymentorderFields[18].Descriptor() + paymentorderDescProviderInstanceID := paymentorderFields[19].Descriptor() // paymentorder.ProviderInstanceIDValidator is a validator for the "provider_instance_id" field. It is called by the builders before save. paymentorder.ProviderInstanceIDValidator = paymentorderDescProviderInstanceID.Validators[0].(func(string) error) // paymentorderDescProviderKey is the schema descriptor for provider_key field. - paymentorderDescProviderKey := paymentorderFields[19].Descriptor() + paymentorderDescProviderKey := paymentorderFields[20].Descriptor() // paymentorder.ProviderKeyValidator is a validator for the "provider_key" field. It is called by the builders before save. paymentorder.ProviderKeyValidator = paymentorderDescProviderKey.Validators[0].(func(string) error) // paymentorderDescStatus is the schema descriptor for status field. - paymentorderDescStatus := paymentorderFields[21].Descriptor() + paymentorderDescStatus := paymentorderFields[22].Descriptor() // paymentorder.DefaultStatus holds the default value on creation for the status field. paymentorder.DefaultStatus = paymentorderDescStatus.Default.(string) // paymentorder.StatusValidator is a validator for the "status" field. It is called by the builders before save. paymentorder.StatusValidator = paymentorderDescStatus.Validators[0].(func(string) error) // paymentorderDescRefundAmount is the schema descriptor for refund_amount field. - paymentorderDescRefundAmount := paymentorderFields[22].Descriptor() + paymentorderDescRefundAmount := paymentorderFields[23].Descriptor() // paymentorder.DefaultRefundAmount holds the default value on creation for the refund_amount field. paymentorder.DefaultRefundAmount = paymentorderDescRefundAmount.Default.(float64) // paymentorderDescForceRefund is the schema descriptor for force_refund field. - paymentorderDescForceRefund := paymentorderFields[25].Descriptor() + paymentorderDescForceRefund := paymentorderFields[26].Descriptor() // paymentorder.DefaultForceRefund holds the default value on creation for the force_refund field. paymentorder.DefaultForceRefund = paymentorderDescForceRefund.Default.(bool) // paymentorderDescRefundRequestedBy is the schema descriptor for refund_requested_by field. - paymentorderDescRefundRequestedBy := paymentorderFields[28].Descriptor() + paymentorderDescRefundRequestedBy := paymentorderFields[29].Descriptor() // paymentorder.RefundRequestedByValidator is a validator for the "refund_requested_by" field. It is called by the builders before save. paymentorder.RefundRequestedByValidator = paymentorderDescRefundRequestedBy.Validators[0].(func(string) error) // paymentorderDescClientIP is the schema descriptor for client_ip field. - paymentorderDescClientIP := paymentorderFields[34].Descriptor() + paymentorderDescClientIP := paymentorderFields[35].Descriptor() // paymentorder.ClientIPValidator is a validator for the "client_ip" field. It is called by the builders before save. paymentorder.ClientIPValidator = paymentorderDescClientIP.Validators[0].(func(string) error) // paymentorderDescSrcHost is the schema descriptor for src_host field. - paymentorderDescSrcHost := paymentorderFields[35].Descriptor() + paymentorderDescSrcHost := paymentorderFields[36].Descriptor() // paymentorder.SrcHostValidator is a validator for the "src_host" field. It is called by the builders before save. paymentorder.SrcHostValidator = paymentorderDescSrcHost.Validators[0].(func(string) error) // paymentorderDescCreatedAt is the schema descriptor for created_at field. - paymentorderDescCreatedAt := paymentorderFields[37].Descriptor() + paymentorderDescCreatedAt := paymentorderFields[38].Descriptor() // paymentorder.DefaultCreatedAt holds the default value on creation for the created_at field. paymentorder.DefaultCreatedAt = paymentorderDescCreatedAt.Default.(func() time.Time) // paymentorderDescUpdatedAt is the schema descriptor for updated_at field. - paymentorderDescUpdatedAt := paymentorderFields[38].Descriptor() + paymentorderDescUpdatedAt := paymentorderFields[39].Descriptor() // paymentorder.DefaultUpdatedAt holds the default value on creation for the updated_at field. paymentorder.DefaultUpdatedAt = paymentorderDescUpdatedAt.Default.(func() time.Time) // paymentorder.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. diff --git a/backend/ent/schema/payment_order.go b/backend/ent/schema/payment_order.go index d25d1e5e17..c8ce5f1ed3 100644 --- a/backend/ent/schema/payment_order.go +++ b/backend/ent/schema/payment_order.go @@ -50,6 +50,10 @@ func (PaymentOrder) Fields() []ent.Field { field.Float("fee_rate"). SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}). Default(0), + // 充值赠送额度(USD)。已计入 amount;单独记录用于订单展示与推广返利基数剔除。 + field.Float("bonus_amount"). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,2)"}). + Default(0), field.String("recharge_code"). MaxLen(64), diff --git a/backend/internal/handler/admin/payment_handler.go b/backend/internal/handler/admin/payment_handler.go index 1749d19670..3598635cb5 100644 --- a/backend/internal/handler/admin/payment_handler.go +++ b/backend/internal/handler/admin/payment_handler.go @@ -125,6 +125,7 @@ type AdminPaymentOrderResult struct { Amount float64 `json:"amount"` PayAmount float64 `json:"pay_amount"` FeeRate float64 `json:"fee_rate"` + BonusAmount float64 `json:"bonus_amount"` Currency string `json:"currency"` RechargeCode string `json:"recharge_code,omitempty"` OutTradeNo string `json:"out_trade_no"` @@ -182,6 +183,7 @@ func sanitizeAdminPaymentOrderForResponse(order *dbent.PaymentOrder) *AdminPayme Amount: order.Amount, PayAmount: order.PayAmount, FeeRate: order.FeeRate, + BonusAmount: order.BonusAmount, Currency: service.PaymentOrderCurrency(order), RechargeCode: order.RechargeCode, OutTradeNo: order.OutTradeNo, diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 7bff15c48a..fb05421a8e 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -362,6 +362,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { PaymentBalanceRechargeMultiplier: paymentCfg.BalanceRechargeMultiplier, PaymentSubscriptionUSDToCNYRate: paymentCfg.SubscriptionUSDToCNYRate, PaymentRechargeFeeRate: paymentCfg.RechargeFeeRate, + PaymentRechargeBonusTiers: rechargeBonusTiersToDTO(paymentCfg.RechargeBonusTiers), + PaymentRechargeBonusNotice: paymentCfg.RechargeBonusNotice, PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy, PaymentProductNamePrefix: paymentCfg.ProductNamePrefix, PaymentProductNameSuffix: paymentCfg.ProductNameSuffix, diff --git a/backend/internal/handler/admin/setting_handler_recharge_bonus.go b/backend/internal/handler/admin/setting_handler_recharge_bonus.go new file mode 100644 index 0000000000..d03b2e7e39 --- /dev/null +++ b/backend/internal/handler/admin/setting_handler_recharge_bonus.go @@ -0,0 +1,27 @@ +package admin + +import ( + "github.com/Wei-Shaw/sub2api/internal/handler/dto" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +// rechargeBonusTiersFromDTO 请求 nil 表示未携带该字段(保持现值);空数组表示清空阶梯。 +func rechargeBonusTiersFromDTO(items *[]dto.RechargeBonusTier) *[]service.RechargeBonusTier { + if items == nil { + return nil + } + out := make([]service.RechargeBonusTier, 0, len(*items)) + for _, item := range *items { + out = append(out, service.RechargeBonusTier{MinAmount: item.MinAmount, BonusPercent: item.BonusPercent}) + } + return &out +} + +// rechargeBonusTiersToDTO 始终返回非 nil 切片,空配置输出 []。 +func rechargeBonusTiersToDTO(items []service.RechargeBonusTier) []dto.RechargeBonusTier { + out := make([]dto.RechargeBonusTier, 0, len(items)) + for _, item := range items { + out = append(out, dto.RechargeBonusTier{MinAmount: item.MinAmount, BonusPercent: item.BonusPercent}) + } + return out +} diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 618f0bde5c..ba27da96c6 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -312,11 +312,14 @@ type UpdateSettingsRequest struct { PaymentBalanceRechargeMultiplier *float64 `json:"payment_balance_recharge_multiplier"` PaymentSubscriptionUSDToCNYRate *float64 `json:"payment_subscription_usd_to_cny_rate"` PaymentRechargeFeeRate *float64 `json:"payment_recharge_fee_rate"` - PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"` - PaymentProductNamePrefix *string `json:"payment_product_name_prefix"` - PaymentProductNameSuffix *string `json:"payment_product_name_suffix"` - PaymentHelpImageURL *string `json:"payment_help_image_url"` - PaymentHelpText *string `json:"payment_help_text"` + // nil 表示不更新;空数组表示清空阶梯 + PaymentRechargeBonusTiers *[]dto.RechargeBonusTier `json:"payment_recharge_bonus_tiers"` + PaymentRechargeBonusNotice *string `json:"payment_recharge_bonus_notice"` + PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"` + PaymentProductNamePrefix *string `json:"payment_product_name_prefix"` + PaymentProductNameSuffix *string `json:"payment_product_name_suffix"` + PaymentHelpImageURL *string `json:"payment_help_image_url"` + PaymentHelpText *string `json:"payment_help_text"` // Cancel rate limit PaymentCancelRateLimitEnabled *bool `json:"payment_cancel_rate_limit_enabled"` @@ -2128,6 +2131,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier, SubscriptionUSDToCNYRate: req.PaymentSubscriptionUSDToCNYRate, RechargeFeeRate: req.PaymentRechargeFeeRate, + RechargeBonusTiers: rechargeBonusTiersFromDTO(req.PaymentRechargeBonusTiers), + RechargeBonusNotice: req.PaymentRechargeBonusNotice, LoadBalanceStrategy: req.PaymentLoadBalanceStrat, ProductNamePrefix: req.PaymentProductNamePrefix, ProductNameSuffix: req.PaymentProductNameSuffix, @@ -2407,6 +2412,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { PaymentBalanceRechargeMultiplier: updatedPaymentCfg.BalanceRechargeMultiplier, PaymentSubscriptionUSDToCNYRate: updatedPaymentCfg.SubscriptionUSDToCNYRate, PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate, + PaymentRechargeBonusTiers: rechargeBonusTiersToDTO(updatedPaymentCfg.RechargeBonusTiers), + PaymentRechargeBonusNotice: updatedPaymentCfg.RechargeBonusNotice, PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy, PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix, PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix, @@ -2483,6 +2490,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.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil || req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil || req.PaymentHelpText != nil || req.PaymentCancelRateLimitEnabled != nil || diff --git a/backend/internal/handler/dto/recharge_bonus_tiers.go b/backend/internal/handler/dto/recharge_bonus_tiers.go new file mode 100644 index 0000000000..21731566a8 --- /dev/null +++ b/backend/internal/handler/dto/recharge_bonus_tiers.go @@ -0,0 +1,7 @@ +package dto + +// RechargeBonusTier 充值赠送档位:余额充值支付金额 ≥ MinAmount 时,在到账基数上赠送 BonusPercent%。 +type RechargeBonusTier struct { + MinAmount float64 `json:"min_amount"` + BonusPercent float64 `json:"bonus_percent"` +} diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 4f16f11d45..aa1477a1be 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -279,11 +279,14 @@ type SystemSettings struct { PaymentBalanceRechargeMultiplier float64 `json:"payment_balance_recharge_multiplier"` PaymentSubscriptionUSDToCNYRate float64 `json:"payment_subscription_usd_to_cny_rate"` PaymentRechargeFeeRate float64 `json:"payment_recharge_fee_rate"` - PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"` - PaymentProductNamePrefix string `json:"payment_product_name_prefix"` - PaymentProductNameSuffix string `json:"payment_product_name_suffix"` - PaymentHelpImageURL string `json:"payment_help_image_url"` - PaymentHelpText string `json:"payment_help_text"` + // 充值赠送阶梯与活动文案 + PaymentRechargeBonusTiers []RechargeBonusTier `json:"payment_recharge_bonus_tiers"` + PaymentRechargeBonusNotice string `json:"payment_recharge_bonus_notice"` + PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"` + PaymentProductNamePrefix string `json:"payment_product_name_prefix"` + PaymentProductNameSuffix string `json:"payment_product_name_suffix"` + PaymentHelpImageURL string `json:"payment_help_image_url"` + PaymentHelpText string `json:"payment_help_text"` // Cancel rate limit PaymentCancelRateLimitEnabled bool `json:"payment_cancel_rate_limit_enabled"` diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 9aab325504..3371fac39f 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -149,6 +149,8 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) { BalanceRechargeMultiplier: cfg.BalanceRechargeMultiplier, SubscriptionUSDToCNYRate: cfg.SubscriptionUSDToCNYRate, RechargeFeeRate: cfg.RechargeFeeRate, + RechargeBonusTiers: cfg.RechargeBonusTiers, + RechargeBonusNotice: cfg.RechargeBonusNotice, HelpText: cfg.HelpText, HelpImageURL: cfg.HelpImageURL, StripePublishableKey: cfg.StripePublishableKey, @@ -166,6 +168,8 @@ type checkoutInfoResponse struct { BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"` SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"` RechargeFeeRate float64 `json:"recharge_fee_rate"` + RechargeBonusTiers []service.RechargeBonusTier `json:"recharge_bonus_tiers"` + RechargeBonusNotice string `json:"recharge_bonus_notice"` HelpText string `json:"help_text"` HelpImageURL string `json:"help_image_url"` StripePublishableKey string `json:"stripe_publishable_key"` @@ -482,6 +486,7 @@ type PublicOrderResult struct { Amount float64 `json:"amount"` PayAmount float64 `json:"pay_amount"` FeeRate float64 `json:"fee_rate"` + BonusAmount float64 `json:"bonus_amount"` Currency string `json:"currency"` PaymentType string `json:"payment_type"` OrderType string `json:"order_type"` @@ -517,6 +522,7 @@ func buildPublicOrderResult(order *dbent.PaymentOrder) PublicOrderResult { Amount: order.Amount, PayAmount: order.PayAmount, FeeRate: order.FeeRate, + BonusAmount: order.BonusAmount, Currency: service.PaymentOrderCurrency(order), PaymentType: order.PaymentType, OrderType: order.OrderType, @@ -625,6 +631,7 @@ type PaymentOrderResult struct { Amount float64 `json:"amount"` PayAmount float64 `json:"pay_amount"` FeeRate float64 `json:"fee_rate"` + BonusAmount float64 `json:"bonus_amount"` Currency string `json:"currency"` PaymentType string `json:"payment_type"` OutTradeNo string `json:"out_trade_no"` @@ -663,6 +670,7 @@ func sanitizePaymentOrderForResponse(order *dbent.PaymentOrder) *PaymentOrderRes Amount: order.Amount, PayAmount: order.PayAmount, FeeRate: order.FeeRate, + BonusAmount: order.BonusAmount, Currency: service.PaymentOrderCurrency(order), PaymentType: order.PaymentType, OutTradeNo: order.OutTradeNo, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 3d7b757b4e..f420e4d2b5 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -971,6 +971,8 @@ func TestAPIContracts(t *testing.T) { "payment_balance_recharge_multiplier": 0, "payment_subscription_usd_to_cny_rate": 0, "payment_recharge_fee_rate": 0, + "payment_recharge_bonus_tiers": [], + "payment_recharge_bonus_notice": "", "payment_load_balance_strategy": "", "payment_product_name_prefix": "", "payment_product_name_suffix": "", @@ -1291,6 +1293,8 @@ func TestAPIContracts(t *testing.T) { "payment_balance_recharge_multiplier": 0, "payment_subscription_usd_to_cny_rate": 0, "payment_recharge_fee_rate": 0, + "payment_recharge_bonus_tiers": [], + "payment_recharge_bonus_notice": "", "payment_load_balance_strategy": "", "payment_product_name_prefix": "", "payment_product_name_suffix": "", diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index aac1cc8e26..ab28c3efa0 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -62,12 +62,16 @@ type PaymentConfig struct { // SubscriptionUSDToCNYRate 为 0 时订阅换算关闭(兼容存量行为)。 SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"` RechargeFeeRate float64 `json:"recharge_fee_rate"` - LoadBalanceStrategy string `json:"load_balance_strategy"` - ProductNamePrefix string `json:"product_name_prefix"` - ProductNameSuffix string `json:"product_name_suffix"` - HelpImageURL string `json:"help_image_url"` - HelpText string `json:"help_text"` - StripePublishableKey string `json:"stripe_publishable_key,omitempty"` + // RechargeBonusTiers 余额充值赠送阶梯(按 MinAmount 升序);空表示不赠送。 + RechargeBonusTiers []RechargeBonusTier `json:"recharge_bonus_tiers"` + // RechargeBonusNotice 充值页展示的 Markdown 活动文案;空表示不展示。 + RechargeBonusNotice string `json:"recharge_bonus_notice"` + LoadBalanceStrategy string `json:"load_balance_strategy"` + ProductNamePrefix string `json:"product_name_prefix"` + ProductNameSuffix string `json:"product_name_suffix"` + HelpImageURL string `json:"help_image_url"` + HelpText string `json:"help_text"` + StripePublishableKey string `json:"stripe_publishable_key,omitempty"` // Cancel rate limit settings CancelRateLimitEnabled bool `json:"cancel_rate_limit_enabled"` @@ -95,11 +99,14 @@ type UpdatePaymentConfigRequest struct { BalanceRechargeMultiplier *float64 `json:"balance_recharge_multiplier"` SubscriptionUSDToCNYRate *float64 `json:"subscription_usd_to_cny_rate"` RechargeFeeRate *float64 `json:"recharge_fee_rate"` - LoadBalanceStrategy *string `json:"load_balance_strategy"` - ProductNamePrefix *string `json:"product_name_prefix"` - ProductNameSuffix *string `json:"product_name_suffix"` - HelpImageURL *string `json:"help_image_url"` - HelpText *string `json:"help_text"` + // RechargeBonusTiers nil 表示不更新;空切片表示清空阶梯。 + RechargeBonusTiers *[]RechargeBonusTier `json:"recharge_bonus_tiers"` + RechargeBonusNotice *string `json:"recharge_bonus_notice"` + LoadBalanceStrategy *string `json:"load_balance_strategy"` + ProductNamePrefix *string `json:"product_name_prefix"` + ProductNameSuffix *string `json:"product_name_suffix"` + HelpImageURL *string `json:"help_image_url"` + HelpText *string `json:"help_text"` // Cancel rate limit settings CancelRateLimitEnabled *bool `json:"cancel_rate_limit_enabled"` @@ -220,6 +227,7 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo SettingPaymentEnabled, SettingMinRechargeAmount, SettingMaxRechargeAmount, SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders, SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingBalanceRechargeMult, SettingSubscriptionUSDToCNYRate, SettingRechargeFeeRate, SettingLoadBalanceStrategy, + SettingRechargeBonusTiers, SettingRechargeBonusNotice, SettingProductNamePrefix, SettingProductNameSuffix, SettingHelpImageURL, SettingHelpText, SettingCancelRateLimitOn, SettingCancelRateLimitMax, @@ -250,6 +258,8 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme BalanceRechargeMultiplier: normalizeBalanceRechargeMultiplier(pcParseFloat(vals[SettingBalanceRechargeMult], defaultBalanceRechargeMultiplier)), SubscriptionUSDToCNYRate: normalizeSubscriptionUSDToCNYRate(pcParseFloat(vals[SettingSubscriptionUSDToCNYRate], 0)), RechargeFeeRate: pcParseFloat(vals[SettingRechargeFeeRate], 0), + RechargeBonusTiers: parseRechargeBonusTiers(vals[SettingRechargeBonusTiers]), + RechargeBonusNotice: vals[SettingRechargeBonusNotice], LoadBalanceStrategy: vals[SettingLoadBalanceStrategy], ProductNamePrefix: vals[SettingProductNamePrefix], ProductNameSuffix: vals[SettingProductNameSuffix], @@ -343,6 +353,23 @@ 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 + } + if req.RechargeBonusNotice != nil { + if err := validateRechargeBonusNotice(*req.RechargeBonusNotice); err != nil { + return infraerrors.BadRequest("INVALID_RECHARGE_BONUS_NOTICE", err.Error()) + } + } m := make(map[string]string) if req.Enabled != nil { m[SettingPaymentEnabled] = formatBoolOrEmpty(req.Enabled) @@ -377,6 +404,12 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda if req.RechargeFeeRate != nil { m[SettingRechargeFeeRate] = formatNonNegativeFloat(req.RechargeFeeRate) } + if req.RechargeBonusTiers != nil { + m[SettingRechargeBonusTiers] = rechargeBonusTiersValue + } + if req.RechargeBonusNotice != nil { + m[SettingRechargeBonusNotice] = strings.TrimSpace(*req.RechargeBonusNotice) + } if req.LoadBalanceStrategy != nil { m[SettingLoadBalanceStrategy] = derefStr(req.LoadBalanceStrategy) } diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index 2b70a90ef6..4c363d3e03 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -741,7 +741,10 @@ func affiliateRebateBaseAmount(o *dbent.PaymentOrder) float64 { return 0 } switch o.OrderType { - case payment.OrderTypeBalance, payment.OrderTypeSubscription: + case payment.OrderTypeBalance: + // 返利只按实充部分计算,赠送额度不参与 + return paymentOrderAmountWithoutBonus(o) + case payment.OrderTypeSubscription: return o.Amount default: return 0 diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index da7178dd7f..be28657015 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -55,11 +55,15 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest } orderAmount := req.Amount limitAmount := req.Amount + bonusAmount := 0.0 if plan != nil { orderAmount = plan.Price limitAmount = plan.Price } else if req.OrderType == payment.OrderTypeBalance { - orderAmount = calculateCreditedBalance(req.Amount, cfg.BalanceRechargeMultiplier) + // 到账 = 基数(支付金额 × 倍率)+ 赠送(按支付金额命中阶梯,按基数计百分比) + baseCredited := calculateCreditedBalance(req.Amount, cfg.BalanceRechargeMultiplier) + bonusAmount, _ = resolveRechargeBonus(cfg.RechargeBonusTiers, req.Amount, baseCredited) + orderAmount = addRechargeBonus(baseCredited, bonusAmount) } feeRate := cfg.RechargeFeeRate methodCurrency := payment.DefaultPaymentCurrency @@ -100,7 +104,7 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest if oauthResp != nil { return oauthResp, nil } - order, err := s.createOrderInTx(ctx, req, user, plan, cfg, orderAmount, limitAmount, feeRate, payAmount, sel) + order, err := s.createOrderInTx(ctx, req, user, plan, cfg, orderAmount, limitAmount, feeRate, payAmount, bonusAmount, sel) if err != nil { return nil, err } @@ -149,7 +153,7 @@ func (s *PaymentService) validateSubOrder(ctx context.Context, req CreateOrderRe return plan, nil } -func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderRequest, user *User, plan *dbent.SubscriptionPlan, cfg *PaymentConfig, orderAmount, limitAmount, feeRate, payAmount float64, sel *payment.InstanceSelection) (*dbent.PaymentOrder, error) { +func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderRequest, user *User, plan *dbent.SubscriptionPlan, cfg *PaymentConfig, orderAmount, limitAmount, feeRate, payAmount, bonusAmount float64, sel *payment.InstanceSelection) (*dbent.PaymentOrder, error) { tx, err := s.entClient.Tx(ctx) if err != nil { return nil, fmt.Errorf("begin transaction: %w", err) @@ -185,6 +189,7 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq SetAmount(orderAmount). SetPayAmount(payAmount). SetFeeRate(feeRate). + SetBonusAmount(bonusAmount). SetRechargeCode(""). SetOutTradeNo(outTradeNo). SetPaymentType(req.PaymentType). @@ -471,6 +476,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen s.writeAuditLog(ctx, order.ID, "ORDER_CREATED", fmt.Sprintf("user:%d", req.UserID), map[string]any{ "paymentAmount": req.Amount, "creditedAmount": order.Amount, + "bonusAmount": order.BonusAmount, "payAmount": order.PayAmount, "paymentType": req.PaymentType, "orderType": req.OrderType, @@ -735,6 +741,7 @@ func buildCreateOrderResponse(order *dbent.PaymentOrder, req CreateOrderRequest, Amount: order.Amount, PayAmount: payAmount, FeeRate: order.FeeRate, + BonusAmount: order.BonusAmount, Status: OrderStatusPending, ResultType: resultType, PaymentType: req.PaymentType, diff --git a/backend/internal/service/payment_order_provider_snapshot_test.go b/backend/internal/service/payment_order_provider_snapshot_test.go index 127202bc23..19047959ed 100644 --- a/backend/internal/service/payment_order_provider_snapshot_test.go +++ b/backend/internal/service/payment_order_provider_snapshot_test.go @@ -88,6 +88,7 @@ func TestCreateOrderInTx_WritesProviderSnapshot(t *testing.T) { 88, 0, 88, + 0, &payment.InstanceSelection{ InstanceID: strconv.FormatInt(instance.ID, 10), ProviderKey: payment.TypeAlipay, diff --git a/backend/internal/service/payment_recharge_bonus.go b/backend/internal/service/payment_recharge_bonus.go new file mode 100644 index 0000000000..a3598bc6a7 --- /dev/null +++ b/backend/internal/service/payment_recharge_bonus.go @@ -0,0 +1,197 @@ +package service + +import ( + "encoding/json" + "fmt" + "log/slog" + "math" + "sort" + "strings" + "unicode/utf8" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/payment" + "github.com/shopspring/decimal" +) + +// 充值赠送阶梯:余额充值订单按用户输入的支付金额命中阶梯(取不超过该金额的最大 MinAmount), +// 在到账基数(输入金额 × 充值倍率)之上额外赠送 BonusPercent% 的 USD 余额。 +// 订阅订单不参与。赠送在下单时按当时配置计算并落到 payment_orders.bonus_amount,后续改配置不影响已建订单。 +const ( + // SettingRechargeBonusTiers 存 JSON 数组(RechargeBonusTier 列表),空/缺失表示未启用赠送。 + SettingRechargeBonusTiers = "RECHARGE_BONUS_TIERS" + // SettingRechargeBonusNotice 充值页金额卡顶部展示的 Markdown 活动文案,空表示不展示。 + SettingRechargeBonusNotice = "RECHARGE_BONUS_NOTICE" +) + +const ( + maxRechargeBonusTiers = 20 + maxRechargeBonusPercent = 1000 + maxRechargeBonusNoticeRunes = 10000 + rechargeBonusAmountEpsilon = 1e-9 +) + +// RechargeBonusTier 一个赠送档位:支付金额 ≥ MinAmount 时赠送到账基数的 BonusPercent%。 +type RechargeBonusTier struct { + MinAmount float64 `json:"min_amount"` + BonusPercent float64 `json:"bonus_percent"` +} + +func rechargeBonusValueValid(v float64, max float64) bool { + if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > max { + return false + } + d := decimal.NewFromFloat(v) + return d.Equal(d.Round(2)) +} + +// NormalizeRechargeBonusTiers 严格归一化(写路径):任何非法项直接报错; +// 成功时返回按 MinAmount 升序排序的副本。 +func NormalizeRechargeBonusTiers(raw []RechargeBonusTier) ([]RechargeBonusTier, error) { + if len(raw) == 0 { + return []RechargeBonusTier{}, nil + } + if len(raw) > maxRechargeBonusTiers { + return nil, fmt.Errorf("recharge bonus tiers exceed limit of %d", maxRechargeBonusTiers) + } + out := make([]RechargeBonusTier, 0, len(raw)) + seen := make(map[string]struct{}, len(raw)) + for _, tier := range raw { + if !rechargeBonusValueValid(tier.MinAmount, math.MaxFloat64) { + return nil, fmt.Errorf("recharge bonus tier min amount must be a non-negative number with at most 2 decimals") + } + if !rechargeBonusValueValid(tier.BonusPercent, maxRechargeBonusPercent) { + return nil, fmt.Errorf("recharge bonus tier percent must be between 0 and %d with at most 2 decimals", maxRechargeBonusPercent) + } + key := decimal.NewFromFloat(tier.MinAmount).Round(2).String() + if _, dup := seen[key]; dup { + return nil, fmt.Errorf("duplicate recharge bonus tier min amount: %s", key) + } + seen[key] = struct{}{} + out = append(out, RechargeBonusTier{MinAmount: tier.MinAmount, BonusPercent: tier.BonusPercent}) + } + sortRechargeBonusTiers(out) + return out, nil +} + +func sortRechargeBonusTiers(tiers []RechargeBonusTier) { + sort.SliceStable(tiers, func(i, j int) bool { + return tiers[i].MinAmount < tiers[j].MinAmount + }) +} + +// encodeRechargeBonusTiers 序列化为设置值;空列表存空串,与「未配置」保持同一形态。 +func encodeRechargeBonusTiers(tiers []RechargeBonusTier) (string, error) { + if len(tiers) == 0 { + return "", nil + } + raw, err := json.Marshal(tiers) + if err != nil { + return "", fmt.Errorf("marshal recharge bonus tiers: %w", err) + } + return string(raw), nil +} + +// parseRechargeBonusTiers 宽松解析(读路径):非法条目丢弃而非报错,避免历史错配置阻断下单。 +// 同一 MinAmount 重复时保留先出现的档位。始终返回非 nil 切片,便于 JSON 输出为 []。 +func parseRechargeBonusTiers(raw string) []RechargeBonusTier { + out := make([]RechargeBonusTier, 0) + raw = strings.TrimSpace(raw) + if raw == "" { + return out + } + var items []RechargeBonusTier + if err := json.Unmarshal([]byte(raw), &items); err != nil { + slog.Warn("[Payment] parseRechargeBonusTiers: unmarshal failed", "error", err) + return out + } + seen := make(map[string]struct{}, len(items)) + for _, tier := range items { + if !rechargeBonusValueValid(tier.MinAmount, math.MaxFloat64) || !rechargeBonusValueValid(tier.BonusPercent, maxRechargeBonusPercent) { + continue + } + key := decimal.NewFromFloat(tier.MinAmount).Round(2).String() + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + out = append(out, tier) + } + sortRechargeBonusTiers(out) + return out +} + +func validateRechargeBonusNotice(notice string) error { + if utf8.RuneCountInString(notice) > maxRechargeBonusNoticeRunes { + return fmt.Errorf("recharge bonus notice exceeds %d characters", maxRechargeBonusNoticeRunes) + } + return nil +} + +// matchRechargeBonusTier 返回不超过 paymentAmount 的最大档位;tiers 需已按 MinAmount 升序。 +func matchRechargeBonusTier(tiers []RechargeBonusTier, paymentAmount float64) (RechargeBonusTier, bool) { + if math.IsNaN(paymentAmount) || math.IsInf(paymentAmount, 0) || paymentAmount <= 0 { + return RechargeBonusTier{}, false + } + var matched RechargeBonusTier + found := false + for _, tier := range tiers { + if paymentAmount+rechargeBonusAmountEpsilon < tier.MinAmount { + break + } + matched = tier + found = true + } + return matched, found +} + +// calculateRechargeBonus 赠送金额 = 到账基数 × 百分比,保留两位小数(四舍五入)。 +func calculateRechargeBonus(baseCredited, bonusPercent float64) float64 { + if baseCredited <= 0 || bonusPercent <= 0 || math.IsNaN(baseCredited) || math.IsNaN(bonusPercent) { + return 0 + } + return decimal.NewFromFloat(baseCredited). + Mul(decimal.NewFromFloat(bonusPercent)). + Div(decimal.NewFromInt(100)). + Round(2). + 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). + Add(decimal.NewFromFloat(bonus)). + Round(2). + InexactFloat64() +} + +// paymentOrderAmountWithoutBonus 订单到账金额剔除赠送后的实充部分(USD),用于推广返利基数。 +func paymentOrderAmountWithoutBonus(o *dbent.PaymentOrder) float64 { + if o == nil { + return 0 + } + if o.OrderType != payment.OrderTypeBalance || o.BonusAmount <= 0 { + return o.Amount + } + base := decimal.NewFromFloat(o.Amount). + Sub(decimal.NewFromFloat(o.BonusAmount)). + Round(2). + InexactFloat64() + if base < 0 { + return 0 + } + return base +} diff --git a/backend/internal/service/payment_recharge_bonus_test.go b/backend/internal/service/payment_recharge_bonus_test.go new file mode 100644 index 0000000000..d0b459e4c2 --- /dev/null +++ b/backend/internal/service/payment_recharge_bonus_test.go @@ -0,0 +1,251 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/payment" + "github.com/stretchr/testify/require" +) + +func TestNormalizeRechargeBonusTiers(t *testing.T) { + t.Run("sorts ascending by min amount", func(t *testing.T) { + out, err := NormalizeRechargeBonusTiers([]RechargeBonusTier{ + {MinAmount: 1000, BonusPercent: 35}, + {MinAmount: 100, BonusPercent: 20}, + {MinAmount: 500, BonusPercent: 30}, + }) + require.NoError(t, err) + require.Equal(t, []RechargeBonusTier{ + {MinAmount: 100, BonusPercent: 20}, + {MinAmount: 500, BonusPercent: 30}, + {MinAmount: 1000, BonusPercent: 35}, + }, out) + }) + + t.Run("empty input yields empty non-nil slice", func(t *testing.T) { + out, err := NormalizeRechargeBonusTiers(nil) + require.NoError(t, err) + require.NotNil(t, out) + require.Len(t, out, 0) + }) + + t.Run("allows zero threshold and zero percent", func(t *testing.T) { + out, err := NormalizeRechargeBonusTiers([]RechargeBonusTier{{MinAmount: 0, BonusPercent: 0}}) + require.NoError(t, err) + require.Len(t, out, 1) + }) + + t.Run("rejects invalid values", func(t *testing.T) { + cases := map[string][]RechargeBonusTier{ + "negative min": {{MinAmount: -1, BonusPercent: 10}}, + "min three decimals": {{MinAmount: 100.123, BonusPercent: 10}}, + "negative percent": {{MinAmount: 100, BonusPercent: -5}}, + "percent over limit": {{MinAmount: 100, BonusPercent: 1000.01}}, + "percent 3 decimals": {{MinAmount: 100, BonusPercent: 12.345}}, + "duplicate min": {{MinAmount: 100, BonusPercent: 10}, {MinAmount: 100, BonusPercent: 20}}, + "duplicate min 2 dec": {{MinAmount: 100, BonusPercent: 10}, {MinAmount: 100.00, BonusPercent: 20}}, + } + for name, tiers := range cases { + _, err := NormalizeRechargeBonusTiers(tiers) + require.Error(t, err, name) + } + }) + + t.Run("rejects too many tiers", func(t *testing.T) { + tiers := make([]RechargeBonusTier, 0, maxRechargeBonusTiers+1) + for i := 0; i <= maxRechargeBonusTiers; i++ { + tiers = append(tiers, RechargeBonusTier{MinAmount: float64(i + 1), BonusPercent: 1}) + } + _, err := NormalizeRechargeBonusTiers(tiers) + require.Error(t, err) + }) +} + +func TestParseRechargeBonusTiers(t *testing.T) { + t.Run("empty or invalid json yields empty slice", func(t *testing.T) { + require.NotNil(t, parseRechargeBonusTiers("")) + require.Len(t, parseRechargeBonusTiers(""), 0) + require.Len(t, parseRechargeBonusTiers("not json"), 0) + }) + + t.Run("drops invalid entries keeps first duplicate and sorts", func(t *testing.T) { + raw := `[{"min_amount":500,"bonus_percent":30},{"min_amount":-1,"bonus_percent":5},` + + `{"min_amount":100,"bonus_percent":20},{"min_amount":100,"bonus_percent":99},` + + `{"min_amount":50,"bonus_percent":5000}]` + out := parseRechargeBonusTiers(raw) + require.Equal(t, []RechargeBonusTier{ + {MinAmount: 100, BonusPercent: 20}, + {MinAmount: 500, BonusPercent: 30}, + }, out) + }) + + t.Run("round trips encode", func(t *testing.T) { + tiers := []RechargeBonusTier{{MinAmount: 100, BonusPercent: 20}, {MinAmount: 500, BonusPercent: 30}} + encoded, err := encodeRechargeBonusTiers(tiers) + require.NoError(t, err) + require.Equal(t, tiers, parseRechargeBonusTiers(encoded)) + + empty, err := encodeRechargeBonusTiers(nil) + require.NoError(t, err) + require.Equal(t, "", empty) + }) +} + +func TestMatchRechargeBonusTier(t *testing.T) { + tiers := []RechargeBonusTier{ + {MinAmount: 100, BonusPercent: 20}, + {MinAmount: 500, BonusPercent: 30}, + {MinAmount: 1000, BonusPercent: 35}, + } + cases := []struct { + amount float64 + percent float64 + ok bool + }{ + {amount: 0, ok: false}, + {amount: 99.99, ok: false}, + {amount: 100, percent: 20, ok: true}, + {amount: 499.99, percent: 20, ok: true}, + {amount: 500, percent: 30, ok: true}, + {amount: 1000, percent: 35, ok: true}, + {amount: 1000000, percent: 35, ok: true}, + } + for _, tc := range cases { + tier, ok := matchRechargeBonusTier(tiers, tc.amount) + require.Equal(t, tc.ok, ok, "amount %v", tc.amount) + if ok { + require.Equal(t, tc.percent, tier.BonusPercent, "amount %v", tc.amount) + } + } + + t.Run("float boundary 0.1+0.2 still matches 0.3 threshold", func(t *testing.T) { + _, ok := matchRechargeBonusTier([]RechargeBonusTier{{MinAmount: 0.3, BonusPercent: 1}}, 0.1+0.2) + require.True(t, ok) + }) + + t.Run("no tiers never matches", func(t *testing.T) { + _, ok := matchRechargeBonusTier(nil, 100) + require.False(t, ok) + }) +} + +func TestResolveRechargeBonus(t *testing.T) { + tiers := []RechargeBonusTier{{MinAmount: 100, BonusPercent: 20}, {MinAmount: 500, BonusPercent: 30}} + + 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) + }) +} + +func TestParsePaymentConfigRechargeBonus(t *testing.T) { + svc := &PaymentConfigService{} + + t.Run("defaults", func(t *testing.T) { + cfg := svc.parsePaymentConfig(map[string]string{}) + require.NotNil(t, cfg.RechargeBonusTiers) + require.Len(t, cfg.RechargeBonusTiers, 0) + require.Equal(t, "", cfg.RechargeBonusNotice) + }) + + t.Run("reads tiers and notice", func(t *testing.T) { + cfg := svc.parsePaymentConfig(map[string]string{ + SettingRechargeBonusTiers: `[{"min_amount":500,"bonus_percent":30},{"min_amount":100,"bonus_percent":20}]`, + SettingRechargeBonusNotice: "**满 100 送 20%**", + }) + require.Equal(t, []RechargeBonusTier{{MinAmount: 100, BonusPercent: 20}, {MinAmount: 500, BonusPercent: 30}}, cfg.RechargeBonusTiers) + require.Equal(t, "**满 100 送 20%**", cfg.RechargeBonusNotice) + }) +} + +func TestUpdatePaymentConfigRechargeBonus(t *testing.T) { + ctx := context.Background() + + t.Run("persists normalized tiers and trimmed notice", func(t *testing.T) { + repo := &paymentConfigSettingRepoStub{values: map[string]string{}} + svc := &PaymentConfigService{settingRepo: repo} + tiers := []RechargeBonusTier{{MinAmount: 500, BonusPercent: 30}, {MinAmount: 100, BonusPercent: 20}} + notice := " 活动文案 " + require.NoError(t, svc.UpdatePaymentConfig(ctx, UpdatePaymentConfigRequest{ + RechargeBonusTiers: &tiers, + RechargeBonusNotice: ¬ice, + })) + require.Equal(t, `[{"min_amount":100,"bonus_percent":20},{"min_amount":500,"bonus_percent":30}]`, repo.updates[SettingRechargeBonusTiers]) + require.Equal(t, "活动文案", repo.updates[SettingRechargeBonusNotice]) + + cfg, err := svc.GetPaymentConfig(ctx) + require.NoError(t, err) + require.Equal(t, []RechargeBonusTier{{MinAmount: 100, BonusPercent: 20}, {MinAmount: 500, BonusPercent: 30}}, cfg.RechargeBonusTiers) + }) + + t.Run("empty tiers clears setting and omitted fields are untouched", func(t *testing.T) { + repo := &paymentConfigSettingRepoStub{values: map[string]string{ + SettingRechargeBonusTiers: `[{"min_amount":100,"bonus_percent":20}]`, + SettingRechargeBonusNotice: "keep me", + }} + svc := &PaymentConfigService{settingRepo: repo} + empty := []RechargeBonusTier{} + require.NoError(t, svc.UpdatePaymentConfig(ctx, UpdatePaymentConfigRequest{RechargeBonusTiers: &empty})) + value, ok := repo.updates[SettingRechargeBonusTiers] + require.True(t, ok) + require.Equal(t, "", value) + _, touched := repo.updates[SettingRechargeBonusNotice] + require.False(t, touched) + require.Equal(t, "keep me", repo.values[SettingRechargeBonusNotice]) + }) + + t.Run("rejects invalid tiers", func(t *testing.T) { + repo := &paymentConfigSettingRepoStub{values: map[string]string{}} + svc := &PaymentConfigService{settingRepo: repo} + bad := []RechargeBonusTier{{MinAmount: 100, BonusPercent: 20}, {MinAmount: 100, BonusPercent: 30}} + err := svc.UpdatePaymentConfig(ctx, UpdatePaymentConfigRequest{RechargeBonusTiers: &bad}) + require.Error(t, err) + require.Nil(t, repo.updates) + }) +} + +func TestAffiliateRebateBaseAmountExcludesRechargeBonus(t *testing.T) { + require.Equal(t, 100.0, affiliateRebateBaseAmount(&dbent.PaymentOrder{ + OrderType: payment.OrderTypeBalance, Amount: 130, BonusAmount: 30, + })) + require.Equal(t, 130.0, affiliateRebateBaseAmount(&dbent.PaymentOrder{ + OrderType: payment.OrderTypeBalance, Amount: 130, + })) + // 订阅订单不受 bonus 字段影响 + require.Equal(t, 50.0, affiliateRebateBaseAmount(&dbent.PaymentOrder{ + OrderType: payment.OrderTypeSubscription, Amount: 50, BonusAmount: 30, + })) + // 异常数据:赠送大于总额时钳到 0 + require.Equal(t, 0.0, affiliateRebateBaseAmount(&dbent.PaymentOrder{ + OrderType: payment.OrderTypeBalance, Amount: 10, BonusAmount: 30, + })) +} diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index 792a842a23..dae1a7d938 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -92,6 +92,7 @@ type CreateOrderResponse struct { Amount float64 `json:"amount"` PayAmount float64 `json:"pay_amount"` FeeRate float64 `json:"fee_rate"` + BonusAmount float64 `json:"bonus_amount"` Status string `json:"status"` ResultType payment.CreatePaymentResultType `json:"result_type,omitempty"` PaymentType string `json:"payment_type"` diff --git a/backend/migrations/241_add_payment_order_bonus_amount.sql b/backend/migrations/241_add_payment_order_bonus_amount.sql new file mode 100644 index 0000000000..1eeddfadba --- /dev/null +++ b/backend/migrations/241_add_payment_order_bonus_amount.sql @@ -0,0 +1,3 @@ +-- 充值赠送额度:余额充值订单命中赠送阶梯时的赠送 USD 金额。 +-- 已计入 payment_orders.amount(到账总额),单独落列用于订单展示与推广返利基数剔除。 +ALTER TABLE payment_orders ADD COLUMN IF NOT EXISTS bonus_amount DECIMAL(20,2) NOT NULL DEFAULT 0; diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 1fcc0021b1..2c57aec3b2 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -10,6 +10,7 @@ import type { LoginAgreementDocument, NotifyEmailEntry, } from "@/types"; +import type { RechargeBonusTier } from "@/utils/rechargeBonus"; export interface DefaultSubscriptionSetting { group_id: number; @@ -670,6 +671,8 @@ export interface SystemSettings { payment_balance_recharge_multiplier: number; payment_subscription_usd_to_cny_rate: number; payment_recharge_fee_rate: number; + payment_recharge_bonus_tiers?: RechargeBonusTier[]; + payment_recharge_bonus_notice?: string; payment_load_balance_strategy: string; payment_product_name_prefix: string; payment_product_name_suffix: string; @@ -991,6 +994,8 @@ export interface UpdateSettingsRequest { payment_balance_recharge_multiplier?: number; payment_subscription_usd_to_cny_rate?: number; payment_recharge_fee_rate?: number; + payment_recharge_bonus_tiers?: RechargeBonusTier[]; + payment_recharge_bonus_notice?: string; payment_load_balance_strategy?: string; payment_product_name_prefix?: string; payment_product_name_suffix?: string; diff --git a/frontend/src/components/admin/payment/AdminOrderDetail.vue b/frontend/src/components/admin/payment/AdminOrderDetail.vue index 28bd614ac8..277cae5e7a 100644 --- a/frontend/src/components/admin/payment/AdminOrderDetail.vue +++ b/frontend/src/components/admin/payment/AdminOrderDetail.vue @@ -29,7 +29,11 @@

{{ t('payment.orders.payAmount') }}

{{ paymentAmountSymbol }}{{ order.pay_amount.toFixed(2) }}

-
+
+

{{ t('payment.orders.bonusAmount') }}

+

+{{ creditedAmountSymbol }}{{ (order.bonus_amount ?? 0).toFixed(2) }}

+
+

{{ t('payment.orders.creditedAmount') }}

{{ creditedAmountSymbol }}{{ order.amount.toFixed(2) }}

diff --git a/frontend/src/components/admin/payment/AdminOrderTable.vue b/frontend/src/components/admin/payment/AdminOrderTable.vue index 5290866cae..dbbb5e87e0 100644 --- a/frontend/src/components/admin/payment/AdminOrderTable.vue +++ b/frontend/src/components/admin/payment/AdminOrderTable.vue @@ -57,8 +57,11 @@ ({{ row.fee_rate }}%) -
+
{{ t('payment.orders.creditedAmount') }}: {{ creditedAmountSymbol }}{{ row.amount.toFixed(2) }} + + ({{ t('payment.orders.bonusIncluded', { amount: creditedAmountSymbol + (row.bonus_amount ?? 0).toFixed(2) }) }}) +
diff --git a/frontend/src/components/admin/settings/RechargeBonusTierEditor.vue b/frontend/src/components/admin/settings/RechargeBonusTierEditor.vue new file mode 100644 index 0000000000..aaf641b68d --- /dev/null +++ b/frontend/src/components/admin/settings/RechargeBonusTierEditor.vue @@ -0,0 +1,206 @@ + + + diff --git a/frontend/src/components/payment/AmountInput.vue b/frontend/src/components/payment/AmountInput.vue index 0e0c9d339a..601193ad4a 100644 --- a/frontend/src/components/payment/AmountInput.vue +++ b/frontend/src/components/payment/AmountInput.vue @@ -5,20 +5,36 @@ -
+
@@ -48,16 +64,24 @@ 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