feat(payment): add /checkout-info API, simplify PaymentView to single call

Backend: new GET /payment/checkout-info returns methods (with limits),
global_min/max, plans (with platform), balance_disabled, help_text,
and stripe_publishable_key in one response.

Frontend: PaymentView now calls getCheckoutInfo() once instead of
fetchConfig + getLimits + fetchPlans separately. Removed plansLoading
state and loadPlans watcher. Reduced from 317 to 301 lines.
This commit is contained in:
erio
2026-04-08 02:49:26 +08:00
parent c016bdba13
commit 3acb3b056e
5 changed files with 108 additions and 38 deletions
@@ -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) {
@@ -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)
+6
View File
@@ -9,6 +9,7 @@ import type {
SubscriptionPlan,
PaymentChannel,
MethodLimitsResponse,
CheckoutInfoResponse,
CreateOrderRequest,
CreateOrderResult,
PaymentOrder
@@ -31,6 +32,11 @@ export const paymentAPI = {
return apiClient.get<PaymentChannel[]>('/payment/channels')
},
/** Get all checkout page data in a single call */
getCheckoutInfo() {
return apiClient.get<CheckoutInfoResponse>('/payment/checkout-info')
},
/** Get payment method limits and fee rates */
getLimits() {
return apiClient.get<MethodLimitsResponse>('/payment/limits')
+11
View File
@@ -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<string, MethodLimit>
global_min: number
global_max: number
plans: SubscriptionPlan[]
balance_disabled: boolean
help_text: string
stripe_publishable_key: string
}
// ==================== Orders ====================
export interface PaymentOrder {
+22 -38
View File
@@ -72,16 +72,13 @@
</template>
<!-- Subscribe Tab -->
<template v-else-if="activeTab === 'subscription'">
<div v-if="plansLoading" class="flex items-center justify-center py-20">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
</div>
<div v-else-if="plans.length === 0" class="py-16 text-center text-gray-500 dark:text-gray-400">{{ t('payment.noPlans') }}</div>
<div v-if="checkout.plans.length === 0" class="py-16 text-center text-gray-500 dark:text-gray-400">{{ t('payment.noPlans') }}</div>
<div v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<SubscriptionPlanCard v-for="plan in plans" :key="plan.id" :plan="plan" @select="openSubscribeDialog" />
<SubscriptionPlanCard v-for="plan in checkout.plans" :key="plan.id" :plan="plan" @select="openSubscribeDialog" />
</div>
</template>
<div v-if="config?.help_text" class="card p-4">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ config.help_text }}</p>
<div v-if="checkout.help_text" class="card p-4">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ checkout.help_text }}</p>
</div>
</template>
</div>
@@ -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<number | null>(null)
const selectedMethod = ref('')
const selectedPlan = ref<SubscriptionPlan | null>(null)
// Limits response from backend (per-method limits + precomputed global range)
const limitsData = ref<MethodLimitsResponse>({ methods: {}, global_min: 0, global_max: 0 })
// All checkout data from single API call
const checkout = ref<CheckoutInfoResponse>({
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<PaymentMethodOption[]>(() =>
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 }
})
</script>