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:
@@ -188,6 +188,7 @@ 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,
|
||||||
PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy,
|
PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy,
|
||||||
PaymentProductNamePrefix: paymentCfg.ProductNamePrefix,
|
PaymentProductNamePrefix: paymentCfg.ProductNamePrefix,
|
||||||
PaymentProductNameSuffix: paymentCfg.ProductNameSuffix,
|
PaymentProductNameSuffix: paymentCfg.ProductNameSuffix,
|
||||||
@@ -323,9 +324,10 @@ type UpdateSettingsRequest struct {
|
|||||||
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"`
|
||||||
|
PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"`
|
||||||
PaymentProductNamePrefix *string `json:"payment_product_name_prefix"`
|
PaymentProductNamePrefix *string `json:"payment_product_name_prefix"`
|
||||||
PaymentProductNameSuffix *string `json:"payment_product_name_suffix"`
|
PaymentProductNameSuffix *string `json:"payment_product_name_suffix"`
|
||||||
PaymentHelpImageURL *string `json:"payment_help_image_url"`
|
PaymentHelpImageURL *string `json:"payment_help_image_url"`
|
||||||
@@ -934,24 +936,25 @@ 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,
|
LoadBalanceStrategy: req.PaymentLoadBalanceStrat,
|
||||||
ProductNameSuffix: req.PaymentProductNameSuffix,
|
ProductNamePrefix: req.PaymentProductNamePrefix,
|
||||||
HelpImageURL: req.PaymentHelpImageURL,
|
ProductNameSuffix: req.PaymentProductNameSuffix,
|
||||||
HelpText: req.PaymentHelpText,
|
HelpImageURL: req.PaymentHelpImageURL,
|
||||||
CancelRateLimitEnabled: req.PaymentCancelRateLimitEnabled,
|
HelpText: req.PaymentHelpText,
|
||||||
CancelRateLimitMax: req.PaymentCancelRateLimitMax,
|
CancelRateLimitEnabled: req.PaymentCancelRateLimitEnabled,
|
||||||
CancelRateLimitWindow: req.PaymentCancelRateLimitWindow,
|
CancelRateLimitMax: req.PaymentCancelRateLimitMax,
|
||||||
CancelRateLimitUnit: req.PaymentCancelRateLimitUnit,
|
CancelRateLimitWindow: req.PaymentCancelRateLimitWindow,
|
||||||
CancelRateLimitMode: req.PaymentCancelRateLimitMode,
|
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 +1085,7 @@ 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,
|
||||||
PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy,
|
PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy,
|
||||||
PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix,
|
PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix,
|
||||||
PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix,
|
PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix,
|
||||||
@@ -1101,6 +1105,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.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 ||
|
||||||
|
|||||||
@@ -134,9 +134,10 @@ type SystemSettings struct {
|
|||||||
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"`
|
||||||
|
PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"`
|
||||||
PaymentProductNamePrefix string `json:"payment_product_name_prefix"`
|
PaymentProductNamePrefix string `json:"payment_product_name_prefix"`
|
||||||
PaymentProductNameSuffix string `json:"payment_product_name_suffix"`
|
PaymentProductNameSuffix string `json:"payment_product_name_suffix"`
|
||||||
PaymentHelpImageURL string `json:"payment_help_image_url"`
|
PaymentHelpImageURL string `json:"payment_help_image_url"`
|
||||||
|
|||||||
@@ -126,26 +126,28 @@ 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,
|
HelpText: cfg.HelpText,
|
||||||
StripePublishableKey: cfg.StripePublishableKey,
|
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"`
|
HelpText string `json:"help_text"`
|
||||||
StripePublishableKey string `json:"stripe_publishable_key"`
|
HelpImageURL string `json:"help_image_url"`
|
||||||
|
StripePublishableKey string `json:"stripe_publishable_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type checkoutPlan struct {
|
type checkoutPlan struct {
|
||||||
@@ -381,6 +383,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 +407,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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
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 (
|
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,7 @@ 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"
|
||||||
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"
|
||||||
@@ -46,9 +49,10 @@ type PaymentConfig struct {
|
|||||||
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"`
|
||||||
|
LoadBalanceStrategy string `json:"load_balance_strategy"`
|
||||||
ProductNamePrefix string `json:"product_name_prefix"`
|
ProductNamePrefix string `json:"product_name_prefix"`
|
||||||
ProductNameSuffix string `json:"product_name_suffix"`
|
ProductNameSuffix string `json:"product_name_suffix"`
|
||||||
HelpImageURL string `json:"help_image_url"`
|
HelpImageURL string `json:"help_image_url"`
|
||||||
@@ -71,9 +75,10 @@ type UpdatePaymentConfigRequest struct {
|
|||||||
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"`
|
||||||
|
LoadBalanceStrategy *string `json:"load_balance_strategy"`
|
||||||
ProductNamePrefix *string `json:"product_name_prefix"`
|
ProductNamePrefix *string `json:"product_name_prefix"`
|
||||||
ProductNameSuffix *string `json:"product_name_suffix"`
|
ProductNameSuffix *string `json:"product_name_suffix"`
|
||||||
HelpImageURL *string `json:"help_image_url"`
|
HelpImageURL *string `json:"help_image_url"`
|
||||||
@@ -183,7 +188,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, SettingLoadBalanceStrategy,
|
||||||
SettingProductNamePrefix, SettingProductNameSuffix,
|
SettingProductNamePrefix, SettingProductNameSuffix,
|
||||||
SettingHelpImageURL, SettingHelpText,
|
SettingHelpImageURL, SettingHelpText,
|
||||||
SettingCancelRateLimitOn, SettingCancelRateLimitMax,
|
SettingCancelRateLimitOn, SettingCancelRateLimitMax,
|
||||||
@@ -207,8 +212,9 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme
|
|||||||
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)),
|
||||||
|
LoadBalanceStrategy: vals[SettingLoadBalanceStrategy],
|
||||||
ProductNamePrefix: vals[SettingProductNamePrefix],
|
ProductNamePrefix: vals[SettingProductNamePrefix],
|
||||||
ProductNameSuffix: vals[SettingProductNameSuffix],
|
ProductNameSuffix: vals[SettingProductNameSuffix],
|
||||||
HelpImageURL: vals[SettingHelpImageURL],
|
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
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
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 +275,7 @@ 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),
|
||||||
SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy),
|
SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy),
|
||||||
SettingProductNamePrefix: derefStr(req.ProductNamePrefix),
|
SettingProductNamePrefix: derefStr(req.ProductNamePrefix),
|
||||||
SettingProductNameSuffix: derefStr(req.ProductNameSuffix),
|
SettingProductNameSuffix: derefStr(req.ProductNameSuffix),
|
||||||
|
|||||||
@@ -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,14 +43,18 @@ 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 := s.getFeeRate(req.PaymentType)
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -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
|
|||||||
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
|
|||||||
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
|
|||||||
}
|
}
|
||||||
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 {
|
||||||
@@ -213,7 +221,13 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
|||||||
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,7 @@ 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_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 +232,7 @@ 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_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
|
||||||
|
|||||||
@@ -19,11 +19,11 @@
|
|||||||
</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.amount') }}</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">{{ 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.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>
|
||||||
<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>
|
||||||
@@ -73,7 +73,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>
|
||||||
|
|||||||
@@ -53,9 +53,9 @@
|
|||||||
|
|
||||||
<template #cell-amount="{ value, row }">
|
<template #cell-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">{{ row.order_type === 'balance' ? '$' : '¥' }}{{ value.toFixed(2) }}</span>
|
||||||
<span v-if="row.pay_amount !== value" class="ml-1 text-xs text-gray-500">
|
<span v-if="row.pay_amount !== value" class="ml-1 text-xs text-gray-500">
|
||||||
({{ t('payment.orders.payAmount') }}: ${{ row.pay_amount.toFixed(2) }})
|
({{ t('payment.orders.payAmount') }}: ¥{{ row.pay_amount.toFixed(2) }})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -35,11 +35,15 @@
|
|||||||
</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.amount') }}</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 @@
|
|||||||
</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 @@
|
|||||||
<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 @@
|
|||||||
/>
|
/>
|
||||||
</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(() => {
|
|||||||
|
|
||||||
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) => {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #cell-amount="{ value, row }">
|
<template #cell-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">{{ row.order_type === 'balance' ? '$' : '¥' }}{{ 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.pay_amount !== value" class="ml-1 text-xs text-gray-500">(¥{{ row.pay_amount.toFixed(2) }})</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-payment_type="{ value }">
|
<template #cell-payment_type="{ value }">
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
</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 @@
|
|||||||
<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 @@
|
|||||||
<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'])
|
|||||||
|
|
||||||
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
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -4566,6 +4566,9 @@ export default {
|
|||||||
minAmount: 'Minimum Amount',
|
minAmount: 'Minimum Amount',
|
||||||
maxAmount: 'Maximum Amount',
|
maxAmount: 'Maximum Amount',
|
||||||
dailyLimit: 'Daily Limit',
|
dailyLimit: 'Daily Limit',
|
||||||
|
balanceRechargeMultiplier: 'Balance Recharge Multiplier',
|
||||||
|
balanceRechargeMultiplierHint: 'How many USD balance the user receives for each 1 CNY paid',
|
||||||
|
balanceRechargePreview: 'Preview: 1 CNY = {usd} USD',
|
||||||
orderTimeout: 'Order Timeout',
|
orderTimeout: 'Order Timeout',
|
||||||
orderTimeoutHint: 'In minutes, minimum 1',
|
orderTimeoutHint: 'In minutes, minimum 1',
|
||||||
maxPendingOrders: 'Max Pending Orders',
|
maxPendingOrders: 'Max Pending Orders',
|
||||||
@@ -5324,6 +5327,8 @@ export default {
|
|||||||
payment: {
|
payment: {
|
||||||
title: 'Recharge / Subscription',
|
title: 'Recharge / Subscription',
|
||||||
amountLabel: 'Amount',
|
amountLabel: 'Amount',
|
||||||
|
paymentAmount: 'Payment Amount',
|
||||||
|
creditedBalance: 'Credited Balance',
|
||||||
quickAmounts: 'Quick Amounts',
|
quickAmounts: 'Quick Amounts',
|
||||||
customAmount: 'Custom Amount',
|
customAmount: 'Custom Amount',
|
||||||
enterAmount: 'Enter amount',
|
enterAmount: 'Enter amount',
|
||||||
@@ -5408,6 +5413,7 @@ export default {
|
|||||||
amountTooLow: 'Minimum amount is {min}',
|
amountTooLow: 'Minimum amount is {min}',
|
||||||
amountTooHigh: 'Maximum amount is {max}',
|
amountTooHigh: 'Maximum amount is {max}',
|
||||||
amountNoMethod: 'No payment method available for this amount',
|
amountNoMethod: 'No payment method available for this amount',
|
||||||
|
rechargeRatePreview: 'Current rate: 1 CNY = {usd} USD',
|
||||||
refundReason: 'Refund Reason',
|
refundReason: 'Refund Reason',
|
||||||
refundReasonPlaceholder: 'Please describe your refund reason',
|
refundReasonPlaceholder: 'Please describe your refund reason',
|
||||||
stripeLoadFailed: 'Failed to load payment component. Please refresh and try again.',
|
stripeLoadFailed: 'Failed to load payment component. Please refresh and try again.',
|
||||||
|
|||||||
@@ -4730,6 +4730,9 @@ export default {
|
|||||||
minAmount: '最低金额',
|
minAmount: '最低金额',
|
||||||
maxAmount: '最高金额',
|
maxAmount: '最高金额',
|
||||||
dailyLimit: '每日限额',
|
dailyLimit: '每日限额',
|
||||||
|
balanceRechargeMultiplier: '余额充值倍率',
|
||||||
|
balanceRechargeMultiplierHint: '用户每支付 1 CNY 可获得多少 USD 余额',
|
||||||
|
balanceRechargePreview: '预览:1 CNY = {usd} USD',
|
||||||
orderTimeout: '订单超时时间',
|
orderTimeout: '订单超时时间',
|
||||||
orderTimeoutHint: '单位:分钟,至少 1 分钟',
|
orderTimeoutHint: '单位:分钟,至少 1 分钟',
|
||||||
maxPendingOrders: '最大待支付订单数',
|
maxPendingOrders: '最大待支付订单数',
|
||||||
@@ -5512,6 +5515,8 @@ export default {
|
|||||||
payment: {
|
payment: {
|
||||||
title: '充值/订阅',
|
title: '充值/订阅',
|
||||||
amountLabel: '充值金额',
|
amountLabel: '充值金额',
|
||||||
|
paymentAmount: '支付金额',
|
||||||
|
creditedBalance: '到账余额',
|
||||||
quickAmounts: '快捷金额',
|
quickAmounts: '快捷金额',
|
||||||
customAmount: '自定义金额',
|
customAmount: '自定义金额',
|
||||||
enterAmount: '输入金额',
|
enterAmount: '输入金额',
|
||||||
@@ -5596,6 +5601,7 @@ export default {
|
|||||||
amountTooLow: '最低金额为 {min}',
|
amountTooLow: '最低金额为 {min}',
|
||||||
amountTooHigh: '最高金额为 {max}',
|
amountTooHigh: '最高金额为 {max}',
|
||||||
amountNoMethod: '该金额没有可用的支付方式',
|
amountNoMethod: '该金额没有可用的支付方式',
|
||||||
|
rechargeRatePreview: '当前倍率:1 CNY = {usd} USD',
|
||||||
refundReason: '退款原因',
|
refundReason: '退款原因',
|
||||||
refundReasonPlaceholder: '请描述您的退款原因',
|
refundReasonPlaceholder: '请描述您的退款原因',
|
||||||
stripeLoadFailed: '支付组件加载失败,请刷新页面重试',
|
stripeLoadFailed: '支付组件加载失败,请刷新页面重试',
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface PaymentConfig {
|
|||||||
max_pending_orders: number
|
max_pending_orders: number
|
||||||
order_timeout_minutes: number
|
order_timeout_minutes: number
|
||||||
balance_disabled: boolean
|
balance_disabled: boolean
|
||||||
|
balance_recharge_multiplier: number
|
||||||
enabled_payment_types: PaymentType[]
|
enabled_payment_types: PaymentType[]
|
||||||
help_image_url: string
|
help_image_url: string
|
||||||
help_text: string
|
help_text: string
|
||||||
@@ -62,6 +63,7 @@ export interface CheckoutInfoResponse {
|
|||||||
global_max: number
|
global_max: number
|
||||||
plans: SubscriptionPlan[]
|
plans: SubscriptionPlan[]
|
||||||
balance_disabled: boolean
|
balance_disabled: boolean
|
||||||
|
balance_recharge_multiplier: number
|
||||||
help_text: string
|
help_text: string
|
||||||
help_image_url: string
|
help_image_url: string
|
||||||
stripe_publishable_key: string
|
stripe_publishable_key: string
|
||||||
@@ -155,6 +157,7 @@ export interface CreateOrderRequest {
|
|||||||
|
|
||||||
export interface CreateOrderResult {
|
export interface CreateOrderResult {
|
||||||
order_id: number
|
order_id: number
|
||||||
|
amount: number
|
||||||
pay_url?: string
|
pay_url?: string
|
||||||
qr_code?: string
|
qr_code?: string
|
||||||
client_secret?: string
|
client_secret?: string
|
||||||
|
|||||||
@@ -2371,10 +2371,16 @@
|
|||||||
<div><label class="input-label">{{ t('admin.settings.payment.preview') }}</label><div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300">{{ (form.payment_product_name_prefix || 'Sub2API') + ' 100 ' + (form.payment_product_name_suffix || 'CNY') }}</div></div>
|
<div><label class="input-label">{{ t('admin.settings.payment.preview') }}</label><div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300">{{ (form.payment_product_name_prefix || 'Sub2API') + ' 100 ' + (form.payment_product_name_suffix || 'CNY') }}</div></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Row 2: Balance toggle + amounts -->
|
<!-- Row 2: Balance toggle + amounts -->
|
||||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||||
<div><label class="input-label">{{ t('admin.settings.payment.minAmount') }}</label><input :value="form.payment_min_amount || ''" @input="form.payment_min_amount = parseFloat(($event.target as HTMLInputElement).value) || 0" type="number" step="0.01" min="0" class="input" :placeholder="t('admin.settings.payment.noLimit')" /></div>
|
<div><label class="input-label">{{ t('admin.settings.payment.minAmount') }}</label><input :value="form.payment_min_amount || ''" @input="form.payment_min_amount = parseFloat(($event.target as HTMLInputElement).value) || 0" type="number" step="0.01" min="0" class="input" :placeholder="t('admin.settings.payment.noLimit')" /></div>
|
||||||
<div><label class="input-label">{{ t('admin.settings.payment.maxAmount') }}</label><input :value="form.payment_max_amount || ''" @input="form.payment_max_amount = parseFloat(($event.target as HTMLInputElement).value) || 0" type="number" step="0.01" min="0" class="input" :placeholder="t('admin.settings.payment.noLimit')" /></div>
|
<div><label class="input-label">{{ t('admin.settings.payment.maxAmount') }}</label><input :value="form.payment_max_amount || ''" @input="form.payment_max_amount = parseFloat(($event.target as HTMLInputElement).value) || 0" type="number" step="0.01" min="0" class="input" :placeholder="t('admin.settings.payment.noLimit')" /></div>
|
||||||
<div><label class="input-label">{{ t('admin.settings.payment.dailyLimit') }}</label><input :value="form.payment_daily_limit || ''" @input="form.payment_daily_limit = parseFloat(($event.target as HTMLInputElement).value) || 0" type="number" step="0.01" min="0" class="input" :placeholder="t('admin.settings.payment.noLimit')" /></div>
|
<div><label class="input-label">{{ t('admin.settings.payment.dailyLimit') }}</label><input :value="form.payment_daily_limit || ''" @input="form.payment_daily_limit = parseFloat(($event.target as HTMLInputElement).value) || 0" type="number" step="0.01" min="0" class="input" :placeholder="t('admin.settings.payment.noLimit')" /></div>
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.settings.payment.balanceRechargeMultiplier') }}</label>
|
||||||
|
<input :value="form.payment_balance_recharge_multiplier || ''" @input="form.payment_balance_recharge_multiplier = parseFloat(($event.target as HTMLInputElement).value) || 1" type="number" step="0.01" min="0.01" class="input" />
|
||||||
|
<p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.payment.balanceRechargeMultiplierHint') }}</p>
|
||||||
|
<p class="mt-1 text-xs font-medium text-primary-600 dark:text-primary-400">{{ t('admin.settings.payment.balanceRechargePreview', { usd: (Number(form.payment_balance_recharge_multiplier) || 1).toFixed(2) }) }}</p>
|
||||||
|
</div>
|
||||||
<div><label class="input-label">{{ t('admin.settings.payment.orderTimeout') }} <span class="text-red-500">*</span></label><input v-model.number="form.payment_order_timeout_minutes" type="number" min="1" class="input" required /><p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.payment.orderTimeoutHint') }}</p></div>
|
<div><label class="input-label">{{ t('admin.settings.payment.orderTimeout') }} <span class="text-red-500">*</span></label><input v-model.number="form.payment_order_timeout_minutes" type="number" min="1" class="input" required /><p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.payment.orderTimeoutHint') }}</p></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Row 3: Pending orders + load balance + cancel rate limit (all in one row) -->
|
<!-- Row 3: Pending orders + load balance + cancel rate limit (all in one row) -->
|
||||||
@@ -2968,7 +2974,7 @@ const form = reactive<SettingsForm>({
|
|||||||
home_content: '',
|
home_content: '',
|
||||||
backend_mode_enabled: false,
|
backend_mode_enabled: false,
|
||||||
hide_ccs_import_button: false,
|
hide_ccs_import_button: false,
|
||||||
payment_enabled: false, payment_min_amount: 1, payment_max_amount: 10000, payment_daily_limit: 50000, payment_max_pending_orders: 3, payment_order_timeout_minutes: 30, payment_balance_disabled: false, payment_enabled_types: [], payment_help_image_url: '', payment_help_text: '', payment_product_name_prefix: '', payment_product_name_suffix: '', payment_load_balance_strategy: 'round-robin', payment_cancel_rate_limit_enabled: false, payment_cancel_rate_limit_max: 10, payment_cancel_rate_limit_window: 1, payment_cancel_rate_limit_unit: 'day', payment_cancel_rate_limit_window_mode: 'rolling',
|
payment_enabled: false, payment_min_amount: 1, payment_max_amount: 10000, payment_daily_limit: 50000, payment_max_pending_orders: 3, payment_order_timeout_minutes: 30, payment_balance_disabled: false, payment_balance_recharge_multiplier: 1, payment_enabled_types: [], payment_help_image_url: '', payment_help_text: '', payment_product_name_prefix: '', payment_product_name_suffix: '', payment_load_balance_strategy: 'round-robin', payment_cancel_rate_limit_enabled: false, payment_cancel_rate_limit_max: 10, payment_cancel_rate_limit_window: 1, payment_cancel_rate_limit_unit: 'day', payment_cancel_rate_limit_window_mode: 'rolling',
|
||||||
table_default_page_size: tablePageSizeDefault,
|
table_default_page_size: tablePageSizeDefault,
|
||||||
table_page_size_options: [10, 20, 50, 100],
|
table_page_size_options: [10, 20, 50, 100],
|
||||||
custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>,
|
custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>,
|
||||||
@@ -3627,6 +3633,7 @@ async function saveSettings() {
|
|||||||
payment_max_pending_orders: Number(form.payment_max_pending_orders) || 0,
|
payment_max_pending_orders: Number(form.payment_max_pending_orders) || 0,
|
||||||
payment_order_timeout_minutes: Number(form.payment_order_timeout_minutes) || 0,
|
payment_order_timeout_minutes: Number(form.payment_order_timeout_minutes) || 0,
|
||||||
payment_balance_disabled: form.payment_balance_disabled,
|
payment_balance_disabled: form.payment_balance_disabled,
|
||||||
|
payment_balance_recharge_multiplier: Number(form.payment_balance_recharge_multiplier) || 1,
|
||||||
payment_enabled_types: form.payment_enabled_types,
|
payment_enabled_types: form.payment_enabled_types,
|
||||||
payment_load_balance_strategy: form.payment_load_balance_strategy,
|
payment_load_balance_strategy: form.payment_load_balance_strategy,
|
||||||
payment_product_name_prefix: form.payment_product_name_prefix,
|
payment_product_name_prefix: form.payment_product_name_prefix,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
{{ t('payment.admin.retry') }}
|
{{ t('payment.admin.retry') }}
|
||||||
</button>
|
</button>
|
||||||
<template v-if="row.status === 'REFUND_REQUESTED'">
|
<template v-if="row.status === 'REFUND_REQUESTED'">
|
||||||
<span v-if="row.refund_amount" class="rounded-full bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">${{ row.refund_amount.toFixed(2) }}</span>
|
<span v-if="row.refund_amount" class="rounded-full bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">{{ row.order_type === 'balance' ? '$' : '¥' }}{{ row.refund_amount.toFixed(2) }}</span>
|
||||||
<button @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20">
|
<button @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20">
|
||||||
<Icon name="check" size="sm" />
|
<Icon name="check" size="sm" />
|
||||||
{{ t('payment.admin.approveRefund') }}
|
{{ t('payment.admin.approveRefund') }}
|
||||||
@@ -62,14 +62,14 @@
|
|||||||
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</p><p class="font-mono text-sm font-medium text-gray-900 dark:text-white">#{{ selectedOrder.id }}</p></div>
|
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</p><p class="font-mono text-sm font-medium text-gray-900 dark:text-white">#{{ selectedOrder.id }}</p></div>
|
||||||
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderNo') }}</p><p class="text-sm font-medium text-gray-900 dark:text-white">{{ selectedOrder.out_trade_no }}</p></div>
|
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderNo') }}</p><p class="text-sm font-medium text-gray-900 dark:text-white">{{ selectedOrder.out_trade_no }}</p></div>
|
||||||
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.status') }}</p><OrderStatusBadge :status="selectedOrder.status" /></div>
|
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.status') }}</p><OrderStatusBadge :status="selectedOrder.status" /></div>
|
||||||
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</p><p class="text-sm font-medium text-gray-900 dark:text-white">${{ selectedOrder.amount.toFixed(2) }}</p></div>
|
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</p><p class="text-sm font-medium text-gray-900 dark:text-white">{{ selectedOrder.order_type === 'balance' ? '$' : '¥' }}{{ selectedOrder.amount.toFixed(2) }}</p></div>
|
||||||
<div><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">${{ selectedOrder.pay_amount.toFixed(2) }}</p></div>
|
<div><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">¥{{ selectedOrder.pay_amount.toFixed(2) }}</p></div>
|
||||||
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.methods.' + selectedOrder.payment_type, selectedOrder.payment_type) }}</p></div>
|
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.methods.' + selectedOrder.payment_type, selectedOrder.payment_type) }}</p></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">{{ (selectedOrder.fee_rate * 100).toFixed(1) }}%</p></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">{{ (selectedOrder.fee_rate * 100).toFixed(1) }}%</p></div>
|
||||||
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.createdAt') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(selectedOrder.created_at) }}</p></div>
|
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.createdAt') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(selectedOrder.created_at) }}</p></div>
|
||||||
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.expiresAt') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(selectedOrder.expires_at) }}</p></div>
|
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.expiresAt') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(selectedOrder.expires_at) }}</p></div>
|
||||||
<div v-if="selectedOrder.paid_at"><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.paidAt') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(selectedOrder.paid_at) }}</p></div>
|
<div v-if="selectedOrder.paid_at"><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.paidAt') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(selectedOrder.paid_at) }}</p></div>
|
||||||
<div v-if="selectedOrder.refund_amount"><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundAmount') }}</p><p class="text-sm font-medium text-red-600 dark:text-red-400">${{ selectedOrder.refund_amount.toFixed(2) }}</p></div>
|
<div v-if="selectedOrder.refund_amount"><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundAmount') }}</p><p class="text-sm font-medium text-red-600 dark:text-red-400">{{ selectedOrder.order_type === 'balance' ? '$' : '¥' }}{{ selectedOrder.refund_amount.toFixed(2) }}</p></div>
|
||||||
<div v-if="selectedOrder.refund_reason" class="col-span-2"><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundReason') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ selectedOrder.refund_reason }}</p></div>
|
<div v-if="selectedOrder.refund_reason" class="col-span-2"><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundReason') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ selectedOrder.refund_reason }}</p></div>
|
||||||
<!-- Refund request info -->
|
<!-- Refund request info -->
|
||||||
<div v-if="selectedOrder.refund_requested_at" class="col-span-2 border-t border-gray-200 pt-3 dark:border-dark-600">
|
<div v-if="selectedOrder.refund_requested_at" class="col-span-2 border-t border-gray-200 pt-3 dark:border-dark-600">
|
||||||
|
|||||||
@@ -38,6 +38,10 @@
|
|||||||
</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">{{ order.order_type === 'balance' ? '$' : '¥' }}{{ order.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">¥{{ order.pay_amount.toFixed(2) }}</span>
|
<span class="font-medium text-gray-900 dark:text-white">¥{{ order.pay_amount.toFixed(2) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
@@ -58,7 +62,7 @@
|
|||||||
<span class="font-medium text-gray-900 dark:text-white">{{ returnInfo.outTradeNo }}</span>
|
<span class="font-medium text-gray-900 dark:text-white">{{ returnInfo.outTradeNo }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="returnInfo.money" class="flex justify-between">
|
<div v-if="returnInfo.money" 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.payAmount') }}</span>
|
||||||
<span class="font-medium text-gray-900 dark:text-white">¥{{ returnInfo.money }}</span>
|
<span class="font-medium text-gray-900 dark:text-white">¥{{ returnInfo.money }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="returnInfo.type" class="flex justify-between">
|
<div v-if="returnInfo.type" class="flex justify-between">
|
||||||
|
|||||||
@@ -28,7 +28,9 @@
|
|||||||
<template v-else-if="paymentPhase === 'stripe'">
|
<template v-else-if="paymentPhase === 'stripe'">
|
||||||
<StripePaymentInline
|
<StripePaymentInline
|
||||||
:order-id="paymentState.orderId"
|
:order-id="paymentState.orderId"
|
||||||
|
:amount="paymentState.amount"
|
||||||
:client-secret="paymentState.clientSecret"
|
:client-secret="paymentState.clientSecret"
|
||||||
|
:order-type="paymentState.orderType || undefined"
|
||||||
:publishable-key="checkout.stripe_publishable_key"
|
:publishable-key="checkout.stripe_publishable_key"
|
||||||
:pay-amount="paymentState.payAmount"
|
:pay-amount="paymentState.payAmount"
|
||||||
@success="onPaymentSuccess"
|
@success="onPaymentSuccess"
|
||||||
@@ -67,13 +69,17 @@
|
|||||||
@select="selectedMethod = $event"
|
@select="selectedMethod = $event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="feeRate > 0 && validAmount > 0" class="card p-6">
|
<div v-if="validAmount > 0" class="card p-6">
|
||||||
<div class="space-y-2 text-sm">
|
<div class="space-y-2 text-sm">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span>
|
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.paymentAmount') }}</span>
|
||||||
<span class="text-gray-900 dark:text-white">¥{{ validAmount.toFixed(2) }}</span>
|
<span class="text-gray-900 dark:text-white">¥{{ validAmount.toFixed(2) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.creditedBalance') }}</span>
|
||||||
|
<span class="text-gray-900 dark:text-white">${{ creditedAmount.toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="feeRate > 0" class="flex justify-between">
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
|
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
|
||||||
<span class="text-gray-900 dark:text-white">¥{{ feeAmount.toFixed(2) }}</span>
|
<span class="text-gray-900 dark:text-white">¥{{ feeAmount.toFixed(2) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,6 +87,9 @@
|
|||||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
|
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
|
||||||
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">¥{{ totalAmount.toFixed(2) }}</span>
|
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">¥{{ totalAmount.toFixed(2) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="border-t border-gray-200 pt-2 text-xs text-gray-500 dark:border-dark-600 dark:text-gray-400">
|
||||||
|
{{ t('payment.rechargeRatePreview', { usd: balanceRechargeMultiplier.toFixed(2) }) }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmit || submitting" @click="handleSubmitRecharge">
|
<button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmit || submitting" @click="handleSubmitRecharge">
|
||||||
@@ -88,7 +97,7 @@
|
|||||||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||||
{{ t('common.processing') }}
|
{{ t('common.processing') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>{{ t('payment.createOrder') }} ¥{{ (feeRate > 0 && validAmount > 0 ? totalAmount : validAmount).toFixed(2) }}</span>
|
<span v-else>{{ t('payment.createOrder') }} ¥{{ totalAmount.toFixed(2) }}</span>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20">
|
<div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20">
|
||||||
<p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
|
<p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
|
||||||
@@ -264,7 +273,7 @@ import { useAppStore } from '@/stores'
|
|||||||
import { paymentAPI } from '@/api/payment'
|
import { paymentAPI } from '@/api/payment'
|
||||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||||
import { isMobileDevice } from '@/utils/device'
|
import { isMobileDevice } from '@/utils/device'
|
||||||
import type { SubscriptionPlan, CheckoutInfoResponse } from '@/types/payment'
|
import type { SubscriptionPlan, CheckoutInfoResponse, OrderType } from '@/types/payment'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
import AmountInput from '@/components/payment/AmountInput.vue'
|
import AmountInput from '@/components/payment/AmountInput.vue'
|
||||||
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
|
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
|
||||||
@@ -302,11 +311,21 @@ const previewImage = ref('')
|
|||||||
|
|
||||||
// Payment phase: 'select' → 'paying' (QR/redirect) or 'stripe' (inline Stripe)
|
// Payment phase: 'select' → 'paying' (QR/redirect) or 'stripe' (inline Stripe)
|
||||||
const paymentPhase = ref<'select' | 'paying' | 'stripe'>('select')
|
const paymentPhase = ref<'select' | 'paying' | 'stripe'>('select')
|
||||||
const paymentState = ref({ orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' })
|
const paymentState = ref<{
|
||||||
|
orderId: number
|
||||||
|
amount: number
|
||||||
|
qrCode: string
|
||||||
|
expiresAt: string
|
||||||
|
paymentType: string
|
||||||
|
payUrl: string
|
||||||
|
clientSecret: string
|
||||||
|
payAmount: number
|
||||||
|
orderType: OrderType | ''
|
||||||
|
}>({ orderId: 0, amount: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' })
|
||||||
|
|
||||||
function resetPayment() {
|
function resetPayment() {
|
||||||
paymentPhase.value = 'select'
|
paymentPhase.value = 'select'
|
||||||
paymentState.value = { orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' }
|
paymentState.value = { orderId: 0, amount: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPaymentDone() {
|
function onPaymentDone() {
|
||||||
@@ -342,7 +361,7 @@ function onStripeRedirect(orderId: number, payUrl: string) {
|
|||||||
// All checkout data from single API call
|
// All checkout data from single API call
|
||||||
const checkout = ref<CheckoutInfoResponse>({
|
const checkout = ref<CheckoutInfoResponse>({
|
||||||
methods: {}, global_min: 0, global_max: 0,
|
methods: {}, global_min: 0, global_max: 0,
|
||||||
plans: [], balance_disabled: false, help_text: '', help_image_url: '', stripe_publishable_key: '',
|
plans: [], balance_disabled: false, balance_recharge_multiplier: 1, help_text: '', help_image_url: '', stripe_publishable_key: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const tabs = computed(() => {
|
const tabs = computed(() => {
|
||||||
@@ -354,6 +373,11 @@ const tabs = computed(() => {
|
|||||||
|
|
||||||
const enabledMethods = computed(() => Object.keys(checkout.value.methods))
|
const enabledMethods = computed(() => Object.keys(checkout.value.methods))
|
||||||
const validAmount = computed(() => amount.value ?? 0)
|
const validAmount = computed(() => amount.value ?? 0)
|
||||||
|
const balanceRechargeMultiplier = computed(() => {
|
||||||
|
const multiplier = checkout.value.balance_recharge_multiplier
|
||||||
|
return multiplier > 0 ? multiplier : 1
|
||||||
|
})
|
||||||
|
const creditedAmount = computed(() => Math.round((validAmount.value * balanceRechargeMultiplier.value) * 100) / 100)
|
||||||
|
|
||||||
// Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+
|
// Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+
|
||||||
const planGridClass = computed(() => {
|
const planGridClass = computed(() => {
|
||||||
@@ -518,7 +542,7 @@ async function confirmSubscribe() {
|
|||||||
await createOrder(selectedPlan.value.price, 'subscription', selectedPlan.value.id)
|
await createOrder(selectedPlan.value.price, 'subscription', selectedPlan.value.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createOrder(orderAmount: number, orderType: string, planId?: number) {
|
async function createOrder(orderAmount: number, orderType: OrderType, planId?: number) {
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
try {
|
try {
|
||||||
@@ -537,7 +561,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
|
|||||||
if (result.client_secret) {
|
if (result.client_secret) {
|
||||||
// Stripe: show Payment Element inline (user picks method → confirms → redirect if needed)
|
// Stripe: show Payment Element inline (user picks method → confirms → redirect if needed)
|
||||||
paymentState.value = {
|
paymentState.value = {
|
||||||
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '',
|
orderId: result.order_id, amount: result.amount, qrCode: '', expiresAt: result.expires_at || '',
|
||||||
paymentType: selectedMethod.value, payUrl: '',
|
paymentType: selectedMethod.value, payUrl: '',
|
||||||
clientSecret: result.client_secret, payAmount: result.pay_amount,
|
clientSecret: result.client_secret, payAmount: result.pay_amount,
|
||||||
orderType,
|
orderType,
|
||||||
@@ -546,7 +570,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
|
|||||||
} else if (isMobileDevice() && result.pay_url) {
|
} else if (isMobileDevice() && result.pay_url) {
|
||||||
// Mobile + pay_url: redirect directly instead of QR/popup (mobile browsers block popups)
|
// Mobile + pay_url: redirect directly instead of QR/popup (mobile browsers block popups)
|
||||||
paymentState.value = {
|
paymentState.value = {
|
||||||
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '',
|
orderId: result.order_id, amount: result.amount, qrCode: '', expiresAt: result.expires_at || '',
|
||||||
paymentType: selectedMethod.value, payUrl: result.pay_url,
|
paymentType: selectedMethod.value, payUrl: result.pay_url,
|
||||||
clientSecret: '', payAmount: 0,
|
clientSecret: '', payAmount: 0,
|
||||||
orderType,
|
orderType,
|
||||||
@@ -557,7 +581,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
|
|||||||
} else if (result.qr_code) {
|
} else if (result.qr_code) {
|
||||||
// QR mode: show QR code inline
|
// QR mode: show QR code inline
|
||||||
paymentState.value = {
|
paymentState.value = {
|
||||||
orderId: result.order_id, qrCode: result.qr_code,
|
orderId: result.order_id, amount: result.amount, qrCode: result.qr_code,
|
||||||
expiresAt: result.expires_at || '', paymentType: selectedMethod.value, payUrl: '',
|
expiresAt: result.expires_at || '', paymentType: selectedMethod.value, payUrl: '',
|
||||||
clientSecret: '', payAmount: 0,
|
clientSecret: '', payAmount: 0,
|
||||||
orderType,
|
orderType,
|
||||||
@@ -567,7 +591,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
|
|||||||
// Redirect/popup mode: open payment URL, show waiting state inline
|
// Redirect/popup mode: open payment URL, show waiting state inline
|
||||||
openWindow(result.pay_url)
|
openWindow(result.pay_url)
|
||||||
paymentState.value = {
|
paymentState.value = {
|
||||||
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '',
|
orderId: result.order_id, amount: result.amount, qrCode: '', expiresAt: result.expires_at || '',
|
||||||
paymentType: selectedMethod.value, payUrl: result.pay_url,
|
paymentType: selectedMethod.value, payUrl: result.pay_url,
|
||||||
clientSecret: '', payAmount: 0,
|
clientSecret: '', payAmount: 0,
|
||||||
orderType,
|
orderType,
|
||||||
|
|||||||
Reference in New Issue
Block a user