import { NextResponse } from "next/server"; import { and, eq } from "drizzle-orm"; import { getMonthlyImageLimitByPlanId, isBillingPlanId, type BillingPlanId, } from "@/lib/billing/plans"; import { verifyStripeSignature } from "@/lib/billing/stripe"; import { db } from "@/lib/db"; import { systemSettings, user } from "@/lib/db/schema"; type StripeEvent = { id: string; type: string; data: { object: { metadata?: Record; customer_details?: { email?: string; }; customer?: string; mode?: string; status?: string; cancel_at_period_end?: boolean; }; }; }; async function applyPlanToUser({ userId, email, planId, }: { userId?: string; email?: string; planId: BillingPlanId; }) { const monthlyImageLimit = getMonthlyImageLimitByPlanId(planId); const updateData = { monthlyImageLimit, updatedAt: new Date(), }; if (userId) { await db.update(user).set(updateData).where(eq(user.id, userId)); return; } if (email) { await db .update(user) .set(updateData) .where(and(eq(user.email, email), eq(user.isActive, true))); } } function extractUserRef(object: StripeEvent["data"]["object"]) { return { userId: object.metadata?.userId, email: object.customer_details?.email, }; } function getEventSettingKey(eventId: string) { return `stripe:webhook:event:${eventId}`; } async function lockStripeWebhookEvent({ eventId, eventType, }: { eventId: string; eventType: string; }) { const key = getEventSettingKey(eventId); const now = new Date(); const [inserted] = await db .insert(systemSettings) .values({ key, value: { id: eventId, type: eventType, status: "processing", occurredAt: now.toISOString(), attempts: 1, }, updatedAt: now, updatedBy: null, }) .onConflictDoNothing() .returning({ id: systemSettings.id }); if (inserted) { return { key, shouldProcess: true as const }; } const [existing] = await db .select({ value: systemSettings.value, }) .from(systemSettings) .where(eq(systemSettings.key, key)) .limit(1); const existingValue = existing?.value as Record | undefined; const status = typeof existingValue?.status === "string" ? existingValue.status : "unknown"; const attempts = typeof existingValue?.attempts === "number" ? existingValue.attempts : 1; if (status === "processed") { return { key, shouldProcess: false as const }; } await db .update(systemSettings) .set({ value: { ...(existingValue || {}), id: eventId, type: eventType, status: "processing", occurredAt: typeof existingValue?.occurredAt === "string" ? existingValue.occurredAt : now.toISOString(), attempts: attempts + 1, }, updatedAt: now, updatedBy: null, }) .where(eq(systemSettings.key, key)); return { key, shouldProcess: true as const }; } async function finalizeStripeWebhookEvent({ key, status, payload, }: { key: string; status: "processed" | "failed"; payload: Record; }) { const now = new Date(); const [existing] = await db .select({ value: systemSettings.value, }) .from(systemSettings) .where(eq(systemSettings.key, key)) .limit(1); const existingValue = existing?.value as Record | undefined; await db .update(systemSettings) .set({ value: { ...(existingValue || {}), ...payload, status, updatedAt: now.toISOString(), }, updatedAt: now, updatedBy: null, }) .where(eq(systemSettings.key, key)); } export async function POST(request: Request) { const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; if (!webhookSecret) { return NextResponse.json({ error: "Webhook secret is not configured" }, { status: 503 }); } const rawPayload = await request.text(); const signature = request.headers.get("stripe-signature"); if (!signature) { return NextResponse.json({ error: "Missing stripe-signature header" }, { status: 400 }); } const verified = verifyStripeSignature({ payload: rawPayload, signatureHeader: signature, webhookSecret, }); if (!verified) { return NextResponse.json({ error: "Invalid webhook signature" }, { status: 400 }); } try { const event = JSON.parse(rawPayload) as StripeEvent; const object = event.data.object; const userRef = extractUserRef(object); const eventLock = await lockStripeWebhookEvent({ eventId: event.id, eventType: event.type, }); if (!eventLock.shouldProcess) { return NextResponse.json({ received: true, duplicate: true }); } if (event.type === "checkout.session.completed") { if (object.mode !== "subscription") { await finalizeStripeWebhookEvent({ key: eventLock.key, status: "processed", payload: { id: event.id, type: event.type, detail: "ignored_non_subscription_checkout", userId: userRef.userId, email: userRef.email, }, }); return NextResponse.json({ received: true }); } const planId = object.metadata?.planId; if (planId && isBillingPlanId(planId)) { await applyPlanToUser({ ...userRef, planId }); } await finalizeStripeWebhookEvent({ key: eventLock.key, status: "processed", payload: { id: event.id, type: event.type, planId, userId: userRef.userId, email: userRef.email, detail: "checkout_completed", }, }); return NextResponse.json({ received: true }); } if (event.type === "customer.subscription.updated") { const planId = object.metadata?.planId; if (planId && isBillingPlanId(planId) && object.status === "active") { await applyPlanToUser({ ...userRef, planId }); } await finalizeStripeWebhookEvent({ key: eventLock.key, status: "processed", payload: { id: event.id, type: event.type, planId, userId: userRef.userId, email: userRef.email, detail: `subscription_status:${object.status || "unknown"}`, }, }); return NextResponse.json({ received: true }); } if (event.type === "customer.subscription.deleted") { await applyPlanToUser({ ...userRef, planId: "starter" }); await finalizeStripeWebhookEvent({ key: eventLock.key, status: "processed", payload: { id: event.id, type: event.type, planId: "starter", userId: userRef.userId, email: userRef.email, detail: "subscription_deleted_to_starter", }, }); return NextResponse.json({ received: true }); } await finalizeStripeWebhookEvent({ key: eventLock.key, status: "processed", payload: { id: event.id, type: event.type, userId: userRef.userId, email: userRef.email, detail: "ignored_event_type", }, }); return NextResponse.json({ received: true }); } catch (error) { try { const parsedEvent = JSON.parse(rawPayload) as StripeEvent; const key = getEventSettingKey(parsedEvent.id); await finalizeStripeWebhookEvent({ key, status: "failed", payload: { id: parsedEvent.id, type: parsedEvent.type, detail: error instanceof Error ? error.message : "unknown_webhook_error", }, }); } catch (_internalError) { // ignore parse/update failure } console.error("[Billing Stripe Webhook] Error:", error); return NextResponse.json({ error: "Webhook processing failed" }, { status: 500 }); } }