La mayoría de los artículos sobre "integración Stripe" se quedan en el botón de Checkout. El billing en producción es el entry point de abajo: una config PLANS tipada que guía cada superficie, una Server Action de Checkout que sabe de divisas y trials, un helper de Customer Portal que vuelve al sitio correcto, y un handler de webhook que escribe la fila de subscription resultante cuando Checkout completa.
1. La config de pricing
Los planes viven en el código. El mismo archivo es la fuente de verdad para Checkout, Customer Portal, dashboard de admin y página de pricing del marketing. Actualizar un precio es una edición tipada, no un click en el dashboard de Stripe.
// src/lib/billing/plans.ts
import { z } from 'zod'
export const PLAN_IDS = ['starter', 'growth', 'enterprise'] as const
export type PlanId = (typeof PLAN_IDS)[number]
export const CURRENCY_IDS = ['eur', 'usd', 'gbp'] as const
export type CurrencyId = (typeof CURRENCY_IDS)[number]
export interface PlanPrice {
stripePriceId: string
monthlyCents: number
yearlyDiscount: number
}
interface PlanDefinition {
trialDays: number
features: string[]
prices: Record<CurrencyId, PlanPrice>
}
export const PLANS: Record<PlanId, PlanDefinition> = {
starter: {
trialDays: 14,
features: ['5 seats', 'core integrations', 'email support'],
prices: {
eur: {
stripePriceId: process.env.STRIPE_PRICE_STARTER_EUR!,
monthlyCents: 2900,
yearlyDiscount: 0.15,
},
usd: {
stripePriceId: process.env.STRIPE_PRICE_STARTER_USD!,
monthlyCents: 3200,
yearlyDiscount: 0.15,
},
gbp: {
stripePriceId: process.env.STRIPE_PRICE_STARTER_GBP!,
monthlyCents: 2500,
yearlyDiscount: 0.15,
},
},
},
growth: {
trialDays: 14,
features: ['20 seats', 'all integrations', 'priority support', 'SSO'],
prices: {
eur: {
stripePriceId: process.env.STRIPE_PRICE_GROWTH_EUR!,
monthlyCents: 9900,
yearlyDiscount: 0.20,
},
usd: {
stripePriceId: process.env.STRIPE_PRICE_GROWTH_USD!,
monthlyCents: 10900,
yearlyDiscount: 0.20,
},
gbp: {
stripePriceId: process.env.STRIPE_PRICE_GROWTH_GBP!,
monthlyCents: 8500,
yearlyDiscount: 0.20,
},
},
},
enterprise: {
trialDays: 0,
features: ['unlimited seats', 'custom integrations', 'dedicated CSM', 'SLA'],
prices: {
eur: {
stripePriceId: process.env.STRIPE_PRICE_ENTERPRISE_EUR!,
monthlyCents: 49900,
yearlyDiscount: 0.25,
},
usd: {
stripePriceId: process.env.STRIPE_PRICE_ENTERPRISE_USD!,
monthlyCents: 54900,
yearlyDiscount: 0.25,
},
gbp: {
stripePriceId: process.env.STRIPE_PRICE_ENTERPRISE_GBP!,
monthlyCents: 42900,
yearlyDiscount: 0.25,
},
},
},
}
export const PlanIdSchema = z.enum(PLAN_IDS)
export const CurrencyIdSchema = z.enum(CURRENCY_IDS)
2. La Server Action de Checkout
Una Server Action tipada crea una Checkout Session. La action lee la config PLANS, elige el precio por plan y divisa, aplica el trial y devuelve la URL a la que Stripe espera que el navegador navegue.
// app/[lang]/(public)/pricing/actions.ts
'use server'
import { z } from 'zod'
import { redirect } from 'next/navigation'
import { stripe } from '@/lib/stripe/server'
import { PLANS, PlanIdSchema, CurrencyIdSchema } from '@/lib/billing/plans'
import { createDraftTenant } from '@/lib/tenants/server'
const Input = z.object({
email: z.string().email(),
planId: PlanIdSchema,
currency: CurrencyIdSchema,
interval: z.enum(['monthly', 'yearly']),
})
export async function startCheckout(raw: unknown): Promise<void> {
const parsed = Input.parse(raw)
const plan = PLANS[parsed.planId]
const price = plan.prices[parsed.currency]
const tenant = await createDraftTenant(parsed.email)
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer_email: parsed.email,
client_reference_id: tenant.id,
line_items: [
{
price: price.stripePriceId,
quantity: 1,
},
],
subscription_data:
plan.trialDays > 0
? { trial_period_days: plan.trialDays }
: undefined,
automatic_tax: { enabled: true },
billing_address_collection: 'required',
success_url: `${process.env.SITE_URL}/welcome?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.SITE_URL}/pricing`,
metadata: {
tenant_id: tenant.id,
plan_id: parsed.planId,
currency: parsed.currency,
interval: parsed.interval,
},
})
redirect(session.url!)
}
3. El handler de webhook post-Checkout
Cuando Checkout completa, Stripe dispara checkout.session.completed. El handler del webhook es el que escribe la fila canónica de subscription, no la página de success. El navegador puede no llegar nunca a /welcome (error de red, navegación a otro sitio); el webhook siempre llega.
// src/lib/stripe/handlers/checkout-completed.ts
import type Stripe from 'stripe'
import { adminClient } from '@/lib/supabase/admin'
export async function handleCheckoutCompleted(
session: Stripe.Checkout.Session,
): Promise<void> {
if (session.mode !== 'subscription') return
if (!session.subscription || !session.client_reference_id) {
throw new Error('checkout session missing required fields')
}
const subscriptionId =
typeof session.subscription === 'string'
? session.subscription
: session.subscription.id
const customerId =
typeof session.customer === 'string'
? session.customer
: session.customer?.id
if (!customerId) throw new Error('checkout session missing customer')
// Promueve el tenant draft: vincula al usuario, guarda los IDs de Stripe.
const { error: tenantError } = await adminClient
.from('tenants')
.update({
stripe_customer_id: customerId,
status: 'active',
})
.eq('id', session.client_reference_id)
if (tenantError) throw tenantError
// Escribe la fila canónica de subscription. El estado completo de la
// subscription llegará en customer.subscription.updated; esto siembra la fila.
const { error: subError } = await adminClient.from('subscriptions').insert({
id: subscriptionId,
tenant_id: session.client_reference_id,
stripe_customer_id: customerId,
plan_id: session.metadata?.plan_id ?? 'unknown',
status: 'trialing',
created_at: new Date().toISOString(),
})
if (subError) throw subError
}
4. El helper del Customer Portal
Cuando el cliente quiere gestionar su subscription, la aplicación abre un portal hosted por Stripe. Nunca recreamos la UI de Stripe; abrimos una sesión firmada y redirigimos.
// app/[lang]/(app)/account/billing/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { stripe } from '@/lib/stripe/server'
import { createServerClient } from '@/lib/supabase/server'
import { getServerSession } from '@/lib/auth/server'
export async function openBillingPortal(): Promise<void> {
const session = await getServerSession()
if (!session) throw new Error('unauthorised')
const supabase = await createServerClient()
const { data: tenant, error } = await supabase
.from('tenants')
.select('stripe_customer_id')
.eq('id', session.tenantId)
.single()
if (error || !tenant?.stripe_customer_id) {
throw new Error('no billing customer found')
}
const portal = await stripe.billingPortal.sessions.create({
customer: tenant.stripe_customer_id,
return_url: `${process.env.SITE_URL}/account`,
})
redirect(portal.url)
}
5. Cómo encaja todo esto
La config PLANS guía cada parte del sistema de billing. Checkout la lee y crea una sesión. El webhook escribe la fila de subscription resultante. El Customer Portal abre para el self-service. Cada pieza es un archivo con un solo propósito, y el código aplicativo no necesita saber nada de la API de Stripe más allá de estos cuatro entry points.
El billing en producción no es un botón de Checkout. Es la composición de estos cuatro archivos, más el webhook router descrito en la página del stack Stripe, más el job de reconciliación nocturno. Tres mil líneas de trabajo, dos a cuatro semanas si dejas que el SDK haga su trabajo, y un lío irreparable si tratas Stripe como algo que se pueda envolver.