feat(payment): balance recharge multiplier and refund amount separation

- Add balance_recharge_multiplier system setting (e.g. 1.2 = charge 100 get 120)
- Separate order_amount (credited balance) from pay_amount (actual payment)
- Refund calculates gateway amount proportionally from pay_amount
- Frontend shows both amounts in order details, payment status, refund dialog
- Admin settings UI for configuring recharge multiplier
This commit is contained in:
erio
2026-04-15 00:14:57 +08:00
parent 7c671b5373
commit 60a4b9316b
24 changed files with 246 additions and 101 deletions

View File

@@ -0,0 +1,37 @@
package service
import (
"math"
"github.com/shopspring/decimal"
)
const defaultBalanceRechargeMultiplier = 1.0
func normalizeBalanceRechargeMultiplier(multiplier float64) float64 {
if math.IsNaN(multiplier) || math.IsInf(multiplier, 0) || multiplier <= 0 {
return defaultBalanceRechargeMultiplier
}
return multiplier
}
func calculateCreditedBalance(paymentAmount, multiplier float64) float64 {
return decimal.NewFromFloat(paymentAmount).
Mul(decimal.NewFromFloat(normalizeBalanceRechargeMultiplier(multiplier))).
Round(2).
InexactFloat64()
}
func calculateGatewayRefundAmount(orderAmount, payAmount, refundAmount float64) float64 {
if orderAmount <= 0 || payAmount <= 0 || refundAmount <= 0 {
return 0
}
if math.Abs(refundAmount-orderAmount) <= amountToleranceCNY {
return decimal.NewFromFloat(payAmount).Round(2).InexactFloat64()
}
return decimal.NewFromFloat(payAmount).
Mul(decimal.NewFromFloat(refundAmount)).
Div(decimal.NewFromFloat(orderAmount)).
Round(2).
InexactFloat64()
}

View File

