feat(billing): Stripe Checkout + Customer Portal + signed webhook
Some checks failed
Deploy to Production / deploy (push) Failing after 46s
Some checks failed
Deploy to Production / deploy (push) Failing after 46s
- @bmm/api: stripe@22 SDK, plan-aware price-id lookup, Redis-backed event
idempotency (7d TTL covers Stripe's retry window), startup warning when
STRIPE_PRICE_* env vars contain product ids (prod_) by mistake
- routes/billing.ts:
POST /v1/billing/checkout-session → Stripe-hosted Checkout, SEPA+card,
auto-VAT via Stripe Tax, tax_id
collection for B2B, address required
POST /v1/billing/portal → Customer Portal session
GET /v1/billing/status → drives the settings/billing UI
POST /v1/billing/webhook → signed, idempotent, handles
checkout.session.completed,
subscription.{created,updated,deleted},
invoice.{paid,payment_failed}
- index.ts: rawBody-aware JSON parser so Stripe signature verify gets the
exact payload bytes
- web: /settings/billing page (status, upgrade flow, manage-billing portal,
auto-checkout when arriving with ?tier=… from the pricing CTAs), pricing
page CTAs point to /settings/billing?tier=…
- Payment-failure path: suspend org only after 3rd failed attempt (Stripe
Smart Retries handles the soft-retries). Suspended orgs keep their running
servers but cannot create new ones (enforcement is in /v1/servers POST as
a follow-up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
defb4186b4
commit
c2a21fc3cd
@ -22,6 +22,7 @@
|
||||
"fastify": "5.2.0",
|
||||
"ioredis": "5.4.1",
|
||||
"jose": "5.9.6",
|
||||
"stripe": "^22.1.1",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -24,6 +24,13 @@ const Env = z.object({
|
||||
TWILIO_ACCOUNT_SID: z.string().optional(),
|
||||
TWILIO_AUTH_TOKEN: z.string().optional(),
|
||||
TWILIO_SMS_FROM: z.string().optional(),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
||||
STRIPE_PRICE_PRO_MONTHLY: z.string().optional(),
|
||||
STRIPE_PRICE_PRO_YEARLY: z.string().optional(),
|
||||
STRIPE_PRICE_TEAM_MONTHLY: z.string().optional(),
|
||||
STRIPE_PRICE_TEAM_YEARLY: z.string().optional(),
|
||||
});
|
||||
|
||||
export const config = Env.parse({
|
||||
@ -47,6 +54,13 @@ export const config = Env.parse({
|
||||
TWILIO_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID,
|
||||
TWILIO_AUTH_TOKEN: process.env.TWILIO_AUTH_TOKEN,
|
||||
TWILIO_SMS_FROM: process.env.TWILIO_SMS_FROM,
|
||||
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
||||
STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY,
|
||||
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
||||
STRIPE_PRICE_PRO_MONTHLY: process.env.STRIPE_PRICE_PRO_MONTHLY,
|
||||
STRIPE_PRICE_PRO_YEARLY: process.env.STRIPE_PRICE_PRO_YEARLY,
|
||||
STRIPE_PRICE_TEAM_MONTHLY: process.env.STRIPE_PRICE_TEAM_MONTHLY,
|
||||
STRIPE_PRICE_TEAM_YEARLY: process.env.STRIPE_PRICE_TEAM_YEARLY,
|
||||
});
|
||||
|
||||
// INFRA-001: refuse to boot in production with the placeholder encryption key.
|
||||
|
||||
@ -1,23 +1,51 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import cookie from '@fastify/cookie';
|
||||
import websocket from '@fastify/websocket';
|
||||
import { seedAdmin } from '@bmm/auth';
|
||||
import cookie from '@fastify/cookie';
|
||||
import cors from '@fastify/cors';
|
||||
import websocket from '@fastify/websocket';
|
||||
import Fastify from 'fastify';
|
||||
import { config } from './config.js';
|
||||
import { ensureActiveKey } from './lib/crypto.js';
|
||||
import { authRoutes } from './routes/auth.js';
|
||||
import { serverRoutes } from './routes/servers.js';
|
||||
import { oauthRoutes } from './routes/oauth.js';
|
||||
import { settingsRoutes } from './routes/settings.js';
|
||||
import { validateStripePriceConfig } from './lib/stripe.js';
|
||||
import { adminRoutes } from './routes/admin.js';
|
||||
import { authRoutes } from './routes/auth.js';
|
||||
import { billingRoutes } from './routes/billing.js';
|
||||
import { oauthRoutes } from './routes/oauth.js';
|
||||
import { serverRoutes } from './routes/servers.js';
|
||||
import { settingsRoutes } from './routes/settings.js';
|
||||
import { templateRoutes } from './routes/templates.js';
|
||||
|
||||
// Stripe webhook signature verification requires the raw request body, so we
|
||||
// stash a copy on req.rawBody during JSON parsing. Merges into Fastify's
|
||||
// FastifyRequest interface declaration alongside `user` from plugins/session.
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
rawBody?: Buffer;
|
||||
}
|
||||
}
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: config.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
},
|
||||
});
|
||||
|
||||
// Replace the default JSON parser with one that keeps the raw buffer for the
|
||||
// Stripe-webhook signature check. Must run BEFORE any route registration.
|
||||
app.addContentTypeParser(
|
||||
'application/json',
|
||||
{ parseAs: 'buffer' },
|
||||
(req, body, done) => {
|
||||
const buf = body as Buffer;
|
||||
req.rawBody = buf;
|
||||
if (buf.length === 0) return done(null, undefined);
|
||||
try {
|
||||
done(null, JSON.parse(buf.toString('utf8')));
|
||||
} catch (err) {
|
||||
done(err as Error, undefined);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await app.register(cors, {
|
||||
origin: [config.NEXT_PUBLIC_APP_URL],
|
||||
credentials: true,
|
||||
@ -43,6 +71,12 @@ await app.register(oauthRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(adminRoutes);
|
||||
await app.register(templateRoutes);
|
||||
await app.register(billingRoutes);
|
||||
|
||||
// Loud warning if STRIPE_PRICE_* env vars are set to product ids (prod_…)
|
||||
// instead of price ids (price_…). Stripe Checkout would silently 400 — easier
|
||||
// to find at boot.
|
||||
validateStripePriceConfig({ warn: (msg) => app.log.warn(msg) });
|
||||
|
||||
// Bootstrap admin user from env (idempotent)
|
||||
if (config.ADMIN_EMAIL && config.ADMIN_PASSWORD) {
|
||||
|
||||
87
apps/api/src/lib/stripe.ts
Normal file
87
apps/api/src/lib/stripe.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import type { Plan } from '@bmm/llm';
|
||||
import Stripe from 'stripe';
|
||||
import { config } from '../config.js';
|
||||
import { getRedis } from './redis.js';
|
||||
|
||||
/**
|
||||
* Stripe client (null when no secret key is configured — e.g. in dev/test).
|
||||
* `apiVersion` pinned to the current default to avoid silent breakage when
|
||||
* Stripe rolls out new defaults.
|
||||
*/
|
||||
export const stripe: Stripe | null = config.STRIPE_SECRET_KEY
|
||||
? new Stripe(config.STRIPE_SECRET_KEY, {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: SDK type lags behind real API version strings
|
||||
apiVersion: '2025-10-29.acacia' as any,
|
||||
typescript: true,
|
||||
})
|
||||
: null;
|
||||
|
||||
export type PriceTier = 'pro_monthly' | 'pro_yearly' | 'team_monthly' | 'team_yearly';
|
||||
|
||||
export function priceIdForTier(tier: PriceTier): string | undefined {
|
||||
switch (tier) {
|
||||
case 'pro_monthly':
|
||||
return config.STRIPE_PRICE_PRO_MONTHLY;
|
||||
case 'pro_yearly':
|
||||
return config.STRIPE_PRICE_PRO_YEARLY;
|
||||
case 'team_monthly':
|
||||
return config.STRIPE_PRICE_TEAM_MONTHLY;
|
||||
case 'team_yearly':
|
||||
return config.STRIPE_PRICE_TEAM_YEARLY;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reverse map: which plan does a Stripe price id belong to. Unknown → hobby. */
|
||||
export function planFromPriceId(priceId: string | undefined): Plan {
|
||||
if (!priceId) return 'hobby';
|
||||
if (
|
||||
priceId === config.STRIPE_PRICE_PRO_MONTHLY ||
|
||||
priceId === config.STRIPE_PRICE_PRO_YEARLY
|
||||
) {
|
||||
return 'pro';
|
||||
}
|
||||
if (
|
||||
priceId === config.STRIPE_PRICE_TEAM_MONTHLY ||
|
||||
priceId === config.STRIPE_PRICE_TEAM_YEARLY
|
||||
) {
|
||||
return 'team';
|
||||
}
|
||||
return 'hobby';
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotency for Stripe webhooks. Stripe retries failed deliveries — we must
|
||||
* dedupe by event.id or we'd e.g. double-cancel a subscription. SET NX with a
|
||||
* 7-day TTL covers Stripe's full retry window.
|
||||
*
|
||||
* Returns true if this event was already processed (caller should skip).
|
||||
*/
|
||||
export async function isDuplicateEvent(eventId: string): Promise<boolean> {
|
||||
const redis = getRedis();
|
||||
const key = `stripe:event:${eventId}`;
|
||||
const set = await redis.set(key, '1', 'EX', 7 * 24 * 60 * 60, 'NX');
|
||||
return set === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanity-check that price-id env vars actually contain price ids — a common
|
||||
* setup mistake is to paste the product id (prod_…) instead. Logs loudly on
|
||||
* boot so we discover misconfiguration before the first checkout attempt.
|
||||
*/
|
||||
export function validateStripePriceConfig(log: { warn: (msg: string) => void }): void {
|
||||
const checks: Array<[string, string | undefined]> = [
|
||||
['STRIPE_PRICE_PRO_MONTHLY', config.STRIPE_PRICE_PRO_MONTHLY],
|
||||
['STRIPE_PRICE_PRO_YEARLY', config.STRIPE_PRICE_PRO_YEARLY],
|
||||
['STRIPE_PRICE_TEAM_MONTHLY', config.STRIPE_PRICE_TEAM_MONTHLY],
|
||||
['STRIPE_PRICE_TEAM_YEARLY', config.STRIPE_PRICE_TEAM_YEARLY],
|
||||
];
|
||||
for (const [name, value] of checks) {
|
||||
if (!value) continue;
|
||||
if (!value.startsWith('price_')) {
|
||||
log.warn(
|
||||
`[stripe] ${name} does not start with "price_" (got "${value.slice(0, 6)}…") — ` +
|
||||
'Stripe Checkout will reject this. Paste the PRICE id (price_…) from the product page, not the product id (prod_…).',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
360
apps/api/src/routes/billing.ts
Normal file
360
apps/api/src/routes/billing.ts
Normal file
@ -0,0 +1,360 @@
|
||||
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 },
|
||||
});
|
||||
}
|
||||
305
apps/web/app/(dashboard)/settings/billing/page.tsx
Normal file
305
apps/web/app/(dashboard)/settings/billing/page.tsx
Normal file
@ -0,0 +1,305 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
type Plan = 'hobby' | 'pro' | 'team' | 'enterprise';
|
||||
type Tier = 'pro_monthly' | 'pro_yearly' | 'team_monthly' | 'team_yearly';
|
||||
|
||||
interface BillingStatus {
|
||||
plan: Plan;
|
||||
hasCustomer: boolean;
|
||||
hasSubscription: boolean;
|
||||
suspended: boolean;
|
||||
suspendedReason: string | null;
|
||||
}
|
||||
|
||||
const PLAN_LABEL: Record<Plan, string> = {
|
||||
hobby: 'Hobby (free)',
|
||||
pro: 'Pro',
|
||||
team: 'Team',
|
||||
enterprise: 'Enterprise',
|
||||
};
|
||||
|
||||
function BillingInner() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const justSubscribed = searchParams.get('success') === 'true';
|
||||
const cancelledCheckout = searchParams.get('cancelled') === 'true';
|
||||
const autoUpgradeTier = searchParams.get('tier') as Tier | null;
|
||||
|
||||
const [status, setStatus] = useState<BillingStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const loadStatus = useCallback(() => {
|
||||
apiFetch<BillingStatus>('/v1/billing/status')
|
||||
.then(setStatus)
|
||||
.catch((e) => {
|
||||
const err = e as { status?: number };
|
||||
if (err.status === 401) router.push('/login?returnTo=/settings/billing');
|
||||
else setError((e as Error).message);
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
// Came back from Stripe Checkout? Poll briefly — the webhook usually arrives
|
||||
// within a couple seconds and flips the plan.
|
||||
useEffect(() => {
|
||||
if (!justSubscribed) return;
|
||||
let tries = 0;
|
||||
const id = setInterval(() => {
|
||||
tries += 1;
|
||||
loadStatus();
|
||||
if (tries >= 6) clearInterval(id);
|
||||
}, 1500);
|
||||
return () => clearInterval(id);
|
||||
}, [justSubscribed, loadStatus]);
|
||||
|
||||
const startCheckout = useCallback(
|
||||
async (tier: Tier) => {
|
||||
setBusy(tier);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch<{ url: string }>('/v1/billing/checkout-session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tier }),
|
||||
});
|
||||
window.location.href = res.url;
|
||||
} catch (e) {
|
||||
setBusy(null);
|
||||
const detail = (e as { detail?: { detail?: string; error?: string } }).detail;
|
||||
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Auto-fire checkout when the user lands here from the pricing page CTA.
|
||||
useEffect(() => {
|
||||
if (!autoUpgradeTier || !status) return;
|
||||
if (status.hasSubscription) return; // already paying — don't redirect
|
||||
void startCheckout(autoUpgradeTier);
|
||||
}, [autoUpgradeTier, status, startCheckout]);
|
||||
|
||||
async function openPortal() {
|
||||
setBusy('portal');
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch<{ url: string }>('/v1/billing/portal', {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
});
|
||||
window.location.href = res.url;
|
||||
} catch (e) {
|
||||
setBusy(null);
|
||||
const detail = (e as { detail?: { detail?: string; error?: string } }).detail;
|
||||
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!status && !error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-12 text-center">
|
||||
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
|
||||
<p className="mt-3 text-[13px] text-[--color-fg-muted]">Loading billing…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-10">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">Billing</h1>
|
||||
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
||||
Manage your subscription, payment method, and invoices.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{cancelledCheckout && (
|
||||
<div className="mt-4 rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-3.5 py-2.5 text-[12.5px]">
|
||||
Checkout cancelled. No charge made.
|
||||
</div>
|
||||
)}
|
||||
{justSubscribed && (
|
||||
<div className="mt-4 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3.5 py-2.5 text-[12.5px]">
|
||||
Subscription active. Plan will update within a few seconds — refreshing…
|
||||
</div>
|
||||
)}
|
||||
{status?.suspended && (
|
||||
<div className="mt-4 rounded-md border border-amber-500/40 bg-amber-500/10 px-3.5 py-2.5 text-[12.5px]">
|
||||
<strong>Subscription paused</strong> — {status.suspendedReason ?? 'payment issue'}.
|
||||
Update your payment method below to restore access. Existing servers keep running.
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-4 rounded-md border border-[--color-danger]/40 bg-[--color-danger]/10 px-3.5 py-2.5 text-[12.5px] text-[--color-fg]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className="panel mt-6 p-5">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
|
||||
Current plan
|
||||
</div>
|
||||
<div className="mt-1 text-[20px] font-semibold tracking-tight">
|
||||
{PLAN_LABEL[status.plan]}
|
||||
</div>
|
||||
</div>
|
||||
{status.hasSubscription && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
onClick={openPortal}
|
||||
disabled={busy === 'portal'}
|
||||
>
|
||||
{busy === 'portal' ? 'Opening…' : 'Manage billing'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!status.hasSubscription && (
|
||||
<p className="mt-3 text-[12.5px] text-[--color-fg-muted]">
|
||||
You're on the free tier. Upgrade below to unlock more servers, faster Claude
|
||||
analysis and higher daily limits.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && !status.hasSubscription && (
|
||||
<>
|
||||
<h2 className="mt-10 text-[15px] font-semibold tracking-tight">Choose a plan</h2>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<TierCard
|
||||
name="Pro"
|
||||
monthly={49}
|
||||
yearly={490}
|
||||
features={['5 MCP servers', '1M tool calls / mo', 'Custom domain', 'Claude Haiku 4.5']}
|
||||
busy={busy}
|
||||
onSubscribe={startCheckout}
|
||||
monthlyTier="pro_monthly"
|
||||
yearlyTier="pro_yearly"
|
||||
highlight
|
||||
/>
|
||||
<TierCard
|
||||
name="Team"
|
||||
monthly={199}
|
||||
yearly={1990}
|
||||
features={[
|
||||
'25 MCP servers',
|
||||
'10M tool calls / mo',
|
||||
'RBAC + audit log',
|
||||
'Claude Sonnet 4.6',
|
||||
]}
|
||||
busy={busy}
|
||||
onSubscribe={startCheckout}
|
||||
monthlyTier="team_monthly"
|
||||
yearlyTier="team_yearly"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-4 text-[12px] text-[--color-fg-subtle]">
|
||||
Annual saves 2 months. VAT calculated automatically based on your billing address. Cancel
|
||||
any time from the billing portal — service continues until end of period.
|
||||
</p>
|
||||
<p className="mt-2 text-[12px] text-[--color-fg-subtle]">
|
||||
Need Enterprise (BYOC, SSO, EU-data-residency)?{' '}
|
||||
<a
|
||||
className="text-[--color-accent] hover:underline"
|
||||
href="mailto:sales@buildmymcpserver.com"
|
||||
>
|
||||
Contact sales
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-10 text-[12px] text-[--color-fg-subtle]">
|
||||
<Link href="/pricing" className="hover:text-[--color-fg]">
|
||||
← Compare all plans
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TierCard({
|
||||
name,
|
||||
monthly,
|
||||
yearly,
|
||||
features,
|
||||
busy,
|
||||
onSubscribe,
|
||||
monthlyTier,
|
||||
yearlyTier,
|
||||
highlight,
|
||||
}: {
|
||||
name: string;
|
||||
monthly: number;
|
||||
yearly: number;
|
||||
features: string[];
|
||||
busy: string | null;
|
||||
onSubscribe: (tier: Tier) => void;
|
||||
monthlyTier: Tier;
|
||||
yearlyTier: Tier;
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`panel flex h-full flex-col p-4 ${highlight ? 'border-[--color-accent]/40' : ''}`}
|
||||
>
|
||||
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">{name}</div>
|
||||
<div className="mt-1 flex items-baseline gap-1">
|
||||
<span className="text-[24px] font-semibold tracking-tight">€{monthly}</span>
|
||||
<span className="text-[12px] text-[--color-fg-subtle]">/ month</span>
|
||||
</div>
|
||||
<ul className="mt-3 space-y-1 text-[12.5px] text-[--color-fg-muted]">
|
||||
{features.map((f) => (
|
||||
<li key={f}>— {f}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Button
|
||||
variant={highlight ? 'primary' : 'secondary'}
|
||||
size="md"
|
||||
onClick={() => onSubscribe(monthlyTier)}
|
||||
disabled={Boolean(busy)}
|
||||
>
|
||||
{busy === monthlyTier ? 'Redirecting…' : `Subscribe — €${monthly}/mo`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
onClick={() => onSubscribe(yearlyTier)}
|
||||
disabled={Boolean(busy)}
|
||||
>
|
||||
{busy === yearlyTier ? 'Redirecting…' : `Or €${yearly}/year — 2 months free`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BillingPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="mx-auto max-w-3xl px-6 py-12 text-center">
|
||||
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<BillingInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@ -42,7 +42,7 @@ const TIERS = [
|
||||
'Email support, 1 business-day SLA',
|
||||
],
|
||||
cta: 'Start Pro',
|
||||
href: '/login',
|
||||
href: '/settings/billing?tier=pro_monthly',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
@ -61,7 +61,7 @@ const TIERS = [
|
||||
'Shared Slack channel support',
|
||||
],
|
||||
cta: 'Start Team',
|
||||
href: '/login',
|
||||
href: '/settings/billing?tier=team_monthly',
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
|
||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@ -56,6 +56,9 @@ importers:
|
||||
jose:
|
||||
specifier: 5.9.6
|
||||
version: 5.9.6
|
||||
stripe:
|
||||
specifier: ^22.1.1
|
||||
version: 22.1.1(@types/node@22.10.2)
|
||||
zod:
|
||||
specifier: 3.25.76
|
||||
version: 3.25.76
|
||||
@ -1982,6 +1985,15 @@ packages:
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
stripe@22.1.1:
|
||||
resolution: {integrity: sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
styled-jsx@5.1.6:
|
||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@ -3616,6 +3628,10 @@ snapshots:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
stripe@22.1.1(@types/node@22.10.2):
|
||||
optionalDependencies:
|
||||
'@types/node': 22.10.2
|
||||
|
||||
styled-jsx@5.1.6(react@19.0.0):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
|
||||
Loading…
Reference in New Issue
Block a user