diff --git a/src/app/api/credits/stripe-topup/route.ts b/src/app/api/credits/stripe-topup/route.ts new file mode 100644 index 0000000..b4bcdec --- /dev/null +++ b/src/app/api/credits/stripe-topup/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from 'next/server' +import { createServerSupabaseClient } from '@/lib/supabase-server' +import { createOrGetStripeCustomer, createPaymentSession } from '@/lib/stripe' + +export async function POST(request: Request) { + try { + const { amount } = await request.json() + + // 验证金额 + if (!amount || amount <= 0) { + return NextResponse.json( + { error: 'Invalid amount. Amount must be greater than 0.' }, + { status: 400 } + ) + } + + if (amount > 1000) { + return NextResponse.json( + { error: 'Amount too large. Maximum top-up amount is $1000.' }, + { status: 400 } + ) + } + + const supabase = await createServerSupabaseClient() + + // 获取当前用户 + const { data: { user }, error: authError } = await supabase.auth.getUser() + + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // 获取用户信息 + const { data: userData, error: userError } = await supabase + .from('users') + .select('email, username') + .eq('id', user.id) + .single() + + if (userError || !userData) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + try { + // 创建或获取 Stripe 客户 + const customer = await createOrGetStripeCustomer( + user.id, + userData.email, + userData.username || undefined + ) + + // 创建支付会话 + const session = await createPaymentSession( + customer.id, + Math.round(amount * 100), // 转换为美分 + `Credit top-up: $${amount}`, + `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/credits/success?session_id={CHECKOUT_SESSION_ID}`, + `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/credits/cancel` + ) + + return NextResponse.json({ + success: true, + sessionId: session.id, + sessionUrl: session.url + }) + + } catch (stripeError) { + console.error('Stripe operation failed:', stripeError) + return NextResponse.json( + { error: 'Failed to create payment session' }, + { status: 500 } + ) + } + + } catch (error) { + console.error('Stripe top-up failed:', error) + return NextResponse.json( + { error: 'Failed to process top-up request' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/credits/topup/route.ts b/src/app/api/credits/topup/route.ts index d47fbfc..b4728ae 100644 --- a/src/app/api/credits/topup/route.ts +++ b/src/app/api/credits/topup/route.ts @@ -50,11 +50,14 @@ export async function POST(request: Request) { transaction: creditTransaction }) } else { - // 生产环境:集成真实的支付网关 - // 这里可以集成 Stripe, PayPal 等支付服务 + // 生产环境:重定向到 Stripe 支付 return NextResponse.json( - { error: 'Payment integration not implemented yet. Please contact support.' }, - { status: 501 } + { + success: false, + message: 'Please use Stripe payment for production', + redirectTo: '/api/credits/stripe-topup' + }, + { status:200 } ) } diff --git a/src/app/api/credits/verify-payment/route.ts b/src/app/api/credits/verify-payment/route.ts new file mode 100644 index 0000000..7a2d3c9 --- /dev/null +++ b/src/app/api/credits/verify-payment/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from 'next/server' +import { createServerSupabaseClient } from '@/lib/supabase-server' +import { stripe } from '@/lib/stripe' +import { addCredit } from '@/lib/services/credit' + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url) + const sessionId = searchParams.get('session_id') + + if (!sessionId) { + return NextResponse.json( + { error: 'Session ID is required' }, + { status: 400 } + ) + } + + const supabase = await createServerSupabaseClient() + + // 获取当前用户 + const { data: { user }, error: authError } = await supabase.auth.getUser() + + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + // 从 Stripe 获取支付会话信息 + const session = await stripe.checkout.sessions.retrieve(sessionId) + + if (!session) { + return NextResponse.json( + { error: 'Session not found' }, + { status: 404 } + ) + } + + // 验证会话是否成功支付 + if (session.payment_status !== 'paid') { + return NextResponse.json( + { error: 'Payment not completed' }, + { status: 400 } + ) + } + + // 获取金额 + const amount = session.metadata?.amount ? parseFloat(session.metadata.amount) : 0 + + if (amount <= 0) { + return NextResponse.json( + { error: 'Invalid amount in session' }, + { status: 400 } + ) + } + + // 检查是否已经处理过这个支付会话 + const { data: existingCredit } = await supabase + .from('credits') + .select('id') + .eq('referenceId', sessionId) + .eq('referenceType', 'stripe_payment') + .single() + + if (existingCredit) { + // 已经处理过,直接返回成功 + return NextResponse.json({ + success: true, + amount, + message: 'Payment already processed' + }) + } + + // 添加信用额度 + const creditTransaction = await addCredit( + user.id, + amount, + 'user_purchase', + `Stripe payment: $${amount}` + ) + + // 更新 credit 记录的 referenceId 和 referenceType + await supabase + .from('credits') + .update({ + referenceId: sessionId, + referenceType: 'stripe_payment' + }) + .eq('id', creditTransaction.id) + + return NextResponse.json({ + success: true, + amount, + message: `Successfully added $${amount} to your account`, + transaction: creditTransaction + }) + + } catch (stripeError) { + console.error('Stripe verification failed:', stripeError) + return NextResponse.json( + { error: 'Failed to verify payment with Stripe' }, + { status: 500 } + ) + } + + } catch (error) { + console.error('Payment verification failed:', error) + return NextResponse.json( + { error: 'Failed to verify payment' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts index 4a40c52..19820fd 100644 --- a/src/app/api/webhooks/stripe/route.ts +++ b/src/app/api/webhooks/stripe/route.ts @@ -3,7 +3,7 @@ import { stripe, STRIPE_CONFIG } from '@/lib/stripe' import { prisma } from '@/lib/prisma' import { headers } from 'next/headers' import { SubscriptionService } from '@/lib/subscription-service' -import { addCreditForSubscription, recordSubscriptionPayment, addCreditForSubscriptionRefund } from '@/lib/services/credit' +import { addCreditForSubscription, recordSubscriptionPayment, addCreditForSubscriptionRefund, addCredit } from '@/lib/services/credit' export async function POST(request: NextRequest) { try { @@ -72,21 +72,39 @@ async function handleCheckoutSessionCompleted(session: Record) console.log('🛒 Processing checkout session completed') console.log('Session data:', JSON.stringify(session, null, 2)) - const subscriptionId = session.subscription as string - console.log('Subscription ID from session:', subscriptionId) + const mode = session.mode as string + const paymentStatus = session.payment_status as string + const customerId = session.customer as string + const sessionId = session.id as string - if (!subscriptionId) { - console.log('❌ No subscription ID found in checkout session') + console.log('Session details:', { mode, paymentStatus, customerId, sessionId }) + + if (paymentStatus !== 'paid') { + console.log('❌ Payment not completed, skipping') return } - // 获取订阅详情 - console.log('📞 Retrieving subscription from Stripe...') - const subscription = await stripe.subscriptions.retrieve(subscriptionId) - console.log('✅ Retrieved subscription:', subscription.id, 'status:', subscription.status) + if (mode === 'payment') { + // 处理一次性支付(充值) + await handleOneTimePayment(session) + } else if (mode === 'subscription') { + // 处理订阅支付 + const subscriptionId = session.subscription as string + console.log('Subscription ID from session:', subscriptionId) - // 处理订阅更新 - await handleSubscriptionUpdate(subscription as unknown as Record) + if (!subscriptionId) { + console.log('❌ No subscription ID found in checkout session') + return + } + + // 获取订阅详情 + console.log('📞 Retrieving subscription from Stripe...') + const subscription = await stripe.subscriptions.retrieve(subscriptionId) + console.log('✅ Retrieved subscription:', subscription.id, 'status:', subscription.status) + + // 处理订阅更新 + await handleSubscriptionUpdate(subscription as unknown as Record) + } } catch (error) { console.error('❌ Error handling checkout session completed:', error) @@ -430,6 +448,82 @@ async function handlePaymentSucceeded(invoice: Record) { } } +async function handleOneTimePayment(session: Record) { + try { + console.log('💳 Processing one-time payment (credit top-up)') + + const customerId = session.customer as string + const sessionId = session.id as string + const metadata = session.metadata as Record || {} + const amountTotal = (session.amount_total as number) / 100 // Convert from cents to dollars + + console.log('Payment details:', { + customerId, + sessionId, + amountTotal, + metadata + }) + + // 检查是否是充值类型 + if (metadata.type !== 'credit_topup') { + console.log('❌ Not a credit top-up payment, skipping') + return + } + + // 查找用户 + const users = await prisma.$queryRaw>` + SELECT id, email, "stripeCustomerId" + FROM users + WHERE "stripeCustomerId" = ${customerId} + LIMIT 1 + ` + const user = users[0] || null + + if (!user) { + console.error('❌ User not found for customer:', customerId) + return + } + + console.log(`👤 Found user: ${user.id}`) + + // 检查是否已经处理过这个支付会话 + const existingCredit = await prisma.credit.findFirst({ + where: { + referenceId: sessionId, + referenceType: 'stripe_payment' + } + }) + + if (existingCredit) { + console.log('⚠️ Payment already processed, skipping') + return + } + + // 添加信用额度 + const creditTransaction = await addCredit( + user.id, + amountTotal, + 'user_purchase', + `Stripe payment: $${amountTotal}` + ) + + // 更新 credit 记录的 referenceId 和 referenceType + await prisma.credit.update({ + where: { id: creditTransaction.id }, + data: { + referenceId: sessionId, + referenceType: 'stripe_payment' + } + }) + + console.log(`✅ Successfully added $${amountTotal} credit for user ${user.id}`) + console.log(`📊 Transaction ID: ${creditTransaction.id}`) + + } catch (error) { + console.error('❌ Error handling one-time payment:', error) + } +} + async function handlePaymentFailed() { try { // 这里可以添加额外的逻辑,比如发送提醒邮件等 diff --git a/src/app/credits/cancel/page.tsx b/src/app/credits/cancel/page.tsx new file mode 100644 index 0000000..05ac191 --- /dev/null +++ b/src/app/credits/cancel/page.tsx @@ -0,0 +1,35 @@ +'use client' + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { XCircle } from 'lucide-react' +import Link from 'next/link' + +export default function CreditTopupCancel() { + return ( +
+ + +
+ +
+ Payment Cancelled + + Your credit top-up was cancelled + +
+ +

+ No charges were made to your account. You can try again or choose a different payment method. +

+ + + + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/app/credits/page.tsx b/src/app/credits/page.tsx index 8775aa2..89c2ca9 100644 --- a/src/app/credits/page.tsx +++ b/src/app/credits/page.tsx @@ -195,24 +195,47 @@ export default function CreditsPage() { setIsTopUpLoading(true) try { - const response = await fetch('/api/credits/topup', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ amount }), - }) + // 首先检查是否是开发环境 + const isDevelopment = process.env.NODE_ENV === 'development' + + if (isDevelopment) { + // 开发环境:使用模拟充值 + const response = await fetch('/api/credits/topup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ amount }), + }) - const data = await response.json() + const data = await response.json() - if (response.ok) { - alert(`Successfully topped up $${amount}!`) - setShowTopUpModal(false) - setTopUpAmount('') - // Reload credit data to reflect the new balance - loadCreditData() + if (response.ok && data.success) { + alert(`Successfully topped up $${amount}!`) + setShowTopUpModal(false) + setTopUpAmount('') + loadCreditData() + } else { + alert(data.error || 'Failed to process top-up') + } } else { - alert(data.error || 'Failed to process top-up') + // 生产环境:使用 Stripe + const response = await fetch('/api/credits/stripe-topup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ amount }), + }) + + const data = await response.json() + + if (response.ok && data.success && data.sessionUrl) { + // 重定向到 Stripe Checkout + window.location.href = data.sessionUrl + } else { + alert(data.error || 'Failed to create payment session') + } } } catch (error) { console.error('Top-up error:', error) diff --git a/src/app/credits/success/page.tsx b/src/app/credits/success/page.tsx new file mode 100644 index 0000000..2e6f479 --- /dev/null +++ b/src/app/credits/success/page.tsx @@ -0,0 +1,100 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useSearchParams } from 'next/navigation' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { CheckCircle, Loader2 } from 'lucide-react' +import Link from 'next/link' + +export default function CreditTopupSuccess() { + const searchParams = useSearchParams() + const sessionId = searchParams.get('session_id') + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [amount, setAmount] = useState(null) + + useEffect(() => { + if (sessionId) { + // 验证支付会话 + fetch(`/api/credits/verify-payment?session_id=${sessionId}`) + .then(res => res.json()) + .then(data => { + if (data.success) { + setAmount(data.amount) + } else { + setError(data.error || 'Payment verification failed') + } + }) + .catch(() => { + setError('Failed to verify payment') + }) + .finally(() => { + setLoading(false) + }) + } else { + setError('No session ID provided') + setLoading(false) + } + }, [sessionId]) + + if (loading) { + return ( +
+ + +
+ + Verifying payment... +
+
+
+
+ ) + } + + if (error) { + return ( +
+ + + Payment Error + {error} + + + + + + + +
+ ) + } + + return ( +
+ + +
+ +
+ Payment Successful! + + {amount && `$${amount} has been added to your account`} + +
+ +

+ Your credit top-up has been processed successfully. The credits have been added to your account. +

+ + + + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/app/subscription/page.tsx b/src/app/subscription/page.tsx index b9fa299..20d4ad7 100644 --- a/src/app/subscription/page.tsx +++ b/src/app/subscription/page.tsx @@ -159,7 +159,7 @@ export default function SubscriptionPage() { } } catch (error) { console.error('Portal creation failed:', error) - const message = error.message.includes('development mode') + const message = (error as Error)?.message?.includes('development mode') ? 'Stripe billing portal is only available in production environment.' : 'Failed to access billing portal. Please try again.' alert(message) diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts index a4f2483..8f362c5 100644 --- a/src/lib/stripe.ts +++ b/src/lib/stripe.ts @@ -109,6 +109,47 @@ export async function reactivateSubscription(subscriptionId: string) { return subscription } +// 创建一次性支付会话(用于充值) +export async function createPaymentSession( + customerId: string, + amount: number, // 金额(美分) + description: string, + successUrl: string, + cancelUrl: string +) { + try { + const session = await stripe.checkout.sessions.create({ + customer: customerId, + payment_method_types: ['card'], + line_items: [ + { + price_data: { + currency: 'usd', + product_data: { + name: 'Credit Top-up', + description: description, + }, + unit_amount: amount, // 金额(美分) + }, + quantity: 1, + }, + ], + mode: 'payment', // 一次性支付 + success_url: successUrl, + cancel_url: cancelUrl, + metadata: { + type: 'credit_topup', + amount: (amount / 100).toString(), // 存储美元金额 + }, + }) + + return session + } catch (error) { + console.error('Stripe payment session creation failed:', error) + throw error + } +} + // 创建客户门户会话 export async function createCustomerPortalSession( customerId: string,