diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index b1e8dcbdce..168508c9f0 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -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) - 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. diff --git a/backend/internal/payment/load_balancer.go b/backend/internal/payment/load_balancer.go index 0c80254482..b6374a74d9 100644 --- a/backend/internal/payment/load_balancer.go +++ b/backend/internal/payment/load_balancer.go @@ -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) diff --git a/backend/internal/service/payment_config_limits.go b/backend/internal/service/payment_config_limits.go new file mode 100644 index 0000000000..c71223608c --- /dev/null +++ b/backend/internal/service/payment_config_limits.go @@ -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 +} diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index de77c30b9e..f6d4556fa9 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -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 diff --git a/frontend/src/api/payment.ts b/frontend/src/api/payment.ts index 46492a07c4..9276765ae5 100644 --- a/frontend/src/api/payment.ts +++ b/frontend/src/api/payment.ts @@ -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>('/payment/limits') + return apiClient.get('/payment/limits') }, /** Create a new payment order */ diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 6595a8ae46..375b646a85 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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.', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 20a3e33943..ef922c6d15 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5340,6 +5340,7 @@ export default { confirmCancel: '确定要取消此订单吗?', amountTooLow: '最低金额为 {min}', amountTooHigh: '最高金额为 {max}', + amountNoMethod: '该金额没有可用的支付方式', refundReason: '退款原因', refundReasonPlaceholder: '请描述您的退款原因', stripeLoadFailed: '支付组件加载失败,请刷新页面重试', diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index 7388a9f819..bda0618e0e 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -48,6 +48,13 @@ export interface MethodLimit { available: boolean } +/** Response from /payment/limits API */ +export interface MethodLimitsResponse { + methods: Record + 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 { diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index eb383b4d2c..464bdd3d03 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -30,8 +30,8 @@

{{ amountError }}

@@ -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(null) const selectedMethod = ref('') const selectedPlan = ref(null) -const methodLimits = ref>({}) +// Limits response from backend (per-method limits + precomputed global range) +const limitsData = ref({ 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(() => 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 } }) - -