diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 168508c9f0..151750c144 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -87,6 +87,74 @@ func (h *PaymentHandler) GetChannels(c *gin.Context) { response.Success(c, channels) } +// GetCheckoutInfo returns all data the payment page needs in a single call: +// payment methods with limits, subscription plans, and configuration. +// GET /api/v1/payment/checkout-info +func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) { + ctx := c.Request.Context() + + // Fetch limits (methods + global range) + limitsResp, err := h.configService.GetAvailableMethodLimits(ctx) + if err != nil { + response.ErrorFrom(c, err) + return + } + + // Fetch payment config + cfg, err := h.configService.GetPaymentConfig(ctx) + if err != nil { + response.ErrorFrom(c, err) + return + } + + // Fetch plans with platform info + plans, _ := h.configService.ListPlansForSale(ctx) + platformMap := h.configService.GetGroupPlatformMap(ctx, plans) + planList := make([]checkoutPlan, 0, len(plans)) + for _, p := range plans { + planList = append(planList, checkoutPlan{ + ID: int64(p.ID), GroupID: p.GroupID, GroupPlatform: platformMap[p.GroupID], + Name: p.Name, Description: p.Description, Price: p.Price, OriginalPrice: p.OriginalPrice, + ValidityDays: p.ValidityDays, ValidityUnit: p.ValidityUnit, Features: p.Features, + ProductName: p.ProductName, + }) + } + + response.Success(c, checkoutInfoResponse{ + Methods: limitsResp.Methods, + GlobalMin: limitsResp.GlobalMin, + GlobalMax: limitsResp.GlobalMax, + Plans: planList, + BalanceDisabled: cfg.BalanceDisabled, + HelpText: cfg.HelpText, + StripePublishableKey: cfg.StripePublishableKey, + }) +} + +type checkoutInfoResponse struct { + Methods map[string]service.MethodLimits `json:"methods"` + GlobalMin float64 `json:"global_min"` + GlobalMax float64 `json:"global_max"` + Plans []checkoutPlan `json:"plans"` + BalanceDisabled bool `json:"balance_disabled"` + HelpText string `json:"help_text"` + StripePublishableKey string `json:"stripe_publishable_key"` +} + +type checkoutPlan struct { + ID int64 `json:"id"` + GroupID int64 `json:"group_id"` + GroupPlatform string `json:"group_platform"` + Name string `json:"name"` + Description string `json:"description"` + Price float64 `json:"price"` + OriginalPrice *float64 `json:"original_price,omitempty"` + ValidityDays int `json:"validity_days"` + ValidityUnit string `json:"validity_unit"` + Features string `json:"features"` + ProductName string `json:"product_name"` +} + // GetLimits returns per-payment-type limits derived from enabled provider instances. // GET /api/v1/payment/limits func (h *PaymentHandler) GetLimits(c *gin.Context) { diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go index 7f734fd2a2..c6d4835f31 100644 --- a/backend/internal/server/routes/payment.go +++ b/backend/internal/server/routes/payment.go @@ -26,6 +26,7 @@ func RegisterPaymentRoutes( authenticated.Use(middleware.BackendModeUserGuard(settingService)) { authenticated.GET("/config", paymentHandler.GetPaymentConfig) + authenticated.GET("/checkout-info", paymentHandler.GetCheckoutInfo) authenticated.GET("/plans", paymentHandler.GetPlans) authenticated.GET("/channels", paymentHandler.GetChannels) authenticated.GET("/limits", paymentHandler.GetLimits) diff --git a/frontend/src/api/payment.ts b/frontend/src/api/payment.ts index 9276765ae5..90f15dec9a 100644 --- a/frontend/src/api/payment.ts +++ b/frontend/src/api/payment.ts @@ -9,6 +9,7 @@ import type { SubscriptionPlan, PaymentChannel, MethodLimitsResponse, + CheckoutInfoResponse, CreateOrderRequest, CreateOrderResult, PaymentOrder @@ -31,6 +32,11 @@ export const paymentAPI = { return apiClient.get('/payment/channels') }, + /** Get all checkout page data in a single call */ + getCheckoutInfo() { + return apiClient.get('/payment/checkout-info') + }, + /** Get payment method limits and fee rates */ getLimits() { return apiClient.get('/payment/limits') diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index bda0618e0e..2b05761531 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -55,6 +55,17 @@ export interface MethodLimitsResponse { global_max: number // widest max across all methods; 0 = no maximum } +/** Response from /payment/checkout-info API — single call for the payment page */ +export interface CheckoutInfoResponse { + methods: Record + global_min: number + global_max: number + plans: SubscriptionPlan[] + balance_disabled: boolean + help_text: string + stripe_publishable_key: string +} + // ==================== Orders ==================== export interface PaymentOrder { diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 2629b8514f..441e4fb09c 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -72,16 +72,13 @@ -
-

