import type { FastifyInstance } from 'fastify'; import type Stripe from 'stripe'; import { and, eq } from 'drizzle-orm'; import { getDb, firms, users, stripeEvents } from '@lawdesk/db'; import { env } from '../env'; import { getStripe } from '../lib/stripe'; import { sendEmail, planUpgradedEmail, paymentFailedEmail, subscriptionEndedEmail, } from '../lib/email'; import { logAudit } from '../lib/audit'; // Registered as a sub-app so its own buffer-only content-type parser doesn't affect the rest of // the API. Stripe webhooks need the raw request body to verify the signature. export async function stripeWebhookRoute(app: FastifyInstance) { app.removeContentTypeParser(['application/json']); app.addContentTypeParser('*', { parseAs: 'buffer' }, (_req, body, done) => done(null, body)); app.post('/api/webhooks/stripe', async (req, reply) => { if (!env.STRIPE_WEBHOOK_SECRET) { return reply.code(503).send({ error: 'webhook_not_configured' }); } const sig = req.headers['stripe-signature']; if (!sig || typeof sig !== 'string') { return reply.code(400).send({ error: 'missing_signature' }); } const stripe = getStripe(); let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(req.body as Buffer, sig, env.STRIPE_WEBHOOK_SECRET); } catch (err) { app.log.warn({ err }, 'stripe webhook signature verification failed'); return reply.code(400).send({ error: 'invalid_signature' }); } try { // Idempotency for Stripe's at-least-once delivery. Skip events we've already fully // processed so retries don't re-send emails or re-write audit rows. const [seen] = await getDb() .select({ id: stripeEvents.id }) .from(stripeEvents) .where(eq(stripeEvents.id, event.id)) .limit(1); if (seen) { app.log.info({ id: event.id, type: event.type }, 'stripe webhook duplicate event ignored'); return { received: true, duplicate: true }; } await handleEvent(event, app); // Record only AFTER successful processing: a transient handler failure (→ 500 → Stripe // retry) then re-processes instead of being skipped forever. applyPlan is idempotent, so // the narrow check-then-insert race on truly concurrent redeliveries is harmless. await getDb() .insert(stripeEvents) .values({ id: event.id, type: event.type }) .onConflictDoNothing(); } catch (err) { app.log.error({ err, type: event.type }, 'stripe webhook handler failed'); // Return 200 anyway for some failures? No — let Stripe retry on transient failures. return reply.code(500).send({ error: 'handler_failed' }); } return { received: true }; }); } async function handleEvent(event: Stripe.Event, app: FastifyInstance) { switch (event.type) { case 'checkout.session.completed': { const session = event.data.object as Stripe.Checkout.Session; const firmId = session.client_reference_id ?? (session.metadata?.firmId as string | undefined); const planFromMeta = (session.metadata?.plan ?? '') as 'pro' | 'lifetime' | ''; if (!firmId) return app.log.warn({ session: session.id }, 'checkout.session.completed without firmId'); // Determine plan from session.mode if metadata didn't pin it. const plan: 'pro' | 'lifetime' = planFromMeta || (session.mode === 'subscription' ? 'pro' : 'lifetime'); const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id ?? null; const subscriptionId = typeof session.subscription === 'string' ? session.subscription : session.subscription?.id ?? null; await applyPlan(firmId, plan, { customerId, subscriptionId }); // Fire-and-forget: an email failure must not throw out of the handler (→ 500 → Stripe // redelivery → duplicate processing). The plan (DB writes above) is already applied. sendPlanUpgradedNotice(firmId, plan).catch((err) => app.log.warn({ err, firmId }, 'plan upgraded email failed'), ); break; } case 'customer.subscription.updated': case 'customer.subscription.created': { const sub = event.data.object as Stripe.Subscription; const firmId = (sub.metadata?.firmId as string | undefined) ?? null; if (!firmId) return; // Only flip to 'pro' while the subscription is paying. const active = ['active', 'trialing', 'past_due'].includes(sub.status); if (active) await applyPlan(firmId, 'pro', { subscriptionId: sub.id }); break; } case 'customer.subscription.deleted': { const sub = event.data.object as Stripe.Subscription; const firmId = (sub.metadata?.firmId as string | undefined) ?? null; if (!firmId) return; await applyPlan(firmId, 'starter', { subscriptionId: null }); for (const u of await firmOwners(firmId)) { const tpl = subscriptionEndedEmail(u.fullName); sendEmail({ to: u.email, ...tpl }).catch((err) => app.log.warn({ err, firmId }, 'subscription ended email failed'), ); } break; } case 'invoice.payment_failed': { const invoice = event.data.object as Stripe.Invoice; app.log.warn({ invoice: invoice.id, customer: invoice.customer }, 'stripe invoice payment failed'); const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id; if (!customerId) break; const [firm] = await getDb() .select({ id: firms.id }) .from(firms) .where(eq(firms.stripeCustomerId, customerId)) .limit(1); if (!firm) break; const amount = invoice.amount_due ? new Intl.NumberFormat('en-US', { style: 'currency', currency: (invoice.currency ?? 'usd').toUpperCase(), }).format(invoice.amount_due / 100) : null; for (const u of await firmOwners(firm.id)) { const tpl = paymentFailedEmail(u.fullName, amount); sendEmail({ to: u.email, ...tpl }).catch((err) => app.log.warn({ err, firmId: firm.id }, 'payment failed email failed'), ); } break; } default: // Ignore — Stripe sends many event types we don't care about. break; } } async function applyPlan( firmId: string, plan: 'starter' | 'pro' | 'lifetime', ids: { customerId?: string | null; subscriptionId?: string | null } = {}, ) { const patch: Record = { plan, watermarkEnabled: plan === 'starter', updatedAt: new Date(), }; if (ids.customerId !== undefined) patch.stripeCustomerId = ids.customerId; if (ids.subscriptionId !== undefined) patch.stripeSubscriptionId = ids.subscriptionId; await getDb().update(firms).set(patch).where(eq(firms.id, firmId)); await logAudit({ firmId, action: `billing.plan.${plan}`, meta: { stripeCustomerId: ids.customerId, stripeSubscriptionId: ids.subscriptionId }, }); } // Billing emails go to owners only — staff shouldn't get payment notices. async function firmOwners(firmId: string) { return getDb() .select({ email: users.email, fullName: users.fullName }) .from(users) .where(and(eq(users.firmId, firmId), eq(users.role, 'owner'))); } async function sendPlanUpgradedNotice(firmId: string, plan: 'pro' | 'lifetime') { const label = plan === 'pro' ? 'Professional' : 'Lifetime'; for (const u of await firmOwners(firmId)) { const tpl = planUpgradedEmail(u.fullName, label); await sendEmail({ to: u.email, ...tpl }); } }