Files
biblical-guide.com/app/api/stripe/webhook/route.ts
Andrei c3cd353f2f feat: implement subscription system with conversation limits
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>
2025-10-12 22:14:22 +00:00

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