import { createDb, eq, organizations } from '@bmm/db'; import type { FastifyInstance } from 'fastify'; import type Stripe from 'stripe'; import { z } from 'zod'; import { config } from '../config.js'; import { audit } from '../lib/audit.js'; import { type PriceTier, isDuplicateEvent, planFromPriceId, priceIdForTier, stripe, } from '../lib/stripe.js'; import { requireAuth } from '../plugins/session.js'; const db = createDb(); const TierBody = z.object({ tier: z.enum(['pro_monthly', 'pro_yearly', 'team_monthly', 'team_yearly']), }); export async function billingRoutes(app: FastifyInstance): Promise { // ─── Checkout ──────────────────────────────────────────────────────────── app.post('/v1/billing/checkout-session', { preHandler: requireAuth }, async (req, reply) => { if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' }); const user = req.user!; const parsed = TierBody.safeParse(req.body); if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); const priceId = priceIdForTier(parsed.data.tier as PriceTier); if (!priceId) { return reply.code(503).send({ error: 'price_not_configured', tier: parsed.data.tier }); } const [org] = await db .select({ stripeCustomerId: organizations.stripeCustomerId }) .from(organizations) .where(eq(organizations.id, user.orgId)) .limit(1); if (!org) return reply.code(404).send({ error: 'org_not_found' }); try { const session = await stripe.checkout.sessions.create({ mode: 'subscription', payment_method_types: ['card', 'sepa_debit'], line_items: [{ price: priceId, quantity: 1 }], // Reuse Stripe customer if we have one — keeps invoices on one account // even when the user upgrades/downgrades repeatedly. ...(org.stripeCustomerId ? { customer: org.stripeCustomerId } : { customer_email: user.email ?? undefined }), client_reference_id: user.orgId, metadata: { orgId: user.orgId, userId: user.userId, tier: parsed.data.tier }, subscription_data: { metadata: { orgId: user.orgId, userId: user.userId }, }, success_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?success=true`, cancel_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?cancelled=true`, automatic_tax: { enabled: true }, tax_id_collection: { enabled: true }, billing_address_collection: 'required', allow_promotion_codes: true, }); await audit({ orgId: user.orgId, userId: user.userId, action: 'billing.checkout_initiated', resourceType: 'subscription', metadata: { tier: parsed.data.tier }, ipAddress: req.ip, }); return reply.send({ url: session.url, sessionId: session.id }); } catch (err) { app.log.error({ err }, 'checkout session create failed'); const msg = err instanceof Error ? err.message : 'unknown_error'; return reply.code(502).send({ error: 'checkout_failed', detail: msg }); } }); // ─── Customer Portal ───────────────────────────────────────────────────── app.post('/v1/billing/portal', { preHandler: requireAuth }, async (req, reply) => { if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' }); const user = req.user!; const [org] = await db .select({ stripeCustomerId: organizations.stripeCustomerId }) .from(organizations) .where(eq(organizations.id, user.orgId)) .limit(1); if (!org?.stripeCustomerId) { return reply.code(409).send({ error: 'no_customer_yet', detail: 'Subscribe first to access the billing portal.', }); } try { const session = await stripe.billingPortal.sessions.create({ customer: org.stripeCustomerId, return_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing`, }); return reply.send({ url: session.url }); } catch (err) { app.log.error({ err }, 'portal session create failed'); return reply.code(502).send({ error: 'portal_failed' }); } }); // ─── Billing status — drives the /settings/billing UI ──────────────────── app.get('/v1/billing/status', { preHandler: requireAuth }, async (req, reply) => { const user = req.user!; const [org] = await db .select({ plan: organizations.plan, stripeCustomerId: organizations.stripeCustomerId, stripeSubscriptionId: organizations.stripeSubscriptionId, suspended: organizations.suspended, suspendedReason: organizations.suspendedReason, }) .from(organizations) .where(eq(organizations.id, user.orgId)) .limit(1); if (!org) return reply.code(404).send({ error: 'org_not_found' }); return reply.send({ plan: org.plan, hasCustomer: Boolean(org.stripeCustomerId), hasSubscription: Boolean(org.stripeSubscriptionId), suspended: org.suspended, suspendedReason: org.suspendedReason, }); }); // ─── Webhook ───────────────────────────────────────────────────────────── // Stripe signs the raw body — our index.ts content parser stashes the // buffer on req.rawBody before JSON-parsing it for normal handlers. app.post('/v1/billing/webhook', async (req, reply) => { if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' }); if (!config.STRIPE_WEBHOOK_SECRET) { app.log.error('webhook called without STRIPE_WEBHOOK_SECRET configured'); return reply.code(503).send({ error: 'webhook_not_configured' }); } const signature = req.headers['stripe-signature']; if (typeof signature !== 'string') { return reply.code(400).send({ error: 'no_signature' }); } const rawBody = (req as { rawBody?: Buffer }).rawBody; if (!rawBody) { app.log.error('webhook called without rawBody — content parser missing'); return reply.code(500).send({ error: 'no_raw_body' }); } let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( rawBody, signature, config.STRIPE_WEBHOOK_SECRET, ); } catch (err) { app.log.warn({ err }, 'webhook signature verify failed'); return reply.code(400).send({ error: 'bad_signature' }); } if (await isDuplicateEvent(event.id)) { app.log.info({ eventId: event.id, type: event.type }, 'webhook duplicate, skipped'); return reply.send({ ok: true, deduped: true }); } try { await handleStripeEvent(app, event); return reply.send({ ok: true }); } catch (err) { // Return 5xx so Stripe retries with exponential backoff. app.log.error( { err, eventId: event.id, type: event.type }, 'webhook handler failed — Stripe will retry', ); return reply.code(500).send({ error: 'handler_failed' }); } }); } // ─── Event dispatch ────────────────────────────────────────────────────────── async function handleStripeEvent(app: FastifyInstance, event: Stripe.Event): Promise { switch (event.type) { case 'checkout.session.completed': await handleCheckoutCompleted(app, event.data.object as Stripe.Checkout.Session); break; case 'customer.subscription.created': case 'customer.subscription.updated': await handleSubscriptionChange(app, event.data.object as Stripe.Subscription); break; case 'customer.subscription.deleted': await handleSubscriptionDeleted(app, event.data.object as Stripe.Subscription); break; case 'invoice.paid': await handleInvoicePaid(app, event.data.object as Stripe.Invoice); break; case 'invoice.payment_failed': await handlePaymentFailed(app, event.data.object as Stripe.Invoice); break; default: app.log.debug({ type: event.type }, 'unhandled stripe event type'); } } async function findOrgIdForSubscription(sub: Stripe.Subscription): Promise { // Prefer the metadata we set at checkout — it's the most reliable mapping. // Fallback: look the org up by stored customer id. const metaOrgId = sub.metadata?.orgId; if (typeof metaOrgId === 'string' && metaOrgId.length > 0) return metaOrgId; const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id; const [row] = await db .select({ id: organizations.id }) .from(organizations) .where(eq(organizations.stripeCustomerId, customerId)) .limit(1); return row?.id ?? null; } async function findOrgIdForInvoice(invoice: Stripe.Invoice): Promise { const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id; if (!customerId) return null; const [row] = await db .select({ id: organizations.id }) .from(organizations) .where(eq(organizations.stripeCustomerId, customerId)) .limit(1); return row?.id ?? null; } async function handleCheckoutCompleted( app: FastifyInstance, session: Stripe.Checkout.Session, ): Promise { const orgId = session.metadata?.orgId ?? session.client_reference_id ?? null; if (!orgId) { app.log.warn({ sessionId: session.id }, 'checkout completed without orgId'); return; } const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id; if (!customerId) return; await db .update(organizations) .set({ stripeCustomerId: customerId }) .where(eq(organizations.id, orgId)); await audit({ orgId, action: 'billing.checkout_completed', resourceType: 'subscription', metadata: { customerId, sessionId: session.id }, }); } async function handleSubscriptionChange( app: FastifyInstance, sub: Stripe.Subscription, ): Promise { const orgId = await findOrgIdForSubscription(sub); if (!orgId) { app.log.warn({ subId: sub.id, customer: sub.customer }, 'sub change for unknown org'); return; } const priceId = sub.items.data[0]?.price.id; const plan = planFromPriceId(priceId); const active = sub.status === 'active' || sub.status === 'trialing'; const suspended = sub.status === 'past_due' || sub.status === 'unpaid'; await db .update(organizations) .set({ plan: active ? plan : 'hobby', stripeSubscriptionId: sub.id, suspended, suspendedReason: suspended ? `subscription_${sub.status}` : null, }) .where(eq(organizations.id, orgId)); await audit({ orgId, action: 'billing.subscription_changed', resourceType: 'subscription', metadata: { plan, status: sub.status, subId: sub.id, priceId: priceId ?? null }, }); } async function handleSubscriptionDeleted( app: FastifyInstance, sub: Stripe.Subscription, ): Promise { const orgId = await findOrgIdForSubscription(sub); if (!orgId) { app.log.warn({ subId: sub.id }, 'sub delete for unknown org'); return; } await db .update(organizations) .set({ plan: 'hobby', stripeSubscriptionId: null, suspended: false, suspendedReason: null, }) .where(eq(organizations.id, orgId)); await audit({ orgId, action: 'billing.subscription_cancelled', resourceType: 'subscription', metadata: { subId: sub.id }, }); } async function handleInvoicePaid(_app: FastifyInstance, invoice: Stripe.Invoice): Promise { const orgId = await findOrgIdForInvoice(invoice); if (!orgId) return; // Successful renewal — clear any past-due suspension and reset the usage // period (so the new month's call quota starts fresh). await db .update(organizations) .set({ suspended: false, suspendedReason: null, callsThisPeriod: 0, periodStartsAt: new Date(), }) .where(eq(organizations.id, orgId)); await audit({ orgId, action: 'billing.invoice_paid', resourceType: 'invoice', metadata: { invoiceId: invoice.id ?? null, amountPaid: invoice.amount_paid ?? 0 }, }); } async function handlePaymentFailed( _app: FastifyInstance, invoice: Stripe.Invoice, ): Promise { const orgId = await findOrgIdForInvoice(invoice); if (!orgId) return; const attempts = invoice.attempt_count ?? 0; // Only suspend after the 3rd failed attempt — Stripe Smart Retries will keep // trying for several days, so the user has time to update their card. if (attempts >= 3) { await db .update(organizations) .set({ suspended: true, suspendedReason: 'payment_failed' }) .where(eq(organizations.id, orgId)); } await audit({ orgId, action: 'billing.payment_failed', resourceType: 'invoice', metadata: { invoiceId: invoice.id ?? null, attempts }, }); }