feat(payment): add complete payment system with multi-provider support
Add a full payment and subscription system supporting EasyPay (Alipay/WeChat), Stripe, and direct Alipay/WeChat Pay providers with multi-instance load balancing.
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
|
||||
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
)
|
||||
|
||||
// --- Provider Instance CRUD ---
|
||||
|
||||
func (s *PaymentConfigService) ListProviderInstances(ctx context.Context) ([]*dbent.PaymentProviderInstance, error) {
|
||||
return s.entClient.PaymentProviderInstance.Query().Order(paymentproviderinstance.BySortOrder()).All(ctx)
|
||||
}
|
||||
|
||||
// ProviderInstanceResponse is the API response for a provider instance.
|
||||
type ProviderInstanceResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
ProviderKey string `json:"provider_key"`
|
||||
Name string `json:"name"`
|
||||
Config map[string]string `json:"config"`
|
||||
SupportedTypes []string `json:"supported_types"`
|
||||
Limits string `json:"limits"`
|
||||
Enabled bool `json:"enabled"`
|
||||
RefundEnabled bool `json:"refund_enabled"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
PaymentMode string `json:"payment_mode"`
|
||||
}
|
||||
|
||||
// ListProviderInstancesWithConfig returns provider instances with decrypted config.
|
||||
func (s *PaymentConfigService) ListProviderInstancesWithConfig(ctx context.Context) ([]ProviderInstanceResponse, error) {
|
||||
instances, err := s.entClient.PaymentProviderInstance.Query().
|
||||
Order(paymentproviderinstance.BySortOrder()).All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]ProviderInstanceResponse, 0, len(instances))
|
||||
for _, inst := range instances {
|
||||
resp := ProviderInstanceResponse{
|
||||
ID: int64(inst.ID), ProviderKey: inst.ProviderKey, Name: inst.Name,
|
||||
SupportedTypes: splitTypes(inst.SupportedTypes), Limits: inst.Limits,
|
||||
Enabled: inst.Enabled, RefundEnabled: inst.RefundEnabled, SortOrder: inst.SortOrder,
|
||||
PaymentMode: inst.PaymentMode,
|
||||
}
|
||||
resp.Config, err = s.decryptAndMaskConfig(inst.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt config for instance %d: %w", inst.ID, err)
|
||||
}
|
||||
result = append(result, resp)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) decryptAndMaskConfig(encrypted string) (map[string]string, error) {
|
||||
return s.decryptConfig(encrypted)
|
||||
}
|
||||
|
||||
// pendingOrderStatuses are order statuses considered "in progress".
|
||||
var pendingOrderStatuses = []string{
|
||||
payment.OrderStatusPending,
|
||||
payment.OrderStatusPaid,
|
||||
payment.OrderStatusRecharging,
|
||||
}
|
||||
|
||||
var sensitiveConfigPatterns = []string{"key", "pkey", "secret", "private", "password"}
|
||||
|
||||
func isSensitiveConfigField(fieldName string) bool {
|
||||
lower := strings.ToLower(fieldName)
|
||||
for _, p := range sensitiveConfigPatterns {
|
||||
if strings.Contains(lower, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) countPendingOrders(ctx context.Context, providerInstanceID int64) (int, error) {
|
||||
return s.entClient.PaymentOrder.Query().
|
||||
Where(
|
||||
paymentorder.ProviderInstanceIDEQ(strconv.FormatInt(providerInstanceID, 10)),
|
||||
paymentorder.StatusIn(pendingOrderStatuses...),
|
||||
).Count(ctx)
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) countPendingOrdersByPlan(ctx context.Context, planID int64) (int, error) {
|
||||
return s.entClient.PaymentOrder.Query().
|
||||
Where(
|
||||
paymentorder.PlanIDEQ(planID),
|
||||
paymentorder.StatusIn(pendingOrderStatuses...),
|
||||
).Count(ctx)
|
||||
}
|
||||
|
||||
var validProviderKeys = map[string]bool{
|
||||
payment.TypeEasyPay: true, payment.TypeAlipay: true, payment.TypeWxpay: true, payment.TypeStripe: true,
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
|
||||
typesStr := joinTypes(req.SupportedTypes)
|
||||
if err := validateProviderRequest(req.ProviderKey, req.Name, typesStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enc, err := s.encryptConfig(req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.entClient.PaymentProviderInstance.Create().
|
||||
SetProviderKey(req.ProviderKey).SetName(req.Name).SetConfig(enc).
|
||||
SetSupportedTypes(typesStr).SetEnabled(req.Enabled).SetPaymentMode(req.PaymentMode).
|
||||
SetSortOrder(req.SortOrder).SetLimits(req.Limits).SetRefundEnabled(req.RefundEnabled).
|
||||
Save(ctx)
|
||||
}
|
||||
|
||||
func validateProviderRequest(providerKey, name, supportedTypes string) error {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return infraerrors.BadRequest("VALIDATION_ERROR", "provider name is required")
|
||||
}
|
||||
if !validProviderKeys[providerKey] {
|
||||
return infraerrors.BadRequest("VALIDATION_ERROR", fmt.Sprintf("invalid provider key: %s", providerKey))
|
||||
}
|
||||
// supported_types can be empty (provider accepts no payment types until configured)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateProviderInstance updates a provider instance by ID (patch semantics).
|
||||
// NOTE: This function exceeds 30 lines due to per-field nil-check patch update
|
||||
// boilerplate and pending-order safety checks.
|
||||
func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id int64, req UpdateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
|
||||
if req.Config != nil {
|
||||
hasSensitive := false
|
||||
for k := range req.Config {
|
||||
if isSensitiveConfigField(k) && req.Config[k] != "" {
|
||||
hasSensitive = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasSensitive {
|
||||
count, err := s.countPendingOrders(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check pending orders: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has pending orders").
|
||||
WithMetadata(map[string]string{"count": strconv.Itoa(count)})
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.Enabled != nil && !*req.Enabled {
|
||||
count, err := s.countPendingOrders(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check pending orders: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has pending orders").
|
||||
WithMetadata(map[string]string{"count": strconv.Itoa(count)})
|
||||
}
|
||||
}
|
||||
u := s.entClient.PaymentProviderInstance.UpdateOneID(id)
|
||||
if req.Name != nil {
|
||||
u.SetName(*req.Name)
|
||||
}
|
||||
if req.Config != nil {
|
||||
merged, err := s.mergeConfig(ctx, id, req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enc, err := s.encryptConfig(merged)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.SetConfig(enc)
|
||||
}
|
||||
if req.SupportedTypes != nil {
|
||||
// Check pending orders before removing payment types
|
||||
count, err := s.countPendingOrders(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check pending orders: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
// Load current instance to compare types
|
||||
inst, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load provider instance: %w", err)
|
||||
}
|
||||
oldTypes := strings.Split(inst.SupportedTypes, ",")
|
||||
newTypes := req.SupportedTypes
|
||||
for _, ot := range oldTypes {
|
||||
ot = strings.TrimSpace(ot)
|
||||
if ot == "" {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, nt := range newTypes {
|
||||
if strings.TrimSpace(nt) == ot {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, infraerrors.Conflict("PENDING_ORDERS", "cannot remove payment types while instance has pending orders").
|
||||
WithMetadata(map[string]string{"count": strconv.Itoa(count)})
|
||||
}
|
||||
}
|
||||
}
|
||||
u.SetSupportedTypes(joinTypes(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)
|
||||
}
|
||||
if req.PaymentMode != nil {
|
||||
u.SetPaymentMode(*req.PaymentMode)
|
||||
}
|
||||
return u.Save(ctx)
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) mergeConfig(ctx context.Context, id int64, newConfig map[string]string) (map[string]string, error) {
|
||||
inst, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load existing provider: %w", err)
|
||||
}
|
||||
existing, err := s.decryptConfig(inst.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt existing config for instance %d: %w", id, err)
|
||||
}
|
||||
if existing == nil {
|
||||
return newConfig, nil
|
||||
}
|
||||
for k, v := range newConfig {
|
||||
existing[k] = v
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) decryptConfig(encrypted string) (map[string]string, error) {
|
||||
if encrypted == "" {
|
||||
return nil, nil
|
||||
}
|
||||
decrypted, err := payment.Decrypt(encrypted, s.encryptionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt config: %w", err)
|
||||
}
|
||||
var raw map[string]string
|
||||
if err := json.Unmarshal([]byte(decrypted), &raw); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal decrypted config: %w", err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id int64) error {
|
||||
count, err := s.countPendingOrders(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check pending orders: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return infraerrors.Conflict("PENDING_ORDERS",
|
||||
fmt.Sprintf("this instance has %d in-progress orders and cannot be deleted — wait for orders to complete or disable the instance first", count))
|
||||
}
|
||||
return s.entClient.PaymentProviderInstance.DeleteOneID(id).Exec(ctx)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
enc, err := payment.Encrypt(string(data), s.encryptionKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encrypt config: %w", err)
|
||||
}
|
||||
return enc, nil
|
||||
}
|
||||
Reference in New Issue
Block a user