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:
172
backend/internal/service/payment_config_limits.go
Normal file
172
backend/internal/service/payment_config_limits.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
)
|
||||
|
||||
// GetAvailableMethodLimits collects all payment types from enabled provider
|
||||
// instances and returns limits for each, plus the global widest range.
|
||||
// Stripe sub-types (card, link) are aggregated under "stripe".
|
||||
func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) (*MethodLimitsResponse, 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)
|
||||
}
|
||||
typeInstances := pcGroupByPaymentType(instances)
|
||||
resp := &MethodLimitsResponse{
|
||||
Methods: make(map[string]MethodLimits, len(typeInstances)),
|
||||
}
|
||||
for pt, insts := range typeInstances {
|
||||
ml := pcAggregateMethodLimits(pt, insts)
|
||||
resp.Methods[ml.PaymentType] = ml
|
||||
}
|
||||
resp.GlobalMin, resp.GlobalMax = pcComputeGlobalRange(resp.Methods)
|
||||
return resp, 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 {
|
||||
var matching []*dbent.PaymentProviderInstance
|
||||
for _, inst := range instances {
|
||||
if payment.InstanceSupportsType(inst.SupportedTypes, pt) {
|
||||
matching = append(matching, inst)
|
||||
}
|
||||
}
|
||||
result = append(result, pcAggregateMethodLimits(pt, matching))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// pcGroupByPaymentType groups instances by user-facing payment type.
|
||||
// For Stripe providers, ALL sub-types (card, link, alipay, wxpay) map to "stripe"
|
||||
// because the user sees a single "Stripe" button, not individual sub-methods.
|
||||
// Uses a seen set to avoid counting one instance twice.
|
||||
func pcGroupByPaymentType(instances []*dbent.PaymentProviderInstance) map[string][]*dbent.PaymentProviderInstance {
|
||||
typeInstances := make(map[string][]*dbent.PaymentProviderInstance)
|
||||
seen := make(map[string]map[int64]bool)
|
||||
add := func(key string, inst *dbent.PaymentProviderInstance) {
|
||||
if seen[key] == nil {
|
||||
seen[key] = make(map[int64]bool)
|
||||
}
|
||||
if !seen[key][int64(inst.ID)] {
|
||||
seen[key][int64(inst.ID)] = true
|
||||
typeInstances[key] = append(typeInstances[key], inst)
|
||||
}
|
||||
}
|
||||
for _, inst := range instances {
|
||||
// Stripe provider: all sub-types → single "stripe" group
|
||||
if inst.ProviderKey == payment.TypeStripe {
|
||||
add(payment.TypeStripe, inst)
|
||||
continue
|
||||
}
|
||||
for _, t := range splitTypes(inst.SupportedTypes) {
|
||||
add(t, inst)
|
||||
}
|
||||
}
|
||||
return typeInstances
|
||||
}
|
||||
|
||||
// pcInstanceTypeLimits extracts per-type limits from a provider instance.
|
||||
// Returns (limits, true) if configured; (zero, false) if unlimited.
|
||||
// For Stripe instances, limits are stored under "stripe" key regardless of sub-types.
|
||||
func pcInstanceTypeLimits(inst *dbent.PaymentProviderInstance, pt string) (payment.ChannelLimits, bool) {
|
||||
if inst.Limits == "" {
|
||||
return payment.ChannelLimits{}, false
|
||||
}
|
||||
var limits payment.InstanceLimits
|
||||
if err := json.Unmarshal([]byte(inst.Limits), &limits); err != nil {
|
||||
return payment.ChannelLimits{}, false
|
||||
}
|
||||
cl, ok := limits[pt]
|
||||
return cl, ok
|
||||
}
|
||||
|
||||
// unionFloat merges a single limit value into the aggregate using UNION semantics.
|
||||
// - For "min" fields (wantMin=true): keeps the lowest non-zero value
|
||||
// - For "max"/"cap" fields (wantMin=false): keeps the highest non-zero value
|
||||
// - If any value is 0 (unlimited), the result is unlimited.
|
||||
//
|
||||
// Returns (aggregated value, still limited).
|
||||
func unionFloat(agg float64, limited bool, val float64, wantMin bool) (float64, bool) {
|
||||
if val == 0 {
|
||||
return agg, false
|
||||
}
|
||||
if !limited {
|
||||
return agg, false
|
||||
}
|
||||
if agg == 0 {
|
||||
return val, true
|
||||
}
|
||||
if wantMin && val < agg {
|
||||
return val, true
|
||||
}
|
||||
if !wantMin && val > agg {
|
||||
return val, true
|
||||
}
|
||||
return agg, true
|
||||
}
|
||||
|
||||
// pcAggregateMethodLimits computes the UNION (least restrictive) of limits
|
||||
// across all provider instances for a given payment type.
|
||||
//
|
||||
// Since the load balancer can route an order to any available instance,
|
||||
// the user should see the widest possible range:
|
||||
// - SingleMin: lowest floor across instances; 0 if any is unlimited
|
||||
// - SingleMax: highest ceiling across instances; 0 if any is unlimited
|
||||
// - DailyLimit: highest cap across instances; 0 if any is unlimited
|
||||
func pcAggregateMethodLimits(pt string, instances []*dbent.PaymentProviderInstance) MethodLimits {
|
||||
ml := MethodLimits{PaymentType: pt}
|
||||
minLimited, maxLimited, dailyLimited := true, true, true
|
||||
|
||||
for _, inst := range instances {
|
||||
cl, hasLimits := pcInstanceTypeLimits(inst, pt)
|
||||
if !hasLimits {
|
||||
return MethodLimits{PaymentType: pt} // any unlimited instance → all zeros
|
||||
}
|
||||
ml.SingleMin, minLimited = unionFloat(ml.SingleMin, minLimited, cl.SingleMin, true)
|
||||
ml.SingleMax, maxLimited = unionFloat(ml.SingleMax, maxLimited, cl.SingleMax, false)
|
||||
ml.DailyLimit, dailyLimited = unionFloat(ml.DailyLimit, dailyLimited, cl.DailyLimit, false)
|
||||
}
|
||||
|
||||
if !minLimited {
|
||||
ml.SingleMin = 0
|
||||
}
|
||||
if !maxLimited {
|
||||
ml.SingleMax = 0
|
||||
}
|
||||
if !dailyLimited {
|
||||
ml.DailyLimit = 0
|
||||
}
|
||||
return ml
|
||||
}
|
||||
|
||||
// pcComputeGlobalRange computes the widest [min, max] across all methods.
|
||||
// Uses the same union logic: lowest min, highest max, 0 if any is unlimited.
|
||||
func pcComputeGlobalRange(methods map[string]MethodLimits) (globalMin, globalMax float64) {
|
||||
minLimited, maxLimited := true, true
|
||||
for _, ml := range methods {
|
||||
globalMin, minLimited = unionFloat(globalMin, minLimited, ml.SingleMin, true)
|
||||
globalMax, maxLimited = unionFloat(globalMax, maxLimited, ml.SingleMax, false)
|
||||
}
|
||||
if !minLimited {
|
||||
globalMin = 0
|
||||
}
|
||||
if !maxLimited {
|
||||
globalMax = 0
|
||||
}
|
||||
return globalMin, globalMax
|
||||
}
|
||||
Reference in New Issue
Block a user