Sistema de Suscripciones y Pagos Recurrentes en SaaS con Next.js, Stripe y Webhooks Idempotentes

El modelo de suscripción es el núcleo financiero de cualquier plataforma Software as a Service (SaaS). Sin embargo, gestionar estados de suscripción, períodos de gracia, cambios de plan en caliente (upgrades/downgrades con prorrateo) y sincronización asíncrona mediante webhooks presenta importantes desafíos de concurrencia y consistencia de datos. En esta guía exploramos la arquitectura definitiva para integrar Stripe Billing en Next.js 15 con PostgreSQL y Prisma ORM.

Anatomía del Ciclo de Vida de una Suscripción SaaS#
El flujo de vida de un suscriptor en Stripe comprende múltiples eventos asíncronos que deben sincronizarse en la base de datos de la aplicación:
[Usuario Selecciona Plan]
|
v
[Stripe Checkout Session / Elements] ---> (Procesa Tarjeta)
|
v
+-----------------------------+ (Webhook Event)
| STRIPE BILLING PLATFORM | ------------------------+
+-----------------------------+ |
| | v
| (Renovación Mensual) | (Fallo de Cobro) +------------------------------------+
v v | NEXT.JS WEBHOOK HANDLER |
[invoice.payment_succeeded] [invoice.payment_failed] | 1. Verificación de Firma Cripto |
| | | 2. Verificación de Idempotencia |
v v | 3. Actualización de Tenant en DB |
(Extiende Acceso) (Período de Gracia/Email) +------------------------------------+
Modelo de Datos para Suscripciones (Prisma Schema)#
Para garantizar consistencia y soportar organizaciones multi-inquilino (multi-tenant), modelamos las suscripciones desacopladas de los usuarios individuales:
// prisma/schema.prisma
enum SubscriptionStatus {
INCOMPLETE
INCOMPLETE_EXPIRED
TRIALING
ACTIVE
PAST_DUE
CANCELED
UNPAID
}
model Organization {
id String @id @default(cuid())
name String
slug String @unique
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
stripePriceId String?
subscriptionStatus SubscriptionStatus? @default(INCOMPLETE)
currentPeriodEnd DateTime?
cancelAtPeriodEnd Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users OrganizationMember[]
processedWebhookEvents ProcessedWebhookEvent[]
}
model ProcessedWebhookEvent {
id String @id // Stripe Event ID (ej: evt_1O4k...)
organizationId String?
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
eventType String
processedAt DateTime @default(now())
}
Creación de la Sesión de Checkout o Portal de Clientes#
En Next.js 15, utilizamos Route Handlers o Server Actions protegidos para generar la sesión de cobro:
// src/app/api/stripe/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { prisma } from '@/lib/prisma';
import { getSession } from '@/lib/auth';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
});
export async function POST(req: NextRequest) {
const session = await getSession(req);
if (!session) {
return NextResponse.json({ error: 'No autorizado' }, { status: 401 });
}
const { priceId, organizationId } = await req.json();
const org = await prisma.organization.findUnique({
where: { id: organizationId },
});
if (!org) {
return NextResponse.json({ error: 'Organización no encontrada' }, { status: 404 });
}
let customerId = org.stripeCustomerId;
// Si no tiene cliente creado en Stripe, crearlo y vincularlo
if (!customerId) {
const customer = await stripe.customers.create({
email: session.user.email,
name: org.name,
metadata: {
organizationId: org.id,
},
});
customerId = customer.id;
await prisma.organization.update({
where: { id: org.id },
data: { stripeCustomerId: customerId },
});
}
// Crear la sesión de Stripe Checkout
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
subscription_data: {
metadata: {
organizationId: org.id,
},
},
});
return NextResponse.json({ url: checkoutSession.url });
}
Manejador de Webhooks Idempotente y Robusto#
Los webhooks pueden entregarse más de una vez debido a reintentos de red. La idempotencia es obligatoria para evitar actualizar o duplicar transacciones.
// src/app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { prisma } from '@/lib/prisma';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
});
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'Firma ausente' }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
console.error(`❌ Error verificando webhook: ${err.message}`);
return NextResponse.json({ error: `Webhook Error: ${err.message}` }, { status: 400 });
}
// 1. Verificación de Idempotencia
const existingEvent = await prisma.processedWebhookEvent.findUnique({
where: { id: event.id },
});
if (existingEvent) {
// Ya procesado previamente
return NextResponse.json({ received: true, note: 'Evento duplicado omitido' });
}
// 2. Procesamiento de eventos clave
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const subscriptionId = session.subscription as string;
const organizationId = session.subscription_data?.metadata?.organizationId || session.metadata?.organizationId;
if (organizationId && subscriptionId) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
await prisma.organization.update({
where: { id: organizationId },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
subscriptionStatus: subscription.status.toUpperCase() as any,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
}
break;
}
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
const customerId = subscription.customer as string;
await prisma.organization.updateMany({
where: { stripeCustomerId: customerId },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0]?.price.id,
subscriptionStatus: subscription.status.toUpperCase() as any,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
const customerId = invoice.customer as string;
await prisma.organization.updateMany({
where: { stripeCustomerId: customerId },
data: {
subscriptionStatus: 'PAST_DUE',
},
});
// Aquí se puede disparar un email o notificación de cobro fallido
break;
}
default:
console.log(`Evento no manejado: ${event.type}`);
}
// 3. Registrar evento procesado
await prisma.processedWebhookEvent.create({
data: {
id: event.id,
eventType: event.type,
},
});
return NextResponse.json({ received: true });
}
Estrategias de Upgrades y Prorrateo#
Cuando un usuario decide cambiar de un plan básico a un plan empresarial a mitad de mes:
- Prorrateo Inmediato (
proration_behavior: 'always_invoice'): Stripe calcula el importe no consumido del plan anterior y emite una factura proporcional de inmediato. - Portal de Facturación del Cliente (Stripe Customer Portal): En lugar de construir pantallas complejas para cambio de tarjetas y descarga de facturas en PDF, se redirige al cliente a la URL generada por
stripe.billingPortal.sessions.create().
Conclusión#
Implementar un sistema de facturación recurrente profesional en SaaS requiere arquitectura de datos desacoplada, gestión estricta de webhooks idempotentes y control de estados de suscripción.
¿Estás construyendo una plataforma SaaS y necesitas integrar pagos y suscripciones de alta seguridad? Descubre nuestras soluciones en Desarrollo de Software y SaaS o escríbenos directamente para cotizar tu arquitectura.
¿Te gustaría profundizar en estos temas?
Aprende sobre desarrollo de software, apps a medida, automatizaciones con N8N, Next.js y Cloud con casos reales.


