buildmymcpserver/apps/api/src/routes/billing.ts

361 lines
13 KiB
TypeScript
Raw Normal View History

import { createDb, eq, organizations } from '@bmm/db';
import type { FastifyInstance } from 'fastify';
import type Stripe from 'stripe';
import { z } from 'zod';
import { config } from '../config.js';
import { audit } from '../lib/audit.js';
import {
type PriceTier,
isDuplicateEvent,
planFromPriceId,
priceIdForTier,
stripe,
} from '../lib/stripe.js';
import { requireAuth } from '../plugins/session.js';
const db = createDb();
const TierBody = z.object({
tier: z.enum(['pro_monthly', 'pro_yearly', 'team_monthly', 'team_yearly']),
});
export async function billingRoutes(app: FastifyInstance): Promise<void> {
// ─── Checkout ────────────────────────────────────────────────────────────
app.post('/v1/billing/checkout-session', { preHandler: requireAuth }, async (req, reply) => {
if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
const user = req.user!;
const parsed = TierBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const priceId = priceIdForTier(parsed.data.tier as PriceTier);
if (!priceId) {
return reply.code(503).send({ error: 'price_not_configured', tier: parsed.data.tier });
}
const [org] = await db
.select({ stripeCustomerId: organizations.stripeCustomerId })
.from(organizations)
.where(eq(organizations.id, user.orgId))
.limit(1);
if (!org) return reply.code(404).send({ error: 'org_not_found' });
try {
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card', 'sepa_debit'],
line_items: [{ price: priceId, quantity: 1 }],
// Reuse Stripe customer if we have one — keeps invoices on one account
// even when the user upgrades/downgrades repeatedly.
...(org.stripeCustomerId
? { customer: org.stripeCustomerId }
: { customer_email: user.email ?? undefined }),
client_reference_id: user.orgId,
metadata: { orgId: user.orgId, userId: user.userId, tier: parsed.data.tier },
subscription_data: {
metadata: { orgId: user.orgId, userId: user.userId },
},
success_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?success=true`,
cancel_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?cancelled=true`,
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
billing_address_collection: 'required',
allow_promotion_codes: true,
});
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'billing.checkout_initiated',
resourceType: 'subscription',
metadata: { tier: parsed.data.tier },
ipAddress: req.ip,
});
return reply.send({ url: session.url, sessionId: session.id });
} catch (err) {
app.log.error({ err }, 'checkout session create failed');
const msg = err instanceof Error ? err.message : 'unknown_error';
return reply.code(502).send({ error: 'checkout_failed', detail: msg });
}
});
// ─── Customer Portal ─────────────────────────────────────────────────────
app.post('/v1/billing/portal', { preHandler: requireAuth }, async (req, reply) => {
if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
const user = req.user!;
const [org] = await db
.select({ stripeCustomerId: organizations.stripeCustomerId })
.from(organizations)
.where(eq(organizations.id, user.orgId))
.limit(1);
if (!org?.stripeCustomerId) {
return reply.code(409).send({
error: 'no_customer_yet',
detail: 'Subscribe first to access the billing portal.',
});
}
try {
const session = await stripe.billingPortal.sessions.create({
customer: org.stripeCustomerId,
return_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing`,
});
return reply.send({ url: session.url });
} catch (err) {
app.log.error({ err }, 'portal session create failed');
return reply.code(502).send({ error: 'portal_failed' });
}
});
// ─── Billing status — drives the /settings/billing UI ────────────────────
app.get('/v1/billing/status', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const [org] = await db
.select({
plan: organizations.plan,
stripeCustomerId: organizations.stripeCustomerId,
stripeSubscriptionId: organizations.stripeSubscriptionId,
suspended: organizations.suspended,
suspendedReason: organizations.suspendedReason,
})
.from(organizations)
.where(eq(organizations.id, user.orgId))
.limit(1);
if (!org) return reply.code(404).send({ error: 'org_not_found' });
return reply.send({
plan: org.plan,
hasCustomer: Boolean(org.stripeCustomerId),
hasSubscription: Boolean(org.stripeSubscriptionId),
suspended: org.suspended,
suspendedReason: org.suspendedReason,
});
});
// ─── Webhook ─────────────────────────────────────────────────────────────
// Stripe signs the raw body — our index.ts content parser stashes the
// buffer on req.rawBody before JSON-parsing it for normal handlers.
app.post('/v1/billing/webhook', async (req, reply) => {
if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
if (!config.STRIPE_WEBHOOK_SECRET) {
app.log.error('webhook called without STRIPE_WEBHOOK_SECRET configured');
return reply.code(503).send({ error: 'webhook_not_configured' });
}
const signature = req.headers['stripe-signature'];
if (typeof signature !== 'string') {
return reply.code(400).send({ error: 'no_signature' });
}
const rawBody = (req as { rawBody?: Buffer }).rawBody;
if (!rawBody) {
app.log.error('webhook called without rawBody — content parser missing');
return reply.code(500).send({ error: 'no_raw_body' });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature,
config.STRIPE_WEBHOOK_SECRET,
);
} catch (err) {
app.log.warn({ err }, 'webhook signature verify failed');
return reply.code(400).send({ error: 'bad_signature' });
}
if (await isDuplicateEvent(event.id)) {
app.log.info({ eventId: event.id, type: event.type }, 'webhook duplicate, skipped');
return reply.send({ ok: true, deduped: true });
}
try {
await handleStripeEvent(app, event);
return reply.send({ ok: true });
} catch (err) {
// Return 5xx so Stripe retries with exponential backoff.
app.log.error(
{ err, eventId: event.id, type: event.type },
'webhook handler failed — Stripe will retry',
);
return reply.code(500).send({ error: 'handler_failed' });
}
});
}
// ─── Event dispatch ──────────────────────────────────────────────────────────
async function handleStripeEvent(app: FastifyInstance, event: Stripe.Event): Promise<void> {
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(app, event.data.object as Stripe.Checkout.Session);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
await handleSubscriptionChange(app, event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(app, event.data.object as Stripe.Subscription);
break;
case 'invoice.paid':
await handleInvoicePaid(app, event.data.object as Stripe.Invoice);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(app, event.data.object as Stripe.Invoice);
break;
default:
app.log.debug({ type: event.type }, 'unhandled stripe event type');
}
}
async function findOrgIdForSubscription(sub: Stripe.Subscription): Promise<string | null> {
// Prefer the metadata we set at checkout — it's the most reliable mapping.
// Fallback: look the org up by stored customer id.
const metaOrgId = sub.metadata?.orgId;
if (typeof metaOrgId === 'string' && metaOrgId.length > 0) return metaOrgId;
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
const [row] = await db
.select({ id: organizations.id })
.from(organizations)
.where(eq(organizations.stripeCustomerId, customerId))
.limit(1);
return row?.id ?? null;
}
async function findOrgIdForInvoice(invoice: Stripe.Invoice): Promise<string | null> {
const customerId =
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
if (!customerId) return null;
const [row] = await db
.select({ id: organizations.id })
.from(organizations)
.where(eq(organizations.stripeCustomerId, customerId))
.limit(1);
return row?.id ?? null;
}
async function handleCheckoutCompleted(
app: FastifyInstance,
session: Stripe.Checkout.Session,
): Promise<void> {
const orgId = session.metadata?.orgId ?? session.client_reference_id ?? null;
if (!orgId) {
app.log.warn({ sessionId: session.id }, 'checkout completed without orgId');
return;
}
const customerId =
typeof session.customer === 'string' ? session.customer : session.customer?.id;
if (!customerId) return;
await db
.update(organizations)
.set({ stripeCustomerId: customerId })
.where(eq(organizations.id, orgId));
await audit({
orgId,
action: 'billing.checkout_completed',
resourceType: 'subscription',
metadata: { customerId, sessionId: session.id },
});
}
async function handleSubscriptionChange(
app: FastifyInstance,
sub: Stripe.Subscription,
): Promise<void> {
const orgId = await findOrgIdForSubscription(sub);
if (!orgId) {
app.log.warn({ subId: sub.id, customer: sub.customer }, 'sub change for unknown org');
return;
}
const priceId = sub.items.data[0]?.price.id;
const plan = planFromPriceId(priceId);
const active = sub.status === 'active' || sub.status === 'trialing';
const suspended = sub.status === 'past_due' || sub.status === 'unpaid';
await db
.update(organizations)
.set({
plan: active ? plan : 'hobby',
stripeSubscriptionId: sub.id,
suspended,
suspendedReason: suspended ? `subscription_${sub.status}` : null,
})
.where(eq(organizations.id, orgId));
await audit({
orgId,
action: 'billing.subscription_changed',
resourceType: 'subscription',
metadata: { plan, status: sub.status, subId: sub.id, priceId: priceId ?? null },
});
}
async function handleSubscriptionDeleted(
app: FastifyInstance,
sub: Stripe.Subscription,
): Promise<void> {
const orgId = await findOrgIdForSubscription(sub);
if (!orgId) {
app.log.warn({ subId: sub.id }, 'sub delete for unknown org');
return;
}
await db
.update(organizations)
.set({
plan: 'hobby',
stripeSubscriptionId: null,
suspended: false,
suspendedReason: null,
})
.where(eq(organizations.id, orgId));
await audit({
orgId,
action: 'billing.subscription_cancelled',
resourceType: 'subscription',
metadata: { subId: sub.id },
});
}
async function handleInvoicePaid(_app: FastifyInstance, invoice: Stripe.Invoice): Promise<void> {
const orgId = await findOrgIdForInvoice(invoice);
if (!orgId) return;
// Successful renewal — clear any past-due suspension and reset the usage
// period (so the new month's call quota starts fresh).
await db
.update(organizations)
.set({
suspended: false,
suspendedReason: null,
callsThisPeriod: 0,
periodStartsAt: new Date(),
})
.where(eq(organizations.id, orgId));
await audit({
orgId,
action: 'billing.invoice_paid',
resourceType: 'invoice',
metadata: { invoiceId: invoice.id ?? null, amountPaid: invoice.amount_paid ?? 0 },
});
}
async function handlePaymentFailed(
_app: FastifyInstance,
invoice: Stripe.Invoice,
): Promise<void> {
const orgId = await findOrgIdForInvoice(invoice);
if (!orgId) return;
const attempts = invoice.attempt_count ?? 0;
// Only suspend after the 3rd failed attempt — Stripe Smart Retries will keep
// trying for several days, so the user has time to update their card.
if (attempts >= 3) {
await db
.update(organizations)
.set({ suspended: true, suspendedReason: 'payment_failed' })
.where(eq(organizations.id, orgId));
}
await audit({
orgId,
action: 'billing.payment_failed',
resourceType: 'invoice',
metadata: { invoiceId: invoice.id ?? null, attempts },
});
}