moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View 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 },
);
}
}

View 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 });
}