import { NextRequest, NextResponse } from "next/server"; import crypto from "crypto"; import { prisma } from "@/lib/prisma"; /** * Polar.sh webhook handler. * * In production, verify the webhook signature using Polar's webhook secret. * For development, we accept a direct ?userId=&tier= fallback for testing. */ export async function POST(req: NextRequest) { try { // Check for direct fallback query params (dev/testing only) const url = new URL(req.url); const directUserId = url.searchParams.get("userId"); const directTier = url.searchParams.get("tier"); if (directUserId && directTier) { // Production guard: never allow direct upgrades in production if (process.env.NODE_ENV === "production") { console.error("Direct upgrade blocked — not allowed in production"); return NextResponse.json( { error: "Direct upgrades not allowed in production" }, { status: 403 } ); } return handleDirectUpgrade(directUserId, directTier); } // Production path: Parse Polar webhook event const rawBody = await req.text(); // Verify webhook signature const signature = req.headers.get("webhook-id") || req.headers.get("polar-signature"); const webhookSecret = process.env.POLAR_WEBHOOK_SECRET; if (webhookSecret) { if (!signature) { console.error("Polar webhook signature missing"); return NextResponse.json( { error: "Missing webhook signature" }, { status: 401 } ); } const expectedSignature = crypto .createHmac("sha256", webhookSecret) .update(rawBody) .digest("hex"); if (signature !== expectedSignature) { console.error("Polar webhook signature mismatch"); return NextResponse.json( { error: "Invalid webhook signature" }, { status: 401 } ); } } else { if (process.env.NODE_ENV === "production") { console.error("POLAR_WEBHOOK_SECRET missing in production — refusing unverified webhook"); return NextResponse.json( { error: "Webhook secret not configured" }, { status: 500 } ); } console.warn("POLAR_WEBHOOK_SECRET not set — skipping signature verification (dev mode)"); } const body = JSON.parse(rawBody); const event = body.type || body.event; if (!event) { return NextResponse.json( { error: "Missing event type" }, { status: 400 } ); } // Handle checkout.completed event if ( event === "checkout.completed" || event === "checkout.updated" ) { const checkout = body.data?.checkout || body.data; if (!checkout) { return NextResponse.json( { error: "Missing checkout data" }, { status: 400 } ); } const metadataType = checkout.metadata?.type; const customerEmail = checkout.customer?.email; // --- Course purchase flow --- if (metadataType === "course_purchase") { const courseSlug = checkout.metadata?.course_slug; if (!courseSlug) { return NextResponse.json( { error: "Missing course_slug in metadata for course_purchase" }, { status: 400 } ); } if (!customerEmail) { return NextResponse.json( { error: "Missing customer email for course_purchase" }, { status: 400 } ); } return handleCoursePurchase(customerEmail, courseSlug); } // --- Learn subscription flow --- if (metadataType === "learn_subscription") { if (!customerEmail) { return NextResponse.json( { error: "Missing customer email for learn_subscription" }, { status: 400 } ); } return handleLearnSubscription(customerEmail, checkout); } // --- Fallback: premium/pro upgrade (existing logic) --- const userId = checkout.metadata?.userId; const priceId = checkout.product?.priceId || checkout.productPrice?.id || checkout.metadata?.priceId; if (!userId) { return NextResponse.json( { error: "Missing userId in metadata" }, { status: 400 } ); } // Determine tier from priceId or product metadata let tier: "premium" | "pro" = "premium"; if (priceId?.includes("pro") || checkout.metadata?.tier === "pro") { tier = "pro"; } return applyUpgrade(userId, tier); } // Handle subscription.cancelled and subscription.revoked events if (event === "subscription.cancelled" || event === "subscription.revoked") { return handleSubscriptionCancelled(body.data); } // Acknowledge other events silently return NextResponse.json({ received: true }); } catch (error) { console.error("Polar webhook error:", error); return NextResponse.json( { error: "Webhook processing failed" }, { status: 500 } ); } } async function handleDirectUpgrade(userId: string, tier: string) { if (tier !== "premium" && tier !== "pro") { return NextResponse.json( { error: "Invalid tier. Must be 'premium' or 'pro'." }, { status: 400 } ); } return applyUpgrade(userId, tier as "premium" | "pro"); } async function applyUpgrade(userId: string, tier: "premium" | "pro") { const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { return NextResponse.json({ error: "User not found" }, { status: 404 }); } const trialEndsAt = new Date(); trialEndsAt.setDate(trialEndsAt.getDate() + 7); const updateData: Record = { trialEndsAt, }; if (tier === "pro") { updateData.isPro = true; updateData.isPremium = false; // Pro supersedes Premium } else { updateData.isPremium = true; } await prisma.user.update({ where: { id: userId }, data: updateData, }); return NextResponse.json({ success: true, tier, trialEndsAt, }); } /** * Handle a course_purchase checkout. * Looks up the user by email, finds the LearnCourse by slug, * and creates (or re-activates) a LearnEnrollment with purchaseType='one_time'. */ async function handleCoursePurchase(customerEmail: string, courseSlug: string) { // Find the user by email const user = await prisma.user.findUnique({ where: { email: customerEmail } }); if (!user) { return NextResponse.json( { error: "User not found for email" }, { status: 404 } ); } // Find the course by slug const course = await prisma.learnCourse.findUnique({ where: { slug: courseSlug } }); if (!course) { return NextResponse.json( { error: "Course not found" }, { status: 404 } ); } // Upsert enrollment: create or re-activate on re-purchase await prisma.learnEnrollment.upsert({ where: { userId_courseId: { userId: user.id, courseId: course.id }, }, create: { userId: user.id, courseId: course.id, purchaseType: "one_time", progress: 0, startedAt: new Date(), }, update: { purchaseType: "one_time", completed: false, progress: 0, completedAt: null, startedAt: new Date(), }, }); return NextResponse.json({ success: true, type: "course_purchase", userId: user.id, courseId: course.id, courseSlug, }); } /** * Handle a learn_subscription checkout. * Looks up the user by email and creates/updates a LearnSubscription record. * Uses Polar subscription data for period management. */ async function handleLearnSubscription(customerEmail: string, checkout: any) { // Find the user by email const user = await prisma.user.findUnique({ where: { email: customerEmail } }); if (!user) { return NextResponse.json( { error: "User not found for email" }, { status: 404 } ); } // Extract subscription details from checkout data const polarSubId = checkout.subscription?.id || checkout.metadata?.subscriptionId || null; const now = new Date(); const currentPeriodStart = checkout.subscription?.currentPeriodStart ? new Date(checkout.subscription.currentPeriodStart) : now; const currentPeriodEnd = checkout.subscription?.currentPeriodEnd ? new Date(checkout.subscription.currentPeriodEnd) : new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // Default 30 days // Upsert subscription record per user (one active subscription per user) if (polarSubId) { await prisma.learnSubscription.upsert({ where: { polarSubId }, create: { userId: user.id, polarSubId, status: "active", currentPeriodStart, currentPeriodEnd, }, update: { userId: user.id, status: "active", currentPeriodStart, currentPeriodEnd, cancelledAt: null, }, }); } else { // No polarSubId: check if user already has a subscription and update it const existing = await prisma.learnSubscription.findFirst({ where: { userId: user.id }, orderBy: { createdAt: "desc" }, }); if (existing) { await prisma.learnSubscription.update({ where: { id: existing.id }, data: { status: "active", currentPeriodStart, currentPeriodEnd, cancelledAt: null, }, }); } else { await prisma.learnSubscription.create({ data: { userId: user.id, status: "active", currentPeriodStart, currentPeriodEnd, }, }); } } // Also update the User's premium status to match the active subscription await prisma.user.update({ where: { id: user.id }, data: { isPremium: true, trialEndsAt: currentPeriodEnd, }, }); return NextResponse.json({ success: true, type: "learn_subscription", userId: user.id, }); } /** * Handle a subscription.cancelled or subscription.revoked event. * Sets the LearnSubscription status to 'cancelled' and removes the user's isPremium flag. */ async function handleSubscriptionCancelled(data: any) { // Extract the Polar subscription ID from the event data const polarSubId = data?.id || data?.subscription?.id || data?.metadata?.subscriptionId || null; if (!polarSubId) { return NextResponse.json( { error: "Missing subscription ID in event data" }, { status: 400 } ); } // Find the LearnSubscription by the Polar subscription ID const subscription = await prisma.learnSubscription.findUnique({ where: { polarSubId }, }); if (!subscription) { return NextResponse.json( { error: "LearnSubscription not found for polarSubId" }, { status: 404 } ); } // Mark the subscription as cancelled await prisma.learnSubscription.update({ where: { id: subscription.id }, data: { status: "cancelled", cancelledAt: new Date(), }, }); // Remove the user's premium flag await prisma.user.update({ where: { id: subscription.userId }, data: { isPremium: false, }, }); return NextResponse.json({ success: true, type: "subscription_cancelled", userId: subscription.userId, }); }