- NextAuth v5 credentials auth with registration/login pages - API key CRUD (create, list, revoke) with secure hashing - Stripe checkout, webhooks, and customer portal integration - Rate limiting per subscription tier - All dashboard API endpoints scoped to authenticated user - Prisma schema: User, Account, Session, ApiKey, plus Stripe fields - Auth middleware protecting dashboard and API routes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
180 lines
5.1 KiB
TypeScript
180 lines
5.1 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import type Stripe from "stripe";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { getStripe, TIER_CONFIG } from "@/lib/stripe";
|
|
|
|
export const runtime = "nodejs";
|
|
|
|
function tierFromPriceId(priceId: string | null): "FREE" | "STARTER" | "PRO" {
|
|
if (priceId === TIER_CONFIG.STARTER.priceId) return "STARTER";
|
|
if (priceId === TIER_CONFIG.PRO.priceId) return "PRO";
|
|
return "FREE";
|
|
}
|
|
|
|
function sessionsLimitForTier(tier: "FREE" | "STARTER" | "PRO"): number {
|
|
return TIER_CONFIG[tier].sessionsLimit;
|
|
}
|
|
|
|
async function handleCheckoutCompleted(
|
|
checkoutSession: Stripe.Checkout.Session
|
|
) {
|
|
const userId = checkoutSession.metadata?.userId;
|
|
if (!userId) return;
|
|
|
|
const subscriptionId = checkoutSession.subscription as string;
|
|
const customerId = checkoutSession.customer as string;
|
|
|
|
const sub = await getStripe().subscriptions.retrieve(subscriptionId);
|
|
const firstItem = sub.items.data[0];
|
|
const priceId = firstItem?.price?.id ?? null;
|
|
const tier = tierFromPriceId(priceId);
|
|
const periodStart = firstItem?.current_period_start
|
|
? new Date(firstItem.current_period_start * 1000)
|
|
: new Date();
|
|
const periodEnd = firstItem?.current_period_end
|
|
? new Date(firstItem.current_period_end * 1000)
|
|
: new Date();
|
|
|
|
await prisma.subscription.upsert({
|
|
where: { userId },
|
|
update: {
|
|
stripeCustomerId: customerId,
|
|
stripeSubscriptionId: subscriptionId,
|
|
stripePriceId: priceId,
|
|
tier,
|
|
sessionsLimit: sessionsLimitForTier(tier),
|
|
sessionsUsed: 0,
|
|
status: "ACTIVE",
|
|
currentPeriodStart: periodStart,
|
|
currentPeriodEnd: periodEnd,
|
|
},
|
|
create: {
|
|
userId,
|
|
stripeCustomerId: customerId,
|
|
stripeSubscriptionId: subscriptionId,
|
|
stripePriceId: priceId,
|
|
tier,
|
|
sessionsLimit: sessionsLimitForTier(tier),
|
|
sessionsUsed: 0,
|
|
status: "ACTIVE",
|
|
currentPeriodStart: periodStart,
|
|
currentPeriodEnd: periodEnd,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function handleSubscriptionUpdated(sub: Stripe.Subscription) {
|
|
const firstItem = sub.items.data[0];
|
|
const priceId = firstItem?.price?.id ?? null;
|
|
const tier = tierFromPriceId(priceId);
|
|
|
|
const statusMap: Record<string, "ACTIVE" | "PAST_DUE" | "CANCELED" | "UNPAID"> = {
|
|
active: "ACTIVE",
|
|
past_due: "PAST_DUE",
|
|
canceled: "CANCELED",
|
|
unpaid: "UNPAID",
|
|
};
|
|
|
|
const dbStatus = statusMap[sub.status] ?? "ACTIVE";
|
|
const periodStart = firstItem?.current_period_start
|
|
? new Date(firstItem.current_period_start * 1000)
|
|
: undefined;
|
|
const periodEnd = firstItem?.current_period_end
|
|
? new Date(firstItem.current_period_end * 1000)
|
|
: undefined;
|
|
|
|
await prisma.subscription.updateMany({
|
|
where: { stripeSubscriptionId: sub.id },
|
|
data: {
|
|
tier,
|
|
stripePriceId: priceId,
|
|
sessionsLimit: sessionsLimitForTier(tier),
|
|
status: dbStatus,
|
|
...(periodStart && { currentPeriodStart: periodStart }),
|
|
...(periodEnd && { currentPeriodEnd: periodEnd }),
|
|
},
|
|
});
|
|
}
|
|
|
|
async function handleSubscriptionDeleted(sub: Stripe.Subscription) {
|
|
await prisma.subscription.updateMany({
|
|
where: { stripeSubscriptionId: sub.id },
|
|
data: {
|
|
status: "CANCELED",
|
|
tier: "FREE",
|
|
sessionsLimit: TIER_CONFIG.FREE.sessionsLimit,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function handleInvoicePaid(invoice: Stripe.Invoice) {
|
|
const subDetail = invoice.parent?.subscription_details?.subscription;
|
|
const subscriptionId =
|
|
typeof subDetail === "string" ? subDetail : subDetail?.id;
|
|
|
|
if (!subscriptionId) return;
|
|
|
|
await prisma.subscription.updateMany({
|
|
where: { stripeSubscriptionId: subscriptionId },
|
|
data: { sessionsUsed: 0 },
|
|
});
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
const body = await request.text();
|
|
const sig = request.headers.get("stripe-signature");
|
|
|
|
if (!sig) {
|
|
return NextResponse.json(
|
|
{ error: "Missing stripe-signature header" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
let event: Stripe.Event;
|
|
try {
|
|
event = getStripe().webhooks.constructEvent(
|
|
body,
|
|
sig,
|
|
process.env.STRIPE_WEBHOOK_SECRET!
|
|
);
|
|
} catch (err) {
|
|
console.error("Webhook signature verification failed");
|
|
return NextResponse.json(
|
|
{ error: "Invalid signature" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
try {
|
|
switch (event.type) {
|
|
case "checkout.session.completed":
|
|
await handleCheckoutCompleted(
|
|
event.data.object as Stripe.Checkout.Session
|
|
);
|
|
break;
|
|
case "customer.subscription.updated":
|
|
await handleSubscriptionUpdated(
|
|
event.data.object as Stripe.Subscription
|
|
);
|
|
break;
|
|
case "customer.subscription.deleted":
|
|
await handleSubscriptionDeleted(
|
|
event.data.object as Stripe.Subscription
|
|
);
|
|
break;
|
|
case "invoice.paid":
|
|
await handleInvoicePaid(event.data.object as Stripe.Invoice);
|
|
break;
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error handling ${event.type}:`, error);
|
|
return NextResponse.json(
|
|
{ error: "Webhook handler failed" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
return NextResponse.json({ received: true }, { status: 200 });
|
|
}
|