From 2e53c3738e7c421f5ddb9982d490191d20e68bbb Mon Sep 17 00:00:00 2001 From: Eddie Belaval Date: Thu, 2 Apr 2026 18:03:15 -0400 Subject: [PATCH] feat: Wire Stripe checkout and webhook for Pro/Elite subscriptions New API routes (Next.js, no Python backend dependency): - /api/checkout: Creates Stripe checkout session for Pro ($19) or Elite ($49) - /api/webhooks/stripe: Handles subscription lifecycle events Implementation: - Looks up prices by product ID at runtime (no price ID config needed) - Product IDs hardcoded: prod_Ta3YZOPq86bM2J (Pro), prod_Ta3aXZCqBcoFnI (Elite) - Creates Stripe customer on first checkout, links to Supabase profile - Webhook updates subscription_tier, subscription_status in profiles table - Handles: checkout.session.completed, subscription.updated/deleted, invoice.payment_failed - Uses Supabase service role key for webhook (no user session available) Also: - Updated pricing page to call /api/checkout (was calling Python backend) - Added stripe server SDK to package.json - Updated .env.example with all required Stripe + Supabase vars Requires Vercel env vars: STRIPE_SECRET_KEY, NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, SUPABASE_SERVICE_ROLE_KEY, STRIPE_WEBHOOK_SECRET (after deploy) Co-Authored-By: Claude Opus 4.6 (1M context) --- web/.env.example | 9 +- web/package.json | 1 + web/src/app/api/checkout/route.ts | 90 +++++++++++++++++ web/src/app/api/webhooks/stripe/route.ts | 123 +++++++++++++++++++++++ web/src/app/pricing/page.tsx | 2 +- 5 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 web/src/app/api/checkout/route.ts create mode 100644 web/src/app/api/webhooks/stripe/route.ts diff --git a/web/.env.example b/web/.env.example index dbbb593b..5936479f 100644 --- a/web/.env.example +++ b/web/.env.example @@ -41,5 +41,12 @@ OLLAMA_EMBED_MODEL=nomic-embed-text # Get this from https://perplexity.ai/ PERPLEXITY_API_KEY= -# Stripe (payments - optional) +# Stripe (payments) NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRODUCT_PRO=prod_Ta3YZOPq86bM2J +STRIPE_PRODUCT_ELITE=prod_Ta3aXZCqBcoFnI + +# Supabase Service Role (for webhook updates - server-side only) +SUPABASE_SERVICE_ROLE_KEY= diff --git a/web/package.json b/web/package.json index 1a0263cc..350eafc6 100644 --- a/web/package.json +++ b/web/package.json @@ -62,6 +62,7 @@ "@sentry/nextjs": "^10.29.0", "@serwist/next": "^9.2.3", "@stripe/stripe-js": "^6.1.0", + "stripe": "^17.7.0", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.86.0", "@tanstack/react-query": "^5.90.3", diff --git a/web/src/app/api/checkout/route.ts b/web/src/app/api/checkout/route.ts new file mode 100644 index 00000000..a1c6c5a6 --- /dev/null +++ b/web/src/app/api/checkout/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server'; +import Stripe from 'stripe'; +import { createClient } from '@/lib/supabase/server'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: '2025-04-30.basil', +}); + +// Use product IDs -- prices are looked up automatically +const PRODUCT_MAP: Record = { + pro: process.env.STRIPE_PRODUCT_PRO || 'prod_Ta3YZOPq86bM2J', + elite: process.env.STRIPE_PRODUCT_ELITE || 'prod_Ta3aXZCqBcoFnI', +}; + +async function getPriceForProduct(productId: string): Promise { + const prices = await stripe.prices.list({ + product: productId, + active: true, + type: 'recurring', + limit: 1, + }); + if (!prices.data.length) { + throw new Error(`No active recurring price found for product ${productId}`); + } + return prices.data[0].id; +} + +export async function POST(req: NextRequest) { + try { + const { tier, user_id, user_email } = await req.json(); + + if (!tier || !PRODUCT_MAP[tier]) { + return NextResponse.json({ error: 'Invalid tier' }, { status: 400 }); + } + + if (!user_id || !user_email) { + return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }); + } + + const supabase = await createClient(); + + // Check if user already has a Stripe customer ID + const { data: profile } = await supabase + .from('profiles') + .select('stripe_customer_id') + .eq('id', user_id) + .single(); + + let customerId = profile?.stripe_customer_id; + + // Create Stripe customer if none exists + if (!customerId) { + const customer = await stripe.customers.create({ + email: user_email, + metadata: { supabase_user_id: user_id }, + }); + customerId = customer.id; + + await supabase + .from('profiles') + .update({ stripe_customer_id: customerId }) + .eq('id', user_id); + } + + // Look up price from product ID + const priceId = await getPriceForProduct(PRODUCT_MAP[tier]); + + // Create checkout session + const session = await stripe.checkout.sessions.create({ + customer: customerId, + mode: 'subscription', + payment_method_types: ['card'], + line_items: [{ price: priceId, quantity: 1 }], + success_url: `${req.nextUrl.origin}/app?upgraded=${tier}`, + cancel_url: `${req.nextUrl.origin}/pricing`, + metadata: { + supabase_user_id: user_id, + tier, + }, + }); + + return NextResponse.json({ url: session.url }); + } catch (error) { + console.error('Checkout error:', error); + return NextResponse.json( + { error: 'Failed to create checkout session' }, + { status: 500 } + ); + } +} diff --git a/web/src/app/api/webhooks/stripe/route.ts b/web/src/app/api/webhooks/stripe/route.ts new file mode 100644 index 00000000..d5749a2d --- /dev/null +++ b/web/src/app/api/webhooks/stripe/route.ts @@ -0,0 +1,123 @@ +import { NextRequest, NextResponse } from 'next/server'; +import Stripe from 'stripe'; +import { createClient as createSupabaseAdmin } from '@supabase/supabase-js'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: '2025-04-30.basil', +}); + +const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; + +// Use service role for webhook (no user session available) +function getAdminClient() { + return createSupabaseAdmin( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ); +} + +const PRODUCT_TO_TIER: Record = { + [process.env.STRIPE_PRODUCT_PRO || 'prod_Ta3YZOPq86bM2J']: 'pro', + [process.env.STRIPE_PRODUCT_ELITE || 'prod_Ta3aXZCqBcoFnI']: 'elite', +}; + +async function updateSubscription( + customerId: string, + tier: string, + status: string, + subscriptionId?: string, + endsAt?: string, +) { + const supabase = getAdminClient(); + + const updates: Record = { + subscription_tier: tier, + subscription_status: status, + }; + + if (subscriptionId) updates.stripe_subscription_id = subscriptionId; + if (status === 'active') updates.subscription_starts_at = new Date().toISOString(); + if (endsAt) updates.subscription_ends_at = endsAt; + + const { error } = await supabase + .from('profiles') + .update(updates) + .eq('stripe_customer_id', customerId); + + if (error) { + console.error('Failed to update subscription:', error); + throw error; + } + + console.log(`Subscription updated: customer=${customerId} tier=${tier} status=${status}`); +} + +export async function POST(req: NextRequest) { + const body = await req.text(); + const signature = req.headers.get('stripe-signature'); + + if (!signature) { + return NextResponse.json({ error: 'Missing signature' }, { status: 400 }); + } + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent(body, signature, webhookSecret); + } catch (err) { + console.error('Webhook signature verification failed:', err); + return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }); + } + + try { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session; + const customerId = session.customer as string; + const tier = session.metadata?.tier || 'pro'; + const subscriptionId = session.subscription as string; + + await updateSubscription(customerId, tier, 'active', subscriptionId); + break; + } + + case 'customer.subscription.updated': { + const sub = event.data.object as Stripe.Subscription; + const customerId = sub.customer as string; + const productId = sub.items.data[0]?.price.product as string; + const tier = PRODUCT_TO_TIER[productId] || 'pro'; + const status = sub.status === 'active' ? 'active' : 'past_due'; + + await updateSubscription(customerId, tier, status, sub.id); + break; + } + + case 'customer.subscription.deleted': { + const sub = event.data.object as Stripe.Subscription; + const customerId = sub.customer as string; + const endsAt = new Date(sub.current_period_end * 1000).toISOString(); + + await updateSubscription(customerId, 'free', 'canceled', sub.id, endsAt); + break; + } + + case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice; + const customerId = invoice.customer as string; + + await updateSubscription(customerId, 'pro', 'past_due'); + console.warn(`Payment failed for customer ${customerId}`); + break; + } + + default: + // Ignore other event types + break; + } + + return NextResponse.json({ received: true }); + } catch (error) { + console.error('Webhook handler error:', error); + return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 }); + } +} diff --git a/web/src/app/pricing/page.tsx b/web/src/app/pricing/page.tsx index 94a1d7ed..3d153591 100644 --- a/web/src/app/pricing/page.tsx +++ b/web/src/app/pricing/page.tsx @@ -83,7 +83,7 @@ export default function PricingPage() { try { // Call backend to create checkout session - const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/checkout/create-session`, { + const response = await fetch('/api/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({