- 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
366 lines
14 KiB
Go
366 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
|
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
|
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
|
|
"github.com/Wei-Shaw/sub2api/internal/payment"
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
|
)
|
|
|
|
const (
|
|
SettingPaymentEnabled = "payment_enabled"
|
|
SettingMinRechargeAmount = "MIN_RECHARGE_AMOUNT"
|
|
SettingMaxRechargeAmount = "MAX_RECHARGE_AMOUNT"
|
|
SettingDailyRechargeLimit = "DAILY_RECHARGE_LIMIT"
|
|
SettingOrderTimeoutMinutes = "ORDER_TIMEOUT_MINUTES"
|
|
SettingMaxPendingOrders = "MAX_PENDING_ORDERS"
|
|
SettingEnabledPaymentTypes = "ENABLED_PAYMENT_TYPES"
|
|
SettingLoadBalanceStrategy = "LOAD_BALANCE_STRATEGY"
|
|
SettingBalancePayDisabled = "BALANCE_PAYMENT_DISABLED"
|
|
SettingBalanceRechargeMult = "BALANCE_RECHARGE_MULTIPLIER"
|
|
SettingProductNamePrefix = "PRODUCT_NAME_PREFIX"
|
|
SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX"
|
|
SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL"
|
|
SettingHelpText = "PAYMENT_HELP_TEXT"
|
|
SettingCancelRateLimitOn = "CANCEL_RATE_LIMIT_ENABLED"
|
|
SettingCancelRateLimitMax = "CANCEL_RATE_LIMIT_MAX"
|
|
SettingCancelWindowSize = "CANCEL_RATE_LIMIT_WINDOW"
|
|
SettingCancelWindowUnit = "CANCEL_RATE_LIMIT_UNIT"
|
|
SettingCancelWindowMode = "CANCEL_RATE_LIMIT_WINDOW_MODE"
|
|
)
|
|
|
|
// Default values for payment configuration settings.
|
|
const (
|
|
defaultOrderTimeoutMin = 30
|
|
defaultMaxPendingOrders = 3
|
|
)
|
|
|
|
// PaymentConfig holds the payment system configuration.
|
|
type PaymentConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
MinAmount float64 `json:"min_amount"`
|
|
MaxAmount float64 `json:"max_amount"`
|
|
DailyLimit float64 `json:"daily_limit"`
|
|
OrderTimeoutMin int `json:"order_timeout_minutes"`
|
|
MaxPendingOrders int `json:"max_pending_orders"`
|
|
EnabledTypes []string `json:"enabled_payment_types"`
|
|
BalanceDisabled bool `json:"balance_disabled"`
|
|
BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"`
|
|
LoadBalanceStrategy string `json:"load_balance_strategy"`
|
|
ProductNamePrefix string `json:"product_name_prefix"`
|
|
ProductNameSuffix string `json:"product_name_suffix"`
|
|
HelpImageURL string `json:"help_image_url"`
|
|
HelpText string `json:"help_text"`
|
|
StripePublishableKey string `json:"stripe_publishable_key,omitempty"`
|
|
|
|
// Cancel rate limit settings
|
|
CancelRateLimitEnabled bool `json:"cancel_rate_limit_enabled"`
|
|
CancelRateLimitMax int `json:"cancel_rate_limit_max"`
|
|
CancelRateLimitWindow int `json:"cancel_rate_limit_window"`
|
|
CancelRateLimitUnit string `json:"cancel_rate_limit_unit"`
|
|
CancelRateLimitMode string `json:"cancel_rate_limit_window_mode"`
|
|
}
|
|
|
|
// UpdatePaymentConfigRequest contains fields to update payment configuration.
|
|
type UpdatePaymentConfigRequest struct {
|
|
Enabled *bool `json:"enabled"`
|
|
MinAmount *float64 `json:"min_amount"`
|
|
MaxAmount *float64 `json:"max_amount"`
|
|
DailyLimit *float64 `json:"daily_limit"`
|
|
OrderTimeoutMin *int `json:"order_timeout_minutes"`
|
|
MaxPendingOrders *int `json:"max_pending_orders"`
|
|
EnabledTypes []string `json:"enabled_payment_types"`
|
|
BalanceDisabled *bool `json:"balance_disabled"`
|
|
BalanceRechargeMultiplier *float64 `json:"balance_recharge_multiplier"`
|
|
LoadBalanceStrategy *string `json:"load_balance_strategy"`
|
|
ProductNamePrefix *string `json:"product_name_prefix"`
|
|
ProductNameSuffix *string `json:"product_name_suffix"`
|
|
HelpImageURL *string `json:"help_image_url"`
|
|
HelpText *string `json:"help_text"`
|
|
|
|
// Cancel rate limit settings
|
|
CancelRateLimitEnabled *bool `json:"cancel_rate_limit_enabled"`
|
|
CancelRateLimitMax *int `json:"cancel_rate_limit_max"`
|
|
CancelRateLimitWindow *int `json:"cancel_rate_limit_window"`
|
|
CancelRateLimitUnit *string `json:"cancel_rate_limit_unit"`
|
|
CancelRateLimitMode *string `json:"cancel_rate_limit_window_mode"`
|
|
}
|
|
|
|
// MethodLimits holds per-payment-type limits.
|
|
type MethodLimits struct {
|
|
PaymentType string `json:"payment_type"`
|
|
FeeRate float64 `json:"fee_rate"`
|
|
DailyLimit float64 `json:"daily_limit"`
|
|
SingleMin float64 `json:"single_min"`
|
|
SingleMax float64 `json:"single_max"`
|
|
}
|
|
|
|
// MethodLimitsResponse is the full response for the user-facing /limits API.
|
|
// It includes per-method limits and the global widest range (union of all methods).
|
|
type MethodLimitsResponse struct {
|
|
Methods map[string]MethodLimits `json:"methods"`
|
|
GlobalMin float64 `json:"global_min"` // 0 = no minimum
|
|
GlobalMax float64 `json:"global_max"` // 0 = no maximum
|
|
}
|
|
|
|
type CreateProviderInstanceRequest struct {
|
|
ProviderKey string `json:"provider_key"`
|
|
Name string `json:"name"`
|
|
Config map[string]string `json:"config"`
|
|
SupportedTypes []string `json:"supported_types"`
|
|
Enabled bool `json:"enabled"`
|
|
PaymentMode string `json:"payment_mode"`
|
|
SortOrder int `json:"sort_order"`
|
|
Limits string `json:"limits"`
|
|
RefundEnabled bool `json:"refund_enabled"`
|
|
AllowUserRefund bool `json:"allow_user_refund"`
|
|
}
|
|
|
|
type UpdateProviderInstanceRequest struct {
|
|
Name *string `json:"name"`
|
|
Config map[string]string `json:"config"`
|
|
SupportedTypes []string `json:"supported_types"`
|
|
Enabled *bool `json:"enabled"`
|
|
PaymentMode *string `json:"payment_mode"`
|
|
SortOrder *int `json:"sort_order"`
|
|
Limits *string `json:"limits"`
|
|
RefundEnabled *bool `json:"refund_enabled"`
|
|
AllowUserRefund *bool `json:"allow_user_refund"`
|
|
}
|
|
type CreatePlanRequest struct {
|
|
GroupID int64 `json:"group_id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Price float64 `json:"price"`
|
|
OriginalPrice *float64 `json:"original_price"`
|
|
ValidityDays int `json:"validity_days"`
|
|
ValidityUnit string `json:"validity_unit"`
|
|
Features string `json:"features"`
|
|
ProductName string `json:"product_name"`
|
|
ForSale bool `json:"for_sale"`
|
|
SortOrder int `json:"sort_order"`
|
|
}
|
|
|
|
type UpdatePlanRequest struct {
|
|
GroupID *int64 `json:"group_id"`
|
|
Name *string `json:"name"`
|
|
Description *string `json:"description"`
|
|
Price *float64 `json:"price"`
|
|
OriginalPrice *float64 `json:"original_price"`
|
|
ValidityDays *int `json:"validity_days"`
|
|
ValidityUnit *string `json:"validity_unit"`
|
|
Features *string `json:"features"`
|
|
ProductName *string `json:"product_name"`
|
|
ForSale *bool `json:"for_sale"`
|
|
SortOrder *int `json:"sort_order"`
|
|
}
|
|
|
|
// PaymentConfigService manages payment configuration and CRUD for
|
|
// provider instances, channels, and subscription plans.
|
|
type PaymentConfigService struct {
|
|
entClient *dbent.Client
|
|
settingRepo SettingRepository
|
|
encryptionKey []byte
|
|
}
|
|
|
|
// NewPaymentConfigService creates a new PaymentConfigService.
|
|
func NewPaymentConfigService(entClient *dbent.Client, settingRepo SettingRepository, encryptionKey []byte) *PaymentConfigService {
|
|
return &PaymentConfigService{entClient: entClient, settingRepo: settingRepo, encryptionKey: encryptionKey}
|
|
}
|
|
|
|
// IsPaymentEnabled returns whether the payment system is enabled.
|
|
func (s *PaymentConfigService) IsPaymentEnabled(ctx context.Context) bool {
|
|
val, err := s.settingRepo.GetValue(ctx, SettingPaymentEnabled)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return val == "true"
|
|
}
|
|
|
|
// GetPaymentConfig returns the full payment configuration.
|
|
func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentConfig, error) {
|
|
keys := []string{
|
|
SettingPaymentEnabled, SettingMinRechargeAmount, SettingMaxRechargeAmount,
|
|
SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders,
|
|
SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingBalanceRechargeMult, SettingLoadBalanceStrategy,
|
|
SettingProductNamePrefix, SettingProductNameSuffix,
|
|
SettingHelpImageURL, SettingHelpText,
|
|
SettingCancelRateLimitOn, SettingCancelRateLimitMax,
|
|
SettingCancelWindowSize, SettingCancelWindowUnit, SettingCancelWindowMode,
|
|
}
|
|
vals, err := s.settingRepo.GetMultiple(ctx, keys)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get payment config settings: %w", err)
|
|
}
|
|
cfg := s.parsePaymentConfig(vals)
|
|
// Load Stripe publishable key from the first enabled Stripe provider instance
|
|
cfg.StripePublishableKey = s.getStripePublishableKey(ctx)
|
|
return cfg, nil
|
|
}
|
|
|
|
func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *PaymentConfig {
|
|
cfg := &PaymentConfig{
|
|
Enabled: vals[SettingPaymentEnabled] == "true",
|
|
MinAmount: pcParseFloat(vals[SettingMinRechargeAmount], 1),
|
|
MaxAmount: pcParseFloat(vals[SettingMaxRechargeAmount], 0),
|
|
DailyLimit: pcParseFloat(vals[SettingDailyRechargeLimit], 0),
|
|
OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], defaultOrderTimeoutMin),
|
|
MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], defaultMaxPendingOrders),
|
|
BalanceDisabled: vals[SettingBalancePayDisabled] == "true",
|
|
BalanceRechargeMultiplier: normalizeBalanceRechargeMultiplier(pcParseFloat(vals[SettingBalanceRechargeMult], defaultBalanceRechargeMultiplier)),
|
|
LoadBalanceStrategy: vals[SettingLoadBalanceStrategy],
|
|
ProductNamePrefix: vals[SettingProductNamePrefix],
|
|
ProductNameSuffix: vals[SettingProductNameSuffix],
|
|
HelpImageURL: vals[SettingHelpImageURL],
|
|
HelpText: vals[SettingHelpText],
|
|
|
|
CancelRateLimitEnabled: vals[SettingCancelRateLimitOn] == "true",
|
|
CancelRateLimitMax: pcParseInt(vals[SettingCancelRateLimitMax], 10),
|
|
CancelRateLimitWindow: pcParseInt(vals[SettingCancelWindowSize], 1),
|
|
CancelRateLimitUnit: vals[SettingCancelWindowUnit],
|
|
CancelRateLimitMode: vals[SettingCancelWindowMode],
|
|
}
|
|
if cfg.LoadBalanceStrategy == "" {
|
|
cfg.LoadBalanceStrategy = payment.DefaultLoadBalanceStrategy
|
|
}
|
|
if raw := vals[SettingEnabledPaymentTypes]; raw != "" {
|
|
for _, t := range strings.Split(raw, ",") {
|
|
t = strings.TrimSpace(t)
|
|
if t != "" {
|
|
cfg.EnabledTypes = append(cfg.EnabledTypes, t)
|
|
}
|
|
}
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// getStripePublishableKey finds the publishable key from the first enabled Stripe provider instance.
|
|
func (s *PaymentConfigService) getStripePublishableKey(ctx context.Context) string {
|
|
instances, err := s.entClient.PaymentProviderInstance.Query().
|
|
Where(
|
|
paymentproviderinstance.EnabledEQ(true),
|
|
paymentproviderinstance.ProviderKeyEQ(payment.TypeStripe),
|
|
).Limit(1).All(ctx)
|
|
if err != nil || len(instances) == 0 {
|
|
return ""
|
|
}
|
|
cfg, err := s.decryptConfig(instances[0].Config)
|
|
if err != nil || cfg == nil {
|
|
return ""
|
|
}
|
|
return cfg[payment.ConfigKeyPublishableKey]
|
|
}
|
|
|
|
// UpdatePaymentConfig updates the payment configuration settings.
|
|
// NOTE: This function exceeds 30 lines because each field requires an independent
|
|
// nil-check before serialisation — this is inherent to patch-style update patterns
|
|
// and cannot be meaningfully decomposed without introducing unnecessary abstraction.
|
|
func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req UpdatePaymentConfigRequest) error {
|
|
if req.BalanceRechargeMultiplier != nil {
|
|
if math.IsNaN(*req.BalanceRechargeMultiplier) || math.IsInf(*req.BalanceRechargeMultiplier, 0) || *req.BalanceRechargeMultiplier <= 0 {
|
|
return infraerrors.BadRequest("INVALID_BALANCE_RECHARGE_MULTIPLIER", "balance recharge multiplier must be greater than 0")
|
|
}
|
|
}
|
|
m := map[string]string{
|
|
SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled),
|
|
SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount),
|
|
SettingMaxRechargeAmount: formatPositiveFloat(req.MaxAmount),
|
|
SettingDailyRechargeLimit: formatPositiveFloat(req.DailyLimit),
|
|
SettingOrderTimeoutMinutes: formatPositiveInt(req.OrderTimeoutMin),
|
|
SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders),
|
|
SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled),
|
|
SettingBalanceRechargeMult: formatPositiveFloat(req.BalanceRechargeMultiplier),
|
|
SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy),
|
|
SettingProductNamePrefix: derefStr(req.ProductNamePrefix),
|
|
SettingProductNameSuffix: derefStr(req.ProductNameSuffix),
|
|
SettingHelpImageURL: derefStr(req.HelpImageURL),
|
|
SettingHelpText: derefStr(req.HelpText),
|
|
SettingCancelRateLimitOn: formatBoolOrEmpty(req.CancelRateLimitEnabled),
|
|
SettingCancelRateLimitMax: formatPositiveInt(req.CancelRateLimitMax),
|
|
SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow),
|
|
SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit),
|
|
SettingCancelWindowMode: derefStr(req.CancelRateLimitMode),
|
|
}
|
|
if req.EnabledTypes != nil {
|
|
m[SettingEnabledPaymentTypes] = strings.Join(req.EnabledTypes, ",")
|
|
} else {
|
|
m[SettingEnabledPaymentTypes] = ""
|
|
}
|
|
return s.settingRepo.SetMultiple(ctx, m)
|
|
}
|
|
|
|
func formatBoolOrEmpty(v *bool) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
return strconv.FormatBool(*v)
|
|
}
|
|
|
|
func formatPositiveFloat(v *float64) string {
|
|
if v == nil || *v <= 0 {
|
|
return "" // empty → parsePaymentConfig uses default
|
|
}
|
|
return strconv.FormatFloat(*v, 'f', 2, 64)
|
|
}
|
|
|
|
func formatPositiveInt(v *int) string {
|
|
if v == nil || *v <= 0 {
|
|
return ""
|
|
}
|
|
return strconv.Itoa(*v)
|
|
}
|
|
|
|
func derefStr(v *string) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
return *v
|
|
}
|
|
|
|
func splitTypes(s string) []string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(s, ",")
|
|
result := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
result = append(result, p)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func joinTypes(types []string) string {
|
|
return strings.Join(types, ",")
|
|
}
|
|
|
|
func pcParseFloat(s string, defaultVal float64) float64 {
|
|
if s == "" {
|
|
return defaultVal
|
|
}
|
|
v, err := strconv.ParseFloat(s, 64)
|
|
if err != nil {
|
|
return defaultVal
|
|
}
|
|
return v
|
|
}
|
|
|
|
func pcParseInt(s string, defaultVal int) int {
|
|
if s == "" {
|
|
return defaultVal
|
|
}
|
|
v, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return defaultVal
|
|
}
|
|
return v
|
|
}
|