add stripe addup
This commit is contained in:
parent
4ce167cdaf
commit
4eccc94a58
82
src/app/api/credits/stripe-topup/route.ts
Normal file
82
src/app/api/credits/stripe-topup/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
|
112
src/app/api/credits/verify-payment/route.ts
Normal file
112
src/app/api/credits/verify-payment/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
@ -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<string, unknown>)
|
||||
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<string, unknown>)
|
||||
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<string, unknown>)
|
||||
}
|
||||
|
||||
} catch (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() {
|
||||
try {
|
||||
// 这里可以添加额外的逻辑,比如发送提醒邮件等
|
||||
|
35
src/app/credits/cancel/page.tsx
Normal file
35
src/app/credits/cancel/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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'
|
||||
|
||||
const data = await response.json()
|
||||
if (isDevelopment) {
|
||||
// 开发环境:使用模拟充值
|
||||
const response = await fetch('/api/credits/topup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ amount }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
alert(`Successfully topped up $${amount}!`)
|
||||
setShowTopUpModal(false)
|
||||
setTopUpAmount('')
|
||||
// Reload credit data to reflect the new balance
|
||||
loadCreditData()
|
||||
const data = await response.json()
|
||||
|
||||
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)
|
||||
|
100
src/app/credits/success/page.tsx
Normal file
100
src/app/credits/success/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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)
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user