Volver al Blog
SaaSSoftwareDesarrollo

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

Brayan Developer
5 min de lectura
Sistema de Suscripciones y Pagos Recurrentes en SaaS con Next.js, Stripe y Webhooks Idempotentes
Guía completa para implementar planes de suscripción, facturación recurrente, gestión de upgrades/downgrades y manejo seguro de webhooks idempotentes en Next.js.

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.

Portada

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.

Etiquetas
SaaSStripe BillingNext.jsWebhooksSuscripcionesPostgreSQL
Compartir:XLinkedInWhatsApp

¿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.

Hablemos