export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const revalidate = 0; import { NextRequest, NextResponse } from "next/server"; import Stripe from "stripe"; import { eq } from "drizzle-orm"; import { getStripeBilling } from "@/lib/stripe/service"; import { db } from "@/lib/db"; import { subscriptions, payments } from "@/lib/schema"; const stripe = getStripeBilling(); export async function POST(req: NextRequest) { try { console.log("🔔 [SUBSCRIPTION WEBHOOK] Received request"); // -------------------------------------------------- // 1️⃣ Verify Stripe signature // -------------------------------------------------- const sig = req.headers.get("stripe-signature"); if (!sig) { console.error("❌ [SUBSCRIPTION WEBHOOK] Missing Stripe signature"); return NextResponse.json({ error: "Missing Stripe signature" }, { status: 400 }); } const body = await req.text(); let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( body, sig, process.env.STRIPE_BILLING_WEBHOOK_SECRET! ); console.log("✅ [SUBSCRIPTION WEBHOOK] Signature verified", { eventType: event.type }); } catch (err: any) { console.error("❌ [SUBSCRIPTION WEBHOOK] Invalid signature", err.message); return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); } // -------------------------------------------------- // 2️⃣ Handle subscription events // -------------------------------------------------- if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") { console.log("🎯 [SUBSCRIPTION WEBHOOK] Processing subscription event:", event.type); const subscription = event.data.object as Stripe.Subscription; const stripeCustomerId = subscription.customer as string; // Look up user by stripe customer ID (subscription metadata may not have userId) const [existingSubscription] = await db .select() .from(subscriptions) .where(eq(subscriptions.stripeCustomerId, stripeCustomerId)) .limit(1); if (!existingSubscription) { console.error("❌ [SUBSCRIPTION WEBHOOK] No subscription found for customer:", stripeCustomerId); return NextResponse.json({ error: "Subscription not found" }, { status: 400 }); } const userId = existingSubscription.userId; console.log("📍 [SUBSCRIPTION WEBHOOK] Subscription details:", { subscriptionId: subscription.id, status: subscription.status, canceledAt: subscription.canceled_at, canceledAtParsed: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null, userId, }); console.log("🔍 [SUBSCRIPTION WEBHOOK] Full subscription object:", JSON.stringify(subscription, null, 2)); // Map Stripe status to our status // Note: Stripe uses cancel_at for scheduled cancellations and canceled_at for immediate cancellations let mappedStatus: "trialing" | "active" | "canceled" | "canceling" = "active"; if (subscription.status === "trialing") { mappedStatus = "trialing"; } else if (subscription.canceled_at) { // Immediately canceled mappedStatus = "canceled"; } else if (subscription.cancel_at) { // Scheduled for cancellation in the future mappedStatus = "canceling"; } // Update subscription record const updateData: any = { stripeSubscriptionId: subscription.id, stripeCustomerId, status: mappedStatus, updatedAt: new Date(), }; // Always update period dates (even for canceling, user has access until period end) const currentPeriodStart = (subscription as any).current_period_start; const currentPeriodEnd = (subscription as any).current_period_end; if (currentPeriodStart) { updateData.currentPeriodStart = new Date(currentPeriodStart * 1000); } if (currentPeriodEnd) { updateData.currentPeriodEnd = new Date(currentPeriodEnd * 1000); } // Update canceledAt based on cancellation status if (subscription.canceled_at) { updateData.canceledAt = new Date(subscription.canceled_at * 1000); } else if (subscription.cancel_at) { // If scheduled for cancellation, use the cancel_at date updateData.canceledAt = new Date(subscription.cancel_at * 1000); } else { // If subscription is active and not being canceled, clear canceledAt updateData.canceledAt = null; } await db .update(subscriptions) .set(updateData) .where(eq(subscriptions.userId, userId)); console.log("✅ [SUBSCRIPTION WEBHOOK] Subscription updated with status:", mappedStatus); return NextResponse.json({ received: true }); } // -------------------------------------------------- // 3️⃣ Handle invoice payment events (payment confirmation) // -------------------------------------------------- if (event.type === "invoice.payment_succeeded") { console.log("💰 [SUBSCRIPTION WEBHOOK] Processing invoice payment_succeeded"); const invoice = event.data.object as Stripe.Invoice; const stripeCustomerId = invoice.customer as string; if (!invoice.id) { console.error("❌ [SUBSCRIPTION WEBHOOK] Missing invoice ID"); return NextResponse.json({ error: "Missing invoice ID" }, { status: 400 }); } console.log("📍 [SUBSCRIPTION WEBHOOK] Invoice details:", { invoiceId: invoice.id, amount: invoice.amount_paid, status: invoice.status, }); // Find user by stripe customer ID const [subscription] = await db .select() .from(subscriptions) .where(eq(subscriptions.stripeCustomerId, stripeCustomerId)) .limit(1); if (!subscription) { console.error("❌ [SUBSCRIPTION WEBHOOK] Subscription not found for customer:", stripeCustomerId); return NextResponse.json({ error: "Subscription not found" }, { status: 400 }); } // Check if payment already recorded const existingPayment = await db .select() .from(payments) .where(eq(payments.stripeInvoiceId, invoice.id)) .limit(1); if (existingPayment.length === 0) { // Record payment // Note: invoice.paid_at is already a Unix timestamp (seconds), convert to milliseconds const paidAt = (invoice as any).paid_at; const paidAtMs = paidAt ? paidAt * 1000 : Date.now(); await db.insert(payments).values({ userId: subscription.userId, stripeInvoiceId: invoice.id, amount: invoice.amount_paid || 0, currency: invoice.currency || "usd", status: invoice.status || "paid", paidAt: new Date(paidAtMs), }); console.log("✅ [SUBSCRIPTION WEBHOOK] Payment recorded"); } // Check Stripe subscription status before updating let mappedStatus: "trialing" | "active" | "canceled" | "canceling" = "active"; let canceledAt: Date | null = null; const subscriptionId = typeof (invoice as any).subscription === "string" ? (invoice as any).subscription : (invoice as any).subscription?.id; if (subscriptionId) { try { const stripeSubscription = await stripe.subscriptions.retrieve(subscriptionId); console.log("📍 [SUBSCRIPTION WEBHOOK] Retrieved Stripe subscription for status check:", { subscriptionId: stripeSubscription.id, status: stripeSubscription.status, canceled_at: stripeSubscription.canceled_at, cancel_at: stripeSubscription.cancel_at, }); // Map Stripe status to our status if (stripeSubscription.status === "trialing") { mappedStatus = "trialing"; } else if (stripeSubscription.canceled_at) { mappedStatus = "canceled"; canceledAt = new Date(stripeSubscription.canceled_at * 1000); } else if (stripeSubscription.cancel_at) { mappedStatus = "canceling"; canceledAt = new Date(stripeSubscription.cancel_at * 1000); } } catch (err: any) { console.error("❌ [SUBSCRIPTION WEBHOOK] Failed to retrieve Stripe subscription:", err.message); // Fall back to "active" if retrieval fails mappedStatus = "active"; canceledAt = null; } } // Update subscription with mapped status await db .update(subscriptions) .set({ status: mappedStatus, canceledAt, updatedAt: new Date(), }) .where(eq(subscriptions.userId, subscription.userId)); console.log("✅ [SUBSCRIPTION WEBHOOK] Subscription updated with status:", mappedStatus); return NextResponse.json({ received: true }); } // -------------------------------------------------- // 4️⃣ Handle subscription cancellation // -------------------------------------------------- if (event.type === "customer.subscription.deleted") { console.log("🔴 [SUBSCRIPTION WEBHOOK] Processing subscription deletion"); const subscription = event.data.object as Stripe.Subscription; const stripeCustomerId = subscription.customer as string; // Look up user by stripe customer ID const [existingSubscription] = await db .select() .from(subscriptions) .where(eq(subscriptions.stripeCustomerId, stripeCustomerId)) .limit(1); if (!existingSubscription) { console.error("❌ [SUBSCRIPTION WEBHOOK] Subscription not found for customer:", stripeCustomerId); return NextResponse.json({ error: "Subscription not found" }, { status: 400 }); } const userId = existingSubscription.userId; await db .update(subscriptions) .set({ status: "canceled", canceledAt: new Date(), updatedAt: new Date(), }) .where(eq(subscriptions.userId, userId)); console.log("✅ [SUBSCRIPTION WEBHOOK] Subscription marked as canceled"); return NextResponse.json({ received: true }); } // Ignore other event types console.log("⏭️ [SUBSCRIPTION WEBHOOK] Ignoring event type:", event.type); return NextResponse.json({ ignored: true }); } catch (error: any) { console.error("❌ [SUBSCRIPTION WEBHOOK] Error:", error.message); console.error("❌ [SUBSCRIPTION WEBHOOK] Stack:", error.stack); return NextResponse.json({ error: error.message }, { status: 500 }); } }