diff --git a/app/membership.tsx b/app/membership.tsx index 9cdc824..a24ac88 100644 --- a/app/membership.tsx +++ b/app/membership.tsx @@ -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('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 ( + + + + + + + ) + } return ( - @@ -172,13 +197,13 @@ export default function MembershipScreen() { - setPointsDrawerVisible(true)} > {t('membership.myPoints')} - {balance} + {creditBalance} {plans.map((plan, index) => { - const isSelected = selectedPlan === plan.id + const isSelected = selectedPlanIndex === index return ( 0 && styles.planCardSpacing, ]} - onPress={() => setSelectedPlan(plan.id)} + onPress={() => setSelectedPlanIndex(index)} > {isSelected ? ( {/* 立即开通按钮 */} - - + {isLoadingSubscriptions || isStripePricingLoading ? ( + + ) : ( {t('membership.subscribeNow')} + )} {/* 协议复选框 */} @@ -423,9 +451,10 @@ export default function MembershipScreen() { setPointsDrawerVisible(false)} - totalPoints={balance} + totalPoints={creditBalance} subscriptionPoints={0} topUpPoints={0} + onRecharge={(amount) => rechargeToken(amount)} /> ) @@ -770,5 +799,9 @@ const styles = StyleSheet.create({ fontWeight: '400', flex: 1, }, + loadingContainer: { + justifyContent: 'center', + alignItems: 'center', + }, }) diff --git a/hooks/use-membership.test.ts b/hooks/use-membership.test.ts new file mode 100644 index 0000000..be0d04a --- /dev/null +++ b/hooks/use-membership.test.ts @@ -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('错误', '充值失败') + }) + }) +}) diff --git a/hooks/use-membership.ts b/hooks/use-membership.ts new file mode 100644 index 0000000..8f25ff4 --- /dev/null +++ b/hooks/use-membership.ts @@ -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(null) + const [authSubscriptions, setAuthSubscriptions] = useState(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, + } +} diff --git a/lib/auth.ts b/lib/auth.ts index c633847..3ef1d16 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -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)) || ''