Files
elegalsoftware/apps/api/src/routes/billing.ts
T
Leon SerfatyandClaude Opus 5 eb36b81dc9 Superadmin impersonation, hardened auth/upload paths, dependency updates
- Add superadmin impersonation: sessions.impersonated_by (migration 0003) is
  stamped onto audit rows so impersonated actions are attributable, with a
  persistent ImpersonationBanner in the app shell.
- Harden auth and upload handling across routes (safe redirect targets,
  filename sanitization, checkout grant handling).
- Update dependencies: Sentry 8 -> 10, @fastify/static 8 -> 10,
  react-router-dom 6.30.6; add find-my-way / fast-uri overrides.
- Add tests for safe-next, checkout-grant, and upload-filename.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 10:25:39 -04:00

91 lines
3.8 KiB
TypeScript

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 };
});
}