From fa761760087cacfea0e431971b23fde8730fe3ca Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sun, 17 May 2026 03:10:47 -0700 Subject: [PATCH] feat(billing): P1e per-space Stripe webhook + dues->status automation app/api/stripe/webhook/[space]/route.ts (nodejs, force-dynamic). Raw req.text() body; loads THIS space's secret + signing secret from the vault; constructEvent verifies signature; idempotency via the stripe_webhook_events PK (replay -> 200). Handles checkout.session. completed / customer.subscription.* / invoice.paid|payment_succeeded: upserts member_billing, records a stripe payments row, and maps status via duesMemberStatus+graceExceeded onto space_members (only current<-> late, never resurrect inactive / auto-approve unverified; last_paid_at on paid). Member resolved by metadata then customer-id, always space-scoped. Bad sig/config -> 400; handler error -> 500 (Stripe retries, idempotency-safe). --- app/api/stripe/webhook/[space]/route.ts | 191 ++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 app/api/stripe/webhook/[space]/route.ts diff --git a/app/api/stripe/webhook/[space]/route.ts b/app/api/stripe/webhook/[space]/route.ts new file mode 100644 index 0000000..73e5af4 --- /dev/null +++ b/app/api/stripe/webhook/[space]/route.ts @@ -0,0 +1,191 @@ +// Per-space Stripe webhook (per-space OWN keys, NOT Connect — so we route by +// path to know which signing secret to verify with). Raw body, signature +// verify, idempotency on Stripe's stable event id, then map the subscription +// lifecycle onto member_billing + member status (grace -> late, never auto- +// inactive). Unauthenticated by design (Stripe calls it); there is no auth +// middleware in this app, and every DB write is post-signature-verify. +import { NextRequest, NextResponse } from 'next/server' +import type Stripe from 'stripe' +import { createAdminClient } from '@/lib/supabase/admin' +import { getStripe } from '@/lib/stripe/client' +import { getStripeConfig, readStripeSecret } from '@/lib/stripe/config' +import { duesMemberStatus, graceExceeded } from '@/lib/stripe-logic' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +function isoFromUnix(s: number | null | undefined): string | null { + return typeof s === 'number' && s > 0 ? new Date(s * 1000).toISOString() : null +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ space: string }> }, +) { + const { space: spaceId } = await params + const admin = createAdminClient() + + const cfg = await getStripeConfig(admin, spaceId) + const secret = cfg ? await readStripeSecret(admin, spaceId, cfg.secret_key_ref) : null + const webhookSecret = cfg ? await readStripeSecret(admin, spaceId, cfg.webhook_secret_ref) : null + if (!secret || !webhookSecret) { + return NextResponse.json({ error: 'Stripe not configured for this space' }, { status: 400 }) + } + + const raw = await req.text() + const sig = req.headers.get('stripe-signature') + if (!sig) return NextResponse.json({ error: 'Missing signature' }, { status: 400 }) + + const stripe = getStripe(secret) + let event: Stripe.Event + try { + event = stripe.webhooks.constructEvent(raw, sig, webhookSecret) + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : 'Invalid signature' }, + { status: 400 }, + ) + } + + // Idempotency: the unique PK rejects a replay; treat that as already-done. + const dedupe = await admin + .from('stripe_webhook_events') + .insert({ event_id: event.id, space_id: spaceId, type: event.type }) + if (dedupe.error) { + if (/duplicate key|already exists|unique/i.test(dedupe.error.message)) { + return NextResponse.json({ received: true, duplicate: true }) + } + return NextResponse.json({ error: dedupe.error.message }, { status: 500 }) + } + + const graceDays = cfg?.grace_days ?? 7 + + // Resolve our member: prefer metadata (set on the subscription + session), + // else map the Stripe customer id back via member_billing. Always confirm + // the member is in THIS space. + async function resolveMemberId( + metaMemberId: string | undefined, + customerId: string | null | undefined, + ): Promise { + if (metaMemberId) { + const { data } = await admin + .from('space_members') + .select('id') + .eq('id', metaMemberId) + .eq('space_id', spaceId) + .maybeSingle() + if (data) return data.id as string + } + if (customerId) { + const { data } = await admin + .from('member_billing') + .select('member_id') + .eq('space_id', spaceId) + .eq('stripe_customer_id', customerId) + .maybeSingle() + if (data) return data.member_id as string + } + return null + } + + async function applySubscription(sub: Stripe.Subscription) { + const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id ?? null + const memberId = await resolveMemberId( + (sub.metadata?.member_id as string | undefined) || undefined, + customerId, + ) + if (!memberId) return + const periodEnd = isoFromUnix((sub as unknown as { current_period_end?: number }).current_period_end) + + await admin.from('member_billing').upsert( + { + space_id: spaceId, + member_id: memberId, + stripe_customer_id: customerId, + stripe_subscription_id: sub.id, + subscription_status: sub.status, + current_period_end: periodEnd, + updated_at: new Date().toISOString(), + }, + { onConflict: 'space_id,member_id' }, + ) + + const desired = duesMemberStatus(sub.status, graceExceeded(periodEnd, graceDays)) + if (desired) { + // Only move between current<->late; never resurrect inactive or + // auto-approve unverified via billing. + const patch: Record = { status: desired } + if (desired === 'current') patch.last_paid_at = new Date().toISOString() + await admin + .from('space_members') + .update(patch) + .eq('id', memberId) + .eq('space_id', spaceId) + .in('status', ['current', 'late']) + } + } + + try { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session + const subId = typeof session.subscription === 'string' ? session.subscription : session.subscription?.id + if (subId) { + const sub = await stripe.subscriptions.retrieve(subId) + await applySubscription(sub) + } + break + } + case 'customer.subscription.created': + case 'customer.subscription.updated': + case 'customer.subscription.deleted': { + await applySubscription(event.data.object as Stripe.Subscription) + break + } + case 'invoice.paid': + case 'invoice.payment_succeeded': { + const inv = event.data.object as Stripe.Invoice + const customerId = typeof inv.customer === 'string' ? inv.customer : inv.customer?.id ?? null + const memberId = await resolveMemberId( + (inv.subscription_details?.metadata?.member_id as string | undefined) || undefined, + customerId, + ) + await admin.from('payments').insert({ + space_id: spaceId, + member_id: memberId, + platform: 'stripe', + amount: (inv.amount_paid ?? 0) / 100, + currency: (inv.currency ?? 'usd').toUpperCase(), + description: inv.description ?? 'Stripe membership dues', + status: memberId ? 'linked' : 'unlinked', + link_status: memberId ? 'linked' : 'unlinked', + external_id: inv.id, + payer_email: inv.customer_email ?? null, + from_identifier: inv.customer_email ?? null, + transaction_date: new Date().toISOString(), + raw_data: { event_id: event.id, invoice: inv.id }, + }) + if (memberId) { + await admin + .from('space_members') + .update({ last_paid_at: new Date().toISOString() }) + .eq('id', memberId) + .eq('space_id', spaceId) + .in('status', ['current', 'late']) + } + break + } + // invoice.payment_failed: the subsequent customer.subscription.updated + // (status past_due) drives the lapse logic; nothing to record here. + default: + break + } + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : 'handler error' }, + { status: 500 }, + ) + } + + return NextResponse.json({ received: true }) +}