feat(payment): union-based limits aggregation and amount-method filtering

Backend: change limits aggregation from intersection (most restrictive) to
union (least restrictive) across provider instances. Since the load balancer
can route to any instance, users should see the widest possible range.
Add global_min/global_max precomputed by backend for quick amount buttons.

Frontend: auto-disable payment methods that can't handle the entered amount,
auto-switch to first available method, show warning when no method fits.

Refactor: extract ChannelLimits named type, unionFloat helper to eliminate
repeated min/max/daily logic, split limits code to payment_config_limits.go,
replace magic strings with payment.Type* constants.
This commit is contained in:
erio
2026-04-08 02:18:02 +08:00
parent 14be762062
commit bd43ed23fd
9 changed files with 256 additions and 135 deletions
+2 -7
View File
@@ -90,17 +90,12 @@ func (h *PaymentHandler) GetChannels(c *gin.Context) {
// GetLimits returns per-payment-type limits derived from enabled provider instances.
// GET /api/v1/payment/limits
func (h *PaymentHandler) GetLimits(c *gin.Context) {
limits, err := h.configService.GetAvailableMethodLimits(c.Request.Context())
resp, err := h.configService.GetAvailableMethodLimits(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
// Return as map keyed by payment type (frontend expects Record<string, MethodLimit>)
result := make(map[string]service.MethodLimits, len(limits))
for _, ml := range limits {
result[ml.PaymentType] = ml
}
response.Success(c, result)
response.Success(c, resp)
}
// CreateOrderRequest is the request body for creating a payment order.
+5 -2
View File
@@ -21,13 +21,16 @@ const (
StrategyLeastAmount Strategy = "least_amount"
)
// InstanceLimits holds per-channel limits for a provider instance (JSON).
type InstanceLimits map[string]struct {
// ChannelLimits holds limits for a single payment channel within a provider instance.
type ChannelLimits struct {
DailyLimit float64 `json:"dailyLimit,omitempty"`
SingleMin float64 `json:"singleMin,omitempty"`
SingleMax float64 `json:"singleMax,omitempty"`
}
// InstanceLimits holds per-channel limits for a provider instance (JSON).
type InstanceLimits map[string]ChannelLimits
// LoadBalancer selects a provider instance for a given payment type.
type LoadBalancer interface {
GetInstanceConfig(ctx context.Context, instanceID int64) (map[string]string, error)
@@ -0,0 +1,175 @@
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"
)
// stripeSubTypes are types that should be aggregated under TypeStripe for user-facing display.
var stripeSubTypes = map[string]bool{string(payment.TypeCard): true, string(payment.TypeLink): true}
// 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.
// Stripe sub-types (card, link) are merged under "stripe".
// 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 {
for _, t := range splitTypes(inst.SupportedTypes) {
if stripeSubTypes[t] || t == payment.TypeStripe {
add(payment.TypeStripe, inst)
} else {
add(t, inst)
}
}
if inst.ProviderKey == payment.TypeStripe {
add(payment.TypeStripe, inst)
}
}
return typeInstances
}
// pcInstanceTypeLimits extracts per-type limits from a provider instance.
// Returns (limits, true) if configured; (zero, false) if unlimited.
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
}
@@ -101,6 +101,14 @@ type MethodLimits struct {
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"`
@@ -234,7 +242,7 @@ func (s *PaymentConfigService) getStripePublishableKey(ctx context.Context) stri
instances, err := s.entClient.PaymentProviderInstance.Query().
Where(
paymentproviderinstance.EnabledEQ(true),
paymentproviderinstance.ProviderKeyEQ("stripe"),
paymentproviderinstance.ProviderKeyEQ(payment.TypeStripe),
).Limit(1).All(ctx)
if err != nil || len(instances) == 0 {
return ""
@@ -415,7 +423,7 @@ func (s *PaymentConfigService) countPendingOrdersByPlan(ctx context.Context, pla
// validProviderKeys enumerates all recognized provider keys.
var validProviderKeys = map[string]bool{
"easypay": true, "alipay": true, "wxpay": true, "stripe": true,
payment.TypeEasyPay: true, payment.TypeAlipay: true, payment.TypeWxpay: true, payment.TypeStripe: true,
}
func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
@@ -686,93 +694,6 @@ func (s *PaymentConfigService) GetPlan(ctx context.Context, id int64) (*dbent.Su
return plan, nil
}
// stripeSubTypes are types that should be aggregated under "stripe" for user-facing display.
var stripeSubTypes = map[string]bool{"card": true, "link": true}
// GetAvailableMethodLimits collects all payment types from enabled provider
// instances and returns limits for each. This is used by the user-facing payment
// page to discover which payment methods are actually available.
// Stripe sub-types (card, link) are aggregated under "stripe".
func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) ([]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)
}
// Collect unique payment types from all enabled providers
typeSet := make(map[string]bool)
for _, inst := range instances {
for _, t := range splitTypes(inst.SupportedTypes) {
// Stripe sub-types (card, link) are represented as "stripe" to users
if stripeSubTypes[t] || t == "stripe" {
typeSet["stripe"] = true
} else {
typeSet[t] = true
}
}
}
result := make([]MethodLimits, 0, len(typeSet))
for pt := range typeSet {
ml := MethodLimits{PaymentType: pt}
for _, inst := range instances {
if !payment.InstanceSupportsType(inst.SupportedTypes, pt) {
// For "stripe", check if instance supports any stripe sub-type
if pt == "stripe" && inst.ProviderKey == "stripe" {
pcApplyInstanceLimits(inst, pt, &ml)
}
continue
}
pcApplyInstanceLimits(inst, pt, &ml)
}
result = append(result, ml)
}
return result, 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 !payment.InstanceSupportsType(inst.SupportedTypes, pt) {
continue
}
pcApplyInstanceLimits(inst, pt, &ml)
}
result = append(result, ml)
}
return result, nil
}
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 pcParseFloat(s string, defaultVal float64) float64 {
if s == "" {
return defaultVal
+2 -2
View File
@@ -8,7 +8,7 @@ import type {
PaymentConfig,
SubscriptionPlan,
PaymentChannel,
MethodLimit,
MethodLimitsResponse,
CreateOrderRequest,
CreateOrderResult,
PaymentOrder
@@ -33,7 +33,7 @@ export const paymentAPI = {
/** Get payment method limits and fee rates */
getLimits() {
return apiClient.get<Record<string, MethodLimit>>('/payment/limits')
return apiClient.get<MethodLimitsResponse>('/payment/limits')
},
/** Create a new payment order */
+1
View File
@@ -5143,6 +5143,7 @@ export default {
confirmCancel: 'Are you sure you want to cancel this order?',
amountTooLow: 'Minimum amount is {min}',
amountTooHigh: 'Maximum amount is {max}',
amountNoMethod: 'No payment method available for this amount',
refundReason: 'Refund Reason',
refundReasonPlaceholder: 'Please describe your refund reason',
stripeLoadFailed: 'Failed to load payment component. Please refresh and try again.',
+1
View File
@@ -5340,6 +5340,7 @@ export default {
confirmCancel: '确定要取消此订单吗?',
amountTooLow: '最低金额为 {min}',
amountTooHigh: '最高金额为 {max}',
amountNoMethod: '该金额没有可用的支付方式',
refundReason: '退款原因',
refundReasonPlaceholder: '请描述您的退款原因',
stripeLoadFailed: '支付组件加载失败,请刷新页面重试',
+7
View File
@@ -48,6 +48,13 @@ export interface MethodLimit {
available: boolean
}
/** Response from /payment/limits API */
export interface MethodLimitsResponse {
methods: Record<string, MethodLimit>
global_min: number // widest min across all methods; 0 = no minimum
global_max: number // widest max across all methods; 0 = no maximum
}
// ==================== Orders ====================
export interface PaymentOrder {
+53 -35
View File
@@ -30,8 +30,8 @@
<AmountInput
v-model="amount"
:amounts="[10, 20, 50, 100, 200, 500, 1000, 2000, 5000]"
:min="minAmount"
:max="maxAmount"
:min="globalMinAmount"
:max="globalMaxAmount"
/>
<p v-if="amountError" class="mt-2 text-xs text-amber-600 dark:text-amber-300">{{ amountError }}</p>
</div>
@@ -121,7 +121,7 @@ import { usePaymentStore } from '@/stores/payment'
import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError'
import type { SubscriptionPlan, MethodLimit } from '@/types/payment'
import type { SubscriptionPlan, MethodLimitsResponse } from '@/types/payment'
import AppLayout from '@/components/layout/AppLayout.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import AmountInput from '@/components/payment/AmountInput.vue'
@@ -148,7 +148,8 @@ const activeTab = ref<'recharge' | 'subscription'>('recharge')
const amount = ref<number | null>(null)
const selectedMethod = ref('')
const selectedPlan = ref<SubscriptionPlan | null>(null)
const methodLimits = ref<Record<string, MethodLimit>>({})
// Limits response from backend (per-method limits + precomputed global range)
const limitsData = ref<MethodLimitsResponse>({ methods: {}, global_min: 0, global_max: 0 })
const tabs = computed(() => {
const result: { key: 'recharge' | 'subscription'; label: string }[] = []
@@ -157,29 +158,38 @@ const tabs = computed(() => {
return result
})
// Available methods derived from limits API (actual provider types)
const enabledMethods = computed(() => Object.keys(methodLimits.value))
// 0 = no limit; provider-level overrides global
const minAmount = computed(() => {
const limit = methodLimits.value[selectedMethod.value]
if (limit?.single_min && limit.single_min > 0) return limit.single_min
return config.value?.min_amount && config.value.min_amount > 0 ? config.value.min_amount : 0
})
const maxAmount = computed(() => {
const limit = methodLimits.value[selectedMethod.value]
if (limit?.single_max && limit.single_max > 0) return limit.single_max
return config.value?.max_amount && config.value.max_amount > 0 ? config.value.max_amount : 0
})
const enabledMethods = computed(() => Object.keys(limitsData.value.methods))
const validAmount = computed(() => amount.value ?? 0)
// Check if an amount fits a method's [min, max]. 0 = no limit.
function amountFitsMethod(amt: number, methodType: string): boolean {
if (amt <= 0) return true
const ml = limitsData.value.methods[methodType]
if (!ml) return false
if (ml.single_min > 0 && amt < ml.single_min) return false
if (ml.single_max > 0 && amt > ml.single_max) return false
return true
}
// Global range for AmountInput quick buttons (precomputed by backend)
const globalMinAmount = computed(() => limitsData.value.global_min)
const globalMaxAmount = computed(() => limitsData.value.global_max)
// Selected method's limits (for validation and error messages)
const selectedLimit = computed(() => limitsData.value.methods[selectedMethod.value])
const methodOptions = computed<PaymentMethodOption[]>(() =>
enabledMethods.value.map((type) => {
const limit = methodLimits.value[type]
return { type, fee_rate: limit?.fee_rate ?? 0, available: limit?.available !== false }
const ml = limitsData.value.methods[type]
return {
type,
fee_rate: ml?.fee_rate ?? 0,
available: ml?.available !== false && amountFitsMethod(validAmount.value, type),
}
})
)
const validAmount = computed(() => amount.value ?? 0)
const feeRate = computed(() => methodLimits.value[selectedMethod.value]?.fee_rate ?? 0)
const feeRate = computed(() => selectedLimit.value?.fee_rate ?? 0)
const feeAmount = computed(() =>
feeRate.value > 0 && validAmount.value > 0
? Math.ceil(((validAmount.value * feeRate.value) / 100) * 100) / 100
@@ -193,17 +203,30 @@ const totalAmount = computed(() =>
const amountError = computed(() => {
if (validAmount.value <= 0) return ''
if (minAmount.value > 0 && validAmount.value < minAmount.value) return t('payment.amountTooLow', { min: minAmount.value })
if (maxAmount.value > 0 && validAmount.value > maxAmount.value) return t('payment.amountTooHigh', { max: maxAmount.value })
// No method can handle this amount
if (!enabledMethods.value.some((m) => amountFitsMethod(validAmount.value, m))) {
return t('payment.amountNoMethod')
}
// Selected method can't handle this amount (but others can)
const ml = selectedLimit.value
if (ml) {
if (ml.single_min > 0 && validAmount.value < ml.single_min) return t('payment.amountTooLow', { min: ml.single_min })
if (ml.single_max > 0 && validAmount.value > ml.single_max) return t('payment.amountTooHigh', { max: ml.single_max })
}
return ''
})
const canSubmit = computed(() => {
const limitInfo = methodLimits.value[selectedMethod.value]
if (validAmount.value <= 0) return false
if (minAmount.value > 0 && validAmount.value < minAmount.value) return false
if (maxAmount.value > 0 && validAmount.value > maxAmount.value) return false
return limitInfo?.available !== false
const canSubmit = computed(() =>
validAmount.value > 0
&& amountFitsMethod(validAmount.value, selectedMethod.value)
&& selectedLimit.value?.available !== false
)
// Auto-switch to first available method when current selection can't handle the amount
watch(() => [validAmount.value, selectedMethod.value] as const, ([amt, method]) => {
if (amt <= 0 || amountFitsMethod(amt, method)) return
const available = enabledMethods.value.find((m) => amountFitsMethod(amt, m))
if (available) selectedMethod.value = available
})
function openSubscribeDialog(plan: SubscriptionPlan) {
@@ -273,7 +296,7 @@ onMounted(async () => {
await paymentStore.fetchConfig(true)
try {
const limitsRes = await paymentAPI.getLimits()
methodLimits.value = limitsRes.data
limitsData.value = limitsRes.data
} catch (e) { /* limits endpoint may not exist */ }
if (enabledMethods.value.length) {
const order: readonly string[] = METHOD_ORDER
@@ -292,8 +315,3 @@ onMounted(async () => {
finally { loading.value = false }
})
</script>
<style scoped>
.fade-enter-active, .fade-leave-active { transition: all 0.3s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(-8px); }
</style>