This commit is contained in:
imeepos
2026-01-28 14:39:46 +08:00
parent b46ad76161
commit ca63868282
4 changed files with 733 additions and 64 deletions

View File

@@ -8,6 +8,7 @@ import {
StatusBar as RNStatusBar,
Platform,
useWindowDimensions,
ActivityIndicator,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { LinearGradient } from 'expo-linear-gradient'
@@ -22,7 +23,7 @@ import { CheckMarkIcon } from '@/components/icon/checkMark'
import PointsDrawer from '@/components/drawer/PointsDrawer'
import Dropdown from '@/components/ui/dropdown'
import GradientText from '@/components/GradientText'
import { useUserBalance } from '@/hooks/use-user-balance'
import { useMembership } from '@/hooks/use-membership'
// 使用唯一 id 的 PointsIcon避免与其他页面的图标 id 冲突
const MembershipPointsIcon = () => {
@@ -65,63 +66,53 @@ export default function MembershipScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { width: screenWidth } = useWindowDimensions()
const [selectedPlan, setSelectedPlan] = useState<PlanType>('pro')
const [agreed, setAgreed] = useState(false)
const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false)
const { t } = useTranslation()
// 获取积分余额
const { balance } = useUserBalance()
// 订阅计划数据(使用国际化)
const plans: Plan[] = [
{
id: 'plus',
name: 'Plus',
price: 9,
points: 750,
features: [
t('membership.features.points750'),
t('membership.features.textToVideo'),
t('membership.features.superClearImage'),
t('membership.features.superDiscount'),
]
},
{
id: 'pro',
name: 'Pro',
price: 29,
recommended: true,
points: 1080,
features: [
t('membership.features.points1080'),
t('membership.features.textToVideo'),
t('membership.features.superClearImage'),
t('membership.features.superDiscount'),
t('membership.features.sora2ProTemplate'),
t('membership.features.removeWatermark'),
t('membership.features.allTemplates'),
]
},
{
id: 'plus-premium',
name: 'Plus',
price: 49,
points: 1500,
features: [
t('membership.features.points1500'),
t('membership.features.textToVideo'),
t('membership.features.superClearImage'),
t('membership.features.superDiscount'),
// 使用 useMembership hook
const {
creditPlans,
creditBalance,
selectedPlanIndex,
setSelectedPlanIndex,
isLoadingSubscriptions,
isStripePricingLoading,
createSubscription,
upgradeSubscription,
restoreSubscription,
rechargeToken,
activeAuthSubscription,
hasActiveSubscription,
stripePricingData,
} = useMembership()
// 映射 API 数据到 UI 格式
const plans: Plan[] = creditPlans.map((plan, index) => ({
id: `plan-${index}` as PlanType,
name: index === 0 ? 'Plus' : index === 1 ? 'Pro' : 'Plus',
price: plan.amountInCents / 100,
recommended: plan.popular,
points: plan.credits,
features: [
t('membership.features.points750'),
t('membership.features.textToVideo'),
t('membership.features.superClearImage'),
t('membership.features.superDiscount'),
...(index > 0 ? [
t('membership.features.sora2ProTemplate'),
t('membership.features.removeWatermark'),
t('membership.features.allTemplates'),
] : []),
...(index > 1 ? [
t('membership.features.higherQuality'),
t('membership.features.prioritySupport'),
]
},
]
] : []),
],
}))
const selectedPlan = plans[selectedPlanIndex] || plans[0]
// 下拉菜单选项
const menuOptions = [
@@ -146,18 +137,52 @@ export default function MembershipScreen() {
]
const [currentImageIndex, setCurrentImageIndex] = useState(0)
// 处理订阅按钮点击
const handleSubscribe = () => {
if (!agreed || !selectedPlan) return
const planData = creditPlans[selectedPlanIndex]
if (!planData) return
if (hasActiveSubscription) {
upgradeSubscription({ credits: planData.credits })
} else if (activeAuthSubscription?.cancelAtPeriodEnd) {
restoreSubscription()
} else {
const pricingItem = stripePricingData?.pricing_table_items?.[selectedPlanIndex]
if (pricingItem) {
createSubscription({
priceId: pricingItem.price_id,
productId: pricingItem.product_id,
})
}
}
}
// 获取当前选中计划的信息
const currentPlan = plans.find(plan => plan.id === selectedPlan) || plans[1]
const currentPlan = selectedPlan
// 计算进度条百分比:当前计划积分 / 最高计划积分
const maxPoints = Math.max(...plans.map(plan => plan.points))
const progressPercentage = (currentPlan.points / maxPoints) * 100
const progressPercentage = (currentPlan?.points / maxPoints) * 100
// 加载状态
if (isStripePricingLoading || creditPlans.length === 0) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<View style={[styles.container, styles.loadingContainer]}>
<ActivityIndicator size="large" color="#FF9966" />
</View>
</SafeAreaView>
)
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar
barStyle="light-content"
<RNStatusBar
barStyle="light-content"
backgroundColor="#090A0B"
translucent={Platform.OS === 'android'}
/>
@@ -172,13 +197,13 @@ export default function MembershipScreen() {
</Pressable>
<View style={styles.headerSpacer} />
<View style={styles.pointsContainer}>
<Pressable
<Pressable
style={styles.pointsPill}
onPress={() => setPointsDrawerVisible(true)}
>
<Text style={styles.pointsLabel}>{t('membership.myPoints')}</Text>
<MembershipPointsIcon />
<Text style={styles.pointsValue}>{balance}</Text>
<Text style={styles.pointsValue}>{creditBalance}</Text>
</Pressable>
<View style={styles.settingsButtonContainer}>
<Dropdown
@@ -253,7 +278,7 @@ export default function MembershipScreen() {
{/* 订阅计划卡片 */}
<View style={styles.plansContainer}>
{plans.map((plan, index) => {
const isSelected = selectedPlan === plan.id
const isSelected = selectedPlanIndex === index
return (
<Pressable
key={plan.id}
@@ -261,7 +286,7 @@ export default function MembershipScreen() {
styles.planCardWrapper,
index > 0 && styles.planCardSpacing,
]}
onPress={() => setSelectedPlan(plan.id)}
onPress={() => setSelectedPlanIndex(index)}
>
{isSelected ? (
<LinearGradient
@@ -365,15 +390,16 @@ export default function MembershipScreen() {
<View style={[styles.subscribeContainer, { paddingBottom: Math.max(insets.bottom, 3) }]}>
{/* 立即开通按钮 */}
<Pressable
disabled={!agreed}
disabled={!agreed || isLoadingSubscriptions || isStripePricingLoading}
style={styles.subscribeButtonPressable}
onPress={handleSubscribe}
>
<LinearGradient
colors={currentPlan.recommended
colors={currentPlan.recommended
? ['#9966FF', '#FF6699', '#FF9966']
: ['#FFE7F8', '#C6A7FA', '#ADBCFF', '#E6E3FF']
}
locations={currentPlan.recommended
locations={currentPlan.recommended
? [0.0015, 0.4985, 0.9956]
: [0, 0.33, 0.66, 1]
}
@@ -381,12 +407,14 @@ export default function MembershipScreen() {
end={{ x: 0, y: 0 }}
style={[
styles.subscribeButton,
!agreed && styles.subscribeButtonDisabled,
(!agreed || isLoadingSubscriptions || isStripePricingLoading) && styles.subscribeButtonDisabled,
]}
>
{isLoadingSubscriptions || isStripePricingLoading ? (
<ActivityIndicator color="#F5F5F5" />
) : (
<Text style={currentPlan.recommended ? styles.subscribeButtonText : styles.subscribeButtonText1}>{t('membership.subscribeNow')}</Text>
)}
</LinearGradient>
</Pressable>
{/* 协议复选框 */}
@@ -423,9 +451,10 @@ export default function MembershipScreen() {
<PointsDrawer
visible={pointsDrawerVisible}
onClose={() => setPointsDrawerVisible(false)}
totalPoints={balance}
totalPoints={creditBalance}
subscriptionPoints={0}
topUpPoints={0}
onRecharge={(amount) => rechargeToken(amount)}
/>
</SafeAreaView>
)
@@ -770,5 +799,9 @@ const styles = StyleSheet.create({
fontWeight: '400',
flex: 1,
},
loadingContainer: {
justifyContent: 'center',
alignItems: 'center',
},
})

View File

@@ -0,0 +1,404 @@
import { renderHook, act, waitFor } from '@testing-library/react-native'
import { useMembership } from './use-membership'
import { subscription, useSession } from '@/lib/auth'
import { useRouter } from 'expo-router'
import { Linking, Alert } from 'react-native'
jest.mock('@/lib/auth', () => ({
subscription: {
plans: jest.fn(),
list: jest.fn(),
create: jest.fn(),
upgrade: jest.fn(),
restore: jest.fn(),
cancel: jest.fn(),
credit: {
topup: jest.fn(),
},
},
useSession: jest.fn(),
}))
jest.mock('expo-router', () => ({
useRouter: jest.fn(),
}))
jest.mock('react-native', () => ({
Linking: {
openURL: jest.fn(),
},
Alert: {
alert: jest.fn(),
},
}))
const mockUseQuery = jest.fn()
const mockUseMutation = jest.fn()
jest.mock('@tanstack/react-query', () => ({
useQuery: (...args: any[]) => mockUseQuery(args[0]),
useMutation: (...args: any[]) => mockUseMutation(args[0]),
}))
describe('useMembership', () => {
const mockRouter = { push: jest.fn() }
const mockSession = {
user: { id: 'user-123' },
}
beforeEach(() => {
jest.clearAllMocks()
;(useRouter as jest.Mock).mockReturnValue(mockRouter)
;(useSession as jest.Mock).mockReturnValue({ data: mockSession })
mockUseQuery.mockReturnValue({
data: null,
isLoading: false,
refetch: jest.fn(),
})
mockUseMutation.mockReturnValue({
mutate: jest.fn(),
})
})
describe('获取 Stripe 定价表数据', () => {
it('应该成功获取定价表数据', async () => {
const mockPricingData = {
pricing_table_items: [
{
amount: '1000',
recurring: { interval: 'month' },
metadata: { grant_token: '1000' },
is_highlight: true,
},
],
}
mockUseQuery.mockReturnValue({
data: mockPricingData,
isLoading: false,
refetch: jest.fn(),
})
const { result } = renderHook(() => useMembership())
expect(result.current.stripePricingData).toEqual(mockPricingData)
})
})
describe('获取用户订阅列表', () => {
it('应该成功获取订阅列表', async () => {
const mockSubscriptions = [
{
type: 'licenced',
status: 'active',
cancelAtPeriodEnd: false,
stripeSubscriptionId: 'sub-123',
},
]
let queryCallCount = 0
mockUseQuery.mockImplementation((config) => {
queryCallCount++
if (queryCallCount === 2) {
return {
data: mockSubscriptions,
isLoading: false,
refetch: jest.fn(),
}
}
return {
data: null,
isLoading: false,
refetch: jest.fn(),
}
})
const { result } = renderHook(() => useMembership())
expect(result.current.isLoadingSubscriptions).toBe(false)
})
it('应该在没有用户会话时不获取订阅', () => {
;(useSession as jest.Mock).mockReturnValue({ data: null })
mockUseQuery.mockImplementation((config) => {
if (config.enabled === false) {
return {
data: null,
isLoading: false,
refetch: jest.fn(),
}
}
return {
data: null,
isLoading: false,
refetch: jest.fn(),
}
})
renderHook(() => useMembership())
expect(mockUseQuery).toHaveBeenCalled()
})
})
describe('创建订阅', () => {
it('应该成功创建订阅并打开支付链接', async () => {
const mockUrl = 'https://checkout.stripe.com/test'
;(subscription.create as jest.Mock).mockResolvedValue({
data: { url: mockUrl },
error: null,
})
const mutateFn = jest.fn(async (variables) => {
const { data, error } = await subscription.create({
priceId: variables.priceId,
productId: variables.productId,
successUrl: expect.stringContaining('/membership'),
cancelUrl: expect.stringContaining('/membership?canceled=true'),
})
if (error) throw new Error(error.message)
if (data?.url) await Linking.openURL(data.url)
return data
})
mockUseMutation.mockReturnValue({
mutate: mutateFn,
})
const { result } = renderHook(() => useMembership())
await act(async () => {
await result.current.createSubscription({
priceId: 'price-123',
productId: 'prod-123',
})
})
expect(mutateFn).toHaveBeenCalled()
expect(Linking.openURL).toHaveBeenCalledWith(mockUrl)
})
it('应该处理创建订阅错误', async () => {
;(subscription.create as jest.Mock).mockResolvedValue({
data: null,
error: { message: 'Failed to create subscription' },
})
const mutateFn = jest.fn(async (variables) => {
try {
const { data, error } = await subscription.create(variables)
if (error) throw new Error(error.message)
return data
} catch (error) {
Alert.alert('错误', '订阅创建失败')
throw error
}
})
mockUseMutation.mockReturnValue({
mutate: mutateFn,
})
const { result } = renderHook(() => useMembership())
await act(async () => {
try {
await result.current.createSubscription({
priceId: 'price-123',
productId: 'prod-123',
})
} catch (e) {
// Expected error
}
})
expect(Alert.alert).toHaveBeenCalledWith('错误', '订阅创建失败')
})
})
describe('升级订阅', () => {
it('应该成功升级订阅', async () => {
const mockSubscriptions = [
{
type: 'licenced',
status: 'active',
cancelAtPeriodEnd: false,
stripeSubscriptionId: 'sub-123',
},
]
let queryCallCount = 0
mockUseQuery.mockImplementation(() => {
queryCallCount++
if (queryCallCount === 2) {
return {
data: mockSubscriptions,
isLoading: false,
refetch: jest.fn(),
}
}
return {
data: null,
isLoading: false,
refetch: jest.fn(),
}
})
;(subscription.upgrade as jest.Mock).mockResolvedValue({
data: { success: true },
error: null,
})
const mutateFn = jest.fn()
mockUseMutation.mockReturnValue({
mutate: mutateFn,
})
const { result } = renderHook(() => useMembership())
await act(async () => {
result.current.upgradeSubscription({ credits: 1000 })
})
expect(mutateFn).toHaveBeenCalled()
})
})
describe('恢复订阅', () => {
it('应该成功恢复订阅', async () => {
const mockSubscriptions = [
{
type: 'licenced',
status: 'active',
cancelAtPeriodEnd: true,
stripeSubscriptionId: 'sub-123',
referenceId: 'user-123',
},
]
let queryCallCount = 0
mockUseQuery.mockImplementation(() => {
queryCallCount++
if (queryCallCount === 2) {
return {
data: mockSubscriptions,
isLoading: false,
refetch: jest.fn(),
}
}
return {
data: null,
isLoading: false,
refetch: jest.fn(),
}
})
;(subscription.restore as jest.Mock).mockResolvedValue({
data: { success: true },
error: null,
})
const mutateFn = jest.fn()
mockUseMutation.mockReturnValue({
mutate: mutateFn,
})
const { result } = renderHook(() => useMembership())
await act(async () => {
result.current.restoreSubscription()
})
expect(mutateFn).toHaveBeenCalled()
})
})
describe('充值积分', () => {
it('应该成功充值积分并打开支付链接', async () => {
const mockUrl = 'https://checkout.stripe.com/topup'
const mockSubscriptions = [
{
type: 'metered',
priceId: 'price-metered-123',
},
]
let queryCallCount = 0
mockUseQuery.mockImplementation(() => {
queryCallCount++
if (queryCallCount === 2) {
return {
data: mockSubscriptions,
isLoading: false,
refetch: jest.fn(),
}
}
return {
data: null,
isLoading: false,
refetch: jest.fn(),
}
})
;(subscription.credit.topup as jest.Mock).mockResolvedValue({
data: { url: mockUrl },
error: null,
})
const mutateFn = jest.fn(async (amount) => {
const { data, error } = await subscription.credit.topup({
amount,
priceId: 'price-metered-123',
successUrl: expect.stringContaining('/membership'),
cancelUrl: expect.stringContaining('/membership?canceled=true'),
})
if (error) throw new Error(error.message)
if (data?.url) await Linking.openURL(data.url)
return data
})
mockUseMutation.mockReturnValue({
mutate: mutateFn,
})
const { result } = renderHook(() => useMembership())
await act(async () => {
await result.current.rechargeToken(1000)
})
expect(mutateFn).toHaveBeenCalledWith(1000)
expect(Linking.openURL).toHaveBeenCalledWith(mockUrl)
})
it('应该处理无效充值金额', async () => {
const mutateFn = jest.fn(async (amount) => {
try {
if (!amount || amount <= 0) throw new Error('充值金额无效')
} catch (error) {
Alert.alert('错误', '充值失败')
throw error
}
})
mockUseMutation.mockReturnValue({
mutate: mutateFn,
})
const { result } = renderHook(() => useMembership())
await act(async () => {
try {
await result.current.rechargeToken(0)
} catch (e) {
// Expected error
}
})
expect(Alert.alert).toHaveBeenCalledWith('错误', '充值失败')
})
})
})

188
hooks/use-membership.ts Normal file
View File

@@ -0,0 +1,188 @@
import { useState, useEffect, useMemo } from 'react'
import { Linking, Alert } from 'react-native'
import { subscription, useSession } from '@/lib/auth'
import type { SubscriptionListItem } from '@/lib/auth'
interface CreditPlan {
amountInCents: number
credits: number
popular?: boolean
}
export function useMembership() {
const { data: session } = useSession()
const [selectedPlanIndex, setSelectedPlanIndex] = useState(0)
const [stripePricingData, setStripePricingData] = useState<any>(null)
const [authSubscriptions, setAuthSubscriptions] = useState<any>(null)
const [isStripePricingLoading, setIsStripePricingLoading] = useState(false)
const [isLoadingSubscriptions, setIsLoadingSubscriptions] = useState(false)
const fetchStripePricing = async () => {
setIsStripePricingLoading(true)
const { data, error } = await subscription.plans({
query: {
id: process.env.EXPO_PUBLIC_STRIPE_PRICING_TABLE_ID || '',
key: process.env.EXPO_PUBLIC_STRIPE_PRICING_TABLE_KEY || '',
},
})
setIsStripePricingLoading(false)
if (!error) setStripePricingData(data)
}
const fetchAuthSubscriptions = async () => {
if (!session?.user?.id) return
setIsLoadingSubscriptions(true)
const { data, error } = await subscription.list({
query: { referenceId: session.user.id },
})
setIsLoadingSubscriptions(false)
if (!error) setAuthSubscriptions(data)
}
useEffect(() => {
fetchStripePricing()
}, [])
useEffect(() => {
if (session?.user?.id) fetchAuthSubscriptions()
}, [session?.user?.id])
const licensedSubscriptions = authSubscriptions?.filter((sub: SubscriptionListItem) => sub.type === 'licenced') || []
const meteredSubscriptions = authSubscriptions?.filter((sub: SubscriptionListItem) => sub.type === 'metered') || []
const activeAuthSubscription = licensedSubscriptions.find(
(sub: any) => sub.status === 'active' || sub.status === 'trialing'
)
const hasActiveSubscription = !!(
activeAuthSubscription &&
activeAuthSubscription.cancelAtPeriodEnd === false &&
activeAuthSubscription.status === 'active'
)
const creditBalance = meteredSubscriptions?.[0]?.creditBalance?.remainingTokenBalance || 0
const creditPlans = useMemo((): CreditPlan[] => {
if (!stripePricingData?.pricing_table_items) return []
return stripePricingData.pricing_table_items
.filter((item: any) => item.recurring?.interval === 'month')
.map((item: any) => ({
amountInCents: parseInt(item.amount),
credits: parseInt(item.metadata?.grant_token || item.amount),
popular: item.is_highlight || item.highlight_text === 'most_popular',
}))
}, [stripePricingData?.pricing_table_items])
const selectedPlan = creditPlans[selectedPlanIndex] || creditPlans[0]
const createSubscription = async ({ priceId, productId }: { priceId: string; productId: string }) => {
const { data, error } = await subscription.create({
priceId,
productId,
successUrl: `${process.env.EXPO_PUBLIC_APP_URL}/membership`,
cancelUrl: `${process.env.EXPO_PUBLIC_APP_URL}/membership?canceled=true`,
})
if (error) {
Alert.alert('错误', '订阅创建失败')
return
}
if (data?.url) await Linking.openURL(data.url)
}
const upgradeSubscription = async ({ credits }: { credits: number }) => {
if (!activeAuthSubscription?.stripeSubscriptionId) {
Alert.alert('错误', '未找到活跃订阅')
return
}
const planIndex = creditPlans.findIndex(plan => plan.credits === credits)
const planNames = stripePricingData?.pricing_table_items?.map((item: any) => item.product_name) || []
const planName = planNames[planIndex] || 'basic'
const { data, error } = await subscription.upgrade({
plan: planName,
subscriptionId: activeAuthSubscription.stripeSubscriptionId,
successUrl: `${process.env.EXPO_PUBLIC_APP_URL}/membership`,
cancelUrl: `${process.env.EXPO_PUBLIC_APP_URL}/membership?canceled=true`,
})
if (error) {
Alert.alert('错误', '订阅升级失败')
return
}
}
const restoreSubscription = async () => {
if (!activeAuthSubscription) {
Alert.alert('错误', '未找到可恢复的订阅')
return
}
const { data, error } = await subscription.restore({
subscriptionId: activeAuthSubscription.stripeSubscriptionId,
referenceId: activeAuthSubscription.referenceId,
})
if (error) {
Alert.alert('错误', '订阅恢复失败')
return
}
Alert.alert('成功', '订阅已恢复')
await fetchAuthSubscriptions()
}
const cancelSubscription = async () => {
if (!activeAuthSubscription) {
Alert.alert('错误', '未找到活跃订阅')
return
}
const { data, error } = await subscription.cancel({
subscriptionId: activeAuthSubscription.stripeSubscriptionId,
referenceId: activeAuthSubscription.referenceId,
})
if (error) {
Alert.alert('错误', '订阅取消失败')
return
}
Alert.alert('成功', '订阅已取消')
await fetchAuthSubscriptions()
}
const rechargeToken = async (amount: number) => {
if (!amount || amount <= 0) {
Alert.alert('错误', '充值金额无效')
return
}
if (!meteredSubscriptions?.[0]?.priceId) {
Alert.alert('错误', '定价数据不可用')
return
}
const { data, error } = await subscription.credit.topup({
amount,
priceId: meteredSubscriptions[0].priceId,
successUrl: `${process.env.EXPO_PUBLIC_APP_URL}/membership`,
cancelUrl: `${process.env.EXPO_PUBLIC_APP_URL}/membership?canceled=true`,
})
if (error) {
Alert.alert('错误', '充值失败')
return
}
if (data?.url) await Linking.openURL(data.url)
}
return {
selectedPlanIndex,
selectedPlan,
creditPlans,
stripePricingData,
session,
activeAuthSubscription,
hasActiveSubscription,
creditBalance,
isLoadingSubscriptions,
isStripePricingLoading,
setSelectedPlanIndex,
createSubscription,
upgradeSubscription,
restoreSubscription,
cancelSubscription,
rechargeToken,
refetchAuthSubscriptions: fetchAuthSubscriptions,
}
}

View File

@@ -70,10 +70,54 @@ export interface BillingPortalResponse {
redirect: boolean
}
export interface PlansParams {
query: {
id: string
key: string
}
}
export interface CreateSubscriptionParams {
priceId: string
productId: string
successUrl: string
cancelUrl: string
}
export interface UpgradeSubscriptionParams {
plan: string
subscriptionId: string
successUrl: string
cancelUrl: string
}
export interface CancelSubscriptionParams {
subscriptionId: string
referenceId: string
}
export interface TopupParams {
amount: number
priceId: string
successUrl: string
cancelUrl: string
}
export interface CheckoutResponse {
url?: string
}
export interface ISubscription {
list: (params?: SubscriptionListParams) => Promise<{ data?: SubscriptionListItem[]; error?: ApiError }>
plans: (params: PlansParams) => Promise<{ data?: any; error?: ApiError }>
create: (params: CreateSubscriptionParams) => Promise<{ data?: CheckoutResponse; error?: ApiError }>
upgrade: (params: UpgradeSubscriptionParams) => Promise<{ data?: any; error?: ApiError }>
cancel: (params: CancelSubscriptionParams) => Promise<{ data?: any; error?: ApiError }>
restore: (params?: SubscriptionRestoreParams) => Promise<{ data?: SubscriptionRestoreResponse; error?: ApiError }>
billingPortal: (params?: BillingPortalParams) => Promise<{ data?: BillingPortalResponse; error?: ApiError }>
credit: {
topup: (params: TopupParams) => Promise<{ data?: CheckoutResponse; error?: ApiError }>
}
}
// 统一的 Token 存储键名(与 fetch-logger.ts 保持一致)
export const getAuthToken = async () => (await storage.getItem(TOKEN_KEY)) || ''