@@ -3,12 +3,14 @@ package service
import (
"context"
"fmt"
"math"
"strconv"
"strings"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
"github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
const (
@@ -21,6 +23,7 @@ const (
SettingEnabledPaymentTypes = "ENABLED_PAYMENT_TYPES"
SettingLoadBalanceStrategy = "LOAD_BALANCE_STRATEGY"
SettingBalancePayDisabled = "BALANCE_PAYMENT_DISABLED"
SettingBalanceRechargeMult = "BALANCE_RECHARGE_MULTIPLIER"
SettingProductNamePrefix = "PRODUCT_NAME_PREFIX"
SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX"
SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL"
@@ -46,9 +49,10 @@ type PaymentConfig struct {
DailyLimit float64 `json:"daily_limit"`
OrderTimeoutMin int `json:"order_timeout_minutes"`
MaxPendingOrders int `json:"max_pending_orders"`
EnabledTypes []string `json:"enabled_payment_types"`
BalanceDisabled bool `json:"balance_disabled"`
LoadBalanceStrategy string `json:"load_balance_strategy"`
EnabledTypes []string `json:"enabled_payment_types"`
BalanceDisabled bool `json:"balance_disabled"`
BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"`
LoadBalanceStrategy string `json:"load_balance_strategy"`
ProductNamePrefix string `json:"product_name_prefix"`
ProductNameSuffix string `json:"product_name_suffix"`
HelpImageURL string `json:"help_image_url"`
@@ -71,9 +75,10 @@ type UpdatePaymentConfigRequest struct {
DailyLimit *float64 `json:"daily_limit"`
OrderTimeoutMin *int `json:"order_timeout_minutes"`
MaxPendingOrders *int `json:"max_pending_orders"`
EnabledTypes []string `json:"enabled_payment_types"`
BalanceDisabled *bool `json:"balance_disabled"`
LoadBalanceStrategy *string `json:"load_balance_strategy"`
EnabledTypes []string `json:"enabled_payment_types"`
BalanceDisabled *bool `json:"balance_disabled"`
BalanceRechargeMultiplier *float64 `json:"balance_recharge_multiplier"`
LoadBalanceStrategy *string `json:"load_balance_strategy"`
ProductNamePrefix *string `json:"product_name_prefix"`
ProductNameSuffix *string `json:"product_name_suffix"`
HelpImageURL *string `json:"help_image_url"`
@@ -183,7 +188,7 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo
keys := []string{
SettingPaymentEnabled, SettingMinRechargeAmount, SettingMaxRechargeAmount,
SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders,
SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingLoadBalanceStrategy,
SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingBalanceRechargeMult, SettingLoadBalanceStrategy,
SettingProductNamePrefix, SettingProductNameSuffix,
SettingHelpImageURL, SettingHelpText,
SettingCancelRateLimitOn, SettingCancelRateLimitMax,
@@ -207,8 +212,9 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme
DailyLimit: pcParseFloat(vals[SettingDailyRechargeLimit], 0),
OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], defaultOrderTimeoutMin),
MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], defaultMaxPendingOrders),
BalanceDisabled: vals[SettingBalancePayDisabled] == "true",
LoadBalanceStrategy: vals[SettingLoadBalanceStrategy],
BalanceDisabled: vals[SettingBalancePayDisabled] == "true",
BalanceRechargeMultiplier: normalizeBalanceRechargeMultiplier(pcParseFloat(vals[SettingBalanceRechargeMult], defaultBalanceRechargeMultiplier)),
LoadBalanceStrategy: vals[SettingLoadBalanceStrategy],
ProductNamePrefix: vals[SettingProductNamePrefix],
ProductNameSuffix: vals[SettingProductNameSuffix],
HelpImageURL: vals[SettingHelpImageURL],
@@ -256,6 +262,11 @@ func (s *PaymentConfigService) getStripePublishableKey(ctx context.Context) stri
// nil-check before serialisation — this is inherent to patch-style update patterns
// and cannot be meaningfully decomposed without introducing unnecessary abstraction.
func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req UpdatePaymentConfigRequest) error {
if req.BalanceRechargeMultiplier != nil {
if math.IsNaN(*req.BalanceRechargeMultiplier) || math.IsInf(*req.BalanceRechargeMultiplier, 0) || *req.BalanceRechargeMultiplier <= 0 {
return infraerrors.BadRequest("INVALID_BALANCE_RECHARGE_MULTIPLIER", "balance recharge multiplier must be greater than 0")
}
}
m := map[string]string{
SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled),
SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount),
@@ -264,6 +275,7 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda
SettingOrderTimeoutMinutes: formatPositiveInt(req.OrderTimeoutMin),
SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders),
SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled),
SettingBalanceRechargeMult: formatPositiveFloat(req.BalanceRechargeMultiplier),
SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy),
SettingProductNamePrefix: derefStr(req.ProductNamePrefix),
SettingProductNameSuffix: derefStr(req.ProductNameSuffix),

View File

@@ -216,7 +216,11 @@ func (s *PaymentService) markCompleted(ctx context.Context, o *dbent.PaymentOrde
if err != nil {
return fmt.Errorf("mark completed: %w", err)
}
s.writeAuditLog(ctx, o.ID, auditAction, "system", map[string]any{"rechargeCode": o.RechargeCode, "amount": o.Amount})
s.writeAuditLog(ctx, o.ID, auditAction, "system", map[string]any{
"rechargeCode": o.RechargeCode,
"creditedAmount": o.Amount,
"payAmount": o.PayAmount,
})
return nil
}

View File

