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:
37
backend/internal/service/payment_amounts.go
Normal file
37
backend/internal/service/payment_amounts.go
Normal 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()
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user