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:
95
apps/web/src/app/api/stripe/checkout/route.ts
Normal file
95
apps/web/src/app/api/stripe/checkout/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getStripe, TIER_CONFIG } from "@/lib/stripe";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { priceId, tierKey } = body as {
|
||||
priceId?: string;
|
||||
tierKey?: string;
|
||||
};
|
||||
|
||||
let resolvedPriceId = priceId;
|
||||
|
||||
if (!resolvedPriceId && tierKey) {
|
||||
const tierConfig =
|
||||
TIER_CONFIG[tierKey as keyof typeof TIER_CONFIG];
|
||||
if (tierConfig && "priceId" in tierConfig) {
|
||||
resolvedPriceId = tierConfig.priceId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedPriceId) {
|
||||
return NextResponse.json(
|
||||
{ error: "priceId or tierKey is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const validPriceIds = [TIER_CONFIG.STARTER.priceId, TIER_CONFIG.PRO.priceId];
|
||||
if (!validPriceIds.includes(resolvedPriceId)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid priceId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
let subscription = await prisma.subscription.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
let stripeCustomerId = subscription?.stripeCustomerId;
|
||||
|
||||
if (!stripeCustomerId) {
|
||||
const customer = await getStripe().customers.create({
|
||||
email: session.user.email,
|
||||
name: session.user.name ?? undefined,
|
||||
metadata: { userId },
|
||||
});
|
||||
stripeCustomerId = customer.id;
|
||||
|
||||
if (subscription) {
|
||||
await prisma.subscription.update({
|
||||
where: { userId },
|
||||
data: { stripeCustomerId },
|
||||
});
|
||||
} else {
|
||||
subscription = await prisma.subscription.create({
|
||||
data: {
|
||||
userId,
|
||||
stripeCustomerId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const origin =
|
||||
request.headers.get("origin") ?? "https://agentlens.vectry.tech";
|
||||
|
||||
const checkoutSession = await getStripe().checkout.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
mode: "subscription",
|
||||
line_items: [{ price: resolvedPriceId, quantity: 1 }],
|
||||
success_url: `${origin}/dashboard/settings?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${origin}/dashboard/settings`,
|
||||
metadata: { userId },
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: checkoutSession.url }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error creating checkout session:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
apps/web/src/app/api/stripe/portal/route.ts
Normal file
41
apps/web/src/app/api/stripe/portal/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getStripe } from "@/lib/stripe";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
select: { stripeCustomerId: true },
|
||||
});
|
||||
|
||||
if (!subscription?.stripeCustomerId) {
|
||||
return NextResponse.json(
|
||||
{ error: "No active subscription to manage" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const origin =
|
||||
request.headers.get("origin") ?? "https://agentlens.vectry.tech";
|
||||
|
||||
const portalSession = await getStripe().billingPortal.sessions.create({
|
||||
customer: subscription.stripeCustomerId,
|
||||
return_url: `${origin}/dashboard/settings`,
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: portalSession.url }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error creating portal session:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
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