fix: bug
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
StatusBar as RNStatusBar,
|
StatusBar as RNStatusBar,
|
||||||
Platform,
|
Platform,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
|
ActivityIndicator,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { StatusBar } from 'expo-status-bar'
|
import { StatusBar } from 'expo-status-bar'
|
||||||
import { LinearGradient } from 'expo-linear-gradient'
|
import { LinearGradient } from 'expo-linear-gradient'
|
||||||
@@ -22,7 +23,7 @@ import { CheckMarkIcon } from '@/components/icon/checkMark'
|
|||||||
import PointsDrawer from '@/components/drawer/PointsDrawer'
|
import PointsDrawer from '@/components/drawer/PointsDrawer'
|
||||||
import Dropdown from '@/components/ui/dropdown'
|
import Dropdown from '@/components/ui/dropdown'
|
||||||
import GradientText from '@/components/GradientText'
|
import GradientText from '@/components/GradientText'
|
||||||
import { useUserBalance } from '@/hooks/use-user-balance'
|
import { useMembership } from '@/hooks/use-membership'
|
||||||
|
|
||||||
// 使用唯一 id 的 PointsIcon,避免与其他页面的图标 id 冲突
|
// 使用唯一 id 的 PointsIcon,避免与其他页面的图标 id 冲突
|
||||||
const MembershipPointsIcon = () => {
|
const MembershipPointsIcon = () => {
|
||||||
@@ -65,63 +66,53 @@ export default function MembershipScreen() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const { width: screenWidth } = useWindowDimensions()
|
const { width: screenWidth } = useWindowDimensions()
|
||||||
const [selectedPlan, setSelectedPlan] = useState<PlanType>('pro')
|
|
||||||
const [agreed, setAgreed] = useState(false)
|
const [agreed, setAgreed] = useState(false)
|
||||||
const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false)
|
const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false)
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
// 获取积分余额
|
// 使用 useMembership hook
|
||||||
const { balance } = useUserBalance()
|
const {
|
||||||
|
creditPlans,
|
||||||
|
creditBalance,
|
||||||
|
selectedPlanIndex,
|
||||||
|
setSelectedPlanIndex,
|
||||||
|
isLoadingSubscriptions,
|
||||||
|
isStripePricingLoading,
|
||||||
|
createSubscription,
|
||||||
|
upgradeSubscription,
|
||||||
|
restoreSubscription,
|
||||||
|
rechargeToken,
|
||||||
|
activeAuthSubscription,
|
||||||
|
hasActiveSubscription,
|
||||||
|
stripePricingData,
|
||||||
|
} = useMembership()
|
||||||
|
|
||||||
// 订阅计划数据(使用国际化)
|
// 映射 API 数据到 UI 格式
|
||||||
const plans: Plan[] = [
|
const plans: Plan[] = creditPlans.map((plan, index) => ({
|
||||||
{
|
id: `plan-${index}` as PlanType,
|
||||||
id: 'plus',
|
name: index === 0 ? 'Plus' : index === 1 ? 'Pro' : 'Plus',
|
||||||
name: 'Plus',
|
price: plan.amountInCents / 100,
|
||||||
price: 9,
|
recommended: plan.popular,
|
||||||
points: 750,
|
points: plan.credits,
|
||||||
features: [
|
features: [
|
||||||
t('membership.features.points750'),
|
t('membership.features.points750'),
|
||||||
t('membership.features.textToVideo'),
|
t('membership.features.textToVideo'),
|
||||||
t('membership.features.superClearImage'),
|
t('membership.features.superClearImage'),
|
||||||
t('membership.features.superDiscount'),
|
t('membership.features.superDiscount'),
|
||||||
]
|
...(index > 0 ? [
|
||||||
},
|
|
||||||
{
|
|
||||||
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'),
|
|
||||||
t('membership.features.sora2ProTemplate'),
|
t('membership.features.sora2ProTemplate'),
|
||||||
t('membership.features.removeWatermark'),
|
t('membership.features.removeWatermark'),
|
||||||
t('membership.features.allTemplates'),
|
t('membership.features.allTemplates'),
|
||||||
|
] : []),
|
||||||
|
...(index > 1 ? [
|
||||||
t('membership.features.higherQuality'),
|
t('membership.features.higherQuality'),
|
||||||
t('membership.features.prioritySupport'),
|
t('membership.features.prioritySupport'),
|
||||||
]
|
] : []),
|
||||||
},
|
],
|
||||||
]
|
}))
|
||||||
|
|
||||||
|
const selectedPlan = plans[selectedPlanIndex] || plans[0]
|
||||||
|
|
||||||
// 下拉菜单选项
|
// 下拉菜单选项
|
||||||
const menuOptions = [
|
const menuOptions = [
|
||||||
@@ -146,12 +137,46 @@ export default function MembershipScreen() {
|
|||||||
]
|
]
|
||||||
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
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 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 (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
@@ -178,7 +203,7 @@ export default function MembershipScreen() {
|
|||||||
>
|
>
|
||||||
<Text style={styles.pointsLabel}>{t('membership.myPoints')}</Text>
|
<Text style={styles.pointsLabel}>{t('membership.myPoints')}</Text>
|
||||||
<MembershipPointsIcon />
|
<MembershipPointsIcon />
|
||||||
<Text style={styles.pointsValue}>{balance}</Text>
|
<Text style={styles.pointsValue}>{creditBalance}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.settingsButtonContainer}>
|
<View style={styles.settingsButtonContainer}>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
@@ -253,7 +278,7 @@ export default function MembershipScreen() {
|
|||||||
{/* 订阅计划卡片 */}
|
{/* 订阅计划卡片 */}
|
||||||
<View style={styles.plansContainer}>
|
<View style={styles.plansContainer}>
|
||||||
{plans.map((plan, index) => {
|
{plans.map((plan, index) => {
|
||||||
const isSelected = selectedPlan === plan.id
|
const isSelected = selectedPlanIndex === index
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={plan.id}
|
key={plan.id}
|
||||||
@@ -261,7 +286,7 @@ export default function MembershipScreen() {
|
|||||||
styles.planCardWrapper,
|
styles.planCardWrapper,
|
||||||
index > 0 && styles.planCardSpacing,
|
index > 0 && styles.planCardSpacing,
|
||||||
]}
|
]}
|
||||||
onPress={() => setSelectedPlan(plan.id)}
|
onPress={() => setSelectedPlanIndex(index)}
|
||||||
>
|
>
|
||||||
{isSelected ? (
|
{isSelected ? (
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
@@ -365,8 +390,9 @@ export default function MembershipScreen() {
|
|||||||
<View style={[styles.subscribeContainer, { paddingBottom: Math.max(insets.bottom, 3) }]}>
|
<View style={[styles.subscribeContainer, { paddingBottom: Math.max(insets.bottom, 3) }]}>
|
||||||
{/* 立即开通按钮 */}
|
{/* 立即开通按钮 */}
|
||||||
<Pressable
|
<Pressable
|
||||||
disabled={!agreed}
|
disabled={!agreed || isLoadingSubscriptions || isStripePricingLoading}
|
||||||
style={styles.subscribeButtonPressable}
|
style={styles.subscribeButtonPressable}
|
||||||
|
onPress={handleSubscribe}
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={currentPlan.recommended
|
colors={currentPlan.recommended
|
||||||
@@ -381,12 +407,14 @@ export default function MembershipScreen() {
|
|||||||
end={{ x: 0, y: 0 }}
|
end={{ x: 0, y: 0 }}
|
||||||
style={[
|
style={[
|
||||||
styles.subscribeButton,
|
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>
|
<Text style={currentPlan.recommended ? styles.subscribeButtonText : styles.subscribeButtonText1}>{t('membership.subscribeNow')}</Text>
|
||||||
|
)}
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{/* 协议复选框 */}
|
{/* 协议复选框 */}
|
||||||
@@ -423,9 +451,10 @@ export default function MembershipScreen() {
|
|||||||
<PointsDrawer
|
<PointsDrawer
|
||||||
visible={pointsDrawerVisible}
|
visible={pointsDrawerVisible}
|
||||||
onClose={() => setPointsDrawerVisible(false)}
|
onClose={() => setPointsDrawerVisible(false)}
|
||||||
totalPoints={balance}
|
totalPoints={creditBalance}
|
||||||
subscriptionPoints={0}
|
subscriptionPoints={0}
|
||||||
topUpPoints={0}
|
topUpPoints={0}
|
||||||
|
onRecharge={(amount) => rechargeToken(amount)}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
@@ -770,5 +799,9 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
404
hooks/use-membership.test.ts
Normal file
404
hooks/use-membership.test.ts
Normal 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
188
hooks/use-membership.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
44
lib/auth.ts
44
lib/auth.ts
@@ -70,10 +70,54 @@ export interface BillingPortalResponse {
|
|||||||
redirect: boolean
|
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 {
|
export interface ISubscription {
|
||||||
list: (params?: SubscriptionListParams) => Promise<{ data?: SubscriptionListItem[]; error?: ApiError }>
|
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 }>
|
restore: (params?: SubscriptionRestoreParams) => Promise<{ data?: SubscriptionRestoreResponse; error?: ApiError }>
|
||||||
billingPortal: (params?: BillingPortalParams) => Promise<{ data?: BillingPortalResponse; error?: ApiError }>
|
billingPortal: (params?: BillingPortalParams) => Promise<{ data?: BillingPortalResponse; error?: ApiError }>
|
||||||
|
credit: {
|
||||||
|
topup: (params: TopupParams) => Promise<{ data?: CheckoutResponse; error?: ApiError }>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 统一的 Token 存储键名(与 fetch-logger.ts 保持一致)
|
// 统一的 Token 存储键名(与 fetch-logger.ts 保持一致)
|
||||||
export const getAuthToken = async () => (await storage.getItem(TOKEN_KEY)) || ''
|
export const getAuthToken = async () => (await storage.getItem(TOKEN_KEY)) || ''
|
||||||
|
|||||||
Reference in New Issue
Block a user