This commit is contained in:
284
apps/www/app/api/stripe/checkout/route.ts
Normal file
284
apps/www/app/api/stripe/checkout/route.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth, clerkClient } from '@clerk/nextjs/server';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
/**
|
||||
* /api/stripe/checkout — Stripe Checkout Session creator with Clerk linkage.
|
||||
*
|
||||
* Sesija E §5.3 Phase C: lazy-create Stripe Customer pattern. On first
|
||||
* paid checkout for a user we create the Customer, store its id in
|
||||
* `Clerk.user.publicMetadata.stripeCustomerId`, and reuse it forever after.
|
||||
* Customer.metadata.clerkUserId mirrors the linkage in the other direction
|
||||
* so subscription webhooks can map back to a Clerk user.
|
||||
*
|
||||
* GET ?tier=teams&billing=monthly|annual
|
||||
* - Canonical entrypoint (per §5.3 brief). Used by Clerk SignUp's
|
||||
* `forceRedirectUrl` after sign-up completion.
|
||||
* - Returns 303 redirect to the Stripe Checkout URL on success.
|
||||
* - Signed-out: 303 to /sign-in with redirect_url back to this endpoint.
|
||||
*
|
||||
* POST { tier, billingPeriod }
|
||||
* - Backward-compat shim for older clients. Pricing.tsx now uses the
|
||||
* canonical GET flow.
|
||||
* - Returns JSON { url } on success or { message } on error.
|
||||
* - Signed-out: 401 JSON { message }.
|
||||
*/
|
||||
|
||||
// New checkout is TEAMS-only (Solo is free). Legacy 'pro' is rejected here;
|
||||
// legacy pro subscription webhooks are still honored in the webhook route.
|
||||
type Tier = 'teams';
|
||||
type Billing = 'monthly' | 'annual';
|
||||
|
||||
const TIERS: readonly Tier[] = ['teams'];
|
||||
const BILLINGS: readonly Billing[] = ['monthly', 'annual'];
|
||||
|
||||
interface ClerkPublicMetadata {
|
||||
readonly stripeCustomerId?: string;
|
||||
readonly subscriptionTier?: Tier;
|
||||
readonly subscriptionStatus?:
|
||||
| 'active'
|
||||
| 'past_due'
|
||||
| 'canceled'
|
||||
| 'trialing'
|
||||
| 'incomplete';
|
||||
}
|
||||
|
||||
interface CheckoutSuccess {
|
||||
readonly kind: 'success';
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
interface CheckoutAuthRedirect {
|
||||
readonly kind: 'auth_required';
|
||||
readonly signInUrl: string;
|
||||
}
|
||||
|
||||
interface CheckoutFailure {
|
||||
readonly kind: 'failure';
|
||||
readonly status: number;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
type CheckoutResult = CheckoutSuccess | CheckoutAuthRedirect | CheckoutFailure;
|
||||
|
||||
function normalizeTier(value: unknown): Tier | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const lower = value.toLowerCase();
|
||||
return TIERS.includes(lower as Tier) ? (lower as Tier) : null;
|
||||
}
|
||||
|
||||
function normalizeBilling(value: unknown): Billing | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const lower = value.toLowerCase();
|
||||
return BILLINGS.includes(lower as Billing) ? (lower as Billing) : null;
|
||||
}
|
||||
|
||||
function isValidStripeKey(key: string | undefined): key is string {
|
||||
return (
|
||||
typeof key === 'string' &&
|
||||
(key.startsWith('sk_test_') || key.startsWith('sk_live_'))
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureStripeCustomer(
|
||||
userId: string,
|
||||
email: string | null,
|
||||
existingId: string | undefined,
|
||||
stripe: Stripe,
|
||||
): Promise<string> {
|
||||
if (existingId) return existingId;
|
||||
|
||||
const customer = await stripe.customers.create({
|
||||
email: email ?? undefined,
|
||||
metadata: { clerkUserId: userId },
|
||||
});
|
||||
|
||||
const cc = await clerkClient();
|
||||
await cc.users.updateUserMetadata(userId, {
|
||||
publicMetadata: { stripeCustomerId: customer.id } satisfies ClerkPublicMetadata,
|
||||
});
|
||||
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
async function resolvePriceId(
|
||||
stripe: Stripe,
|
||||
tier: Tier,
|
||||
billing: Billing,
|
||||
): Promise<string | null> {
|
||||
// Prefer env-pinned IDs (zero round-trip). Fall back to lookup_key resolution
|
||||
// so the route still works in environments where price IDs aren't pinned.
|
||||
const envKey = `STRIPE_PRICE_${tier.toUpperCase()}_${billing.toUpperCase()}`;
|
||||
const pinned = process.env[envKey];
|
||||
if (pinned && pinned.startsWith('price_')) return pinned;
|
||||
|
||||
const lookupKey = `${tier}_${billing}`;
|
||||
const list = await stripe.prices.list({
|
||||
lookup_keys: [lookupKey],
|
||||
active: true,
|
||||
limit: 1,
|
||||
});
|
||||
return list.data[0]?.id ?? null;
|
||||
}
|
||||
|
||||
async function runCheckout(
|
||||
origin: string,
|
||||
tier: Tier,
|
||||
billing: Billing,
|
||||
): Promise<CheckoutResult> {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
const target = `/api/stripe/checkout?tier=${tier}&billing=${billing}`;
|
||||
return {
|
||||
kind: 'auth_required',
|
||||
signInUrl: `${origin}/sign-in?redirect_url=${encodeURIComponent(target)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!isValidStripeKey(secretKey)) {
|
||||
return {
|
||||
kind: 'failure',
|
||||
status: 503,
|
||||
message:
|
||||
'Stripe checkout configuration required. Set STRIPE_SECRET_KEY in env (sk_test_* or sk_live_*).',
|
||||
};
|
||||
}
|
||||
|
||||
const stripe = new Stripe(secretKey);
|
||||
|
||||
const cc = await clerkClient();
|
||||
const user = await cc.users.getUser(userId);
|
||||
const publicMetadata = (user.publicMetadata ?? {}) as ClerkPublicMetadata;
|
||||
const email = user.primaryEmailAddress?.emailAddress ?? null;
|
||||
|
||||
const customerId = await ensureStripeCustomer(
|
||||
userId,
|
||||
email,
|
||||
publicMetadata.stripeCustomerId,
|
||||
stripe,
|
||||
);
|
||||
|
||||
const priceId = await resolvePriceId(stripe, tier, billing);
|
||||
if (!priceId) {
|
||||
return {
|
||||
kind: 'failure',
|
||||
status: 503,
|
||||
message: `No active Stripe price found for ${tier}/${billing}. Set STRIPE_PRICE_${tier.toUpperCase()}_${billing.toUpperCase()} or assign lookup_key="${tier}_${billing}".`,
|
||||
};
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
customer: customerId,
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: `${origin}/account?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${origin}/?checkout=cancelled#pricing`,
|
||||
metadata: { clerkUserId: userId, tier, billing },
|
||||
subscription_data: {
|
||||
metadata: { clerkUserId: userId, tier, billing },
|
||||
},
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
return {
|
||||
kind: 'failure',
|
||||
status: 500,
|
||||
message: 'Stripe session created without redirect URL',
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: 'success', url: session.url };
|
||||
}
|
||||
|
||||
function originOf(req: Request): string {
|
||||
const headerOrigin = req.headers.get('origin');
|
||||
if (headerOrigin) return headerOrigin;
|
||||
return new URL(req.url).origin;
|
||||
}
|
||||
|
||||
async function safeRunCheckout(
|
||||
origin: string,
|
||||
tier: Tier,
|
||||
billing: Billing,
|
||||
): Promise<CheckoutResult> {
|
||||
try {
|
||||
return await runCheckout(origin, tier, billing);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Stripe API error';
|
||||
return { kind: 'failure', status: 500, message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const tier = normalizeTier(url.searchParams.get('tier'));
|
||||
const billing = normalizeBilling(url.searchParams.get('billing'));
|
||||
if (!tier || !billing) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message:
|
||||
'Invalid query. Expected ?tier=teams&billing=monthly|annual.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await safeRunCheckout(originOf(req), tier, billing);
|
||||
|
||||
switch (result.kind) {
|
||||
case 'success':
|
||||
return Response.redirect(result.url, 303);
|
||||
case 'auth_required':
|
||||
return Response.redirect(result.signInUrl, 303);
|
||||
case 'failure':
|
||||
return NextResponse.json(
|
||||
{ message: result.message },
|
||||
{ status: result.status },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request): Promise<NextResponse> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ message: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
return NextResponse.json({ message: 'Invalid body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const obj = body as Record<string, unknown>;
|
||||
const tier = normalizeTier(obj.tier);
|
||||
// Accept legacy field name `billingPeriod` from existing Pricing.tsx
|
||||
// alongside the new canonical `billing`.
|
||||
const billing = normalizeBilling(obj.billingPeriod ?? obj.billing);
|
||||
if (!tier || !billing) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message:
|
||||
'Invalid body. Expected { tier: "teams", billingPeriod: "monthly"|"annual" }.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await safeRunCheckout(originOf(req), tier, billing);
|
||||
|
||||
switch (result.kind) {
|
||||
case 'success':
|
||||
return NextResponse.json({ url: result.url });
|
||||
case 'auth_required':
|
||||
return NextResponse.json(
|
||||
{ message: 'Sign in required', signInUrl: result.signInUrl },
|
||||
{ status: 401 },
|
||||
);
|
||||
case 'failure':
|
||||
return NextResponse.json(
|
||||
{ message: result.message },
|
||||
{ status: result.status },
|
||||
);
|
||||
}
|
||||
}
|
||||
224
apps/www/app/api/webhooks/stripe/route.ts
Normal file
224
apps/www/app/api/webhooks/stripe/route.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { clerkClient } from '@clerk/nextjs/server';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
/**
|
||||
* /api/webhooks/stripe — Stripe webhook receiver (Sesija E §5.3 Phase C).
|
||||
*
|
||||
* Verifies signature against STRIPE_WEBHOOK_SECRET, then mirrors subscription
|
||||
* state from Stripe → Clerk user.publicMetadata so the Next.js app can gate
|
||||
* features on tier/status without round-tripping to Stripe on every request.
|
||||
*
|
||||
* Handled events:
|
||||
* checkout.session.completed → set tier + status='active'
|
||||
* customer.subscription.updated → map status, refresh tier
|
||||
* customer.subscription.deleted → status='canceled'
|
||||
*
|
||||
* Linkage strategy: every Checkout Session and Subscription gets
|
||||
* `metadata.clerkUserId` set by the checkout route. As a fallback, the Stripe
|
||||
* Customer also carries `metadata.clerkUserId` (set during lazy-create), so
|
||||
* subscription events that lack metadata can still resolve the user.
|
||||
*
|
||||
* Returns 200 quickly so Stripe's retry queue stays clean. Handler errors
|
||||
* surface as 500 (Stripe will retry up to its standard backoff schedule).
|
||||
*/
|
||||
|
||||
// New checkout is TEAMS-only, but this webhook still accepts legacy 'pro'
|
||||
// subscription events so existing subscribers keep getting status updates
|
||||
// (renewals, cancellations). Pro is no longer a sold tier — the app coerces
|
||||
// it to the free Solo label at display time (parseTier('PRO') → 'FREE').
|
||||
type Tier = 'pro' | 'teams';
|
||||
|
||||
interface ClerkPublicMetadata {
|
||||
readonly stripeCustomerId?: string;
|
||||
readonly subscriptionTier?: Tier;
|
||||
readonly subscriptionStatus?:
|
||||
| 'active'
|
||||
| 'past_due'
|
||||
| 'canceled'
|
||||
| 'trialing'
|
||||
| 'incomplete';
|
||||
}
|
||||
|
||||
function isValidStripeKey(key: string | undefined): key is string {
|
||||
return (
|
||||
typeof key === 'string' &&
|
||||
(key.startsWith('sk_test_') || key.startsWith('sk_live_'))
|
||||
);
|
||||
}
|
||||
|
||||
function isValidWebhookSecret(value: string | undefined): value is string {
|
||||
return typeof value === 'string' && value.startsWith('whsec_');
|
||||
}
|
||||
|
||||
function asTier(value: unknown): Tier | undefined {
|
||||
return value === 'pro' || value === 'teams' ? value : undefined;
|
||||
}
|
||||
|
||||
function mapStatus(
|
||||
status: Stripe.Subscription.Status,
|
||||
): NonNullable<ClerkPublicMetadata['subscriptionStatus']> {
|
||||
// Collapse Stripe's 8 statuses into the 5 we expose to the app:
|
||||
// active | past_due | canceled | trialing | incomplete
|
||||
switch (status) {
|
||||
case 'active':
|
||||
case 'past_due':
|
||||
case 'canceled':
|
||||
case 'trialing':
|
||||
case 'incomplete':
|
||||
return status;
|
||||
case 'unpaid':
|
||||
case 'incomplete_expired':
|
||||
return 'past_due';
|
||||
case 'paused':
|
||||
return 'canceled';
|
||||
default:
|
||||
return 'incomplete';
|
||||
}
|
||||
}
|
||||
|
||||
async function findClerkUserIdFromCustomer(
|
||||
customerId: string,
|
||||
stripe: Stripe,
|
||||
): Promise<string | null> {
|
||||
const customer = await stripe.customers.retrieve(customerId);
|
||||
if ('deleted' in customer && customer.deleted) return null;
|
||||
const meta = (customer as Stripe.Customer).metadata;
|
||||
return meta?.clerkUserId ?? null;
|
||||
}
|
||||
|
||||
async function patchClerkPublicMetadata(
|
||||
userId: string,
|
||||
patch: Partial<ClerkPublicMetadata>,
|
||||
): Promise<void> {
|
||||
const cc = await clerkClient();
|
||||
const user = await cc.users.getUser(userId);
|
||||
const current = (user.publicMetadata ?? {}) as ClerkPublicMetadata;
|
||||
await cc.users.updateUserMetadata(userId, {
|
||||
publicMetadata: { ...current, ...patch } satisfies ClerkPublicMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCheckoutCompleted(
|
||||
event: Stripe.CheckoutSessionCompletedEvent,
|
||||
): Promise<void> {
|
||||
const session = event.data.object;
|
||||
const userId = session.metadata?.clerkUserId;
|
||||
const tier = asTier(session.metadata?.tier);
|
||||
// R1-002 (webhook path): only grant once payment has settled.
|
||||
// checkout.session.completed also fires for unpaid/async sessions.
|
||||
const paid =
|
||||
session.payment_status === 'paid' ||
|
||||
session.payment_status === 'no_payment_required';
|
||||
if (!userId || !tier || !paid) return;
|
||||
|
||||
await patchClerkPublicMetadata(userId, {
|
||||
subscriptionTier: tier,
|
||||
subscriptionStatus: 'active',
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubscriptionUpdated(
|
||||
event: Stripe.CustomerSubscriptionUpdatedEvent,
|
||||
stripe: Stripe,
|
||||
): Promise<void> {
|
||||
const sub = event.data.object;
|
||||
const userId =
|
||||
sub.metadata?.clerkUserId ??
|
||||
(typeof sub.customer === 'string'
|
||||
? await findClerkUserIdFromCustomer(sub.customer, stripe)
|
||||
: null);
|
||||
if (!userId) return;
|
||||
|
||||
const tier = asTier(sub.metadata?.tier);
|
||||
const patch: ClerkPublicMetadata = {
|
||||
subscriptionStatus: mapStatus(sub.status),
|
||||
...(tier ? { subscriptionTier: tier } : {}),
|
||||
};
|
||||
|
||||
await patchClerkPublicMetadata(userId, patch);
|
||||
}
|
||||
|
||||
async function handleSubscriptionDeleted(
|
||||
event: Stripe.CustomerSubscriptionDeletedEvent,
|
||||
stripe: Stripe,
|
||||
): Promise<void> {
|
||||
const sub = event.data.object;
|
||||
const userId =
|
||||
sub.metadata?.clerkUserId ??
|
||||
(typeof sub.customer === 'string'
|
||||
? await findClerkUserIdFromCustomer(sub.customer, stripe)
|
||||
: null);
|
||||
if (!userId) return;
|
||||
|
||||
await patchClerkPublicMetadata(userId, { subscriptionStatus: 'canceled' });
|
||||
}
|
||||
|
||||
export async function POST(req: Request): Promise<NextResponse> {
|
||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
if (!isValidStripeKey(secretKey)) {
|
||||
return NextResponse.json(
|
||||
{ message: 'Stripe not configured (STRIPE_SECRET_KEY missing/invalid)' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (!isValidWebhookSecret(webhookSecret)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message:
|
||||
'Webhook secret not configured (STRIPE_WEBHOOK_SECRET missing/invalid)',
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const sig = req.headers.get('stripe-signature');
|
||||
if (!sig) {
|
||||
return NextResponse.json(
|
||||
{ message: 'Missing stripe-signature header' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const rawBody = await req.text();
|
||||
const stripe = new Stripe(secretKey);
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(rawBody, sig, webhookSecret);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Invalid signature';
|
||||
return NextResponse.json(
|
||||
{ message: `Signature verification failed: ${msg}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed':
|
||||
await handleCheckoutCompleted(event);
|
||||
break;
|
||||
case 'customer.subscription.updated':
|
||||
await handleSubscriptionUpdated(event, stripe);
|
||||
break;
|
||||
case 'customer.subscription.deleted':
|
||||
await handleSubscriptionDeleted(event, stripe);
|
||||
break;
|
||||
default:
|
||||
// Unhandled events ack with 200 — Stripe will keep delivering them
|
||||
// even if we don't act, so just no-op rather than returning an error.
|
||||
break;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Handler error';
|
||||
return NextResponse.json(
|
||||
{ message: `Handler error: ${msg}`, eventType: event.type },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true, eventType: event.type });
|
||||
}
|
||||
Reference in New Issue
Block a user