Your Name 7bcab25a7c
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add billing webhook idempotency and event timeline
2026-02-12 13:00:01 +08:00

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