import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { eq } from 'drizzle-orm'; import { getDb, firms } from '@lawdesk/db'; import { env } from '../env'; import { getStripe, getPlanConfig, stripeIsConfigured } from '../lib/stripe'; export async function billingRoutes(app: FastifyInstance) { app.addHook('preHandler', app.requireFirm); // Reading plan state is fine for anyone in the firm; spending money or opening the Stripe // portal (which exposes payment methods and invoice history) is the owner's call alone. const ownerOnly = { preHandler: app.requireRole('owner') }; // Status — what does the UI need to show? Configured at all? Current plan? Has subscription? app.get('/api/billing/status', async (req) => { const firmId = req.user!.firmId!; const [firm] = await getDb().select().from(firms).where(eq(firms.id, firmId)).limit(1); return { configured: stripeIsConfigured(), plan: firm?.plan ?? 'starter', hasSubscription: !!firm?.stripeSubscriptionId, hasCustomer: !!firm?.stripeCustomerId, }; }); // Create a Checkout Session — returns the URL to redirect the user to. app.post('/api/billing/checkout', ownerOnly, async (req, reply) => { const parsed = z .object({ plan: z.enum(['pro', 'lifetime']) }) .safeParse(req.body); if (!parsed.success) return reply.code(400).send({ error: 'invalid_plan' }); if (!stripeIsConfigured()) return reply.code(503).send({ error: 'stripe_not_configured' }); const firmId = req.user!.firmId!; const userEmail = req.user!.email; const planCfg = getPlanConfig(parsed.data.plan); if (!planCfg) return reply.code(503).send({ error: 'plan_not_configured' }); const db = getDb(); const [firm] = await db.select().from(firms).where(eq(firms.id, firmId)).limit(1); if (!firm) return reply.code(404).send({ error: 'firm_not_found' }); // Guard against double-billing: a firm already on a paid plan (or with a live subscription) // must not be able to open a second Checkout session. Send them to the portal instead. if (firm.plan !== 'starter' || firm.stripeSubscriptionId) { return reply.code(409).send({ error: 'already_on_paid_plan', hint: 'Manage or change your current plan from the billing portal.', }); } const stripe = getStripe(); // Reuse the customer if we've made one before; otherwise let Checkout create one and we'll // capture it on the webhook. const session = await stripe.checkout.sessions.create({ mode: planCfg.mode, line_items: [{ price: planCfg.priceId, quantity: 1 }], customer: firm.stripeCustomerId ?? undefined, customer_email: firm.stripeCustomerId ? undefined : userEmail, client_reference_id: firmId, metadata: { firmId, plan: planCfg.planName }, subscription_data: planCfg.mode === 'subscription' ? { metadata: { firmId, plan: planCfg.planName } } : undefined, success_url: `${env.PUBLIC_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${env.PUBLIC_URL}/billing/cancel`, allow_promotion_codes: true, }); return { url: session.url }; }); // Customer Portal — for managing the subscription, updating payment method, viewing invoices. app.post('/api/billing/portal', ownerOnly, async (req, reply) => { if (!stripeIsConfigured()) return reply.code(503).send({ error: 'stripe_not_configured' }); const firmId = req.user!.firmId!; const [firm] = await getDb().select().from(firms).where(eq(firms.id, firmId)).limit(1); if (!firm?.stripeCustomerId) return reply.code(404).send({ error: 'no_customer' }); const stripe = getStripe(); const session = await stripe.billingPortal.sessions.create({ customer: firm.stripeCustomerId, return_url: `${env.PUBLIC_URL}/app/settings`, }); return { url: session.url }; }); }