Spaces:
Configuration error
Configuration error
| import { NextRequest, NextResponse } from 'next/server' | |
| import Stripe from 'stripe' | |
| import { stripe } from '@/lib/stripe' | |
| import prisma from '@/lib/prisma' | |
| const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || '' | |
| /** | |
| * POST /api/stripe/webhook | |
| * Handles Stripe webhook events to keep subscription status in sync. | |
| * | |
| * Add this URL to your Stripe Dashboard webhook settings: | |
| * https://your-domain.com/api/stripe/webhook | |
| * | |
| * Required events to enable: | |
| * - checkout.session.completed | |
| * - customer.subscription.updated | |
| * - customer.subscription.deleted | |
| * - invoice.payment_failed | |
| */ | |
| export async function POST(request: NextRequest) { | |
| const body = await request.text() | |
| const signature = request.headers.get('stripe-signature') | |
| if (!signature) { | |
| return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 }) | |
| } | |
| let event: Stripe.Event | |
| try { | |
| event = stripe.webhooks.constructEvent(body, signature, WEBHOOK_SECRET) | |
| } catch (err) { | |
| console.error('Webhook signature verification failed:', err) | |
| return NextResponse.json({ error: 'Invalid webhook signature' }, { status: 400 }) | |
| } | |
| try { | |
| switch (event.type) { | |
| case 'checkout.session.completed': { | |
| const session = event.data.object as Stripe.Checkout.Session | |
| await handleCheckoutCompleted(session) | |
| break | |
| } | |
| case 'customer.subscription.updated': { | |
| const subscription = event.data.object as Stripe.Subscription | |
| await handleSubscriptionUpdated(subscription) | |
| break | |
| } | |
| case 'customer.subscription.deleted': { | |
| const subscription = event.data.object as Stripe.Subscription | |
| await handleSubscriptionDeleted(subscription) | |
| break | |
| } | |
| case 'invoice.payment_failed': { | |
| const invoice = event.data.object as Stripe.Invoice | |
| await handlePaymentFailed(invoice) | |
| break | |
| } | |
| default: | |
| // Unhandled event type — ignore | |
| break | |
| } | |
| return NextResponse.json({ received: true }) | |
| } catch (error) { | |
| console.error(`Webhook handler error for ${event.type}:`, error) | |
| return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 }) | |
| } | |
| } | |
| async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { | |
| if (session.mode !== 'subscription') return | |
| const userId = session.metadata?.userId | |
| if (!userId) { | |
| console.error('No userId in checkout session metadata') | |
| return | |
| } | |
| const subscription = await stripe.subscriptions.retrieve(session.subscription as string) | |
| // Stripe 2026 SDK renamed period fields — cast to access them | |
| const sub = subscription as unknown as Record<string, unknown> | |
| const periodStart = sub.current_period_start as number | |
| const periodEnd = sub.current_period_end as number | |
| // Upsert subscription record | |
| await prisma.$transaction([ | |
| prisma.subscription.upsert({ | |
| where: { userId }, | |
| create: { | |
| userId, | |
| stripeCustomerId: session.customer as string, | |
| stripeSubscriptionId: subscription.id, | |
| stripePriceId: subscription.items.data[0].price.id, | |
| plan: 'pro', | |
| status: subscription.status, | |
| currentPeriodStart: new Date(periodStart * 1000), | |
| currentPeriodEnd: new Date(periodEnd * 1000), | |
| cancelAtPeriodEnd: subscription.cancel_at_period_end, | |
| }, | |
| update: { | |
| stripeSubscriptionId: subscription.id, | |
| stripePriceId: subscription.items.data[0].price.id, | |
| status: subscription.status, | |
| currentPeriodStart: new Date(periodStart * 1000), | |
| currentPeriodEnd: new Date(periodEnd * 1000), | |
| cancelAtPeriodEnd: subscription.cancel_at_period_end, | |
| }, | |
| }), | |
| // Update user plan | |
| prisma.user.update({ | |
| where: { id: userId }, | |
| data: { | |
| stripePlan: 'pro', | |
| stripeCustomerId: session.customer as string, | |
| stripeSubscriptionId: subscription.id, | |
| stripeSubscriptionStatus: subscription.status, | |
| }, | |
| }), | |
| ]) | |
| console.log(`✅ Pro subscription activated for user ${userId}`) | |
| } | |
| async function handleSubscriptionUpdated(subscription: Stripe.Subscription) { | |
| const userId = subscription.metadata?.userId | |
| if (!userId) return | |
| const isActive = subscription.status === 'active' || subscription.status === 'trialing' | |
| const sub = subscription as unknown as Record<string, unknown> | |
| const periodStart = sub.current_period_start as number | |
| const periodEnd = sub.current_period_end as number | |
| await prisma.$transaction([ | |
| prisma.subscription.updateMany({ | |
| where: { stripeSubscriptionId: subscription.id }, | |
| data: { | |
| status: subscription.status, | |
| currentPeriodStart: new Date(periodStart * 1000), | |
| currentPeriodEnd: new Date(periodEnd * 1000), | |
| cancelAtPeriodEnd: subscription.cancel_at_period_end, | |
| canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null, | |
| }, | |
| }), | |
| prisma.user.update({ | |
| where: { id: userId }, | |
| data: { | |
| stripePlan: isActive ? 'pro' : 'free', | |
| stripeSubscriptionStatus: subscription.status, | |
| }, | |
| }), | |
| ]) | |
| } | |
| async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { | |
| const userId = subscription.metadata?.userId | |
| if (!userId) return | |
| // Downgrade to free | |
| await prisma.$transaction([ | |
| prisma.subscription.updateMany({ | |
| where: { stripeSubscriptionId: subscription.id }, | |
| data: { status: 'canceled', canceledAt: new Date() }, | |
| }), | |
| prisma.user.update({ | |
| where: { id: userId }, | |
| data: { | |
| stripePlan: 'free', | |
| stripeSubscriptionId: null, | |
| stripeSubscriptionStatus: 'canceled', | |
| }, | |
| }), | |
| ]) | |
| console.log(`Subscription canceled for user ${userId} — downgraded to free`) | |
| } | |
| async function handlePaymentFailed(invoice: Stripe.Invoice) { | |
| const customerId = invoice.customer as string | |
| if (!customerId) return | |
| const user = await prisma.user.findUnique({ | |
| where: { stripeCustomerId: customerId }, | |
| select: { id: true }, | |
| }) | |
| if (!user) return | |
| await prisma.user.update({ | |
| where: { id: user.id }, | |
| data: { stripeSubscriptionStatus: 'past_due' }, | |
| }) | |
| console.warn(`Payment failed for customer ${customerId}`) | |
| } | |