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:
@@ -24,6 +24,13 @@ const Env = z.object({
|
||||
TWILIO_ACCOUNT_SID: z.string().optional(),
|
||||
TWILIO_AUTH_TOKEN: z.string().optional(),
|
||||
TWILIO_SMS_FROM: z.string().optional(),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
||||
STRIPE_PRICE_PRO_MONTHLY: z.string().optional(),
|
||||
STRIPE_PRICE_PRO_YEARLY: z.string().optional(),
|
||||
STRIPE_PRICE_TEAM_MONTHLY: z.string().optional(),
|
||||
STRIPE_PRICE_TEAM_YEARLY: z.string().optional(),
|
||||
});
|
||||
|
||||
export const config = Env.parse({
|
||||
@@ -47,6 +54,13 @@ export const config = Env.parse({
|
||||
TWILIO_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID,
|
||||
TWILIO_AUTH_TOKEN: process.env.TWILIO_AUTH_TOKEN,
|
||||
TWILIO_SMS_FROM: process.env.TWILIO_SMS_FROM,
|
||||
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
||||
STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY,
|
||||
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
||||
STRIPE_PRICE_PRO_MONTHLY: process.env.STRIPE_PRICE_PRO_MONTHLY,
|
||||
STRIPE_PRICE_PRO_YEARLY: process.env.STRIPE_PRICE_PRO_YEARLY,
|
||||
STRIPE_PRICE_TEAM_MONTHLY: process.env.STRIPE_PRICE_TEAM_MONTHLY,
|
||||
STRIPE_PRICE_TEAM_YEARLY: process.env.STRIPE_PRICE_TEAM_YEARLY,
|
||||
});
|
||||
|
||||
// INFRA-001: refuse to boot in production with the placeholder encryption key.
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import cookie from '@fastify/cookie';
|
||||
import websocket from '@fastify/websocket';
|
||||
import { seedAdmin } from '@bmm/auth';
|
||||
import cookie from '@fastify/cookie';
|
||||
import cors from '@fastify/cors';
|
||||
import websocket from '@fastify/websocket';
|
||||
import Fastify from 'fastify';
|
||||
import { config } from './config.js';
|
||||
import { ensureActiveKey } from './lib/crypto.js';
|
||||
import { authRoutes } from './routes/auth.js';
|
||||
import { serverRoutes } from './routes/servers.js';
|
||||
import { oauthRoutes } from './routes/oauth.js';
|
||||
import { settingsRoutes } from './routes/settings.js';
|
||||
import { validateStripePriceConfig } from './lib/stripe.js';
|
||||
import { adminRoutes } from './routes/admin.js';
|
||||
import { authRoutes } from './routes/auth.js';
|
||||
import { billingRoutes } from './routes/billing.js';
|
||||
import { oauthRoutes } from './routes/oauth.js';
|
||||
import { serverRoutes } from './routes/servers.js';
|
||||
import { settingsRoutes } from './routes/settings.js';
|
||||
import { templateRoutes } from './routes/templates.js';
|
||||
|
||||
// Stripe webhook signature verification requires the raw request body, so we
|
||||
// stash a copy on req.rawBody during JSON parsing. Merges into Fastify's
|
||||
// FastifyRequest interface declaration alongside `user` from plugins/session.
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
rawBody?: Buffer;
|
||||
}
|
||||
}
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: config.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
},
|
||||
});
|
||||
|
||||
// Replace the default JSON parser with one that keeps the raw buffer for the
|
||||
// Stripe-webhook signature check. Must run BEFORE any route registration.
|
||||
app.addContentTypeParser(
|
||||
'application/json',
|
||||
{ parseAs: 'buffer' },
|
||||
(req, body, done) => {
|
||||
const buf = body as Buffer;
|
||||
req.rawBody = buf;
|
||||
if (buf.length === 0) return done(null, undefined);
|
||||
try {
|
||||
done(null, JSON.parse(buf.toString('utf8')));
|
||||
} catch (err) {
|
||||
done(err as Error, undefined);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await app.register(cors, {
|
||||
origin: [config.NEXT_PUBLIC_APP_URL],
|
||||
credentials: true,
|
||||
@@ -43,6 +71,12 @@ await app.register(oauthRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(adminRoutes);
|
||||
await app.register(templateRoutes);
|
||||
await app.register(billingRoutes);
|
||||
|
||||
// Loud warning if STRIPE_PRICE_* env vars are set to product ids (prod_…)
|
||||
// instead of price ids (price_…). Stripe Checkout would silently 400 — easier
|
||||
// to find at boot.
|
||||
validateStripePriceConfig({ warn: (msg) => app.log.warn(msg) });
|
||||
|
||||
// Bootstrap admin user from env (idempotent)
|
||||
if (config.ADMIN_EMAIL && config.ADMIN_PASSWORD) {
|
||||
|
||||
87
apps/api/src/lib/stripe.ts
Normal file
87
apps/api/src/lib/stripe.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Plan } from '@bmm/llm';
|
||||
import Stripe from 'stripe';
|
||||
import { config } from '../config.js';
|
||||
import { getRedis } from './redis.js';
|
||||
|
||||
/**
|
||||
* Stripe client (null when no secret key is configured — e.g. in dev/test).
|
||||
* `apiVersion` pinned to the current default to avoid silent breakage when
|
||||
* Stripe rolls out new defaults.
|
||||
*/
|
||||
export const stripe: Stripe | null = config.STRIPE_SECRET_KEY
|
||||
? new Stripe(config.STRIPE_SECRET_KEY, {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: SDK type lags behind real API version strings
|
||||
apiVersion: '2025-10-29.acacia' as any,
|
||||
typescript: true,
|
||||
})
|
||||
: null;
|
||||
|
||||
export type PriceTier = 'pro_monthly' | 'pro_yearly' | 'team_monthly' | 'team_yearly';
|
||||
|
||||
export function priceIdForTier(tier: PriceTier): string | undefined {
|
||||
switch (tier) {
|
||||
case 'pro_monthly':
|
||||
return config.STRIPE_PRICE_PRO_MONTHLY;
|
||||
case 'pro_yearly':
|
||||
return config.STRIPE_PRICE_PRO_YEARLY;
|
||||
case 'team_monthly':
|
||||
return config.STRIPE_PRICE_TEAM_MONTHLY;
|
||||
case 'team_yearly':
|
||||
return config.STRIPE_PRICE_TEAM_YEARLY;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reverse map: which plan does a Stripe price id belong to. Unknown → hobby. */
|
||||
export function planFromPriceId(priceId: string | undefined): Plan {
|
||||
if (!priceId) return 'hobby';
|
||||
if (
|
||||
priceId === config.STRIPE_PRICE_PRO_MONTHLY ||
|
||||
priceId === config.STRIPE_PRICE_PRO_YEARLY
|
||||
) {
|
||||
return 'pro';
|
||||
}
|
||||
if (
|
||||
priceId === config.STRIPE_PRICE_TEAM_MONTHLY ||
|
||||
priceId === config.STRIPE_PRICE_TEAM_YEARLY
|
||||
) {
|
||||
return 'team';
|
||||
}
|
||||
return 'hobby';
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotency for Stripe webhooks. Stripe retries failed deliveries — we must
|
||||
* dedupe by event.id or we'd e.g. double-cancel a subscription. SET NX with a
|
||||
* 7-day TTL covers Stripe's full retry window.
|
||||
*
|
||||
* Returns true if this event was already processed (caller should skip).
|
||||
*/
|
||||
export async function isDuplicateEvent(eventId: string): Promise<boolean> {
|
||||
const redis = getRedis();
|
||||
const key = `stripe:event:${eventId}`;
|
||||
const set = await redis.set(key, '1', 'EX', 7 * 24 * 60 * 60, 'NX');
|
||||
return set === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanity-check that price-id env vars actually contain price ids — a common
|
||||
* setup mistake is to paste the product id (prod_…) instead. Logs loudly on
|
||||
* boot so we discover misconfiguration before the first checkout attempt.
|
||||
*/
|
||||
export function validateStripePriceConfig(log: { warn: (msg: string) => void }): void {
|
||||
const checks: Array<[string, string | undefined]> = [
|
||||
['STRIPE_PRICE_PRO_MONTHLY', config.STRIPE_PRICE_PRO_MONTHLY],
|
||||
['STRIPE_PRICE_PRO_YEARLY', config.STRIPE_PRICE_PRO_YEARLY],
|
||||
['STRIPE_PRICE_TEAM_MONTHLY', config.STRIPE_PRICE_TEAM_MONTHLY],
|
||||
['STRIPE_PRICE_TEAM_YEARLY', config.STRIPE_PRICE_TEAM_YEARLY],
|
||||
];
|
||||
for (const [name, value] of checks) {
|
||||
if (!value) continue;
|
||||
if (!value.startsWith('price_')) {
|
||||
log.warn(
|
||||
`[stripe] ${name} does not start with "price_" (got "${value.slice(0, 6)}…") — ` +
|
||||
'Stripe Checkout will reject this. Paste the PRICE id (price_…) from the product page, not the product id (prod_…).',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
360
apps/api/src/routes/billing.ts
Normal file
360
apps/api/src/routes/billing.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { createDb, eq, organizations } from '@bmm/db';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type Stripe from 'stripe';
|
||||
import { z } from 'zod';
|
||||
import { config } from '../config.js';
|
||||
import { audit } from '../lib/audit.js';
|
||||
import {
|
||||
type PriceTier,
|
||||
isDuplicateEvent,
|
||||
planFromPriceId,
|
||||
priceIdForTier,
|
||||
stripe,
|
||||
} from '../lib/stripe.js';
|
||||
import { requireAuth } from '../plugins/session.js';
|
||||
|
||||
const db = createDb();
|
||||
|
||||
const TierBody = z.object({
|
||||
tier: z.enum(['pro_monthly', 'pro_yearly', 'team_monthly', 'team_yearly']),
|
||||
});
|
||||
|
||||
export async function billingRoutes(app: FastifyInstance): Promise<void> {
|
||||
// ─── Checkout ────────────────────────────────────────────────────────────
|
||||
app.post('/v1/billing/checkout-session', { preHandler: requireAuth }, async (req, reply) => {
|
||||
if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
|
||||
const user = req.user!;
|
||||
const parsed = TierBody.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
|
||||
|
||||
const priceId = priceIdForTier(parsed.data.tier as PriceTier);
|
||||
if (!priceId) {
|
||||
return reply.code(503).send({ error: 'price_not_configured', tier: parsed.data.tier });
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select({ stripeCustomerId: organizations.stripeCustomerId })
|
||||
.from(organizations)
|
||||
.where(eq(organizations.id, user.orgId))
|
||||
.limit(1);
|
||||
if (!org) return reply.code(404).send({ error: 'org_not_found' });
|
||||
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
payment_method_types: ['card', 'sepa_debit'],
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
// Reuse Stripe customer if we have one — keeps invoices on one account
|
||||
// even when the user upgrades/downgrades repeatedly.
|
||||
...(org.stripeCustomerId
|
||||
? { customer: org.stripeCustomerId }
|
||||
: { customer_email: user.email ?? undefined }),
|
||||
client_reference_id: user.orgId,
|
||||
metadata: { orgId: user.orgId, userId: user.userId, tier: parsed.data.tier },
|
||||
subscription_data: {
|
||||
metadata: { orgId: user.orgId, userId: user.userId },
|
||||
},
|
||||
success_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?success=true`,
|
||||
cancel_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?cancelled=true`,
|
||||
automatic_tax: { enabled: true },
|
||||
tax_id_collection: { enabled: true },
|
||||
billing_address_collection: 'required',
|
||||
allow_promotion_codes: true,
|
||||
});
|
||||
|
||||
await audit({
|
||||
orgId: user.orgId,
|
||||
userId: user.userId,
|
||||
action: 'billing.checkout_initiated',
|
||||
resourceType: 'subscription',
|
||||
metadata: { tier: parsed.data.tier },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
return reply.send({ url: session.url, sessionId: session.id });
|
||||
} catch (err) {
|
||||
app.log.error({ err }, 'checkout session create failed');
|
||||
const msg = err instanceof Error ? err.message : 'unknown_error';
|
||||
return reply.code(502).send({ error: 'checkout_failed', detail: msg });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Customer Portal ─────────────────────────────────────────────────────
|
||||
app.post('/v1/billing/portal', { preHandler: requireAuth }, async (req, reply) => {
|
||||
if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
|
||||
const user = req.user!;
|
||||
const [org] = await db
|
||||
.select({ stripeCustomerId: organizations.stripeCustomerId })
|
||||
.from(organizations)
|
||||
.where(eq(organizations.id, user.orgId))
|
||||
.limit(1);
|
||||
if (!org?.stripeCustomerId) {
|
||||
return reply.code(409).send({
|
||||
error: 'no_customer_yet',
|
||||
detail: 'Subscribe first to access the billing portal.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: org.stripeCustomerId,
|
||||
return_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing`,
|
||||
});
|
||||
return reply.send({ url: session.url });
|
||||
} catch (err) {
|
||||
app.log.error({ err }, 'portal session create failed');
|
||||
return reply.code(502).send({ error: 'portal_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Billing status — drives the /settings/billing UI ────────────────────
|
||||
app.get('/v1/billing/status', { preHandler: requireAuth }, async (req, reply) => {
|
||||
const user = req.user!;
|
||||
const [org] = await db
|
||||
.select({
|
||||
plan: organizations.plan,
|
||||
stripeCustomerId: organizations.stripeCustomerId,
|
||||
stripeSubscriptionId: organizations.stripeSubscriptionId,
|
||||
suspended: organizations.suspended,
|
||||
suspendedReason: organizations.suspendedReason,
|
||||
})
|
||||
.from(organizations)
|
||||
.where(eq(organizations.id, user.orgId))
|
||||
.limit(1);
|
||||
if (!org) return reply.code(404).send({ error: 'org_not_found' });
|
||||
return reply.send({
|
||||
plan: org.plan,
|
||||
hasCustomer: Boolean(org.stripeCustomerId),
|
||||
hasSubscription: Boolean(org.stripeSubscriptionId),
|
||||
suspended: org.suspended,
|
||||
suspendedReason: org.suspendedReason,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Webhook ─────────────────────────────────────────────────────────────
|
||||
// Stripe signs the raw body — our index.ts content parser stashes the
|
||||
// buffer on req.rawBody before JSON-parsing it for normal handlers.
|
||||
app.post('/v1/billing/webhook', async (req, reply) => {
|
||||
if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
|
||||
if (!config.STRIPE_WEBHOOK_SECRET) {
|
||||
app.log.error('webhook called without STRIPE_WEBHOOK_SECRET configured');
|
||||
return reply.code(503).send({ error: 'webhook_not_configured' });
|
||||
}
|
||||
const signature = req.headers['stripe-signature'];
|
||||
if (typeof signature !== 'string') {
|
||||
return reply.code(400).send({ error: 'no_signature' });
|
||||
}
|
||||
const rawBody = (req as { rawBody?: Buffer }).rawBody;
|
||||
if (!rawBody) {
|
||||
app.log.error('webhook called without rawBody — content parser missing');
|
||||
return reply.code(500).send({ error: 'no_raw_body' });
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
rawBody,
|
||||
signature,
|
||||
config.STRIPE_WEBHOOK_SECRET,
|
||||
);
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'webhook signature verify failed');
|
||||
return reply.code(400).send({ error: 'bad_signature' });
|
||||
}
|
||||
|
||||
if (await isDuplicateEvent(event.id)) {
|
||||
app.log.info({ eventId: event.id, type: event.type }, 'webhook duplicate, skipped');
|
||||
return reply.send({ ok: true, deduped: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await handleStripeEvent(app, event);
|
||||
return reply.send({ ok: true });
|
||||
} catch (err) {
|
||||
// Return 5xx so Stripe retries with exponential backoff.
|
||||
app.log.error(
|
||||
{ err, eventId: event.id, type: event.type },
|
||||
'webhook handler failed — Stripe will retry',
|
||||
);
|
||||
return reply.code(500).send({ error: 'handler_failed' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Event dispatch ──────────────────────────────────────────────────────────
|
||||
|
||||
async function handleStripeEvent(app: FastifyInstance, event: Stripe.Event): Promise<void> {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed':
|
||||
await handleCheckoutCompleted(app, event.data.object as Stripe.Checkout.Session);
|
||||
break;
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated':
|
||||
await handleSubscriptionChange(app, event.data.object as Stripe.Subscription);
|
||||
break;
|
||||
case 'customer.subscription.deleted':
|
||||
await handleSubscriptionDeleted(app, event.data.object as Stripe.Subscription);
|
||||
break;
|
||||
case 'invoice.paid':
|
||||
await handleInvoicePaid(app, event.data.object as Stripe.Invoice);
|
||||
break;
|
||||
case 'invoice.payment_failed':
|
||||
await handlePaymentFailed(app, event.data.object as Stripe.Invoice);
|
||||
break;
|
||||
default:
|
||||
app.log.debug({ type: event.type }, 'unhandled stripe event type');
|
||||
}
|
||||
}
|
||||
|
||||
async function findOrgIdForSubscription(sub: Stripe.Subscription): Promise<string | null> {
|
||||
// Prefer the metadata we set at checkout — it's the most reliable mapping.
|
||||
// Fallback: look the org up by stored customer id.
|
||||
const metaOrgId = sub.metadata?.orgId;
|
||||
if (typeof metaOrgId === 'string' && metaOrgId.length > 0) return metaOrgId;
|
||||
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
|
||||
const [row] = await db
|
||||
.select({ id: organizations.id })
|
||||
.from(organizations)
|
||||
.where(eq(organizations.stripeCustomerId, customerId))
|
||||
.limit(1);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
async function findOrgIdForInvoice(invoice: Stripe.Invoice): Promise<string | null> {
|
||||
const customerId =
|
||||
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
|
||||
if (!customerId) return null;
|
||||
const [row] = await db
|
||||
.select({ id: organizations.id })
|
||||
.from(organizations)
|
||||
.where(eq(organizations.stripeCustomerId, customerId))
|
||||
.limit(1);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
async function handleCheckoutCompleted(
|
||||
app: FastifyInstance,
|
||||
session: Stripe.Checkout.Session,
|
||||
): Promise<void> {
|
||||
const orgId = session.metadata?.orgId ?? session.client_reference_id ?? null;
|
||||
if (!orgId) {
|
||||
app.log.warn({ sessionId: session.id }, 'checkout completed without orgId');
|
||||
return;
|
||||
}
|
||||
const customerId =
|
||||
typeof session.customer === 'string' ? session.customer : session.customer?.id;
|
||||
if (!customerId) return;
|
||||
await db
|
||||
.update(organizations)
|
||||
.set({ stripeCustomerId: customerId })
|
||||
.where(eq(organizations.id, orgId));
|
||||
await audit({
|
||||
orgId,
|
||||
action: 'billing.checkout_completed',
|
||||
resourceType: 'subscription',
|
||||
metadata: { customerId, sessionId: session.id },
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubscriptionChange(
|
||||
app: FastifyInstance,
|
||||
sub: Stripe.Subscription,
|
||||
): Promise<void> {
|
||||
const orgId = await findOrgIdForSubscription(sub);
|
||||
if (!orgId) {
|
||||
app.log.warn({ subId: sub.id, customer: sub.customer }, 'sub change for unknown org');
|
||||
return;
|
||||
}
|
||||
const priceId = sub.items.data[0]?.price.id;
|
||||
const plan = planFromPriceId(priceId);
|
||||
const active = sub.status === 'active' || sub.status === 'trialing';
|
||||
const suspended = sub.status === 'past_due' || sub.status === 'unpaid';
|
||||
|
||||
await db
|
||||
.update(organizations)
|
||||
.set({
|
||||
plan: active ? plan : 'hobby',
|
||||
stripeSubscriptionId: sub.id,
|
||||
suspended,
|
||||
suspendedReason: suspended ? `subscription_${sub.status}` : null,
|
||||
})
|
||||
.where(eq(organizations.id, orgId));
|
||||
|
||||
await audit({
|
||||
orgId,
|
||||
action: 'billing.subscription_changed',
|
||||
resourceType: 'subscription',
|
||||
metadata: { plan, status: sub.status, subId: sub.id, priceId: priceId ?? null },
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubscriptionDeleted(
|
||||
app: FastifyInstance,
|
||||
sub: Stripe.Subscription,
|
||||
): Promise<void> {
|
||||
const orgId = await findOrgIdForSubscription(sub);
|
||||
if (!orgId) {
|
||||
app.log.warn({ subId: sub.id }, 'sub delete for unknown org');
|
||||
return;
|
||||
}
|
||||
await db
|
||||
.update(organizations)
|
||||
.set({
|
||||
plan: 'hobby',
|
||||
stripeSubscriptionId: null,
|
||||
suspended: false,
|
||||
suspendedReason: null,
|
||||
})
|
||||
.where(eq(organizations.id, orgId));
|
||||
await audit({
|
||||
orgId,
|
||||
action: 'billing.subscription_cancelled',
|
||||
resourceType: 'subscription',
|
||||
metadata: { subId: sub.id },
|
||||
});
|
||||
}
|
||||
|
||||
async function handleInvoicePaid(_app: FastifyInstance, invoice: Stripe.Invoice): Promise<void> {
|
||||
const orgId = await findOrgIdForInvoice(invoice);
|
||||
if (!orgId) return;
|
||||
// Successful renewal — clear any past-due suspension and reset the usage
|
||||
// period (so the new month's call quota starts fresh).
|
||||
await db
|
||||
.update(organizations)
|
||||
.set({
|
||||
suspended: false,
|
||||
suspendedReason: null,
|
||||
callsThisPeriod: 0,
|
||||
periodStartsAt: new Date(),
|
||||
})
|
||||
.where(eq(organizations.id, orgId));
|
||||
await audit({
|
||||
orgId,
|
||||
action: 'billing.invoice_paid',
|
||||
resourceType: 'invoice',
|
||||
metadata: { invoiceId: invoice.id ?? null, amountPaid: invoice.amount_paid ?? 0 },
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePaymentFailed(
|
||||
_app: FastifyInstance,
|
||||
invoice: Stripe.Invoice,
|
||||
): Promise<void> {
|
||||
const orgId = await findOrgIdForInvoice(invoice);
|
||||
if (!orgId) return;
|
||||
const attempts = invoice.attempt_count ?? 0;
|
||||
// Only suspend after the 3rd failed attempt — Stripe Smart Retries will keep
|
||||
// trying for several days, so the user has time to update their card.
|
||||
if (attempts >= 3) {
|
||||
await db
|
||||
.update(organizations)
|
||||
.set({ suspended: true, suspendedReason: 'payment_failed' })
|
||||
.where(eq(organizations.id, orgId));
|
||||
}
|
||||
await audit({
|
||||
orgId,
|
||||
action: 'billing.payment_failed',
|
||||
resourceType: 'invoice',
|
||||
metadata: { invoiceId: invoice.id ?? null, attempts },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user