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",
|
"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": {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
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',
|
'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
16
pnpm-lock.yaml
generated
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user