- 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>
91 lines
3.8 KiB
TypeScript
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 };
|
|
});
|
|
}
|