From 955504448509d77730b8ca7323c034a79db86af1 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Sat, 30 May 2026 12:46:01 +0330 Subject: [PATCH] feat(frontend): route checkout through V2 Identity plan-purchase; drop dead Stripe webhook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/checkout now resolves the requested plan (pro|business × monthly|annual) to a plan GUID via gateway /v1/plans (codes follow pro_monthly / business_annual) and POSTs /v1/users/me/plan/purchase. The payments service owns the gateway (ZarinPal/Stripe) and returns redirect_url, which we hand back unchanged. Removes the orphaned Stripe→Supabase webhook + lib/stripe.ts client: profile plan reads come from Identity now, so the Supabase profiles upsert loop is dead. V2 payments has its own gateway callback (skip-auth, payment-callback route). Co-Authored-By: Claude Opus 4.7 --- src/app/api/checkout/route.ts | 185 +++++++++++++++------------ src/app/api/webhooks/stripe/route.ts | 123 ------------------ src/lib/stripe.ts | 20 --- 3 files changed, 105 insertions(+), 223 deletions(-) delete mode 100644 src/app/api/webhooks/stripe/route.ts delete mode 100644 src/lib/stripe.ts diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts index e505e8e..b9979a5 100644 --- a/src/app/api/checkout/route.ts +++ b/src/app/api/checkout/route.ts @@ -1,90 +1,115 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import type { BillingPeriod } from "@/components/sections/pricing-data"; -import { getStripePriceId, isPaidPlanId } from "@/lib/plans"; -import { getStripe } from "@/lib/stripe"; -import { createClient } from "@/lib/supabase/server"; +import { gatewayUrl } from "@/lib/api/gateway"; +import { getAccessToken } from "@/lib/auth/session"; + +export const dynamic = "force-dynamic"; const checkoutSchema = z.object({ plan: z.enum(["pro", "business"]), billing: z.enum(["monthly", "annual"]), }); -export async function POST(request: Request) { - try { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user?.email) { - return NextResponse.json( - { error: "You must be signed in to checkout." }, - { status: 401 } - ); - } - - const body: unknown = await request.json(); - const parsed = checkoutSchema.safeParse(body); - - if (!parsed.success) { - return NextResponse.json( - { error: "Invalid plan or billing period." }, - { status: 400 } - ); - } - - const { plan, billing } = parsed.data; - - if (!isPaidPlanId(plan)) { - return NextResponse.json({ error: "Invalid plan." }, { status: 400 }); - } - - const priceId = getStripePriceId(plan, billing as BillingPeriod); - const siteUrl = - process.env.NEXT_PUBLIC_SITE_URL ?? new URL(request.url).origin; - - const stripe = getStripe(); - - const session = await stripe.checkout.sessions.create({ - mode: "subscription", - payment_method_types: ["card"], - line_items: [ - { - price: priceId, - quantity: 1, - }, - ], - success_url: `${siteUrl}/dashboard?checkout=success`, - cancel_url: `${siteUrl}/#pricing`, - customer_email: user.email, - client_reference_id: user.id, - metadata: { - userId: user.id, - planId: plan, - billingPeriod: billing, - }, - subscription_data: { - metadata: { - userId: user.id, - planId: plan, - billingPeriod: billing, - }, - }, - }); - - if (!session.url) { - return NextResponse.json( - { error: "Failed to create checkout session." }, - { status: 500 } - ); - } - - return NextResponse.json({ url: session.url }); - } catch (error) { - const message = - error instanceof Error ? error.message : "Checkout failed."; - return NextResponse.json({ error: message }, { status: 500 }); - } +interface V2Plan { + id: string; + code: string; + name: string; + billing_period: string; +} + +/** + * Start a plan purchase through the V2 Identity/payments flow. + * + * Replaces the direct Stripe Checkout + Supabase profile loop. We resolve the + * requested plan ("pro"/"business" × "monthly"/"annual") to a plan GUID via + * `/v1/plans` (codes follow the `pro_monthly` / `business_annual` convention), + * then POST `/v1/users/me/plan/purchase`. The payments service owns the gateway + * (ZarinPal/Stripe) and returns a redirect URL we hand back to the client. + */ +export async function POST(request: Request) { + const token = await getAccessToken(); + if (!token) { + return NextResponse.json( + { error: "You must be signed in to checkout." }, + { status: 401 } + ); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + const parsed = checkoutSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid plan or billing period." }, + { status: 400 } + ); + } + + const { plan, billing } = parsed.data; + const targetCode = `${plan}_${billing === "annual" ? "annual" : "monthly"}`; + + // Resolve plan code → GUID. Plans are public, but pass the token so tenant + // overrides resolve correctly. + const plansRes = await fetch(gatewayUrl("/v1/plans"), { + cache: "no-store", + headers: { Accept: "application/json", Authorization: `Bearer ${token}` }, + }); + const plansJson = plansRes.ok + ? ((await plansRes.json().catch(() => null)) as { data?: V2Plan[] } | null) + : null; + const match = plansJson?.data?.find( + (p) => p.code?.toLowerCase() === targetCode + ); + + if (!match) { + return NextResponse.json( + { + error: + "This plan is not available yet. Please try again later or contact support.", + code: "PLAN_NOT_AVAILABLE", + }, + { status: 503 } + ); + } + + const purchaseRes = await fetch(gatewayUrl("/v1/users/me/plan/purchase"), { + method: "POST", + cache: "no-store", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ plan_id: match.id }), + }); + + if (!purchaseRes.ok) { + const err = (await purchaseRes.json().catch(() => null)) as { + message?: string; + } | null; + return NextResponse.json( + { error: err?.message ?? "Failed to start checkout." }, + { status: purchaseRes.status } + ); + } + + const result = (await purchaseRes.json().catch(() => null)) as { + redirect_url?: string; + payment_id?: string; + } | null; + + if (!result?.redirect_url) { + return NextResponse.json( + { error: "Payment gateway did not return a redirect URL." }, + { status: 502 } + ); + } + + return NextResponse.json({ url: result.redirect_url }); } diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts deleted file mode 100644 index b7146a1..0000000 --- a/src/app/api/webhooks/stripe/route.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { NextResponse } from "next/server"; -import type Stripe from "stripe"; - -import { isPaidPlanId, type PlanId } from "@/lib/plans"; -import { getStripe } from "@/lib/stripe"; -import { createAdminClient } from "@/lib/supabase/admin"; - -export const runtime = "nodejs"; - -function resolvePlanId(metadata: Stripe.Metadata | null): PlanId | null { - const planId = metadata?.planId; - if (planId && isPaidPlanId(planId)) { - return planId; - } - return null; -} - -async function upsertProfileFromSession(session: Stripe.Checkout.Session) { - const userId = session.client_reference_id ?? session.metadata?.userId; - - if (!userId) { - return; - } - - const plan = resolvePlanId(session.metadata); - if (!plan) { - return; - } - - const admin = createAdminClient(); - - const { error } = await admin.from("profiles").upsert( - { - id: userId, - email: session.customer_email ?? session.customer_details?.email ?? null, - plan, - billing_period: session.metadata?.billingPeriod ?? null, - stripe_customer_id: - typeof session.customer === "string" ? session.customer : null, - stripe_subscription_id: - typeof session.subscription === "string" - ? session.subscription - : null, - updated_at: new Date().toISOString(), - }, - { onConflict: "id" } - ); - - if (error) { - throw new Error(`Failed to update profile: ${error.message}`); - } -} - -export async function POST(request: Request) { - const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; - - if (!webhookSecret) { - return NextResponse.json( - { error: "Webhook secret not configured." }, - { status: 500 } - ); - } - - const signature = request.headers.get("stripe-signature"); - - if (!signature) { - return NextResponse.json( - { error: "Missing stripe-signature header." }, - { status: 400 } - ); - } - - const body = await request.text(); - const stripe = getStripe(); - - let event: Stripe.Event; - - try { - event = stripe.webhooks.constructEvent(body, signature, webhookSecret); - } catch (error) { - const message = - error instanceof Error ? error.message : "Webhook signature verification failed."; - return NextResponse.json({ error: message }, { status: 400 }); - } - - try { - switch (event.type) { - case "checkout.session.completed": { - const session = event.data.object as Stripe.Checkout.Session; - if (session.mode === "subscription") { - await upsertProfileFromSession(session); - } - break; - } - case "customer.subscription.deleted": { - const subscription = event.data.object as Stripe.Subscription; - const userId = subscription.metadata?.userId; - - if (userId) { - const admin = createAdminClient(); - await admin - .from("profiles") - .update({ - plan: "free", - billing_period: null, - stripe_subscription_id: null, - updated_at: new Date().toISOString(), - }) - .eq("id", userId); - } - break; - } - default: - break; - } - } catch (error) { - const message = - error instanceof Error ? error.message : "Webhook handler failed."; - return NextResponse.json({ error: message }, { status: 500 }); - } - - return NextResponse.json({ received: true }); -} diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts deleted file mode 100644 index 64a84ed..0000000 --- a/src/lib/stripe.ts +++ /dev/null @@ -1,20 +0,0 @@ -import Stripe from "stripe"; - -let stripeClient: Stripe | null = null; - -export function getStripe(): Stripe { - if (!stripeClient) { - const secretKey = process.env.STRIPE_SECRET_KEY; - - if (!secretKey) { - throw new Error("Missing STRIPE_SECRET_KEY environment variable."); - } - - stripeClient = new Stripe(secretKey, { - apiVersion: "2026-04-22.dahlia", - typescript: true, - }); - } - - return stripeClient; -}