El producto subscription entero, no un botón de Checkout
Planes definidos en código, Customer Portal embebido, webhook router que sobrevive a los reintentos, emails de dunning conectados al proveedor de email transaccionales, fiscalidad gestionada por Stripe Tax, reconciliación nocturna contra la base de datos. La capa de billing que necesita tu SaaS de verdad, definida y entregada en dos a cuatro semanas.
El problema
La mayoría de "integraciones Stripe" se queda en el botón Checkout
El Checkout hosted funciona en una hora. Luego el founder descubre que la capa de billing de verdad necesita subscriptions con prorating, un Customer Portal para cancelaciones, un handler de webhooks que no pierda eventos cuando Stripe reintenta, emails de dunning cuando una tarjeta falla, configuración fiscal por región, plantillas de factura que lleven la marca, y un job de reconciliación que atrape el día en que Stripe y la base de datos discrepan. Seis meses después el equipo tiene un stack de billing a medio hacer que un ingeniero odia en secreto. Nosotros lo entregamos todo en dos a cuatro semanas, definido y con el runbook para mantenerlo sano.
Cómo lo abordamos
Los seis pasos de una integración de billing Stripe
Cada paso cierra una fuente de riesgo. La integración sale cuando el equipo puede correr un signup nuevo, un cambio de plan, una actualización de tarjeta, una cancelación y un reembolso sin abrir el dashboard de Stripe.
- 01
Definir el modelo de pricing en código
Planes, divisas, intervalos de billing, comportamiento de trials y add-ons se definen en una config PLANS tipada bajo source control. Las mismas definiciones alimentan Checkout, Customer Portal, dashboard de admin y página de pricing del marketing. Una sola fuente de verdad, sin sync manual.
- 02
Configurar la cuenta Stripe
Configuración fiscal, umbrales de billing, configuración del Customer Portal, plantillas de factura brandeadas, policy de dunning y Smart Retries se configuran en Stripe y se committean en el runbook. Nada importante vive solo en el dashboard de Stripe.
- 03
Modelar el estado de la subscription en Postgres
Una tabla subscriptions refleja la subscription Stripe de forma canónica (status, current_period_end, cancel_at_period_end, price_id, quantity). La aplicación lee desde Postgres; el webhook mantiene Postgres alineado con Stripe.
- 04
Conectar Checkout y Customer Portal
Una Server Action tipada crea una Checkout Session con el precio, la divisa y el trial correctos. El Customer Portal abre vía URL de sesión firmada con una return path. Los dos flujos viven en el dominio aplicativo.
- 05
Construir el webhook event router
Una sola route en runtime Node recibe cada evento Stripe, verifica la firma, deduplica por event ID contra una tabla de idempotencia, hace routing por tipo de evento vía discriminated union, y escribe en Postgres en transacción. Los reintentos son seguros por diseño.
- 06
Añadir reconciliación y dunning
Un job nocturno compara los eventos de Stripe con la base de datos y hace surgir el drift. Los emails de dunning se conectan al proveedor de transaccional (Resend por defecto) con una secuencia que refleja los Smart Retries de Stripe. El MRR recuperado aparece en el dashboard de admin.
Qué entregamos
Config PLANS tipada
Un solo archivo TypeScript definiendo cada plan, divisa, intervalo y feature flag. Usado por Checkout, Portal, admin y página de pricing.
Integración Stripe Checkout
Checkout hosted para el lanzamiento rápido, o Elements embebido cuando el buyer necesita un checkout completamente brandeado. Elegimos según la evidencia de conversión.
Customer Portal embebido
Gestión de plan self-service, cancelaciones, métodos de pago e historial de facturas. Configurado para encajar con marca y producto.
Webhook event router
Handler idempotente con verificación de firma, tabla de dedupe por event ID, routing type-safe por tipo de evento, escrituras DB transaccionales.
Mirror Postgres del estado Stripe
Una tabla subscriptions que tiene la vista local canónica. El código aplicativo lee desde Postgres; el webhook lo mantiene alineado.
Gestión de trials
Periodos de trial configurados por plan, handlers trial_will_end conectados a notificación, expiración de trial que se cierra de forma limpia cuando no hay método de pago.
Upgrades y downgrades con prorating
Cambios de plan vía `stripe.subscriptions.update` con `proration_behavior: create_prorations`. Opciones mid-cycle, end-of-cycle e immediate, expuestas según las necesidades de UX.
Dunning y Smart Retries
Recuperación de pago fallido, ventanas de smart retry, secuencia de emails de dunning conectada a tu proveedor de transaccional, banners in-app cuando el método de pago necesita atención.
Stripe Tax conectado
Cálculo automático de IVA, GST y sales tax por región. Tax IDs recogidos en el checkout, formato de factura conforme a la jurisdicción del buyer.
Plantillas de factura brandeadas
PDFs de factura y recibo personalizados con marca, copy del footer, condiciones de pago y datos de contacto. Formato multi-divisa gestionado correctamente.
Job de reconciliación
Un job nocturno compara los eventos Stripe con la base de datos, hace surgir el drift, alerta sobre inconsistencias antes de que lo haga el equipo financiero.
Migración desde billing existente
Subscriptions existentes migradas desde PayPal, Paddle, Lemon Squeezy u otra cuenta Stripe, ciclo de billing preservado, sin doble cobro.
Config de pricing, Checkout, Portal y sync de estado post-checkout
Cuatro archivos que componen un entry point de billing completo. La config PLANS es la fuente de verdad; Checkout la lee; el handler del webhook escribe la fila subscription resultante; el Customer Portal abre vía URL de sesión firmada.
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.
Stacks relacionados
Preguntas frecuentes
¿En cuánto tiempo entregáis la capa de billing?
Dos a cuatro semanas para un SaaS típico con tres planes, dos divisas y comportamiento estándar de trial. Más rápido si el alcance es acotado (un solo plan, una divisa, sin portal). Más largo cuando el modelo de pricing es inusual (usage-based, tiered complejo, marketplace vía Connect). La fase de scoping produce un número concreto.
¿Checkout o Elements?
Checkout para la gran mayoría de los SaaS: más rápido de entregar, scope PCI más pequeño (SAQ A), menos regresiones de conversión porque el flujo de Stripe ya está optimizado. Elements cuando el buyer pide un checkout in-app completamente brandeado (a menudo premium consumer o marketplace). Elegimos por datos de conversión, no por preferencia.
¿Cómo manejáis multi-divisa?
Una sola cuenta Stripe, varias divisas de presentación. Los precios viven en la config PLANS con una entrada por divisa. La divisa del buyer se detecta desde el locale o se selecciona en el checkout. El dashboard de reporting siempre muestra la divisa canónica para la reconciliación.
¿Y la fiabilidad de los webhooks bajo carga?
Cada evento es idempotente por event ID de Stripe. El handler persiste el event ID en una tabla con constraint unique antes de hacer el trabajo; un reintento choca con el constraint y sale temprano. La escritura en la base es transaccional; un fallo hace rollback y Stripe reintenta. La tabla de auditoría registra cada evento que hemos visto.
¿Migráis desde PayPal, Paddle, Lemon Squeezy?
Sí. Las subscriptions activas migran con su ciclo de billing preservado. Los métodos de pago se importan vía las herramientas de migración del proveedor donde se soportan (Stripe tiene importadores directos para algunos). El cutover ocurre en un solo día con una freeze window.
¿Cómo manejáis IVA UE y compliance fiscal?
Stripe Tax maneja automáticamente cálculo de IVA, GST y sales tax para las regiones en las que vende tu producto. Los tax IDs se recogen al checkout, las facturas formatean correctamente por jurisdicción, la postura de compliance queda documentada en el traspaso técnico. No inventamos ley fiscal.
¿De quién es la infraestructura de billing tras el traspaso?
Tuya. La cuenta Stripe es tuya, el código está en tu repositorio, el runbook vive a su lado. Podemos quedarnos como partner para experimentos continuos de pricing, tuning del dunning y cambios fiscales, o pasarlo a tu equipo. En cualquier caso la capa operativa es portable.
Cuéntanos tu modelo de pricing
Una llamada de scoping, un número concreto en la primera respuesta, sin teatro de agencia. La capa de billing completa en dos a cuatro semanas.