juntekim.com/stripe_to_invoice/app/api/stripe/subscription/webhook/route.ts
2026-02-21 14:48:09 +00:00

282 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 });
}
}