feat: user auth, API keys, Stripe billing, and dashboard scoping
- 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>
This commit is contained in:
179
apps/web/src/app/api/stripe/webhook/route.ts
Normal file
179
apps/web/src/app/api/stripe/webhook/route.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user