318 lines
7.8 KiB
TypeScript
318 lines
7.8 KiB
TypeScript
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<string, string>;
|
|
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<string, unknown> | 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<string, unknown>;
|
|
}) {
|
|
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<string, unknown> | 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 });
|
|
}
|
|
}
|