282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
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 });
|
||
}
|
||
}
|