fix(payment): audit fixes for alipay/wxpay/stripe payment providers
Backend: - Extract YuanToFen/FenToYuan to payment/amount.go using shopspring/decimal - Require alipay publicKey in config validation - Fix wxpay webhook response to return JSON per V3 spec - Remove wxpay certSerial fallback to publicKeyId - Define magic strings as named constants in wxpay/alipay providers - Add slog warning for wxpay H5→Native payment downgrade - Make EncryptionKey validation return error on invalid (non-empty) key - Make decryptConfig propagate errors instead of returning nil - Add idempotency check in doBalance to prevent stuck FAILED retries Frontend: - Fix dashboard currency symbol from $ to ¥ - Fix AdminPaymentPlansView any type to proper SubscriptionPlan type - Make quick amount buttons follow selected payment method limits - Center help image with larger height and text below
This commit is contained in:
@@ -2,16 +2,13 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
|
||||
"github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -26,6 +23,8 @@ const (
|
||||
SettingBalancePayDisabled = "BALANCE_PAYMENT_DISABLED"
|
||||
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"
|
||||
@@ -33,91 +32,126 @@ const (
|
||||
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:"minAmount"`
|
||||
MaxAmount float64 `json:"maxAmount"`
|
||||
DailyLimit float64 `json:"dailyLimit"`
|
||||
OrderTimeoutMin int `json:"orderTimeoutMinutes"`
|
||||
MaxPendingOrders int `json:"maxPendingOrders"`
|
||||
EnabledTypes []string `json:"enabledTypes"`
|
||||
BalanceDisabled bool `json:"balanceDisabled"`
|
||||
LoadBalanceStrategy string `json:"loadBalanceStrategy"`
|
||||
ProductNamePrefix string `json:"productNamePrefix"`
|
||||
ProductNameSuffix string `json:"productNameSuffix"`
|
||||
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"`
|
||||
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:"minAmount"`
|
||||
MaxAmount *float64 `json:"maxAmount"`
|
||||
DailyLimit *float64 `json:"dailyLimit"`
|
||||
OrderTimeoutMin *int `json:"orderTimeoutMinutes"`
|
||||
MaxPendingOrders *int `json:"maxPendingOrders"`
|
||||
EnabledTypes []string `json:"enabledTypes"`
|
||||
BalanceDisabled *bool `json:"balanceDisabled"`
|
||||
LoadBalanceStrategy *string `json:"loadBalanceStrategy"`
|
||||
ProductNamePrefix *string `json:"productNamePrefix"`
|
||||
ProductNameSuffix *string `json:"productNameSuffix"`
|
||||
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"`
|
||||
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:"paymentType"`
|
||||
FeeRate float64 `json:"feeRate"`
|
||||
DailyLimit float64 `json:"dailyLimit"`
|
||||
SingleMin float64 `json:"singleMin"`
|
||||
SingleMax float64 `json:"singleMax"`
|
||||
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:"providerKey"`
|
||||
ProviderKey string `json:"provider_key"`
|
||||
Name string `json:"name"`
|
||||
Config map[string]string `json:"config"`
|
||||
SupportedTypes string `json:"supportedTypes"`
|
||||
SupportedTypes []string `json:"supported_types"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
PaymentMode string `json:"payment_mode"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Limits string `json:"limits"`
|
||||
RefundEnabled bool `json:"refundEnabled"`
|
||||
RefundEnabled bool `json:"refund_enabled"`
|
||||
}
|
||||
|
||||
type UpdateProviderInstanceRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Config map[string]string `json:"config"`
|
||||
SupportedTypes *string `json:"supportedTypes"`
|
||||
SupportedTypes []string `json:"supported_types"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
SortOrder *int `json:"sortOrder"`
|
||||
PaymentMode *string `json:"payment_mode"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
Limits *string `json:"limits"`
|
||||
RefundEnabled *bool `json:"refundEnabled"`
|
||||
RefundEnabled *bool `json:"refund_enabled"`
|
||||
}
|
||||
type CreatePlanRequest struct {
|
||||
GroupID int64 `json:"groupId"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Price float64 `json:"price"`
|
||||
OriginalPrice *float64 `json:"originalPrice"`
|
||||
ValidityDays int `json:"validityDays"`
|
||||
ValidityUnit string `json:"validityUnit"`
|
||||
OriginalPrice *float64 `json:"original_price"`
|
||||
ValidityDays int `json:"validity_days"`
|
||||
ValidityUnit string `json:"validity_unit"`
|
||||
Features string `json:"features"`
|
||||
ProductName string `json:"productName"`
|
||||
ForSale bool `json:"forSale"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
ProductName string `json:"product_name"`
|
||||
ForSale bool `json:"for_sale"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
type UpdatePlanRequest struct {
|
||||
GroupID *int64 `json:"groupId"`
|
||||
GroupID *int64 `json:"group_id"`
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Price *float64 `json:"price"`
|
||||
OriginalPrice *float64 `json:"originalPrice"`
|
||||
ValidityDays *int `json:"validityDays"`
|
||||
ValidityUnit *string `json:"validityUnit"`
|
||||
OriginalPrice *float64 `json:"original_price"`
|
||||
ValidityDays *int `json:"validity_days"`
|
||||
ValidityUnit *string `json:"validity_unit"`
|
||||
Features *string `json:"features"`
|
||||
ProductName *string `json:"productName"`
|
||||
ForSale *bool `json:"forSale"`
|
||||
SortOrder *int `json:"sortOrder"`
|
||||
ProductName *string `json:"product_name"`
|
||||
ForSale *bool `json:"for_sale"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// PaymentConfigService manages payment configuration and CRUD for
|
||||
@@ -149,29 +183,43 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo
|
||||
SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders,
|
||||
SettingEnabledPaymentTypes, SettingBalancePayDisabled, 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)
|
||||
}
|
||||
return s.parsePaymentConfig(vals), nil
|
||||
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], 99999999.99),
|
||||
MaxAmount: pcParseFloat(vals[SettingMaxRechargeAmount], 0),
|
||||
DailyLimit: pcParseFloat(vals[SettingDailyRechargeLimit], 0),
|
||||
OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], 30),
|
||||
MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], 3),
|
||||
OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], defaultOrderTimeoutMin),
|
||||
MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], defaultMaxPendingOrders),
|
||||
BalanceDisabled: vals[SettingBalancePayDisabled] == "true",
|
||||
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 = "round-robin"
|
||||
cfg.LoadBalanceStrategy = payment.DefaultLoadBalanceStrategy
|
||||
}
|
||||
if raw := vals[SettingEnabledPaymentTypes]; raw != "" {
|
||||
for _, t := range strings.Split(raw, ",") {
|
||||
@@ -184,242 +232,100 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme
|
||||
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 {
|
||||
m := make(map[string]string)
|
||||
if req.Enabled != nil {
|
||||
m[SettingPaymentEnabled] = strconv.FormatBool(*req.Enabled)
|
||||
}
|
||||
if req.MinAmount != nil {
|
||||
m[SettingMinRechargeAmount] = strconv.FormatFloat(*req.MinAmount, 'f', 2, 64)
|
||||
}
|
||||
if req.MaxAmount != nil {
|
||||
m[SettingMaxRechargeAmount] = strconv.FormatFloat(*req.MaxAmount, 'f', 2, 64)
|
||||
}
|
||||
if req.DailyLimit != nil {
|
||||
m[SettingDailyRechargeLimit] = strconv.FormatFloat(*req.DailyLimit, 'f', 2, 64)
|
||||
}
|
||||
if req.OrderTimeoutMin != nil {
|
||||
m[SettingOrderTimeoutMinutes] = strconv.Itoa(*req.OrderTimeoutMin)
|
||||
}
|
||||
if req.MaxPendingOrders != nil {
|
||||
m[SettingMaxPendingOrders] = strconv.Itoa(*req.MaxPendingOrders)
|
||||
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),
|
||||
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, ",")
|
||||
}
|
||||
if req.BalanceDisabled != nil {
|
||||
m[SettingBalancePayDisabled] = strconv.FormatBool(*req.BalanceDisabled)
|
||||
}
|
||||
if req.LoadBalanceStrategy != nil {
|
||||
m[SettingLoadBalanceStrategy] = *req.LoadBalanceStrategy
|
||||
}
|
||||
if req.ProductNamePrefix != nil {
|
||||
m[SettingProductNamePrefix] = *req.ProductNamePrefix
|
||||
}
|
||||
if req.ProductNameSuffix != nil {
|
||||
m[SettingProductNameSuffix] = *req.ProductNameSuffix
|
||||
}
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
} else {
|
||||
m[SettingEnabledPaymentTypes] = ""
|
||||
}
|
||||
return s.settingRepo.SetMultiple(ctx, m)
|
||||
}
|
||||
|
||||
// --- Provider Instance CRUD ---
|
||||
|
||||
func (s *PaymentConfigService) ListProviderInstances(ctx context.Context) ([]*dbent.PaymentProviderInstance, error) {
|
||||
return s.entClient.PaymentProviderInstance.Query().Order(paymentproviderinstance.BySortOrder()).All(ctx)
|
||||
func formatBoolOrEmpty(v *bool) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatBool(*v)
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
|
||||
enc, err := s.encryptConfig(req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func formatPositiveFloat(v *float64) string {
|
||||
if v == nil || *v <= 0 {
|
||||
return "" // empty → parsePaymentConfig uses default
|
||||
}
|
||||
return s.entClient.PaymentProviderInstance.Create().
|
||||
SetProviderKey(req.ProviderKey).SetName(req.Name).SetConfig(enc).
|
||||
SetSupportedTypes(req.SupportedTypes).SetEnabled(req.Enabled).
|
||||
SetSortOrder(req.SortOrder).SetLimits(req.Limits).SetRefundEnabled(req.RefundEnabled).
|
||||
Save(ctx)
|
||||
return strconv.FormatFloat(*v, 'f', 2, 64)
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id int64, req UpdateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
|
||||
u := s.entClient.PaymentProviderInstance.UpdateOneID(id)
|
||||
if req.Name != nil {
|
||||
u.SetName(*req.Name)
|
||||
func formatPositiveInt(v *int) string {
|
||||
if v == nil || *v <= 0 {
|
||||
return ""
|
||||
}
|
||||
if req.Config != nil {
|
||||
enc, err := s.encryptConfig(req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.SetConfig(enc)
|
||||
}
|
||||
if req.SupportedTypes != nil {
|
||||
u.SetSupportedTypes(*req.SupportedTypes)
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
u.SetEnabled(*req.Enabled)
|
||||
}
|
||||
if req.SortOrder != nil {
|
||||
u.SetSortOrder(*req.SortOrder)
|
||||
}
|
||||
if req.Limits != nil {
|
||||
u.SetLimits(*req.Limits)
|
||||
}
|
||||
if req.RefundEnabled != nil {
|
||||
u.SetRefundEnabled(*req.RefundEnabled)
|
||||
}
|
||||
return u.Save(ctx)
|
||||
return strconv.Itoa(*v)
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id int64) error {
|
||||
return s.entClient.PaymentProviderInstance.DeleteOneID(id).Exec(ctx)
|
||||
func derefStr(v *string) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) encryptConfig(cfg map[string]string) (string, error) {
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal config: %w", err)
|
||||
func splitTypes(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
enc, err := payment.Encrypt(string(data), s.encryptionKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encrypt config: %w", err)
|
||||
}
|
||||
return enc, nil
|
||||
}
|
||||
|
||||
// --- Channel CRUD ---
|
||||
|
||||
|
||||
// --- Plan CRUD ---
|
||||
|
||||
func (s *PaymentConfigService) ListPlans(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
|
||||
return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.BySortOrder()).All(ctx)
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) ListPlansForSale(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
|
||||
return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.BySortOrder()).All(ctx)
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*dbent.SubscriptionPlan, error) {
|
||||
b := s.entClient.SubscriptionPlan.Create().
|
||||
SetGroupID(req.GroupID).SetName(req.Name).SetDescription(req.Description).
|
||||
SetPrice(req.Price).SetValidityDays(req.ValidityDays).SetValidityUnit(req.ValidityUnit).
|
||||
SetFeatures(req.Features).SetProductName(req.ProductName).
|
||||
SetForSale(req.ForSale).SetSortOrder(req.SortOrder)
|
||||
if req.OriginalPrice != nil {
|
||||
b.SetOriginalPrice(*req.OriginalPrice)
|
||||
}
|
||||
return b.Save(ctx)
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) UpdatePlan(ctx context.Context, id int64, req UpdatePlanRequest) (*dbent.SubscriptionPlan, error) {
|
||||
u := s.entClient.SubscriptionPlan.UpdateOneID(id)
|
||||
if req.GroupID != nil {
|
||||
u.SetGroupID(*req.GroupID)
|
||||
}
|
||||
if req.Name != nil {
|
||||
u.SetName(*req.Name)
|
||||
}
|
||||
if req.Description != nil {
|
||||
u.SetDescription(*req.Description)
|
||||
}
|
||||
if req.Price != nil {
|
||||
u.SetPrice(*req.Price)
|
||||
}
|
||||
if req.OriginalPrice != nil {
|
||||
u.SetOriginalPrice(*req.OriginalPrice)
|
||||
}
|
||||
if req.ValidityDays != nil {
|
||||
u.SetValidityDays(*req.ValidityDays)
|
||||
}
|
||||
if req.ValidityUnit != nil {
|
||||
u.SetValidityUnit(*req.ValidityUnit)
|
||||
}
|
||||
if req.Features != nil {
|
||||
u.SetFeatures(*req.Features)
|
||||
}
|
||||
if req.ProductName != nil {
|
||||
u.SetProductName(*req.ProductName)
|
||||
}
|
||||
if req.ForSale != nil {
|
||||
u.SetForSale(*req.ForSale)
|
||||
}
|
||||
if req.SortOrder != nil {
|
||||
u.SetSortOrder(*req.SortOrder)
|
||||
}
|
||||
return u.Save(ctx)
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) DeletePlan(ctx context.Context, id int64) error {
|
||||
return s.entClient.SubscriptionPlan.DeleteOneID(id).Exec(ctx)
|
||||
}
|
||||
|
||||
// GetPlan returns a subscription plan by ID.
|
||||
func (s *PaymentConfigService) GetPlan(ctx context.Context, id int64) (*dbent.SubscriptionPlan, error) {
|
||||
plan, err := s.entClient.SubscriptionPlan.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, infraerrors.NotFound("PLAN_NOT_FOUND", "subscription plan not found")
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// GetMethodLimits returns per-payment-type limits from enabled provider instances.
|
||||
func (s *PaymentConfigService) GetMethodLimits(ctx context.Context, types []string) ([]MethodLimits, error) {
|
||||
instances, err := s.entClient.PaymentProviderInstance.Query().
|
||||
Where(paymentproviderinstance.EnabledEQ(true)).All(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query provider instances: %w", err)
|
||||
}
|
||||
result := make([]MethodLimits, 0, len(types))
|
||||
for _, pt := range types {
|
||||
ml := MethodLimits{PaymentType: pt}
|
||||
for _, inst := range instances {
|
||||
if !pcInstanceSupportsType(inst, pt) {
|
||||
continue
|
||||
}
|
||||
pcApplyInstanceLimits(inst, pt, &ml)
|
||||
}
|
||||
result = append(result, ml)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func pcInstanceSupportsType(inst *dbent.PaymentProviderInstance, pt string) bool {
|
||||
if inst.SupportedTypes == "" {
|
||||
return true
|
||||
}
|
||||
for _, t := range strings.Split(inst.SupportedTypes, ",") {
|
||||
if strings.TrimSpace(t) == pt {
|
||||
return true
|
||||
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 false
|
||||
return result
|
||||
}
|
||||
|
||||
func pcApplyInstanceLimits(inst *dbent.PaymentProviderInstance, pt string, ml *MethodLimits) {
|
||||
if inst.Limits == "" {
|
||||
return
|
||||
}
|
||||
var limits payment.InstanceLimits
|
||||
if err := json.Unmarshal([]byte(inst.Limits), &limits); err != nil {
|
||||
return
|
||||
}
|
||||
cl, ok := limits[pt]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if cl.DailyLimit > 0 && (ml.DailyLimit == 0 || cl.DailyLimit < ml.DailyLimit) {
|
||||
ml.DailyLimit = cl.DailyLimit
|
||||
}
|
||||
if cl.SingleMin > 0 && (ml.SingleMin == 0 || cl.SingleMin > ml.SingleMin) {
|
||||
ml.SingleMin = cl.SingleMin
|
||||
}
|
||||
if cl.SingleMax > 0 && (ml.SingleMax == 0 || cl.SingleMax < ml.SingleMax) {
|
||||
ml.SingleMax = cl.SingleMax
|
||||
}
|
||||
func joinTypes(types []string) string {
|
||||
return strings.Join(types, ",")
|
||||
}
|
||||
|
||||
func pcParseFloat(s string, defaultVal float64) float64 {
|
||||
|
||||
@@ -5,12 +5,9 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
|
||||
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
@@ -19,20 +16,14 @@ import (
|
||||
// --- Payment Notification & Fulfillment ---
|
||||
|
||||
func (s *PaymentService) HandlePaymentNotification(ctx context.Context, n *payment.PaymentNotification, pk string) error {
|
||||
if n.Status != payment.NotificationStatusSuccess {
|
||||
if n.Status != "success" {
|
||||
return nil
|
||||
}
|
||||
// Look up order by out_trade_no (the external order ID we sent to the provider)
|
||||
order, err := s.entClient.PaymentOrder.Query().Where(paymentorder.OutTradeNo(n.OrderID)).Only(ctx)
|
||||
oid, err := parseOrderID(n.OrderID)
|
||||
if err != nil {
|
||||
// Fallback: try legacy format (sub2_N where N is DB ID)
|
||||
trimmed := strings.TrimPrefix(n.OrderID, orderIDPrefix)
|
||||
if oid, parseErr := strconv.ParseInt(trimmed, 10, 64); parseErr == nil {
|
||||
return s.confirmPayment(ctx, oid, n.TradeNo, n.Amount, pk)
|
||||
}
|
||||
return fmt.Errorf("order not found for out_trade_no: %s", n.OrderID)
|
||||
return fmt.Errorf("invalid order ID: %s", n.OrderID)
|
||||
}
|
||||
return s.confirmPayment(ctx, order.ID, n.TradeNo, n.Amount, pk)
|
||||
return s.confirmPayment(ctx, oid, n.TradeNo, n.Amount, pk)
|
||||
}
|
||||
|
||||
func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo string, paid float64, pk string) error {
|
||||
@@ -41,17 +32,9 @@ func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo
|
||||
slog.Error("order not found", "orderID", oid)
|
||||
return nil
|
||||
}
|
||||
// Skip amount check when paid=0 (e.g. QueryOrder doesn't return amount).
|
||||
// Also skip if paid is NaN/Inf (malformed provider data).
|
||||
if paid > 0 && !math.IsNaN(paid) && !math.IsInf(paid, 0) {
|
||||
if math.Abs(paid-o.PayAmount) > amountToleranceCNY {
|
||||
s.writeAuditLog(ctx, o.ID, "PAYMENT_AMOUNT_MISMATCH", pk, map[string]any{"expected": o.PayAmount, "paid": paid, "tradeNo": tradeNo})
|
||||
return fmt.Errorf("amount mismatch: expected %.2f, got %.2f", o.PayAmount, paid)
|
||||
}
|
||||
}
|
||||
// Use order's expected amount when provider didn't report one
|
||||
if paid <= 0 || math.IsNaN(paid) || math.IsInf(paid, 0) {
|
||||
paid = o.PayAmount
|
||||
if math.Abs(paid-o.PayAmount) > amountToleranceCNY {
|
||||
s.writeAuditLog(ctx, o.ID, "PAYMENT_AMOUNT_MISMATCH", pk, map[string]any{"expected": o.PayAmount, "paid": paid, "tradeNo": tradeNo})
|
||||
return fmt.Errorf("amount mismatch: expected %.2f, got %.2f", o.PayAmount, paid)
|
||||
}
|
||||
return s.toPaid(ctx, o, tradeNo, paid, pk)
|
||||
}
|
||||
@@ -129,7 +112,7 @@ func (s *PaymentService) executeFulfillment(ctx context.Context, oid int64) erro
|
||||
if err != nil {
|
||||
return fmt.Errorf("get order: %w", err)
|
||||
}
|
||||
if o.OrderType == payment.OrderTypeSubscription {
|
||||
if o.OrderType == "subscription" {
|
||||
return s.ExecuteSubscriptionFulfillment(ctx, oid)
|
||||
}
|
||||
return s.ExecuteBalanceFulfillment(ctx, oid)
|
||||
@@ -163,46 +146,20 @@ func (s *PaymentService) ExecuteBalanceFulfillment(ctx context.Context, oid int6
|
||||
return nil
|
||||
}
|
||||
|
||||
// redeemAction represents the idempotency decision for balance fulfillment.
|
||||
type redeemAction int
|
||||
|
||||
const (
|
||||
// redeemActionCreate: code does not exist — create it, then redeem.
|
||||
redeemActionCreate redeemAction = iota
|
||||
// redeemActionRedeem: code exists but is unused — skip creation, redeem only.
|
||||
redeemActionRedeem
|
||||
// redeemActionSkipCompleted: code exists and is already used — skip to mark completed.
|
||||
redeemActionSkipCompleted
|
||||
)
|
||||
|
||||
// resolveRedeemAction decides the idempotency action based on an existing redeem code lookup.
|
||||
// existing is the result of GetByCode; lookupErr is the error from that call.
|
||||
func resolveRedeemAction(existing *RedeemCode, lookupErr error) redeemAction {
|
||||
if existing == nil || lookupErr != nil {
|
||||
return redeemActionCreate
|
||||
}
|
||||
if existing.IsUsed() {
|
||||
return redeemActionSkipCompleted
|
||||
}
|
||||
return redeemActionRedeem
|
||||
}
|
||||
|
||||
func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) error {
|
||||
// Idempotency: check if redeem code already exists (from a previous partial run)
|
||||
existing, lookupErr := s.redeemService.GetByCode(ctx, o.RechargeCode)
|
||||
action := resolveRedeemAction(existing, lookupErr)
|
||||
|
||||
switch action {
|
||||
case redeemActionSkipCompleted:
|
||||
// Code already created and redeemed — just mark completed
|
||||
return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
|
||||
case redeemActionCreate:
|
||||
existing, _ := s.redeemService.GetByCode(ctx, o.RechargeCode)
|
||||
if existing != nil {
|
||||
if existing.IsUsed() {
|
||||
// Code already created and redeemed — just mark completed
|
||||
return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
|
||||
}
|
||||
// Code exists but unused — skip creation, proceed to redeem
|
||||
} else {
|
||||
rc := &RedeemCode{Code: o.RechargeCode, Type: RedeemTypeBalance, Value: o.Amount, Status: StatusUnused}
|
||||
if err := s.redeemService.CreateCode(ctx, rc); err != nil {
|
||||
return fmt.Errorf("create redeem code: %w", err)
|
||||
}
|
||||
case redeemActionRedeem:
|
||||
// Code exists but unused — skip creation, proceed to redeem
|
||||
}
|
||||
if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil {
|
||||
return fmt.Errorf("redeem balance: %w", err)
|
||||
@@ -255,45 +212,30 @@ func (s *PaymentService) doSub(ctx context.Context, o *dbent.PaymentOrder) error
|
||||
gid := *o.SubscriptionGroupID
|
||||
days := *o.SubscriptionDays
|
||||
g, err := s.groupRepo.GetByID(ctx, gid)
|
||||
if err != nil || g.Status != payment.EntityStatusActive {
|
||||
if err != nil || g.Status != "active" {
|
||||
return fmt.Errorf("group %d no longer exists or inactive", gid)
|
||||
}
|
||||
// Idempotency: check audit log to see if subscription was already assigned.
|
||||
// Prevents double-extension on retry after markCompleted fails.
|
||||
if s.hasAuditLog(ctx, o.ID, "SUBSCRIPTION_SUCCESS") {
|
||||
slog.Info("subscription already assigned for order, skipping", "orderID", o.ID, "groupID", gid)
|
||||
return s.markCompleted(ctx, o, "SUBSCRIPTION_SUCCESS")
|
||||
}
|
||||
orderNote := fmt.Sprintf("payment order %d", o.ID)
|
||||
_, _, err = s.subscriptionSvc.AssignOrExtendSubscription(ctx, &AssignSubscriptionInput{UserID: o.UserID, GroupID: gid, ValidityDays: days, AssignedBy: 0, Notes: orderNote})
|
||||
_, _, err = s.subscriptionSvc.AssignOrExtendSubscription(ctx, &AssignSubscriptionInput{UserID: o.UserID, GroupID: gid, ValidityDays: days, AssignedBy: 0, Notes: fmt.Sprintf("payment order %d", o.ID)})
|
||||
if err != nil {
|
||||
return fmt.Errorf("assign subscription: %w", err)
|
||||
}
|
||||
return s.markCompleted(ctx, o, "SUBSCRIPTION_SUCCESS")
|
||||
}
|
||||
|
||||
func (s *PaymentService) hasAuditLog(ctx context.Context, orderID int64, action string) bool {
|
||||
oid := strconv.FormatInt(orderID, 10)
|
||||
c, _ := s.entClient.PaymentAuditLog.Query().
|
||||
Where(paymentauditlog.OrderIDEQ(oid), paymentauditlog.ActionEQ(action)).
|
||||
Limit(1).Count(ctx)
|
||||
return c > 0
|
||||
now := time.Now()
|
||||
_, err = s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(o.ID), paymentorder.StatusEQ(OrderStatusRecharging)).SetStatus(OrderStatusCompleted).SetCompletedAt(now).Save(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark completed: %w", err)
|
||||
}
|
||||
s.writeAuditLog(ctx, o.ID, "SUBSCRIPTION_SUCCESS", "system", map[string]any{"groupId": gid, "days": days, "amount": o.Amount})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) markFailed(ctx context.Context, oid int64, cause error) {
|
||||
now := time.Now()
|
||||
r := psErrMsg(cause)
|
||||
// Only mark FAILED if still in RECHARGING state — prevents overwriting
|
||||
// a COMPLETED order when markCompleted failed but fulfillment succeeded.
|
||||
c, e := s.entClient.PaymentOrder.Update().
|
||||
Where(paymentorder.IDEQ(oid), paymentorder.StatusEQ(OrderStatusRecharging)).
|
||||
SetStatus(OrderStatusFailed).SetFailedAt(now).SetFailedReason(r).Save(ctx)
|
||||
_, e := s.entClient.PaymentOrder.UpdateOneID(oid).SetStatus(OrderStatusFailed).SetFailedAt(now).SetFailedReason(r).Save(ctx)
|
||||
if e != nil {
|
||||
slog.Error("mark FAILED", "orderID", oid, "error", e)
|
||||
}
|
||||
if c > 0 {
|
||||
s.writeAuditLog(ctx, oid, "FULFILLMENT_FAILED", "system", map[string]any{"reason": r})
|
||||
}
|
||||
s.writeAuditLog(ctx, oid, "FULFILLMENT_FAILED", "system", map[string]any{"reason": r})
|
||||
}
|
||||
|
||||
func (s *PaymentService) RetryFulfillment(ctx context.Context, oid int64) error {
|
||||
|
||||
Reference in New Issue
Block a user