2026-04-26 02:42:42 -04:00
|
|
|
import type { FastifyInstance } from 'fastify';
|
|
|
|
|
import type Stripe from 'stripe';
|
2026-07-16 13:18:10 -04:00
|
|
|
import { and, eq } from 'drizzle-orm';
|
2026-07-17 13:34:33 -04:00
|
|
|
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';
|
2026-07-16 13:18:10 -04:00
|
|
|
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 {
|
2026-07-17 13:34:33 -04:00
|
|
|
// 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);
|
2026-07-17 13:34:33 -04:00
|
|
|
|
|
|
|
|
// 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 });
|
2026-07-17 13:34:33 -04:00
|
|
|
// 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 });
|
2026-07-16 13:18:10 -04:00
|
|
|
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');
|
2026-07-16 13:18:10 -04:00
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-16 13:18:10 -04:00
|
|
|
// 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)
|
2026-07-16 13:18:10 -04:00
|
|
|
.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';
|
2026-07-16 13:18:10 -04:00
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
}
|