Unverified Commit 9bf079b7 authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #1655 from touwaeriol/feat/payment-fee-multiplier

feat(payment): balance recharge multiplier and fee rate
parents 7c671b53 c2108421
Antigravity-Manager @ a9d96bd5
Subproject commit a9d96bd54978c22d3033830debfe77aeeeee2500
...@@ -188,6 +188,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { ...@@ -188,6 +188,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PaymentMaxPendingOrders: paymentCfg.MaxPendingOrders, PaymentMaxPendingOrders: paymentCfg.MaxPendingOrders,
PaymentEnabledTypes: paymentCfg.EnabledTypes, PaymentEnabledTypes: paymentCfg.EnabledTypes,
PaymentBalanceDisabled: paymentCfg.BalanceDisabled, PaymentBalanceDisabled: paymentCfg.BalanceDisabled,
PaymentBalanceRechargeMultiplier: paymentCfg.BalanceRechargeMultiplier,
PaymentRechargeFeeRate: paymentCfg.RechargeFeeRate,
PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy, PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy,
PaymentProductNamePrefix: paymentCfg.ProductNamePrefix, PaymentProductNamePrefix: paymentCfg.ProductNamePrefix,
PaymentProductNameSuffix: paymentCfg.ProductNameSuffix, PaymentProductNameSuffix: paymentCfg.ProductNameSuffix,
...@@ -317,19 +319,21 @@ type UpdateSettingsRequest struct { ...@@ -317,19 +319,21 @@ type UpdateSettingsRequest struct {
AccountQuotaNotifyEmails *[]dto.NotifyEmailEntry `json:"account_quota_notify_emails"` AccountQuotaNotifyEmails *[]dto.NotifyEmailEntry `json:"account_quota_notify_emails"`
// Payment configuration (integrated into settings, full replace) // Payment configuration (integrated into settings, full replace)
PaymentEnabled *bool `json:"payment_enabled"` PaymentEnabled *bool `json:"payment_enabled"`
PaymentMinAmount *float64 `json:"payment_min_amount"` PaymentMinAmount *float64 `json:"payment_min_amount"`
PaymentMaxAmount *float64 `json:"payment_max_amount"` PaymentMaxAmount *float64 `json:"payment_max_amount"`
PaymentDailyLimit *float64 `json:"payment_daily_limit"` PaymentDailyLimit *float64 `json:"payment_daily_limit"`
PaymentOrderTimeoutMin *int `json:"payment_order_timeout_minutes"` PaymentOrderTimeoutMin *int `json:"payment_order_timeout_minutes"`
PaymentMaxPendingOrders *int `json:"payment_max_pending_orders"` PaymentMaxPendingOrders *int `json:"payment_max_pending_orders"`
PaymentEnabledTypes []string `json:"payment_enabled_types"` PaymentEnabledTypes []string `json:"payment_enabled_types"`
PaymentBalanceDisabled *bool `json:"payment_balance_disabled"` PaymentBalanceDisabled *bool `json:"payment_balance_disabled"`
PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"` PaymentBalanceRechargeMultiplier *float64 `json:"payment_balance_recharge_multiplier"`
PaymentProductNamePrefix *string `json:"payment_product_name_prefix"` PaymentRechargeFeeRate *float64 `json:"payment_recharge_fee_rate"`
PaymentProductNameSuffix *string `json:"payment_product_name_suffix"` PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"`
PaymentHelpImageURL *string `json:"payment_help_image_url"` PaymentProductNamePrefix *string `json:"payment_product_name_prefix"`
PaymentHelpText *string `json:"payment_help_text"` PaymentProductNameSuffix *string `json:"payment_product_name_suffix"`
PaymentHelpImageURL *string `json:"payment_help_image_url"`
PaymentHelpText *string `json:"payment_help_text"`
// Cancel rate limit // Cancel rate limit
PaymentCancelRateLimitEnabled *bool `json:"payment_cancel_rate_limit_enabled"` PaymentCancelRateLimitEnabled *bool `json:"payment_cancel_rate_limit_enabled"`
...@@ -934,24 +938,26 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -934,24 +938,26 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
// Skip if no payment fields were provided (prevents accidental wipe). // Skip if no payment fields were provided (prevents accidental wipe).
if h.paymentConfigService != nil && hasPaymentFields(req) { if h.paymentConfigService != nil && hasPaymentFields(req) {
paymentReq := service.UpdatePaymentConfigRequest{ paymentReq := service.UpdatePaymentConfigRequest{
Enabled: req.PaymentEnabled, Enabled: req.PaymentEnabled,
MinAmount: req.PaymentMinAmount, MinAmount: req.PaymentMinAmount,
MaxAmount: req.PaymentMaxAmount, MaxAmount: req.PaymentMaxAmount,
DailyLimit: req.PaymentDailyLimit, DailyLimit: req.PaymentDailyLimit,
OrderTimeoutMin: req.PaymentOrderTimeoutMin, OrderTimeoutMin: req.PaymentOrderTimeoutMin,
MaxPendingOrders: req.PaymentMaxPendingOrders, MaxPendingOrders: req.PaymentMaxPendingOrders,
EnabledTypes: req.PaymentEnabledTypes, EnabledTypes: req.PaymentEnabledTypes,
BalanceDisabled: req.PaymentBalanceDisabled, BalanceDisabled: req.PaymentBalanceDisabled,
LoadBalanceStrategy: req.PaymentLoadBalanceStrat, BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier,
ProductNamePrefix: req.PaymentProductNamePrefix, RechargeFeeRate: req.PaymentRechargeFeeRate,
ProductNameSuffix: req.PaymentProductNameSuffix, LoadBalanceStrategy: req.PaymentLoadBalanceStrat,
HelpImageURL: req.PaymentHelpImageURL, ProductNamePrefix: req.PaymentProductNamePrefix,
HelpText: req.PaymentHelpText, ProductNameSuffix: req.PaymentProductNameSuffix,
CancelRateLimitEnabled: req.PaymentCancelRateLimitEnabled, HelpImageURL: req.PaymentHelpImageURL,
CancelRateLimitMax: req.PaymentCancelRateLimitMax, HelpText: req.PaymentHelpText,
CancelRateLimitWindow: req.PaymentCancelRateLimitWindow, CancelRateLimitEnabled: req.PaymentCancelRateLimitEnabled,
CancelRateLimitUnit: req.PaymentCancelRateLimitUnit, CancelRateLimitMax: req.PaymentCancelRateLimitMax,
CancelRateLimitMode: req.PaymentCancelRateLimitMode, CancelRateLimitWindow: req.PaymentCancelRateLimitWindow,
CancelRateLimitUnit: req.PaymentCancelRateLimitUnit,
CancelRateLimitMode: req.PaymentCancelRateLimitMode,
} }
if err := h.paymentConfigService.UpdatePaymentConfig(c.Request.Context(), paymentReq); err != nil { if err := h.paymentConfigService.UpdatePaymentConfig(c.Request.Context(), paymentReq); err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
...@@ -1082,6 +1088,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -1082,6 +1088,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PaymentMaxPendingOrders: updatedPaymentCfg.MaxPendingOrders, PaymentMaxPendingOrders: updatedPaymentCfg.MaxPendingOrders,
PaymentEnabledTypes: updatedPaymentCfg.EnabledTypes, PaymentEnabledTypes: updatedPaymentCfg.EnabledTypes,
PaymentBalanceDisabled: updatedPaymentCfg.BalanceDisabled, PaymentBalanceDisabled: updatedPaymentCfg.BalanceDisabled,
PaymentBalanceRechargeMultiplier: updatedPaymentCfg.BalanceRechargeMultiplier,
PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate,
PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy, PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy,
PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix, PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix,
PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix, PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix,
...@@ -1101,6 +1109,7 @@ func hasPaymentFields(req UpdateSettingsRequest) bool { ...@@ -1101,6 +1109,7 @@ func hasPaymentFields(req UpdateSettingsRequest) bool {
req.PaymentMaxAmount != nil || req.PaymentDailyLimit != nil || req.PaymentMaxAmount != nil || req.PaymentDailyLimit != nil ||
req.PaymentOrderTimeoutMin != nil || req.PaymentMaxPendingOrders != nil || req.PaymentOrderTimeoutMin != nil || req.PaymentMaxPendingOrders != nil ||
req.PaymentEnabledTypes != nil || req.PaymentBalanceDisabled != nil || req.PaymentEnabledTypes != nil || req.PaymentBalanceDisabled != nil ||
req.PaymentBalanceRechargeMultiplier != nil || req.PaymentRechargeFeeRate != nil ||
req.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil || req.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil ||
req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil || req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil ||
req.PaymentHelpText != nil || req.PaymentCancelRateLimitEnabled != nil || req.PaymentHelpText != nil || req.PaymentCancelRateLimitEnabled != nil ||
......
...@@ -128,19 +128,21 @@ type SystemSettings struct { ...@@ -128,19 +128,21 @@ type SystemSettings struct {
WebSearchEmulationEnabled bool `json:"web_search_emulation_enabled"` WebSearchEmulationEnabled bool `json:"web_search_emulation_enabled"`
// Payment configuration // Payment configuration
PaymentEnabled bool `json:"payment_enabled"` PaymentEnabled bool `json:"payment_enabled"`
PaymentMinAmount float64 `json:"payment_min_amount"` PaymentMinAmount float64 `json:"payment_min_amount"`
PaymentMaxAmount float64 `json:"payment_max_amount"` PaymentMaxAmount float64 `json:"payment_max_amount"`
PaymentDailyLimit float64 `json:"payment_daily_limit"` PaymentDailyLimit float64 `json:"payment_daily_limit"`
PaymentOrderTimeoutMin int `json:"payment_order_timeout_minutes"` PaymentOrderTimeoutMin int `json:"payment_order_timeout_minutes"`
PaymentMaxPendingOrders int `json:"payment_max_pending_orders"` PaymentMaxPendingOrders int `json:"payment_max_pending_orders"`
PaymentEnabledTypes []string `json:"payment_enabled_types"` PaymentEnabledTypes []string `json:"payment_enabled_types"`
PaymentBalanceDisabled bool `json:"payment_balance_disabled"` PaymentBalanceDisabled bool `json:"payment_balance_disabled"`
PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"` PaymentBalanceRechargeMultiplier float64 `json:"payment_balance_recharge_multiplier"`
PaymentProductNamePrefix string `json:"payment_product_name_prefix"` PaymentRechargeFeeRate float64 `json:"payment_recharge_fee_rate"`
PaymentProductNameSuffix string `json:"payment_product_name_suffix"` PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"`
PaymentHelpImageURL string `json:"payment_help_image_url"` PaymentProductNamePrefix string `json:"payment_product_name_prefix"`
PaymentHelpText string `json:"payment_help_text"` PaymentProductNameSuffix string `json:"payment_product_name_suffix"`
PaymentHelpImageURL string `json:"payment_help_image_url"`
PaymentHelpText string `json:"payment_help_text"`
// Cancel rate limit // Cancel rate limit
PaymentCancelRateLimitEnabled bool `json:"payment_cancel_rate_limit_enabled"` PaymentCancelRateLimitEnabled bool `json:"payment_cancel_rate_limit_enabled"`
......
...@@ -126,26 +126,30 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) { ...@@ -126,26 +126,30 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) {
} }
response.Success(c, checkoutInfoResponse{ response.Success(c, checkoutInfoResponse{
Methods: limitsResp.Methods, Methods: limitsResp.Methods,
GlobalMin: limitsResp.GlobalMin, GlobalMin: limitsResp.GlobalMin,
GlobalMax: limitsResp.GlobalMax, GlobalMax: limitsResp.GlobalMax,
Plans: planList, Plans: planList,
BalanceDisabled: cfg.BalanceDisabled, BalanceDisabled: cfg.BalanceDisabled,
HelpText: cfg.HelpText, BalanceRechargeMultiplier: cfg.BalanceRechargeMultiplier,
HelpImageURL: cfg.HelpImageURL, RechargeFeeRate: cfg.RechargeFeeRate,
StripePublishableKey: cfg.StripePublishableKey, HelpText: cfg.HelpText,
HelpImageURL: cfg.HelpImageURL,
StripePublishableKey: cfg.StripePublishableKey,
}) })
} }
type checkoutInfoResponse struct { type checkoutInfoResponse struct {
Methods map[string]service.MethodLimits `json:"methods"` Methods map[string]service.MethodLimits `json:"methods"`
GlobalMin float64 `json:"global_min"` GlobalMin float64 `json:"global_min"`
GlobalMax float64 `json:"global_max"` GlobalMax float64 `json:"global_max"`
Plans []checkoutPlan `json:"plans"` Plans []checkoutPlan `json:"plans"`
BalanceDisabled bool `json:"balance_disabled"` BalanceDisabled bool `json:"balance_disabled"`
HelpText string `json:"help_text"` BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"`
HelpImageURL string `json:"help_image_url"` RechargeFeeRate float64 `json:"recharge_fee_rate"`
StripePublishableKey string `json:"stripe_publishable_key"` HelpText string `json:"help_text"`
HelpImageURL string `json:"help_image_url"`
StripePublishableKey string `json:"stripe_publishable_key"`
} }
type checkoutPlan struct { type checkoutPlan struct {
...@@ -381,6 +385,7 @@ type PublicOrderResult struct { ...@@ -381,6 +385,7 @@ type PublicOrderResult struct {
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
PayAmount float64 `json:"pay_amount"` PayAmount float64 `json:"pay_amount"`
PaymentType string `json:"payment_type"` PaymentType string `json:"payment_type"`
OrderType string `json:"order_type"`
Status string `json:"status"` Status string `json:"status"`
} }
...@@ -404,6 +409,7 @@ func (h *PaymentHandler) VerifyOrderPublic(c *gin.Context) { ...@@ -404,6 +409,7 @@ func (h *PaymentHandler) VerifyOrderPublic(c *gin.Context) {
Amount: order.Amount, Amount: order.Amount,
PayAmount: order.PayAmount, PayAmount: order.PayAmount,
PaymentType: order.PaymentType, PaymentType: order.PaymentType,
OrderType: order.OrderType,
Status: order.Status, Status: order.Status,
}) })
} }
......
...@@ -601,6 +601,8 @@ func TestAPIContracts(t *testing.T) { ...@@ -601,6 +601,8 @@ func TestAPIContracts(t *testing.T) {
"payment_order_timeout_minutes": 0, "payment_order_timeout_minutes": 0,
"payment_max_pending_orders": 0, "payment_max_pending_orders": 0,
"payment_balance_disabled": false, "payment_balance_disabled": false,
"payment_balance_recharge_multiplier": 0,
"payment_recharge_fee_rate": 0,
"payment_load_balance_strategy": "", "payment_load_balance_strategy": "",
"payment_product_name_prefix": "", "payment_product_name_prefix": "",
"payment_product_name_suffix": "", "payment_product_name_suffix": "",
......
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 ...@@ -3,12 +3,14 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings" "strings"
dbent "github.com/Wei-Shaw/sub2api/ent" dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance" "github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
"github.com/Wei-Shaw/sub2api/internal/payment" "github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
) )
const ( const (
...@@ -21,6 +23,8 @@ const ( ...@@ -21,6 +23,8 @@ const (
SettingEnabledPaymentTypes = "ENABLED_PAYMENT_TYPES" SettingEnabledPaymentTypes = "ENABLED_PAYMENT_TYPES"
SettingLoadBalanceStrategy = "LOAD_BALANCE_STRATEGY" SettingLoadBalanceStrategy = "LOAD_BALANCE_STRATEGY"
SettingBalancePayDisabled = "BALANCE_PAYMENT_DISABLED" SettingBalancePayDisabled = "BALANCE_PAYMENT_DISABLED"
SettingBalanceRechargeMult = "BALANCE_RECHARGE_MULTIPLIER"
SettingRechargeFeeRate = "RECHARGE_FEE_RATE"
SettingProductNamePrefix = "PRODUCT_NAME_PREFIX" SettingProductNamePrefix = "PRODUCT_NAME_PREFIX"
SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX" SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX"
SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL" SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL"
...@@ -40,20 +44,22 @@ const ( ...@@ -40,20 +44,22 @@ const (
// PaymentConfig holds the payment system configuration. // PaymentConfig holds the payment system configuration.
type PaymentConfig struct { type PaymentConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
MinAmount float64 `json:"min_amount"` MinAmount float64 `json:"min_amount"`
MaxAmount float64 `json:"max_amount"` MaxAmount float64 `json:"max_amount"`
DailyLimit float64 `json:"daily_limit"` DailyLimit float64 `json:"daily_limit"`
OrderTimeoutMin int `json:"order_timeout_minutes"` OrderTimeoutMin int `json:"order_timeout_minutes"`
MaxPendingOrders int `json:"max_pending_orders"` MaxPendingOrders int `json:"max_pending_orders"`
EnabledTypes []string `json:"enabled_payment_types"` EnabledTypes []string `json:"enabled_payment_types"`
BalanceDisabled bool `json:"balance_disabled"` BalanceDisabled bool `json:"balance_disabled"`
LoadBalanceStrategy string `json:"load_balance_strategy"` BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"`
ProductNamePrefix string `json:"product_name_prefix"` RechargeFeeRate float64 `json:"recharge_fee_rate"`
ProductNameSuffix string `json:"product_name_suffix"` LoadBalanceStrategy string `json:"load_balance_strategy"`
HelpImageURL string `json:"help_image_url"` ProductNamePrefix string `json:"product_name_prefix"`
HelpText string `json:"help_text"` ProductNameSuffix string `json:"product_name_suffix"`
StripePublishableKey string `json:"stripe_publishable_key,omitempty"` HelpImageURL string `json:"help_image_url"`
HelpText string `json:"help_text"`
StripePublishableKey string `json:"stripe_publishable_key,omitempty"`
// Cancel rate limit settings // Cancel rate limit settings
CancelRateLimitEnabled bool `json:"cancel_rate_limit_enabled"` CancelRateLimitEnabled bool `json:"cancel_rate_limit_enabled"`
...@@ -65,19 +71,21 @@ type PaymentConfig struct { ...@@ -65,19 +71,21 @@ type PaymentConfig struct {
// UpdatePaymentConfigRequest contains fields to update payment configuration. // UpdatePaymentConfigRequest contains fields to update payment configuration.
type UpdatePaymentConfigRequest struct { type UpdatePaymentConfigRequest struct {
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
MinAmount *float64 `json:"min_amount"` MinAmount *float64 `json:"min_amount"`
MaxAmount *float64 `json:"max_amount"` MaxAmount *float64 `json:"max_amount"`
DailyLimit *float64 `json:"daily_limit"` DailyLimit *float64 `json:"daily_limit"`
OrderTimeoutMin *int `json:"order_timeout_minutes"` OrderTimeoutMin *int `json:"order_timeout_minutes"`
MaxPendingOrders *int `json:"max_pending_orders"` MaxPendingOrders *int `json:"max_pending_orders"`
EnabledTypes []string `json:"enabled_payment_types"` EnabledTypes []string `json:"enabled_payment_types"`
BalanceDisabled *bool `json:"balance_disabled"` BalanceDisabled *bool `json:"balance_disabled"`
LoadBalanceStrategy *string `json:"load_balance_strategy"` BalanceRechargeMultiplier *float64 `json:"balance_recharge_multiplier"`
ProductNamePrefix *string `json:"product_name_prefix"` RechargeFeeRate *float64 `json:"recharge_fee_rate"`
ProductNameSuffix *string `json:"product_name_suffix"` LoadBalanceStrategy *string `json:"load_balance_strategy"`
HelpImageURL *string `json:"help_image_url"` ProductNamePrefix *string `json:"product_name_prefix"`
HelpText *string `json:"help_text"` ProductNameSuffix *string `json:"product_name_suffix"`
HelpImageURL *string `json:"help_image_url"`
HelpText *string `json:"help_text"`
// Cancel rate limit settings // Cancel rate limit settings
CancelRateLimitEnabled *bool `json:"cancel_rate_limit_enabled"` CancelRateLimitEnabled *bool `json:"cancel_rate_limit_enabled"`
...@@ -183,7 +191,7 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo ...@@ -183,7 +191,7 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo
keys := []string{ keys := []string{
SettingPaymentEnabled, SettingMinRechargeAmount, SettingMaxRechargeAmount, SettingPaymentEnabled, SettingMinRechargeAmount, SettingMaxRechargeAmount,
SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders, SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders,
SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingLoadBalanceStrategy, SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingBalanceRechargeMult, SettingRechargeFeeRate, SettingLoadBalanceStrategy,
SettingProductNamePrefix, SettingProductNameSuffix, SettingProductNamePrefix, SettingProductNameSuffix,
SettingHelpImageURL, SettingHelpText, SettingHelpImageURL, SettingHelpText,
SettingCancelRateLimitOn, SettingCancelRateLimitMax, SettingCancelRateLimitOn, SettingCancelRateLimitMax,
...@@ -201,18 +209,20 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo ...@@ -201,18 +209,20 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo
func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *PaymentConfig { func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *PaymentConfig {
cfg := &PaymentConfig{ cfg := &PaymentConfig{
Enabled: vals[SettingPaymentEnabled] == "true", Enabled: vals[SettingPaymentEnabled] == "true",
MinAmount: pcParseFloat(vals[SettingMinRechargeAmount], 1), MinAmount: pcParseFloat(vals[SettingMinRechargeAmount], 1),
MaxAmount: pcParseFloat(vals[SettingMaxRechargeAmount], 0), MaxAmount: pcParseFloat(vals[SettingMaxRechargeAmount], 0),
DailyLimit: pcParseFloat(vals[SettingDailyRechargeLimit], 0), DailyLimit: pcParseFloat(vals[SettingDailyRechargeLimit], 0),
OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], defaultOrderTimeoutMin), OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], defaultOrderTimeoutMin),
MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], defaultMaxPendingOrders), MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], defaultMaxPendingOrders),
BalanceDisabled: vals[SettingBalancePayDisabled] == "true", BalanceDisabled: vals[SettingBalancePayDisabled] == "true",
LoadBalanceStrategy: vals[SettingLoadBalanceStrategy], BalanceRechargeMultiplier: normalizeBalanceRechargeMultiplier(pcParseFloat(vals[SettingBalanceRechargeMult], defaultBalanceRechargeMultiplier)),
ProductNamePrefix: vals[SettingProductNamePrefix], RechargeFeeRate: pcParseFloat(vals[SettingRechargeFeeRate], 0),
ProductNameSuffix: vals[SettingProductNameSuffix], LoadBalanceStrategy: vals[SettingLoadBalanceStrategy],
HelpImageURL: vals[SettingHelpImageURL], ProductNamePrefix: vals[SettingProductNamePrefix],
HelpText: vals[SettingHelpText], ProductNameSuffix: vals[SettingProductNameSuffix],
HelpImageURL: vals[SettingHelpImageURL],
HelpText: vals[SettingHelpText],
CancelRateLimitEnabled: vals[SettingCancelRateLimitOn] == "true", CancelRateLimitEnabled: vals[SettingCancelRateLimitOn] == "true",
CancelRateLimitMax: pcParseInt(vals[SettingCancelRateLimitMax], 10), CancelRateLimitMax: pcParseInt(vals[SettingCancelRateLimitMax], 10),
...@@ -256,6 +266,21 @@ func (s *PaymentConfigService) getStripePublishableKey(ctx context.Context) stri ...@@ -256,6 +266,21 @@ func (s *PaymentConfigService) getStripePublishableKey(ctx context.Context) stri
// nil-check before serialisation — this is inherent to patch-style update patterns // nil-check before serialisation — this is inherent to patch-style update patterns
// and cannot be meaningfully decomposed without introducing unnecessary abstraction. // and cannot be meaningfully decomposed without introducing unnecessary abstraction.
func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req UpdatePaymentConfigRequest) error { 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")
}
}
if req.RechargeFeeRate != nil {
v := *req.RechargeFeeRate
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > 100 {
return infraerrors.BadRequest("INVALID_RECHARGE_FEE_RATE", "recharge fee rate must be between 0 and 100")
}
// Enforce max 2 decimal places
if math.Round(v*100) != v*100 {
return infraerrors.BadRequest("INVALID_RECHARGE_FEE_RATE", "recharge fee rate allows at most 2 decimal places")
}
}
m := map[string]string{ m := map[string]string{
SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled), SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled),
SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount), SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount),
...@@ -264,6 +289,8 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda ...@@ -264,6 +289,8 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda
SettingOrderTimeoutMinutes: formatPositiveInt(req.OrderTimeoutMin), SettingOrderTimeoutMinutes: formatPositiveInt(req.OrderTimeoutMin),
SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders), SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders),
SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled), SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled),
SettingBalanceRechargeMult: formatPositiveFloat(req.BalanceRechargeMultiplier),
SettingRechargeFeeRate: formatNonNegativeFloat(req.RechargeFeeRate),
SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy), SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy),
SettingProductNamePrefix: derefStr(req.ProductNamePrefix), SettingProductNamePrefix: derefStr(req.ProductNamePrefix),
SettingProductNameSuffix: derefStr(req.ProductNameSuffix), SettingProductNameSuffix: derefStr(req.ProductNameSuffix),
...@@ -297,6 +324,13 @@ func formatPositiveFloat(v *float64) string { ...@@ -297,6 +324,13 @@ func formatPositiveFloat(v *float64) string {
return strconv.FormatFloat(*v, 'f', 2, 64) return strconv.FormatFloat(*v, 'f', 2, 64)
} }
func formatNonNegativeFloat(v *float64) string {
if v == nil || *v < 0 {
return ""
}
return strconv.FormatFloat(*v, 'f', 2, 64)
}
func formatPositiveInt(v *int) string { func formatPositiveInt(v *int) string {
if v == nil || *v <= 0 { if v == nil || *v <= 0 {
return "" return ""
......
...@@ -216,7 +216,11 @@ func (s *PaymentService) markCompleted(ctx context.Context, o *dbent.PaymentOrde ...@@ -216,7 +216,11 @@ func (s *PaymentService) markCompleted(ctx context.Context, o *dbent.PaymentOrde
if err != nil { if err != nil {
return fmt.Errorf("mark completed: %w", err) 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 return nil
} }
......
...@@ -43,18 +43,22 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest ...@@ -43,18 +43,22 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
if user.Status != payment.EntityStatusActive { if user.Status != payment.EntityStatusActive {
return nil, infraerrors.Forbidden("USER_INACTIVE", "user account is disabled") return nil, infraerrors.Forbidden("USER_INACTIVE", "user account is disabled")
} }
amount := req.Amount orderAmount := req.Amount
limitAmount := req.Amount
if plan != nil { 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) feeRate := cfg.RechargeFeeRate
payAmountStr := payment.CalculatePayAmount(amount, feeRate) payAmountStr := payment.CalculatePayAmount(limitAmount, feeRate)
payAmount, _ := strconv.ParseFloat(payAmountStr, 64) 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 { if err != nil {
return nil, err return nil, err
} }
resp, err := s.invokeProvider(ctx, order, req, cfg, payAmountStr, payAmount, plan) resp, err := s.invokeProvider(ctx, order, req, cfg, limitAmount, payAmountStr, payAmount, plan)
if err != nil { if err != nil {
_, _ = s.entClient.PaymentOrder.UpdateOneID(order.ID). _, _ = s.entClient.PaymentOrder.UpdateOneID(order.ID).
SetStatus(OrderStatusFailed). SetStatus(OrderStatusFailed).
...@@ -99,7 +103,7 @@ func (s *PaymentService) validateSubOrder(ctx context.Context, req CreateOrderRe ...@@ -99,7 +103,7 @@ func (s *PaymentService) validateSubOrder(ctx context.Context, req CreateOrderRe
return plan, nil 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) tx, err := s.entClient.Tx(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err) return nil, fmt.Errorf("begin transaction: %w", err)
...@@ -108,7 +112,7 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq ...@@ -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 { if err := s.checkPendingLimit(ctx, tx, req.UserID, cfg.MaxPendingOrders); err != nil {
return nil, err 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 return nil, err
} }
tm := cfg.OrderTimeoutMin tm := cfg.OrderTimeoutMin
...@@ -121,7 +125,7 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq ...@@ -121,7 +125,7 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq
SetUserEmail(user.Email). SetUserEmail(user.Email).
SetUserName(user.Username). SetUserName(user.Username).
SetNillableUserNotes(psNilIfEmpty(user.Notes)). SetNillableUserNotes(psNilIfEmpty(user.Notes)).
SetAmount(amount). SetAmount(orderAmount).
SetPayAmount(payAmount). SetPayAmount(payAmount).
SetFeeRate(feeRate). SetFeeRate(feeRate).
SetRechargeCode(""). SetRechargeCode("").
...@@ -180,6 +184,10 @@ func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, user ...@@ -180,6 +184,10 @@ func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, user
} }
var used float64 var used float64
for _, o := range orders { for _, o := range orders {
if o.OrderType == payment.OrderTypeBalance {
used += o.PayAmount
continue
}
used += o.Amount used += o.Amount
} }
if used+amount > limit { if used+amount > limit {
...@@ -188,7 +196,7 @@ func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, user ...@@ -188,7 +196,7 @@ func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, user
return nil return nil
} }
func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.PaymentOrder, req CreateOrderRequest, cfg *PaymentConfig, payAmountStr string, payAmount float64, plan *dbent.SubscriptionPlan) (*CreateOrderResponse, error) { func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.PaymentOrder, req CreateOrderRequest, cfg *PaymentConfig, limitAmount float64, payAmountStr string, payAmount float64, plan *dbent.SubscriptionPlan) (*CreateOrderResponse, error) {
// Select an instance across all providers that support the requested payment type. // Select an instance across all providers that support the requested payment type.
// This enables cross-provider load balancing (e.g. EasyPay + Alipay direct for "alipay"). // This enables cross-provider load balancing (e.g. EasyPay + Alipay direct for "alipay").
sel, err := s.loadBalancer.SelectInstance(ctx, "", req.PaymentType, payment.Strategy(cfg.LoadBalanceStrategy), payAmount) sel, err := s.loadBalancer.SelectInstance(ctx, "", req.PaymentType, payment.Strategy(cfg.LoadBalanceStrategy), payAmount)
...@@ -202,7 +210,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen ...@@ -202,7 +210,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
if err != nil { if err != nil {
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "payment method is temporarily unavailable") return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "payment method is temporarily unavailable")
} }
subject := s.buildPaymentSubject(plan, payAmountStr, cfg) subject := s.buildPaymentSubject(plan, limitAmount, cfg)
outTradeNo := order.OutTradeNo outTradeNo := order.OutTradeNo
pr, err := prov.CreatePayment(ctx, payment.CreatePaymentRequest{OrderID: outTradeNo, Amount: payAmountStr, PaymentType: req.PaymentType, Subject: subject, ClientIP: req.ClientIP, IsMobile: req.IsMobile, InstanceSubMethods: sel.SupportedTypes}) pr, err := prov.CreatePayment(ctx, payment.CreatePaymentRequest{OrderID: outTradeNo, Amount: payAmountStr, PaymentType: req.PaymentType, Subject: subject, ClientIP: req.ClientIP, IsMobile: req.IsMobile, InstanceSubMethods: sel.SupportedTypes})
if err != nil { if err != nil {
...@@ -213,23 +221,30 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen ...@@ -213,23 +221,30 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
if err != nil { if err != nil {
return nil, fmt.Errorf("update order with payment details: %w", err) 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 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
} }
func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, payAmountStr string, cfg *PaymentConfig) string { func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, limitAmount float64, cfg *PaymentConfig) string {
if plan != nil { if plan != nil {
if plan.ProductName != "" { if plan.ProductName != "" {
return plan.ProductName return plan.ProductName
} }
return "Sub2API Subscription " + plan.Name return "Sub2API Subscription " + plan.Name
} }
amountStr := strconv.FormatFloat(limitAmount, 'f', 2, 64)
pf := strings.TrimSpace(cfg.ProductNamePrefix) pf := strings.TrimSpace(cfg.ProductNamePrefix)
sf := strings.TrimSpace(cfg.ProductNameSuffix) sf := strings.TrimSpace(cfg.ProductNameSuffix)
if pf != "" || sf != "" { if pf != "" || sf != "" {
return strings.TrimSpace(pf + " " + payAmountStr + " " + sf) return strings.TrimSpace(pf + " " + amountStr + " " + sf)
} }
return "Sub2API " + payAmountStr + " CNY" return "Sub2API " + amountStr + " CNY"
} }
// --- Order Queries --- // --- Order Queries ---
......
...@@ -113,11 +113,7 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float ...@@ -113,11 +113,7 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float
if amt-o.Amount > amountToleranceCNY { if amt-o.Amount > amountToleranceCNY {
return nil, nil, infraerrors.BadRequest("REFUND_AMOUNT_EXCEEDED", "refund amount exceeds recharge") return nil, nil, infraerrors.BadRequest("REFUND_AMOUNT_EXCEEDED", "refund amount exceeds recharge")
} }
// Full refund: use actual pay_amount for gateway (includes fees) ga := calculateGatewayRefundAmount(o.Amount, o.PayAmount, amt)
ga := amt
if math.Abs(amt-o.Amount) <= amountToleranceCNY {
ga = o.PayAmount
}
rr := strings.TrimSpace(reason) rr := strings.TrimSpace(reason)
if rr == "" && o.RefundRequestReason != nil { if rr == "" && o.RefundRequestReason != nil {
rr = *o.RefundRequestReason rr = *o.RefundRequestReason
......
...@@ -288,8 +288,6 @@ func psComputeValidityDays(days int, unit string) int { ...@@ -288,8 +288,6 @@ func psComputeValidityDays(days int, unit string) int {
} }
} }
func (s *PaymentService) getFeeRate(_ string) float64 { return 0 }
func psStartOfDayUTC(t time.Time) time.Time { func psStartOfDayUTC(t time.Time) time.Time {
y, m, d := t.UTC().Date() y, m, d := t.UTC().Date()
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
......
...@@ -23,6 +23,7 @@ export interface AdminPaymentConfig { ...@@ -23,6 +23,7 @@ export interface AdminPaymentConfig {
max_pending_orders: number max_pending_orders: number
enabled_payment_types: string[] enabled_payment_types: string[]
balance_disabled: boolean balance_disabled: boolean
balance_recharge_multiplier: number
load_balance_strategy: string load_balance_strategy: string
product_name_prefix: string product_name_prefix: string
product_name_suffix: string product_name_suffix: string
...@@ -40,6 +41,7 @@ export interface UpdatePaymentConfigRequest { ...@@ -40,6 +41,7 @@ export interface UpdatePaymentConfigRequest {
max_pending_orders?: number max_pending_orders?: number
enabled_payment_types?: string[] enabled_payment_types?: string[]
balance_disabled?: boolean balance_disabled?: boolean
balance_recharge_multiplier?: number
load_balance_strategy?: string load_balance_strategy?: string
product_name_prefix?: string product_name_prefix?: string
product_name_suffix?: string product_name_suffix?: string
......
...@@ -125,6 +125,8 @@ export interface SystemSettings { ...@@ -125,6 +125,8 @@ export interface SystemSettings {
payment_max_pending_orders: number payment_max_pending_orders: number
payment_enabled_types: string[] payment_enabled_types: string[]
payment_balance_disabled: boolean payment_balance_disabled: boolean
payment_balance_recharge_multiplier: number
payment_recharge_fee_rate: number
payment_load_balance_strategy: string payment_load_balance_strategy: string
payment_product_name_prefix: string payment_product_name_prefix: string
payment_product_name_suffix: string payment_product_name_suffix: string
...@@ -231,6 +233,8 @@ export interface UpdateSettingsRequest { ...@@ -231,6 +233,8 @@ export interface UpdateSettingsRequest {
payment_max_pending_orders?: number payment_max_pending_orders?: number
payment_enabled_types?: string[] payment_enabled_types?: string[]
payment_balance_disabled?: boolean payment_balance_disabled?: boolean
payment_balance_recharge_multiplier?: number
payment_recharge_fee_rate?: number
payment_load_balance_strategy?: string payment_load_balance_strategy?: string
payment_product_name_prefix?: string payment_product_name_prefix?: string
payment_product_name_suffix?: string payment_product_name_suffix?: string
......
...@@ -18,12 +18,20 @@ ...@@ -18,12 +18,20 @@
</span> </span>
</div> </div>
<div> <div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</p> <p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.baseAmount') }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">${{ order.amount.toFixed(2) }}</p> <p class="text-sm font-medium text-gray-900 dark:text-white">¥{{ baseAmount.toFixed(2) }}</p>
</div>
<div v-if="order.fee_rate > 0">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.fee') }} ({{ order.fee_rate }}%)</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">¥{{ feeAmount.toFixed(2) }}</p>
</div> </div>
<div> <div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</p> <p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">${{ order.pay_amount.toFixed(2) }}</p> <p class="text-sm font-medium text-gray-900 dark:text-white">¥{{ order.pay_amount.toFixed(2) }}</p>
</div>
<div v-if="order.amount !== order.pay_amount">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.creditedAmount') }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ order.order_type === 'balance' ? '$' : '¥' }}{{ order.amount.toFixed(2) }}</p>
</div> </div>
<div> <div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</p> <p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</p>
...@@ -31,10 +39,6 @@ ...@@ -31,10 +39,6 @@
{{ t('payment.methods.' + order.payment_type, order.payment_type) }} {{ t('payment.methods.' + order.payment_type, order.payment_type) }}
</p> </p>
</div> </div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.feeRate') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">{{ (order.fee_rate * 100).toFixed(1) }}%</p>
</div>
<div> <div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.orderType') }}</p> <p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.orderType') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300"> <p class="text-sm text-gray-700 dark:text-gray-300">
...@@ -73,7 +77,7 @@ ...@@ -73,7 +77,7 @@
<div class="grid grid-cols-2 gap-2 text-sm"> <div class="grid grid-cols-2 gap-2 text-sm">
<div> <div>
<span class="text-red-600 dark:text-red-400">{{ t('payment.admin.refundAmount') }}:</span> <span class="text-red-600 dark:text-red-400">{{ t('payment.admin.refundAmount') }}:</span>
<span class="ml-1 font-medium text-red-700 dark:text-red-300">${{ order.refund_amount.toFixed(2) }}</span> <span class="ml-1 font-medium text-red-700 dark:text-red-300">{{ order.order_type === 'balance' ? '$' : '¥' }}{{ order.refund_amount.toFixed(2) }}</span>
</div> </div>
<div v-if="order.refund_reason" class="col-span-2"> <div v-if="order.refund_reason" class="col-span-2">
<span class="text-red-600 dark:text-red-400">{{ t('payment.admin.refundReason') }}:</span> <span class="text-red-600 dark:text-red-400">{{ t('payment.admin.refundReason') }}:</span>
...@@ -110,6 +114,7 @@ ...@@ -110,6 +114,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import type { PaymentOrder } from '@/types/payment' import type { PaymentOrder } from '@/types/payment'
...@@ -117,11 +122,24 @@ import { statusBadgeClass, canRefund as canRefundStatus, formatOrderDateTime } f ...@@ -117,11 +122,24 @@ import { statusBadgeClass, canRefund as canRefundStatus, formatOrderDateTime } f
const { t } = useI18n() const { t } = useI18n()
defineProps<{ const props = defineProps<{
show: boolean show: boolean
order: PaymentOrder | null order: PaymentOrder | null
}>() }>()
/** 充值金额 (base amount before fee) = pay_amount - fee = pay_amount / (1 + fee_rate/100) */
const baseAmount = computed(() => {
if (!props.order) return 0
if (props.order.fee_rate <= 0) return props.order.pay_amount
return props.order.pay_amount / (1 + props.order.fee_rate / 100)
})
/** 手续费 = pay_amount - baseAmount */
const feeAmount = computed(() => {
if (!props.order || props.order.fee_rate <= 0) return 0
return props.order.pay_amount - baseAmount.value
})
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'close'): void (e: 'close'): void
(e: 'cancel', order: PaymentOrder): void (e: 'cancel', order: PaymentOrder): void
......
...@@ -51,12 +51,15 @@ ...@@ -51,12 +51,15 @@
<span class="text-sm text-gray-600 dark:text-gray-400">#{{ value }}</span> <span class="text-sm text-gray-600 dark:text-gray-400">#{{ value }}</span>
</template> </template>
<template #cell-amount="{ value, row }"> <template #cell-pay_amount="{ value, row }">
<div class="text-sm"> <div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span> <span class="font-medium text-gray-900 dark:text-white">¥{{ value.toFixed(2) }}</span>
<span v-if="row.pay_amount !== value" class="ml-1 text-xs text-gray-500"> <span v-if="row.fee_rate > 0" class="ml-1 text-xs text-gray-400" :title="t('payment.orders.fee') + ': ' + row.fee_rate + '%'">
({{ t('payment.orders.payAmount') }}: ${{ row.pay_amount.toFixed(2) }}) ({{ row.fee_rate }}%)
</span> </span>
<div v-if="row.amount !== row.pay_amount" class="text-xs text-gray-500">
{{ t('payment.orders.creditedAmount') }}: {{ row.order_type === 'balance' ? '$' : '¥' }}{{ row.amount.toFixed(2) }}
</div>
</div> </div>
</template> </template>
...@@ -183,7 +186,7 @@ function emitFiltersChanged() { ...@@ -183,7 +186,7 @@ function emitFiltersChanged() {
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'id', label: t('payment.orders.orderId') }, { key: 'id', label: t('payment.orders.orderId') },
{ key: 'user_id', label: t('payment.orders.userId') }, { key: 'user_id', label: t('payment.orders.userId') },
{ key: 'amount', label: t('payment.orders.amount') }, { key: 'pay_amount', label: t('payment.orders.payAmount') },
{ key: 'payment_type', label: t('payment.orders.paymentMethod') }, { key: 'payment_type', label: t('payment.orders.paymentMethod') },
{ key: 'status', label: t('payment.orders.status') }, { key: 'status', label: t('payment.orders.status') },
{ key: 'order_type', label: t('payment.orders.orderType') }, { key: 'order_type', label: t('payment.orders.orderType') },
......
...@@ -34,12 +34,16 @@ ...@@ -34,12 +34,16 @@
<span class="font-mono text-gray-900 dark:text-white">#{{ order?.id }}</span> <span class="font-mono text-gray-900 dark:text-white">#{{ order?.id }}</span>
</div> </div>
<div class="mt-1 flex justify-between text-sm"> <div class="mt-1 flex justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.creditedAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">${{ order?.pay_amount?.toFixed(2) }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ order?.order_type === 'balance' ? '$' : '¥' }}{{ order?.amount?.toFixed(2) }}</span>
</div>
<div class="mt-1 flex justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">¥{{ order?.pay_amount?.toFixed(2) }}</span>
</div> </div>
<div v-if="actuallyRefunded > 0" class="mt-1 flex justify-between text-sm"> <div v-if="actuallyRefunded > 0" class="mt-1 flex justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.alreadyRefunded') }}</span> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.alreadyRefunded') }}</span>
<span class="font-medium text-red-600 dark:text-red-400">${{ actuallyRefunded.toFixed(2) }}</span> <span class="font-medium text-red-600 dark:text-red-400">{{ order?.order_type === 'balance' ? '$' : '¥' }}{{ actuallyRefunded.toFixed(2) }}</span>
</div> </div>
</div> </div>
...@@ -66,7 +70,7 @@ ...@@ -66,7 +70,7 @@
</div> </div>
<div class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-dark-700"> <div class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-dark-700">
<div class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.orderAmount') }}</div> <div class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.orderAmount') }}</div>
<div class="mt-1 font-semibold text-gray-900 dark:text-white">${{ order?.pay_amount?.toFixed(2) }}</div> <div class="mt-1 font-semibold text-gray-900 dark:text-white">{{ order?.order_type === 'balance' ? '$' : '¥' }}{{ order?.amount?.toFixed(2) }}</div>
</div> </div>
</div> </div>
...@@ -91,7 +95,7 @@ ...@@ -91,7 +95,7 @@
<div> <div>
<label class="input-label">{{ t('payment.admin.refundAmount') }}</label> <label class="input-label">{{ t('payment.admin.refundAmount') }}</label>
<div class="relative"> <div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span> <span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">{{ order?.order_type === 'balance' ? '$' : '¥' }}</span>
<input <input
v-model.number="form.amount" v-model.number="form.amount"
type="number" type="number"
...@@ -103,7 +107,7 @@ ...@@ -103,7 +107,7 @@
/> />
</div> </div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('payment.admin.maxRefundable') }}: ${{ maxRefundable.toFixed(2) }} {{ t('payment.admin.maxRefundable') }}: {{ order?.order_type === 'balance' ? '$' : '¥' }}{{ maxRefundable.toFixed(2) }}
</p> </p>
</div> </div>
...@@ -200,12 +204,12 @@ const actuallyRefunded = computed(() => { ...@@ -200,12 +204,12 @@ const actuallyRefunded = computed(() => {
const maxRefundable = computed(() => { const maxRefundable = computed(() => {
if (!props.order) return 0 if (!props.order) return 0
return props.order.pay_amount - actuallyRefunded.value return props.order.amount - actuallyRefunded.value
}) })
const balanceInsufficient = computed(() => { const balanceInsufficient = computed(() => {
if (props.userBalance == null || !props.order) return false if (props.userBalance == null || !props.order) return false
return props.userBalance < props.order.pay_amount return props.userBalance < props.order.amount
}) })
watch(() => props.show, (val) => { watch(() => props.show, (val) => {
......
...@@ -12,10 +12,15 @@ ...@@ -12,10 +12,15 @@
<span v-if="row.user_notes" class="ml-1 text-xs text-gray-400">({{ row.user_notes }})</span> <span v-if="row.user_notes" class="ml-1 text-xs text-gray-400">({{ row.user_notes }})</span>
</div> </div>
</template> </template>
<template #cell-amount="{ value, row }"> <template #cell-pay_amount="{ value, row }">
<div class="text-sm"> <div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span> <span class="font-medium text-gray-900 dark:text-white">¥{{ value.toFixed(2) }}</span>
<span v-if="row.pay_amount !== value" class="ml-1 text-xs text-gray-500">(${{ row.pay_amount.toFixed(2) }})</span> <span v-if="row.fee_rate > 0" class="ml-1 text-xs text-gray-400" :title="t('payment.orders.fee') + ': ' + row.fee_rate + '%'">
({{ t('payment.orders.fee') }} {{ row.fee_rate }}%)
</span>
<div v-if="row.amount !== row.pay_amount" class="text-xs text-gray-500">
{{ t('payment.orders.creditedAmount') }}: {{ row.order_type === 'balance' ? '$' : '¥' }}{{ row.amount.toFixed(2) }}
</div>
</div> </div>
</template> </template>
<template #cell-payment_type="{ value }"> <template #cell-payment_type="{ value }">
...@@ -60,7 +65,7 @@ const columns = computed((): Column[] => { ...@@ -60,7 +65,7 @@ const columns = computed((): Column[] => {
cols.push({ key: 'user_email', label: t('payment.admin.colUser') }) cols.push({ key: 'user_email', label: t('payment.admin.colUser') })
} }
cols.push( cols.push(
{ key: 'amount', label: t('payment.orders.amount') }, { key: 'pay_amount', label: t('payment.orders.payAmount') },
{ key: 'payment_type', label: t('payment.orders.paymentMethod') }, { key: 'payment_type', label: t('payment.orders.paymentMethod') },
{ key: 'status', label: t('payment.orders.status') }, { key: 'status', label: t('payment.orders.status') },
{ key: 'created_at', label: t('payment.orders.createdAt') }, { key: 'created_at', label: t('payment.orders.createdAt') },
......
...@@ -45,7 +45,11 @@ ...@@ -45,7 +45,11 @@
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">${{ paidOrder.pay_amount.toFixed(2) }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ paidOrder.order_type === 'balance' ? '$' : '¥' }}{{ paidOrder.amount.toFixed(2) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">¥{{ paidOrder.pay_amount.toFixed(2) }}</span>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -22,7 +22,11 @@ ...@@ -22,7 +22,11 @@
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">${{ paidOrder.pay_amount.toFixed(2) }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ paidOrder.order_type === 'balance' ? '$' : '¥' }}{{ paidOrder.amount.toFixed(2) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">¥{{ paidOrder.pay_amount.toFixed(2) }}</span>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -21,9 +21,13 @@ ...@@ -21,9 +21,13 @@
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
<span class="font-medium text-gray-900 dark:text-white">#{{ orderId }}</span> <span class="font-medium text-gray-900 dark:text-white">#{{ orderId }}</span>
</div> </div>
<div class="flex justify-between"> <div v-if="amount > 0" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">${{ payAmount.toFixed(2) }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ orderType === 'balance' ? '$' : '¥' }}{{ amount.toFixed(2) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">¥{{ payAmount.toFixed(2) }}</span>
</div> </div>
</div> </div>
</div> </div>
...@@ -36,7 +40,7 @@ ...@@ -36,7 +40,7 @@
<div class="card overflow-hidden"> <div class="card overflow-hidden">
<div class="bg-gradient-to-br from-[#635bff] to-[#4f46e5] px-6 py-5 text-center"> <div class="bg-gradient-to-br from-[#635bff] to-[#4f46e5] px-6 py-5 text-center">
<p class="text-sm font-medium text-indigo-200">{{ t('payment.actualPay') }}</p> <p class="text-sm font-medium text-indigo-200">{{ t('payment.actualPay') }}</p>
<p class="mt-1 text-3xl font-bold text-white">${{ payAmount.toFixed(2) }}</p> <p class="mt-1 text-3xl font-bold text-white">¥{{ payAmount.toFixed(2) }}</p>
</div> </div>
</div> </div>
<!-- Stripe Payment Element --> <!-- Stripe Payment Element -->
...@@ -75,7 +79,9 @@ const POPUP_METHODS = new Set(['alipay', 'wechat_pay']) ...@@ -75,7 +79,9 @@ const POPUP_METHODS = new Set(['alipay', 'wechat_pay'])
const props = defineProps<{ const props = defineProps<{
orderId: number orderId: number
amount: number
clientSecret: string clientSecret: string
orderType?: 'balance' | 'subscription'
publishableKey: string publishableKey: string
payAmount: number payAmount: number
}>() }>()
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment