Skip to content

Commit d53d16f

Browse files
feat: add Stripe billing, plan gating, pricing page, and billing settings
- Migration 040: Stripe customer/subscription columns on users, subscription_events table - lib/plans.ts: centralized plan limits (free/pro/enterprise) with getUserPlan, requirePlan, checkCountLimit - Stripe API routes: checkout session, webhook handler (checkout.session.completed, invoice.paid, subscription.updated/deleted), customer portal redirect - Plan gating enforced on webhooks, budgets, teams, exports, Slack install, SLA dashboard - Pricing page with 3-tier comparison, monthly/yearly toggle, Stripe checkout CTAs - Billing section in dashboard settings: plan badge, period end, manage billing portal link - User profile API extended to return plan, plan_period_end, plan_cancel_at_period_end Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e52d582 commit d53d16f

14 files changed

Lines changed: 985 additions & 1 deletion

File tree

app/api/admin/sla/route.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { NextResponse } from "next/server";
66
import { createSupabaseCookieClient } from "@/lib/supabase-server";
77
import { createSupabaseServerClient } from "@/lib/supabase-client";
8+
import { requirePlan } from "@/lib/plans";
89

910
export const runtime = "edge";
1011

@@ -27,6 +28,15 @@ export async function GET() {
2728
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
2829
}
2930

31+
// SLA dashboard requires Enterprise plan
32+
const planCheck = await requirePlan(user.id, "sla_dashboard");
33+
if (!planCheck.allowed) {
34+
return NextResponse.json(
35+
{ error: planCheck.reason, upgrade_to: planCheck.upgrade_to },
36+
{ status: 403 }
37+
);
38+
}
39+
3040
const supabase = createSupabaseServerClient();
3141
const now = new Date();
3242
const thirtyDaysAgo = new Date(

app/api/budgets/route.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { NextRequest, NextResponse } from "next/server";
66
import { createSupabaseCookieClient } from "@/lib/supabase-server";
77
import { createSupabaseServerClient } from "@/lib/supabase-client";
8+
import { checkCountLimit } from "@/lib/plans";
89

910
export const runtime = "edge";
1011

@@ -76,6 +77,17 @@ export async function POST(req: NextRequest) {
7677
);
7778
}
7879

80+
// Plan limit check
81+
const supabaseCount = createSupabaseServerClient();
82+
const { count: budgetCount } = await supabaseCount
83+
.from("budgets")
84+
.select("id", { count: "exact", head: true })
85+
.eq("user_id", user.id);
86+
const limitCheck = await checkCountLimit(user.id, "max_budgets", budgetCount ?? 0);
87+
if (!limitCheck.allowed) {
88+
return NextResponse.json({ error: limitCheck.reason, limit: limitCheck.limit }, { status: 403 });
89+
}
90+
7991
const supabase = createSupabaseServerClient();
8092

8193
const { data, error } = await supabase

app/api/integrations/slack/install/route.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { NextRequest, NextResponse } from "next/server";
66
import { createSupabaseCookieClient } from "@/lib/supabase-server";
77
import { createSupabaseServerClient } from "@/lib/supabase-client";
8+
import { requirePlan } from "@/lib/plans";
89

910
export const runtime = "edge";
1011

@@ -29,6 +30,15 @@ export async function GET(req: NextRequest) {
2930
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
3031
}
3132

33+
// Plan gate: Slack requires Pro+
34+
const planCheck = await requirePlan(user.id, "slack_integration");
35+
if (!planCheck.allowed) {
36+
return NextResponse.json(
37+
{ error: planCheck.reason, upgrade_to: planCheck.upgrade_to },
38+
{ status: 403 }
39+
);
40+
}
41+
3242
const code = req.nextUrl.searchParams.get("code");
3343

3444
// Step 1: If no code, redirect to Slack OAuth authorization page

app/api/settings/export/route.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { NextRequest, NextResponse } from "next/server";
55
import { createSupabaseCookieClient } from "@/lib/supabase-server";
66
import { createSupabaseServerClient } from "@/lib/supabase-client";
7+
import { checkCountLimit } from "@/lib/plans";
78

89
export const runtime = "edge";
910

@@ -62,6 +63,17 @@ export async function POST(req: NextRequest) {
6263
gcs_credentials,
6364
} = body;
6465

66+
// Plan limit check
67+
const supabaseCount = createSupabaseServerClient();
68+
const { count: exportCount } = await supabaseCount
69+
.from("export_configs")
70+
.select("id", { count: "exact", head: true })
71+
.eq("user_id", user.id);
72+
const limitCheck = await checkCountLimit(user.id, "max_export_configs", exportCount ?? 0);
73+
if (!limitCheck.allowed) {
74+
return NextResponse.json({ error: limitCheck.reason, limit: limitCheck.limit }, { status: 403 });
75+
}
76+
6577
if (!provider || !["s3", "gcs"].includes(provider)) {
6678
return NextResponse.json(
6779
{ error: "provider must be 's3' or 'gcs'" },

app/api/stripe/checkout/route.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// POST /api/stripe/checkout — create a Stripe Checkout session for plan upgrade
2+
export const runtime = "edge";
3+
4+
import { NextRequest, NextResponse } from "next/server";
5+
import Stripe from "stripe";
6+
import { createSupabaseCookieClient } from "@/lib/supabase-server";
7+
import { createSupabaseServerClient } from "@/lib/supabase-client";
8+
import { STRIPE_PRICES, type PlanTier } from "@/lib/plans";
9+
10+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "", {
11+
apiVersion: "2024-12-18.acacia",
12+
});
13+
14+
export async function POST(req: NextRequest) {
15+
try {
16+
const supabaseCookie = await createSupabaseCookieClient();
17+
const {
18+
data: { user },
19+
} = await supabaseCookie.auth.getUser();
20+
if (!user) {
21+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
22+
}
23+
24+
const body = await req.json();
25+
const { plan, interval } = body as {
26+
plan: PlanTier;
27+
interval: "monthly" | "yearly";
28+
};
29+
30+
if (!plan || !["pro", "enterprise"].includes(plan)) {
31+
return NextResponse.json(
32+
{ error: "Invalid plan. Must be pro or enterprise." },
33+
{ status: 400 }
34+
);
35+
}
36+
if (!interval || !["monthly", "yearly"].includes(interval)) {
37+
return NextResponse.json(
38+
{ error: "Invalid interval. Must be monthly or yearly." },
39+
{ status: 400 }
40+
);
41+
}
42+
43+
const priceKey = `${plan}_${interval}` as keyof typeof STRIPE_PRICES;
44+
const priceId = STRIPE_PRICES[priceKey];
45+
if (!priceId) {
46+
return NextResponse.json(
47+
{ error: "Stripe price not configured for this plan/interval" },
48+
{ status: 500 }
49+
);
50+
}
51+
52+
// Get or create Stripe customer
53+
const supabase = createSupabaseServerClient();
54+
const { data: userData } = await supabase
55+
.from("users")
56+
.select("stripe_customer_id, email")
57+
.eq("id", user.id)
58+
.single();
59+
60+
let customerId = userData?.stripe_customer_id;
61+
62+
if (!customerId) {
63+
const customer = await stripe.customers.create({
64+
email: userData?.email ?? user.email,
65+
metadata: { user_id: user.id },
66+
});
67+
customerId = customer.id;
68+
69+
await supabase
70+
.from("users")
71+
.update({ stripe_customer_id: customerId })
72+
.eq("id", user.id);
73+
}
74+
75+
const origin = req.headers.get("origin") ?? process.env.NEXT_PUBLIC_APP_URL ?? "https://www.aluminatai.com";
76+
77+
const session = await stripe.checkout.sessions.create({
78+
customer: customerId,
79+
mode: "subscription",
80+
line_items: [{ price: priceId, quantity: 1 }],
81+
success_url: `${origin}/dashboard/settings?billing=success`,
82+
cancel_url: `${origin}/dashboard/settings?billing=cancel`,
83+
metadata: { user_id: user.id, plan },
84+
subscription_data: {
85+
metadata: { user_id: user.id, plan },
86+
},
87+
});
88+
89+
return NextResponse.json({ url: session.url });
90+
} catch (err: unknown) {
91+
const message = err instanceof Error ? err.message : "Unknown error";
92+
return NextResponse.json({ error: message }, { status: 500 });
93+
}
94+
}

app/api/stripe/portal/route.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// POST /api/stripe/portal — redirect user to Stripe Customer Portal
2+
export const runtime = "edge";
3+
4+
import { NextRequest, NextResponse } from "next/server";
5+
import Stripe from "stripe";
6+
import { createSupabaseCookieClient } from "@/lib/supabase-server";
7+
import { createSupabaseServerClient } from "@/lib/supabase-client";
8+
9+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "", {
10+
apiVersion: "2024-12-18.acacia",
11+
});
12+
13+
export async function POST(req: NextRequest) {
14+
try {
15+
const supabaseCookie = await createSupabaseCookieClient();
16+
const {
17+
data: { user },
18+
} = await supabaseCookie.auth.getUser();
19+
if (!user) {
20+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
21+
}
22+
23+
const supabase = createSupabaseServerClient();
24+
const { data: userData } = await supabase
25+
.from("users")
26+
.select("stripe_customer_id")
27+
.eq("id", user.id)
28+
.single();
29+
30+
if (!userData?.stripe_customer_id) {
31+
return NextResponse.json(
32+
{ error: "No billing account found. Subscribe to a plan first." },
33+
{ status: 400 }
34+
);
35+
}
36+
37+
const origin = req.headers.get("origin") ?? process.env.NEXT_PUBLIC_APP_URL ?? "https://www.aluminatai.com";
38+
39+
const session = await stripe.billingPortal.sessions.create({
40+
customer: userData.stripe_customer_id,
41+
return_url: `${origin}/dashboard/settings`,
42+
});
43+
44+
return NextResponse.json({ url: session.url });
45+
} catch (err: unknown) {
46+
const message = err instanceof Error ? err.message : "Unknown error";
47+
return NextResponse.json({ error: message }, { status: 500 });
48+
}
49+
}

0 commit comments

Comments
 (0)