@@ -43,14 +43,18 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
if user.Status != payment.EntityStatusActive {
return nil, infraerrors.Forbidden("USER_INACTIVE", "user account is disabled")
}
amount := req.Amount
orderAmount := req.Amount
limitAmount := req.Amount
if plan != nil {
amount = plan.Price
orderAmount = plan.Price
limitAmount = plan.Price
} else if req.OrderType == payment.OrderTypeBalance {
orderAmount = calculateCreditedBalance(req.Amount, cfg.BalanceRechargeMultiplier)
}
feeRate := s.getFeeRate(req.PaymentType)
payAmountStr := payment.CalculatePayAmount(amount, feeRate)
payAmountStr := payment.CalculatePayAmount(limitAmount, feeRate)
payAmount, _ := strconv.ParseFloat(payAmountStr, 64)
order, err := s.createOrderInTx(ctx, req, user, plan, cfg, amount, feeRate, payAmount)
order, err := s.createOrderInTx(ctx, req, user, plan, cfg, orderAmount, limitAmount, feeRate, payAmount)
if err != nil {
return nil, err
}
@@ -99,7 +103,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, amount, feeRate, payAmount float64) (*dbent.PaymentOrder, error) {
func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderRequest, user *User, plan *dbent.SubscriptionPlan, cfg *PaymentConfig, orderAmount, limitAmount, feeRate, payAmount float64) (*dbent.PaymentOrder, error) {
tx, err := s.entClient.Tx(ctx)
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
@@ -108,7 +112,7 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq
if err := s.checkPendingLimit(ctx, tx, req.UserID, cfg.MaxPendingOrders); err != nil {
return nil, err
}
if err := s.checkDailyLimit(ctx, tx, req.UserID, amount, cfg.DailyLimit); err != nil {
if err := s.checkDailyLimit(ctx, tx, req.UserID, limitAmount, cfg.DailyLimit); err != nil {
return nil, err
}
tm := cfg.OrderTimeoutMin
@@ -121,7 +125,7 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq
SetUserEmail(user.Email).
SetUserName(user.Username).
SetNillableUserNotes(psNilIfEmpty(user.Notes)).
SetAmount(amount).
SetAmount(orderAmount).
SetPayAmount(payAmount).
SetFeeRate(feeRate).
SetRechargeCode("").
@@ -180,6 +184,10 @@ func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, user
}
var used float64
for _, o := range orders {
if o.OrderType == payment.OrderTypeBalance {
used += o.PayAmount
continue
}
used += o.Amount
}
if used+amount > limit {
@@ -213,7 +221,13 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
if err != nil {
return nil, fmt.Errorf("update order with payment details: %w", err)
}
s.writeAuditLog(ctx, order.ID, "ORDER_CREATED", fmt.Sprintf("user:%d", req.UserID), map[string]any{"amount": req.Amount, "paymentType": req.PaymentType, "orderType": req.OrderType})
s.writeAuditLog(ctx, order.ID, "ORDER_CREATED", fmt.Sprintf("user:%d", req.UserID), map[string]any{
"paymentAmount": req.Amount,
"creditedAmount": order.Amount,
"payAmount": order.PayAmount,
"paymentType": req.PaymentType,
"orderType": req.OrderType,
})
return &CreateOrderResponse{OrderID: order.ID, Amount: order.Amount, PayAmount: payAmount, FeeRate: order.FeeRate, Status: OrderStatusPending, PaymentType: req.PaymentType, PayURL: pr.PayURL, QRCode: pr.QRCode, ClientSecret: pr.ClientSecret, ExpiresAt: order.ExpiresAt, PaymentMode: sel.PaymentMode}, nil
}

View File

@@ -113,11 +113,7 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float
if amt-o.Amount > amountToleranceCNY {
return nil, nil, infraerrors.BadRequest("REFUND_AMOUNT_EXCEEDED", "refund amount exceeds recharge")
}
// Full refund: use actual pay_amount for gateway (includes fees)
ga := amt
if math.Abs(amt-o.Amount) <= amountToleranceCNY {
ga = o.PayAmount
}
ga := calculateGatewayRefundAmount(o.Amount, o.PayAmount, amt)
rr := strings.TrimSpace(reason)
if rr == "" && o.RefundRequestReason != nil {
rr = *o.RefundRequestReason