Implement complete backend subscription system that limits free users to 10 AI conversations per month and offers Premium tier ($10/month or $100/year) with unlimited conversations. Changes: - Add User subscription fields (tier, status, limits, counters) - Create Subscription model to track Stripe subscriptions - Implement conversation limit enforcement in chat API - Add subscription checkout and customer portal APIs - Update Stripe webhook to handle subscription events - Add subscription utility functions (limit checks, tier management) - Add comprehensive subscription translations (en, ro, es, it) - Update environment variables for Stripe price IDs - Update footer "Sponsor Us" link to point to /donate - Add "Sponsor Us" button to home page hero section Database: - User model: subscriptionTier, subscriptionStatus, conversationLimit, conversationCount, limitResetDate, stripeCustomerId, stripeSubscriptionId - Subscription model: tracks Stripe subscription details, periods, status - SubscriptionStatus enum: ACTIVE, CANCELLED, PAST_DUE, TRIALING, etc. API Routes: - POST /api/subscriptions/checkout - Create Stripe checkout session - POST /api/subscriptions/portal - Get customer portal link - Webhook handlers for: customer.subscription.created/updated/deleted, invoice.payment_succeeded/failed Features: - Free tier: 10 conversations/month with automatic monthly reset - Premium tier: Unlimited conversations - Automatic limit enforcement before conversation creation - Returns LIMIT_REACHED error with upgrade URL when limit hit - Stripe Customer Portal integration for subscription management - Automatic tier upgrade/downgrade via webhooks Documentation: - SUBSCRIPTION_IMPLEMENTATION_PLAN.md - Complete implementation plan - SUBSCRIPTION_IMPLEMENTATION_STATUS.md - Current status and next steps Frontend UI still needed: subscription page, upgrade modal, usage display 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
260 lines
8.1 KiB
TypeScript
260 lines
8.1 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import { stripe } from '@/lib/stripe-server'
|
|
import { prisma } from '@/lib/db'
|
|
import Stripe from 'stripe'
|
|
import { getTierFromPriceId, getIntervalFromPriceId, getLimitForTier } from '@/lib/subscription-utils'
|
|
|
|
export async function POST(req: NextRequest) {
|
|
const body = await req.text()
|
|
const signature = req.headers.get('stripe-signature')
|
|
|
|
if (!signature) {
|
|
console.error('No stripe signature found')
|
|
return NextResponse.json({ error: 'No signature' }, { status: 400 })
|
|
}
|
|
|
|
let event: Stripe.Event
|
|
|
|
try {
|
|
// Verify webhook signature
|
|
event = stripe.webhooks.constructEvent(
|
|
body,
|
|
signature,
|
|
process.env.STRIPE_WEBHOOK_SECRET!
|
|
)
|
|
} catch (err) {
|
|
console.error('Webhook signature verification failed:', err)
|
|
return NextResponse.json(
|
|
{ error: 'Webhook signature verification failed' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Handle the event
|
|
try {
|
|
switch (event.type) {
|
|
case 'checkout.session.completed': {
|
|
const session = event.data.object as Stripe.Checkout.Session
|
|
|
|
// Update donation status to COMPLETED
|
|
await prisma.donation.update({
|
|
where: { stripeSessionId: session.id },
|
|
data: {
|
|
status: 'COMPLETED',
|
|
stripePaymentId: session.payment_intent as string,
|
|
metadata: {
|
|
paymentStatus: session.payment_status,
|
|
customerEmail: session.customer_email,
|
|
},
|
|
},
|
|
})
|
|
|
|
console.log(`Donation completed for session: ${session.id}`)
|
|
break
|
|
}
|
|
|
|
case 'checkout.session.expired': {
|
|
const session = event.data.object as Stripe.Checkout.Session
|
|
|
|
// Update donation status to CANCELLED
|
|
await prisma.donation.update({
|
|
where: { stripeSessionId: session.id },
|
|
data: {
|
|
status: 'CANCELLED',
|
|
},
|
|
})
|
|
|
|
console.log(`Donation cancelled for session: ${session.id}`)
|
|
break
|
|
}
|
|
|
|
case 'payment_intent.payment_failed': {
|
|
const paymentIntent = event.data.object as Stripe.PaymentIntent
|
|
|
|
// Update donation status to FAILED
|
|
const donation = await prisma.donation.findFirst({
|
|
where: { stripePaymentId: paymentIntent.id },
|
|
})
|
|
|
|
if (donation) {
|
|
await prisma.donation.update({
|
|
where: { id: donation.id },
|
|
data: {
|
|
status: 'FAILED',
|
|
metadata: {
|
|
error: paymentIntent.last_payment_error?.message,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
console.log(`Payment failed for intent: ${paymentIntent.id}`)
|
|
break
|
|
}
|
|
|
|
case 'charge.refunded': {
|
|
const charge = event.data.object as Stripe.Charge
|
|
|
|
// Update donation status to REFUNDED
|
|
const donation = await prisma.donation.findFirst({
|
|
where: { stripePaymentId: charge.payment_intent as string },
|
|
})
|
|
|
|
if (donation) {
|
|
await prisma.donation.update({
|
|
where: { id: donation.id },
|
|
data: {
|
|
status: 'REFUNDED',
|
|
metadata: {
|
|
refundReason: charge.refunds?.data[0]?.reason,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
console.log(`Donation refunded for charge: ${charge.id}`)
|
|
break
|
|
}
|
|
|
|
// Subscription events
|
|
case 'customer.subscription.created':
|
|
case 'customer.subscription.updated': {
|
|
const stripeSubscription = event.data.object as Stripe.Subscription
|
|
const userId = stripeSubscription.metadata.userId
|
|
|
|
if (!userId) {
|
|
console.warn('⚠️ No userId in subscription metadata:', stripeSubscription.id)
|
|
break
|
|
}
|
|
|
|
const priceId = stripeSubscription.items.data[0]?.price.id
|
|
if (!priceId) {
|
|
console.warn('⚠️ No price ID in subscription:', stripeSubscription.id)
|
|
break
|
|
}
|
|
|
|
const tier = getTierFromPriceId(priceId)
|
|
const interval = getIntervalFromPriceId(priceId)
|
|
const limit = getLimitForTier(tier)
|
|
|
|
// Upsert subscription record
|
|
await prisma.subscription.upsert({
|
|
where: { stripeSubscriptionId: stripeSubscription.id },
|
|
create: {
|
|
userId,
|
|
stripeSubscriptionId: stripeSubscription.id,
|
|
stripePriceId: priceId,
|
|
stripeCustomerId: stripeSubscription.customer as string,
|
|
status: stripeSubscription.status.toUpperCase() as any,
|
|
currentPeriodStart: new Date((stripeSubscription as any).current_period_start * 1000),
|
|
currentPeriodEnd: new Date((stripeSubscription as any).current_period_end * 1000),
|
|
cancelAtPeriodEnd: (stripeSubscription as any).cancel_at_period_end,
|
|
tier,
|
|
interval
|
|
},
|
|
update: {
|
|
status: stripeSubscription.status.toUpperCase() as any,
|
|
currentPeriodStart: new Date((stripeSubscription as any).current_period_start * 1000),
|
|
currentPeriodEnd: new Date((stripeSubscription as any).current_period_end * 1000),
|
|
cancelAtPeriodEnd: (stripeSubscription as any).cancel_at_period_end,
|
|
stripePriceId: priceId
|
|
}
|
|
})
|
|
|
|
// Update user subscription tier and limit
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
subscriptionTier: tier,
|
|
conversationLimit: limit,
|
|
subscriptionStatus: stripeSubscription.status,
|
|
stripeSubscriptionId: stripeSubscription.id,
|
|
stripeCustomerId: stripeSubscription.customer as string
|
|
}
|
|
})
|
|
|
|
console.log(`✅ Subscription ${stripeSubscription.status} for user ${userId} (tier: ${tier})`)
|
|
break
|
|
}
|
|
|
|
case 'customer.subscription.deleted': {
|
|
const subscription = event.data.object as Stripe.Subscription
|
|
|
|
const sub = await prisma.subscription.findUnique({
|
|
where: { stripeSubscriptionId: subscription.id },
|
|
select: { userId: true }
|
|
})
|
|
|
|
if (sub) {
|
|
// Downgrade to free tier
|
|
await prisma.user.update({
|
|
where: { id: sub.userId },
|
|
data: {
|
|
subscriptionTier: 'free',
|
|
conversationLimit: 10,
|
|
subscriptionStatus: 'cancelled'
|
|
}
|
|
})
|
|
|
|
await prisma.subscription.update({
|
|
where: { stripeSubscriptionId: subscription.id },
|
|
data: { status: 'CANCELLED' }
|
|
})
|
|
|
|
console.log(`✅ Subscription cancelled for user ${sub.userId}`)
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'invoice.payment_succeeded': {
|
|
const invoice = event.data.object as any
|
|
if (invoice.subscription) {
|
|
console.log(`✅ Payment succeeded for subscription ${invoice.subscription}`)
|
|
|
|
// Ensure subscription is still active
|
|
const subscription = await prisma.subscription.findUnique({
|
|
where: { stripeSubscriptionId: invoice.subscription as string }
|
|
})
|
|
|
|
if (subscription) {
|
|
await prisma.user.update({
|
|
where: { id: subscription.userId },
|
|
data: { subscriptionStatus: 'active' }
|
|
})
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'invoice.payment_failed': {
|
|
const invoice = event.data.object as any
|
|
if (invoice.subscription) {
|
|
const subscription = await prisma.subscription.findUnique({
|
|
where: { stripeSubscriptionId: invoice.subscription as string }
|
|
})
|
|
|
|
if (subscription) {
|
|
await prisma.user.update({
|
|
where: { id: subscription.userId },
|
|
data: { subscriptionStatus: 'past_due' }
|
|
})
|
|
console.warn(`⚠️ Payment failed for subscription ${invoice.subscription}`)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
default:
|
|
console.log(`Unhandled event type: ${event.type}`)
|
|
}
|
|
|
|
return NextResponse.json({ received: true })
|
|
} catch (error) {
|
|
console.error('Error processing webhook:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Webhook processing failed' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
}
|