From 0f2e4518eca5f1bd25e95229c4bfdd6b6b2ce1ef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 18 May 2026 20:07:18 +0000 Subject: [PATCH] fix: harden Stripe webhook handler and document live endpoint setup - Require STRIPE_WEBHOOK_SECRET separately from signature header - Force Node.js runtime; log event id/type for Vercel debugging - Return 200 for unhandled event types; clearer 503 when env missing - Document repofuse.com webhook URL and env vars in VERCEL_SETUP Co-authored-by: Cole Collins --- .env.example | 12 +- VERCEL_SETUP.md | 15 ++ app/api/stripe/webhook/route.ts | 380 +++++++++++++------------------- 3 files changed, 181 insertions(+), 226 deletions(-) diff --git a/.env.example b/.env.example index e25292d..025682a 100644 --- a/.env.example +++ b/.env.example @@ -8,8 +8,16 @@ GITHUB_CLIENT_ID=0v23li58m3t8TIbfIr8A NEXT_PUBLIC_GITHUB_CLIENT_ID=Ov231iS8m3t8TIbfIr8A GITHUB_CLIENT_SECRET=your_github_oauth_client_secret -# Public URL of your app (used for OAuth callback redirect) -NEXT_PUBLIC_APP_URL=https://repo-app-architect.vercel.app +# Public URL of your app (used for OAuth + Stripe redirects). Use your custom domain in production. +NEXT_PUBLIC_APP_URL=https://repofuse.com + +# Stripe (live mode for production) +STRIPE_SECRET_KEY=sk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... +# Signing secret from: Stripe Dashboard → Developers → Webhooks → your endpoint → Signing secret +# Endpoint URL must be: https://repofuse.com/api/stripe/webhook (lowercase host is fine) +STRIPE_PRO_PRICE_ID=price_... +STRIPE_SCALE_PRICE_ID=price_... # OpenAI API Key (used by Vercel AI SDK for analysis) OPENAI_API_KEY=sk-... diff --git a/VERCEL_SETUP.md b/VERCEL_SETUP.md index d899412..079a6b0 100644 --- a/VERCEL_SETUP.md +++ b/VERCEL_SETUP.md @@ -17,6 +17,21 @@ Go to your Vercel project → **Settings** → **Environment Variables** and add | `NEXT_PUBLIC_APP_URL` | Preview | Leave blank — Vercel sets this automatically for previews | | `OPENAI_API_KEY` | Production, Preview | OpenAI API key for AI analysis | | `ANTHROPIC_API_KEY` | Production, Preview | Anthropic API key for scaffold generation | +| `STRIPE_SECRET_KEY` | Production | Live secret key (`sk_live_...`) | +| `STRIPE_WEBHOOK_SECRET` | Production | Signing secret (`whsec_...`) from the **live** webhook endpoint | +| `STRIPE_PRO_PRICE_ID` | Production | Pro plan Price ID | +| `STRIPE_SCALE_PRICE_ID` | Production | Scale plan Price ID (optional) | + +### Stripe webhooks (live mode) + +1. [Stripe Dashboard → Developers → Webhooks](https://dashboard.stripe.com/webhooks) (ensure **Live** mode toggle is on). +2. Endpoint URL: `https://repofuse.com/api/stripe/webhook` + (`https://RepoFuse.com/...` works too; hostnames are case-insensitive.) +3. Subscribe to at least: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_succeeded`, `invoice.payment_failed`. +4. Copy **Signing secret** → Vercel env `STRIPE_WEBHOOK_SECRET` for **Production** only. +5. Redeploy after changing `STRIPE_WEBHOOK_SECRET`. + +**If Stripe emails about failed deliveries:** open the webhook → **Event deliveries** and check the HTTP status. `400 Invalid signature` means `STRIPE_WEBHOOK_SECRET` does not match that endpoint’s signing secret. `503 Webhook not configured` means the env var is missing on Vercel. --- diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index eddca3e..aacd3db 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -1,252 +1,184 @@ -import { NextRequest, NextResponse } from "next/server"; -import Stripe from "stripe"; -import { createClient } from "@supabase/supabase-js"; - -// ─── Clients ────────────────────────────────────────────────────────────────── - -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: "2024-11-20.acacia", -}); - -// Use the service role key so we can write to Supabase from the server -const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY! -); - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function getPlanFromPriceId(priceId: string): string { - const priceMap: Record = { - [process.env.STRIPE_PRICE_PRO_MONTHLY || ""]: "pro", - [process.env.STRIPE_PRICE_PRO_YEARLY || ""]: "pro", - [process.env.STRIPE_PRICE_STARTER_MONTHLY || ""]: "starter", - }; - return priceMap[priceId] || "free"; -} - -// ─── Webhook Handler ────────────────────────────────────────────────────────── - -export async function POST(req: NextRequest) { - // 1. Get the raw body — required for Stripe signature verification - const body = await req.text(); - const sig = req.headers.get("stripe-signature"); +import { NextRequest, NextResponse } from 'next/server' +import { getStripe } from '@/lib/stripe' +import { upsertSubscription, getSubscriptionByStripeCustomerId, getUserByGithubId } from '@/lib/queries' +import { grantCredits, CREDITS } from '@/lib/credits' +import type Stripe from 'stripe' + +/** Stripe SDK requires Node.js (not Edge). */ +export const runtime = 'nodejs' + +const HANDLED_EVENTS = new Set([ + 'checkout.session.completed', + 'customer.subscription.updated', + 'customer.subscription.deleted', + 'invoice.payment_failed', + 'invoice.payment_succeeded', +]) + +export async function POST(request: NextRequest) { + const signature = request.headers.get('stripe-signature') + + if (!process.env.STRIPE_WEBHOOK_SECRET || !process.env.STRIPE_SECRET_KEY) { + console.error('[stripe/webhook] Missing STRIPE_WEBHOOK_SECRET or STRIPE_SECRET_KEY in environment') + return NextResponse.json({ error: 'Webhook not configured' }, { status: 503 }) + } - if (!sig) { - console.error("⚠️ Missing stripe-signature header"); - return NextResponse.json({ error: "Missing signature" }, { status: 400 }); + if (!signature) { + return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 }) } - if (!process.env.STRIPE_WEBHOOK_SECRET) { - console.error("⚠️ STRIPE_WEBHOOK_SECRET is not set in environment variables"); - return NextResponse.json({ error: "Webhook secret not configured" }, { status: 500 }); + let body: string + try { + body = await request.text() + } catch (err) { + console.error('[stripe/webhook] Failed to read request body:', err) + return NextResponse.json({ error: 'Invalid body' }, { status: 400 }) } - // 2. Verify the event actually came from Stripe - let event: Stripe.Event; + let stripe: ReturnType try { - event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "Unknown error"; - console.error(`⚠️ Webhook signature verification failed: ${message}`); - return NextResponse.json({ error: `Webhook Error: ${message}` }, { status: 400 }); + stripe = getStripe() + } catch (err) { + console.error('[stripe/webhook] Stripe client init failed:', err) + return NextResponse.json({ error: 'Stripe not configured' }, { status: 503 }) } - console.log(`✅ Stripe webhook received: ${event.type}`); + let event: Stripe.Event - // 3. Handle each event type try { - switch (event.type) { + event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET) + } catch (err) { + console.error('[stripe/webhook] Signature verification failed:', err) + return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }) + } - // ── Checkout completed (first subscription purchase) ────────────────── - case "checkout.session.completed": { - const session = event.data.object as Stripe.Checkout.Session; - const customerId = session.customer as string; - const subscriptionId = session.subscription as string; - const customerEmail = session.customer_email || session.customer_details?.email; - - if (!customerEmail) break; - - await supabase - .from("subscriptions") - .upsert({ - stripe_customer_id: customerId, - stripe_subscription_id: subscriptionId, - email: customerEmail, - status: "active", - updated_at: new Date().toISOString(), - }, { onConflict: "email" }); - - // Also update the user's plan in the profiles table - await supabase - .from("profiles") - .update({ - stripe_customer_id: customerId, - subscription_status: "active", - }) - .eq("email", customerEmail); + console.log('[stripe/webhook] Received event', { id: event.id, type: event.type }) - console.log(`✅ Checkout completed for ${customerEmail}`); - break; - } + if (!HANDLED_EVENTS.has(event.type)) { + return NextResponse.json({ received: true, ignored: event.type }) + } - // ── Subscription created ─────────────────────────────────────────────── - case "customer.subscription.created": { - const subscription = event.data.object as Stripe.Subscription; - const customerId = subscription.customer as string; - const priceId = subscription.items.data[0]?.price.id; - const plan = getPlanFromPriceId(priceId); - - // Get customer email from Stripe - const customer = await stripe.customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; - - if (!email) break; - - await supabase - .from("subscriptions") - .upsert({ - stripe_customer_id: customerId, - stripe_subscription_id: subscription.id, - email, - status: subscription.status, - plan, - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), - updated_at: new Date().toISOString(), - }, { onConflict: "stripe_subscription_id" }); - - await supabase - .from("profiles") - .update({ plan, subscription_status: subscription.status }) - .eq("email", email); - - console.log(`✅ Subscription created: ${email} → ${plan}`); - break; + try { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session + if (session.mode === 'subscription' && session.customer && session.subscription) { + const sub = await stripe.subscriptions.retrieve(session.subscription as string) + const githubId = Number(sub.metadata.github_id || session.metadata?.github_id) + const periodEnd = (sub as unknown as { current_period_end?: number }).current_period_end + if (githubId) { + await upsertSubscription({ + github_id: githubId, + stripe_customer_id: session.customer as string, + stripe_subscription_id: sub.id, + plan: 'pro', + status: 'active', + current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null, + }) + + try { + const user = await getUserByGithubId(githubId) + if (user) { + await grantCredits( + user.id, + CREDITS.INITIAL_GRANT, + 'Pro plan signup bonus', + { stripe_customer_id: session.customer as string }, + ) + console.log(`[stripe/webhook] Granted ${CREDITS.INITIAL_GRANT} signup credits to ${user.id}`) + } + } catch (err) { + console.error('[stripe/webhook] Failed to grant signup credits (subscription saved):', err) + } + } else { + console.warn('[stripe/webhook] checkout.session.completed missing github_id metadata', { + sessionId: session.id, + }) + } + } + break } - // ── Subscription updated (plan change, renewal, etc.) ───────────────── - case "customer.subscription.updated": { - const subscription = event.data.object as Stripe.Subscription; - const customerId = subscription.customer as string; - const priceId = subscription.items.data[0]?.price.id; - const plan = getPlanFromPriceId(priceId); - - const customer = await stripe.customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; - - if (!email) break; - - await supabase - .from("subscriptions") - .update({ - status: subscription.status, - plan, - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), - updated_at: new Date().toISOString(), + case 'customer.subscription.updated': { + const sub = event.data.object as Stripe.Subscription + const existing = await getSubscriptionByStripeCustomerId(sub.customer as string) + const periodEnd = (sub as { current_period_end?: number }).current_period_end + if (existing) { + const isPro = sub.status === 'active' || sub.status === 'trialing' + await upsertSubscription({ + github_id: existing.github_id, + plan: isPro ? 'pro' : 'free', + status: + sub.status === 'active' + ? 'active' + : sub.status === 'past_due' + ? 'past_due' + : sub.status === 'trialing' + ? 'trialing' + : 'canceled', + current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null, }) - .eq("stripe_subscription_id", subscription.id); - - await supabase - .from("profiles") - .update({ plan, subscription_status: subscription.status }) - .eq("email", email); - - console.log(`✅ Subscription updated: ${email} → ${plan} (${subscription.status})`); - break; + } + break } - // ── Subscription cancelled/deleted ──────────────────────────────────── - case "customer.subscription.deleted": { - const subscription = event.data.object as Stripe.Subscription; - const customerId = subscription.customer as string; - - const customer = await stripe.customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; - - if (!email) break; - - await supabase - .from("subscriptions") - .update({ - status: "canceled", - plan: "free", - updated_at: new Date().toISOString(), + case 'customer.subscription.deleted': { + const sub = event.data.object as Stripe.Subscription + const existing = await getSubscriptionByStripeCustomerId(sub.customer as string) + if (existing) { + await upsertSubscription({ + github_id: existing.github_id, + plan: 'free', + status: 'canceled', }) - .eq("stripe_subscription_id", subscription.id); - - await supabase - .from("profiles") - .update({ plan: "free", subscription_status: "canceled" }) - .eq("email", email); - - console.log(`✅ Subscription cancelled: ${email}`); - break; + } + break } - // ── Invoice paid (recurring renewal) ───────────────────────────────── - case "invoice.payment_succeeded": { - const invoice = event.data.object as Stripe.Invoice; - const subscriptionId = invoice.subscription as string; - - if (!subscriptionId) break; - - // Update period end on renewal - const subscription = await stripe.subscriptions.retrieve(subscriptionId); - await supabase - .from("subscriptions") - .update({ - status: "active", - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), - updated_at: new Date().toISOString(), - }) - .eq("stripe_subscription_id", subscriptionId); - - console.log(`✅ Invoice paid for subscription: ${subscriptionId}`); - break; + case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice + if (invoice.customer) { + const existing = await getSubscriptionByStripeCustomerId(invoice.customer as string) + if (existing) { + await upsertSubscription({ + github_id: existing.github_id, + status: 'past_due', + }) + } + } + break } - // ── Invoice payment failed ──────────────────────────────────────────── - case "invoice.payment_failed": { - const invoice = event.data.object as Stripe.Invoice; - const customerId = invoice.customer as string; - - const customer = await stripe.customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; - - if (!email) break; - - await supabase - .from("subscriptions") - .update({ - status: "past_due", - updated_at: new Date().toISOString(), - }) - .eq("stripe_customer_id", customerId); - - await supabase - .from("profiles") - .update({ subscription_status: "past_due" }) - .eq("email", email); - - console.log(`⚠️ Payment failed for: ${email}`); - break; + case 'invoice.payment_succeeded': { + const invoice = event.data.object as Stripe.Invoice + if (invoice.customer) { + const existing = await getSubscriptionByStripeCustomerId(invoice.customer as string) + if (existing) { + try { + if (invoice.number && invoice.number !== '0001') { + const user = await getUserByGithubId(existing.github_id) + if (user) { + await grantCredits( + user.id, + CREDITS.MONTHLY_GRANT, + 'Monthly subscription renewal', + { invoice_id: invoice.id, stripe_customer_id: invoice.customer as string }, + ) + console.log(`[stripe/webhook] Granted ${CREDITS.MONTHLY_GRANT} renewal credits to ${user.id}`) + } + } + } catch (err) { + console.error('[stripe/webhook] Failed to grant renewal credits:', err) + } + } + } + break } - - default: - console.log(`ℹ️ Unhandled event type: ${event.type}`); } - } catch (error) { - console.error("❌ Error processing webhook event:", error); - // Still return 200 so Stripe doesn't keep retrying for logic errors - return NextResponse.json({ received: true, warning: "Processing error" }, { status: 200 }); + } catch (err) { + console.error('[stripe/webhook] Handler error:', { eventId: event.id, type: event.type, err }) + return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 }) } - // 4. Always return 200 so Stripe knows we received the event - return NextResponse.json({ received: true }, { status: 200 }); + return NextResponse.json({ received: true }) } - -// ─── Block all other HTTP methods ───────────────────────────────────────────── -// This prevents the 405 error Stripe was getting before -export async function GET() { - return NextResponse.json({ error: "Method not allowed" }, { status: 405 }); -} \ No newline at end of file