add stripe addup

This commit is contained in:
songtianlun 2025-08-27 07:08:02 +08:00
parent 4ce167cdaf
commit 4eccc94a58
9 changed files with 521 additions and 31 deletions

View File

@ -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 }
)
}
}

View File

@ -50,11 +50,14 @@ export async function POST(request: Request) {
transaction: creditTransaction transaction: creditTransaction
}) })
} else { } else {
// 生产环境:集成真实的支付网关 // 生产环境:重定向到 Stripe 支付
// 这里可以集成 Stripe, PayPal 等支付服务
return NextResponse.json( 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 }
) )
} }

View File

@ -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 }
)
}
}

View File

@ -3,7 +3,7 @@ import { stripe, STRIPE_CONFIG } from '@/lib/stripe'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { headers } from 'next/headers' import { headers } from 'next/headers'
import { SubscriptionService } from '@/lib/subscription-service' 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) { export async function POST(request: NextRequest) {
try { try {
@ -72,21 +72,39 @@ async function handleCheckoutSessionCompleted(session: Record<string, unknown>)
console.log('🛒 Processing checkout session completed') console.log('🛒 Processing checkout session completed')
console.log('Session data:', JSON.stringify(session, null, 2)) console.log('Session data:', JSON.stringify(session, null, 2))
const subscriptionId = session.subscription as string const mode = session.mode as string
console.log('Subscription ID from session:', subscriptionId) const paymentStatus = session.payment_status as string
const customerId = session.customer as string
const sessionId = session.id as string
if (!subscriptionId) { console.log('Session details:', { mode, paymentStatus, customerId, sessionId })
console.log('❌ No subscription ID found in checkout session')
if (paymentStatus !== 'paid') {
console.log('❌ Payment not completed, skipping')
return return
} }
// 获取订阅详情 if (mode === 'payment') {
console.log('📞 Retrieving subscription from Stripe...') // 处理一次性支付(充值)
const subscription = await stripe.subscriptions.retrieve(subscriptionId) await handleOneTimePayment(session)
console.log('✅ Retrieved subscription:', subscription.id, 'status:', subscription.status) } else if (mode === 'subscription') {
// 处理订阅支付
const subscriptionId = session.subscription as string
console.log('Subscription ID from session:', subscriptionId)
// 处理订阅更新 if (!subscriptionId) {
await handleSubscriptionUpdate(subscription as unknown as Record<string, unknown>) 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<string, unknown>)
}
} catch (error) { } catch (error) {
console.error('❌ Error handling checkout session completed:', error) console.error('❌ Error handling checkout session completed:', error)
@ -430,6 +448,82 @@ async function handlePaymentSucceeded(invoice: Record<string, unknown>) {
} }
} }
async function handleOneTimePayment(session: Record<string, unknown>) {
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<string, unknown> || {}
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<Array<{id: string, email: string, stripeCustomerId: string}>>`
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() { async function handlePaymentFailed() {
try { try {
// 这里可以添加额外的逻辑,比如发送提醒邮件等 // 这里可以添加额外的逻辑,比如发送提醒邮件等

View File

@ -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 (
<div className="container mx-auto py-8 px-4">
<Card className="max-w-md mx-auto">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<XCircle className="h-16 w-16 text-red-500" />
</div>
<CardTitle className="text-red-600">Payment Cancelled</CardTitle>
<CardDescription>
Your credit top-up was cancelled
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-center text-muted-foreground">
No charges were made to your account. You can try again or choose a different payment method.
</p>
<Link href="/credits" className="w-full">
<Button className="w-full">Try Again</Button>
</Link>
<Link href="/prompts" className="w-full">
<Button variant="outline" className="w-full">Back to Prompts</Button>
</Link>
</CardContent>
</Card>
</div>
)
}

View File

@ -195,24 +195,47 @@ export default function CreditsPage() {
setIsTopUpLoading(true) setIsTopUpLoading(true)
try { try {
const response = await fetch('/api/credits/topup', { // 首先检查是否是开发环境
method: 'POST', const isDevelopment = process.env.NODE_ENV === 'development'
headers: {
'Content-Type': 'application/json', if (isDevelopment) {
}, // 开发环境:使用模拟充值
body: JSON.stringify({ amount }), 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) { if (response.ok && data.success) {
alert(`Successfully topped up $${amount}!`) alert(`Successfully topped up $${amount}!`)
setShowTopUpModal(false) setShowTopUpModal(false)
setTopUpAmount('') setTopUpAmount('')
// Reload credit data to reflect the new balance loadCreditData()
loadCreditData() } else {
alert(data.error || 'Failed to process top-up')
}
} else { } 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) { } catch (error) {
console.error('Top-up error:', error) console.error('Top-up error:', error)

View File

@ -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<string | null>(null)
const [amount, setAmount] = useState<number | null>(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 (
<div className="container mx-auto py-8 px-4">
<Card className="max-w-md mx-auto">
<CardContent className="pt-6">
<div className="flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">Verifying payment...</span>
</div>
</CardContent>
</Card>
</div>
)
}
if (error) {
return (
<div className="container mx-auto py-8 px-4">
<Card className="max-w-md mx-auto">
<CardHeader>
<CardTitle className="text-red-600">Payment Error</CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardContent>
<Link href="/credits" className="w-full">
<Button className="w-full">Back to Credits</Button>
</Link>
</CardContent>
</Card>
</div>
)
}
return (
<div className="container mx-auto py-8 px-4">
<Card className="max-w-md mx-auto">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<CheckCircle className="h-16 w-16 text-green-500" />
</div>
<CardTitle className="text-green-600">Payment Successful!</CardTitle>
<CardDescription>
{amount && `$${amount} has been added to your account`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-center text-muted-foreground">
Your credit top-up has been processed successfully. The credits have been added to your account.
</p>
<Link href="/credits" className="w-full">
<Button className="w-full">View Credits</Button>
</Link>
<Link href="/prompts" className="w-full">
<Button variant="outline" className="w-full">Continue to Prompts</Button>
</Link>
</CardContent>
</Card>
</div>
)
}

View File

@ -159,7 +159,7 @@ export default function SubscriptionPage() {
} }
} catch (error) { } catch (error) {
console.error('Portal creation failed:', 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.' ? 'Stripe billing portal is only available in production environment.'
: 'Failed to access billing portal. Please try again.' : 'Failed to access billing portal. Please try again.'
alert(message) alert(message)

View File

@ -109,6 +109,47 @@ export async function reactivateSubscription(subscriptionId: string) {
return subscription 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( export async function createCustomerPortalSession(
customerId: string, customerId: string,