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

191 lines
7.4 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';
import { getDb, firms, users, stripeEvents } from '@lawdesk/db';
2026-04-26 02:42:42 -04:00
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 {
// Idempotency for Stripe's at-least-once delivery. Skip events we've already fully
// processed so retries don't re-send emails or re-write audit rows.
const [seen] = await getDb()
.select({ id: stripeEvents.id })
.from(stripeEvents)
.where(eq(stripeEvents.id, event.id))
.limit(1);
if (seen) {
app.log.info({ id: event.id, type: event.type }, 'stripe webhook duplicate event ignored');
return { received: true, duplicate: true };
}
2026-04-26 02:42:42 -04:00
await handleEvent(event, app);
// Record only AFTER successful processing: a transient handler failure (→ 500 → Stripe
// retry) then re-processes instead of being skipped forever. applyPlan is idempotent, so
// the narrow check-then-insert race on truly concurrent redeliveries is harmless.
await getDb()
.insert(stripeEvents)
.values({ id: event.id, type: event.type })
.onConflictDoNothing();
2026-04-26 02:42:42 -04:00
} 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 });
// Fire-and-forget: an email failure must not throw out of the handler (→ 500 → Stripe
// redelivery → duplicate processing). The plan (DB writes above) is already applied.
sendPlanUpgradedNotice(firmId, plan).catch((err) =>
app.log.warn({ err, firmId }, 'plan upgraded email failed'),
);
2026-04-26 02:42:42 -04:00
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 });
}
}