{{ config.help_text }}

+
+

{{ checkout.help_text }}

@@ -121,7 +118,7 @@ import { usePaymentStore } from '@/stores/payment' import { useAppStore } from '@/stores' import { paymentAPI } from '@/api/payment' import { extractApiErrorMessage } from '@/utils/apiError' -import type { SubscriptionPlan, MethodLimitsResponse } from '@/types/payment' +import type { SubscriptionPlan, CheckoutInfoResponse } from '@/types/payment' import AppLayout from '@/components/layout/AppLayout.vue' import BaseDialog from '@/components/common/BaseDialog.vue' import AmountInput from '@/components/payment/AmountInput.vue' @@ -137,34 +134,35 @@ const paymentStore = usePaymentStore() const appStore = useAppStore() const user = computed(() => authStore.user) -const config = computed(() => paymentStore.config) -const plans = computed(() => paymentStore.plans) const loading = ref(true) -const plansLoading = ref(false) const submitting = ref(false) const errorMessage = ref('') const activeTab = ref<'recharge' | 'subscription'>('recharge') const amount = ref(null) const selectedMethod = ref('') const selectedPlan = ref(null) -// Limits response from backend (per-method limits + precomputed global range) -const limitsData = ref({ methods: {}, global_min: 0, global_max: 0 }) + +// All checkout data from single API call +const checkout = ref({ + methods: {}, global_min: 0, global_max: 0, + plans: [], balance_disabled: false, help_text: '', stripe_publishable_key: '', +}) const tabs = computed(() => { const result: { key: 'recharge' | 'subscription'; label: string }[] = [] - if (!config.value?.balance_disabled) result.push({ key: 'recharge', label: t('payment.tabTopUp') }) + if (!checkout.value.balance_disabled) result.push({ key: 'recharge', label: t('payment.tabTopUp') }) result.push({ key: 'subscription', label: t('payment.tabSubscribe') }) return result }) -const enabledMethods = computed(() => Object.keys(limitsData.value.methods)) +const enabledMethods = computed(() => Object.keys(checkout.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] + const ml = checkout.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 @@ -172,15 +170,15 @@ function amountFitsMethod(amt: number, methodType: string): boolean { } // Global range for AmountInput quick buttons (precomputed by backend) -const globalMinAmount = computed(() => limitsData.value.global_min) -const globalMaxAmount = computed(() => limitsData.value.global_max) +const globalMinAmount = computed(() => checkout.value.global_min) +const globalMaxAmount = computed(() => checkout.value.global_max) // Selected method's limits (for validation and error messages) -const selectedLimit = computed(() => limitsData.value.methods[selectedMethod.value]) +const selectedLimit = computed(() => checkout.value.methods[selectedMethod.value]) const methodOptions = computed(() => enabledMethods.value.map((type) => { - const ml = limitsData.value.methods[type] + const ml = checkout.value.methods[type] return { type, fee_rate: ml?.fee_rate ?? 0, @@ -281,23 +279,10 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb } } -async function loadPlans() { - plansLoading.value = true - try { await paymentStore.fetchPlans() } catch (err: unknown) { console.error('Failed to load plans:', err) } - finally { plansLoading.value = false } -} - -watch(() => activeTab.value, (tab) => { - if (tab === 'subscription' && plans.value.length === 0) loadPlans() -}) - onMounted(async () => { try { - await paymentStore.fetchConfig(true) - try { - const limitsRes = await paymentAPI.getLimits() - limitsData.value = limitsRes.data - } catch (e: unknown) { /* limits endpoint may not exist */ } + const res = await paymentAPI.getCheckoutInfo() + checkout.value = res.data if (enabledMethods.value.length) { const order: readonly string[] = METHOD_ORDER const sorted = [...enabledMethods.value].sort((a, b) => { @@ -307,11 +292,10 @@ onMounted(async () => { }) selectedMethod.value = sorted[0] } - if (config.value?.balance_disabled) { + if (checkout.value.balance_disabled) { activeTab.value = 'subscription' - await loadPlans() } - } catch (err: unknown) { console.error('Failed to load config:', err) } + } catch (err: unknown) { console.error('Failed to load checkout info:', err) } finally { loading.value = false } })