feat(billing): Stripe Checkout + Customer Portal + signed webhook
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:
Marco Sadjadi 2026-05-25 16:30:42 +02:00
parent defb4186b4
commit c2a21fc3cd
8 changed files with 827 additions and 10 deletions

View File

@ -22,6 +22,7 @@
"fastify": "5.2.0", "fastify": "5.2.0",
"ioredis": "5.4.1", "ioredis": "5.4.1",
"jose": "5.9.6", "jose": "5.9.6",
"stripe": "^22.1.1",
"zod": "3.25.76" "zod": "3.25.76"
}, },
"devDependencies": { "devDependencies": {

View File

@ -24,6 +24,13 @@ const Env = z.object({
TWILIO_ACCOUNT_SID: z.string().optional(), TWILIO_ACCOUNT_SID: z.string().optional(),
TWILIO_AUTH_TOKEN: z.string().optional(), TWILIO_AUTH_TOKEN: z.string().optional(),
TWILIO_SMS_FROM: 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({ export const config = Env.parse({
@ -47,6 +54,13 @@ export const config = Env.parse({
TWILIO_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID, TWILIO_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID,
TWILIO_AUTH_TOKEN: process.env.TWILIO_AUTH_TOKEN, TWILIO_AUTH_TOKEN: process.env.TWILIO_AUTH_TOKEN,
TWILIO_SMS_FROM: process.env.TWILIO_SMS_FROM, 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. // INFRA-001: refuse to boot in production with the placeholder encryption key.

View File

@ -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 { 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 { config } from './config.js';
import { ensureActiveKey } from './lib/crypto.js'; import { ensureActiveKey } from './lib/crypto.js';
import { authRoutes } from './routes/auth.js'; import { validateStripePriceConfig } from './lib/stripe.js';
import { serverRoutes } from './routes/servers.js';
import { oauthRoutes } from './routes/oauth.js';
import { settingsRoutes } from './routes/settings.js';
import { adminRoutes } from './routes/admin.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'; 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({ const app = Fastify({
logger: { logger: {
level: config.NODE_ENV === 'production' ? 'info' : 'debug', 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, { await app.register(cors, {
origin: [config.NEXT_PUBLIC_APP_URL], origin: [config.NEXT_PUBLIC_APP_URL],
credentials: true, credentials: true,
@ -43,6 +71,12 @@ await app.register(oauthRoutes);
await app.register(settingsRoutes); await app.register(settingsRoutes);
await app.register(adminRoutes); await app.register(adminRoutes);
await app.register(templateRoutes); 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) // Bootstrap admin user from env (idempotent)
if (config.ADMIN_EMAIL && config.ADMIN_PASSWORD) { if (config.ADMIN_EMAIL && config.ADMIN_PASSWORD) {

View 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_…).',
);
}
}
}

View 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 },
});
}

View 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&apos;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>
);
}

View File

@ -42,7 +42,7 @@ const TIERS = [
'Email support, 1 business-day SLA', 'Email support, 1 business-day SLA',
], ],
cta: 'Start Pro', cta: 'Start Pro',
href: '/login', href: '/settings/billing?tier=pro_monthly',
highlight: true, highlight: true,
}, },
{ {
@ -61,7 +61,7 @@ const TIERS = [
'Shared Slack channel support', 'Shared Slack channel support',
], ],
cta: 'Start Team', cta: 'Start Team',
href: '/login', href: '/settings/billing?tier=team_monthly',
}, },
{ {
name: 'Enterprise', name: 'Enterprise',

16
pnpm-lock.yaml generated
View File

@ -56,6 +56,9 @@ importers:
jose: jose:
specifier: 5.9.6 specifier: 5.9.6
version: 5.9.6 version: 5.9.6
stripe:
specifier: ^22.1.1
version: 22.1.1(@types/node@22.10.2)
zod: zod:
specifier: 3.25.76 specifier: 3.25.76
version: 3.25.76 version: 3.25.76
@ -1982,6 +1985,15 @@ packages:
string_decoder@1.3.0: string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} 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: styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@ -3616,6 +3628,10 @@ snapshots:
dependencies: dependencies:
safe-buffer: 5.2.1 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): styled-jsx@5.1.6(react@19.0.0):
dependencies: dependencies:
client-only: 0.0.1 client-only: 0.0.1