diff --git a/apps/api/package.json b/apps/api/package.json index b6b8b5f..fb6ba28 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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": { diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 03cec2b..d79c844 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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. diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 696ecb3..7fbb4a8 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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) { diff --git a/apps/api/src/lib/stripe.ts b/apps/api/src/lib/stripe.ts new file mode 100644 index 0000000..71933e0 --- /dev/null +++ b/apps/api/src/lib/stripe.ts @@ -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 { + 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_…).', + ); + } + } +} diff --git a/apps/api/src/routes/billing.ts b/apps/api/src/routes/billing.ts new file mode 100644 index 0000000..9f0f450 --- /dev/null +++ b/apps/api/src/routes/billing.ts @@ -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 { + // ─── 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }, + }); +} diff --git a/apps/web/app/(dashboard)/settings/billing/page.tsx b/apps/web/app/(dashboard)/settings/billing/page.tsx new file mode 100644 index 0000000..5bb29a4 --- /dev/null +++ b/apps/web/app/(dashboard)/settings/billing/page.tsx @@ -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 = { + 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(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + + const loadStatus = useCallback(() => { + apiFetch('/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 ( +
+ +

Loading billing…

+
+ ); + } + + return ( +
+
+

Billing

+

+ Manage your subscription, payment method, and invoices. +

+
+ + {cancelledCheckout && ( +
+ Checkout cancelled. No charge made. +
+ )} + {justSubscribed && ( +
+ Subscription active. Plan will update within a few seconds — refreshing… +
+ )} + {status?.suspended && ( +
+ Subscription paused — {status.suspendedReason ?? 'payment issue'}. + Update your payment method below to restore access. Existing servers keep running. +
+ )} + {error && ( +
+ {error} +
+ )} + + {status && ( +
+
+
+
+ Current plan +
+
+ {PLAN_LABEL[status.plan]} +
+
+ {status.hasSubscription && ( + + )} +
+ {!status.hasSubscription && ( +

+ You're on the free tier. Upgrade below to unlock more servers, faster Claude + analysis and higher daily limits. +

+ )} +
+ )} + + {status && !status.hasSubscription && ( + <> +

Choose a plan

+
+ + +
+

+ 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. +

+

+ Need Enterprise (BYOC, SSO, EU-data-residency)?{' '} + + Contact sales + + . +

+ + )} + +
+ + ← Compare all plans + +
+
+ ); +} + +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 ( +
+
{name}
+
+ €{monthly} + / month +
+
    + {features.map((f) => ( +
  • — {f}
  • + ))} +
+
+ + +
+
+ ); +} + +export default function BillingPage() { + return ( + + + + } + > + + + ); +} diff --git a/apps/web/app/(marketing)/pricing/page.tsx b/apps/web/app/(marketing)/pricing/page.tsx index dc83f1f..a746942 100644 --- a/apps/web/app/(marketing)/pricing/page.tsx +++ b/apps/web/app/(marketing)/pricing/page.tsx @@ -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', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ffc37c..fc16747 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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