Initial commit — eLegal Software monorepo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-04-26 02:42:42 -04:00
co-authored by Claude Sonnet 4.6
commit 0700d54225
160 changed files with 22771 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
import type { FastifyInstance } from 'fastify';
import type Stripe from 'stripe';
import { eq } from 'drizzle-orm';
import { getDb, firms, users } from '@lawdesk/db';
import { env } from '../env';
import { getStripe } from '../lib/stripe';
import { sendEmail, planUpgradedEmail } 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 {
await handleEvent(event, app);
} 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 });
await sendPlanUpgradedNotice(firmId, plan);
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 });
break;
}
case 'invoice.payment_failed': {
// Optional: surface to the user via email later. For now, just log.
const invoice = event.data.object as Stripe.Invoice;
app.log.warn({ invoice: invoice.id, customer: invoice.customer }, 'stripe invoice payment 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<string, unknown> = {
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 },
});
}
async function sendPlanUpgradedNotice(firmId: string, plan: 'pro' | 'lifetime') {
const owners = await getDb()
.select({ email: users.email, fullName: users.fullName })
.from(users)
.where(eq(users.firmId, firmId));
const label = plan === 'pro' ? 'Professional' : 'Lifetime';
for (const u of owners) {
const tpl = planUpgradedEmail(u.fullName, label);
await sendEmail({ to: u.email, ...tpl });
}
}