mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-22 11:58:56 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user