361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
|
|
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 },
|
||
|
|
});
|
||
|
|
}
|