Initial commit — eLegal Software monorepo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user