Files
elegalsoftware/apps/api/src/routes/webhooks-stripe.ts
T

167 lines
6.2 KiB
TypeScript
Raw Normal View History

2026-04-26 02:42:42 -04:00
import type { FastifyInstance } from 'fastify';
import type Stripe from 'stripe';
import { and, eq } from 'drizzle-orm';
2026-04-26 02:42:42 -04:00
import { getDb, firms, users } from '@lawdesk/db';
import { env } from '../env';
import { getStripe } from '../lib/stripe';
import {
sendEmail,
planUpgradedEmail,
paymentFailedEmail,
subscriptionEndedEmail,
} from '../lib/email';
2026-04-26 02:42:42 -04:00
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 });
for (const u of await firmOwners(firmId)) {
const tpl = subscriptionEndedEmail(u.fullName);
sendEmail({ to: u.email, ...tpl }).catch((err) =>
app.log.warn({ err, firmId }, 'subscription ended email failed'),
);
}
2026-04-26 02:42:42 -04:00
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
app.log.warn({ invoice: invoice.id, customer: invoice.customer }, 'stripe invoice payment failed');
const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
if (!customerId) break;
const [firm] = await getDb()
.select({ id: firms.id })
.from(firms)
.where(eq(firms.stripeCustomerId, customerId))
.limit(1);
if (!firm) break;
const amount = invoice.amount_due
? new Intl.NumberFormat('en-US', {
style: 'currency',
currency: (invoice.currency ?? 'usd').toUpperCase(),
}).format(invoice.amount_due / 100)
: null;
for (const u of await firmOwners(firm.id)) {
const tpl = paymentFailedEmail(u.fullName, amount);
sendEmail({ to: u.email, ...tpl }).catch((err) =>
app.log.warn({ err, firmId: firm.id }, 'payment failed email failed'),
);
}
2026-04-26 02:42:42 -04:00
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 },
});
}
// Billing emails go to owners only — staff shouldn't get payment notices.
async function firmOwners(firmId: string) {
return getDb()
2026-04-26 02:42:42 -04:00
.select({ email: users.email, fullName: users.fullName })
.from(users)
.where(and(eq(users.firmId, firmId), eq(users.role, 'owner')));
}
async function sendPlanUpgradedNotice(firmId: string, plan: 'pro' | 'lifetime') {
2026-04-26 02:42:42 -04:00
const label = plan === 'pro' ? 'Professional' : 'Lifetime';
for (const u of await firmOwners(firmId)) {
2026-04-26 02:42:42 -04:00
const tpl = planUpgradedEmail(u.fullName, label);
await sendEmail({ to: u.email, ...tpl });
}
}