Ir al contenido

Stack · Stripe

Stripe bien hecho es parte del producto

Subscriptions con prorating, Customer Portal para el self-service, un webhook router idempotente que sobrevive al día en que Stripe reintenta el mismo evento cuatro veces. La capa de billing que crece con el producto, en vez de pedir una reescritura en la serie A.

Por qué este stack

Customer Portal es la función más barata que no construyes tú

Cambios de plan, flujos de cancelación, descarga de recibos, historial de facturas, actualización del método de pago. Todo llega gratis con Customer Portal. Construirlo en casa son dos meses-ingeniero de trabajo que nadie quiere mantener después.

Subscriptions gestiona los casos límite que hunden el billing casero

Prorating en upgrade, downgrade al límite del periodo, add-ons a mitad de ciclo, extensión de trials, reintentos en tarjeta rechazada, emails de dunning. Stripe los gestiona todos. Nosotros conectamos el listener para que la base de datos refleje lo que Stripe sabe.

Los webhooks como fuente de verdad

El client nunca decide si una subscripción está activa. El webhook recibe el evento, valida la firma, escribe la fila canónica, y la aplicación lee desde ahí. Sin race condition entre el success del checkout y el estado de la base.

Tax y Radar son compliance que de otra manera pospondrías

Stripe Tax gestiona en automático IVA, GST y sales tax para las regiones que importan a tu buyer. Radar gestiona las reglas anti-fraude sin que escribas un motor de heurísticas. Las dos llegan como configuración, no como proyecto paralelo.

Sigma e informes para el equipo financiero sin dashboard custom

Tu equipo de finanzas escribe SQL contra los datos vivos de Stripe. Sin export CSV, sin script de reconciliación mensual, sin libro contable paralelo que va en drift de la verdad.

Qué construimos con esta tecnología

Integración Stripe Checkout

Checkout hosted para lanzamientos rápidos, o Elements embebido para flujos completamente brandeados. Elegimos por datos de conversión.

Subscriptions con prorating

Planes por tier, mensual y anual, add-ons usage-based, prorating en cada upgrade, downgrade al límite del periodo, subscripciones pausadas.

Customer Portal embebido

Gestión de plan self-service, flujos de cancelación, historial de facturas, actualización del método de pago. Configurado para encajar con marca y producto.

Webhook event router

Handler idempotente que deduplica por event ID, routing type-safe por nombre de evento, transacción DB por evento, dead-letter queue para mensajes envenenados.

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.

Reglas anti-fraude Radar

Reglas de bloqueo, reglas de revisión, allow lists para clientes recurrentes. Calibradas a los patrones de fraude reales del producto, no a un ruleset genérico.

Stripe Connect para marketplaces

Cuentas conectadas Express, Standard o Custom según el modelo del marketplace. Onboarding, payouts, KYC, reportes 1099 donde aplique.

Billing usage-based

Metering, ventanas de agregación, precios por tier, umbrales de overage. Stripe Meters o registros de uso custom según el modelo.

Personalización de facturas

Templates de factura brandeados, formato de líneas, multi-divisa, breakdown de líneas de impuestos, footer copy personalizado.

Dunning y reintentos inteligentes

Recuperación de pago fallido, ventanas de smart retry, secuencia de email de dunning conectada a tu proveedor de email transaccional.

Jobs de reconciliación

Un job nocturno que compara eventos de Stripe con la base de datos y hace emerger el drift antes de que lo haga el equipo financiero.

Migración desde PayPal, Paddle, Lemon Squeezy

Subscripciones existentes migradas con el ciclo de billing preservado, métodos de pago de los clientes importados donde el proveedor origen lo permite.

Un webhook router idempotente que sobrevive a los reintentos de Stripe

Stripe reintenta los webhooks fallidos durante hasta tres días. Cada handler que enviamos es idempotente por event ID, type-safe por nombre de evento, y transaccional respecto a la escritura en la base de datos.

La mayoría de los tutoriales de Stripe conectan un botón Checkout y se quedan ahí. El billing en producción se rompe el día en que Stripe reintenta un evento cuatro veces y la base de datos termina con tres filas actualizadas a medias. El webhook router de abajo es el que enviamos: idempotente por event ID, type-safe por nombre de evento, transaccional respecto a la base de datos, y observable cuando un evento falla.

1. El route handler

El webhook llega a una route en runtime Node. Necesitamos el body raw para validar la firma, así que lo leemos como texto antes de parsear. El check de idempotencia ocurre antes de que el evento se procese.

// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe/server'
import { recordWebhookEvent, isAlreadyProcessed } from '@/lib/stripe/events'
import { routeEvent } from '@/lib/stripe/router'

export const runtime = 'nodejs'

export async function POST(request: Request) {
  const signature = (await headers()).get('stripe-signature')
  if (!signature) return new Response('missing signature', { status: 400 })

  const body = await request.text()
  let event
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    )
  } catch {
    return new Response('invalid signature', { status: 400 })
  }

  // Idempotencia: salimos si este event ID ya se procesó.
  if (await isAlreadyProcessed(event.id)) {
    return new Response(null, { status: 200 })
  }

  try {
    await routeEvent(event)
    await recordWebhookEvent(event.id, event.type, 'processed')
    return new Response(null, { status: 200 })
  } catch (err) {
    await recordWebhookEvent(
      event.id,
      event.type,
      'failed',
      err instanceof Error ? err.message : 'unknown',
    )
    // Devolver 500 hace que Stripe reintente. Devolver 200 hace ack;
    // elegimos según si el fallo es recuperable.
    return new Response('handler failed', { status: 500 })
  }
}

2. La tabla de idempotencia

Una sola tabla con un constraint unique sobre event_id es todo el mecanismo. Un reintento duplicado choca con el constraint y sale temprano.

-- supabase/migrations/0010_stripe_webhook_events.sql
create table stripe_webhook_events (
  event_id text primary key,
  type text not null,
  status text not null,
  error text,
  received_at timestamptz not null default now()
);

create index stripe_webhook_events_received_idx
  on stripe_webhook_events(received_at desc);
// src/lib/stripe/events.ts
import { adminClient } from '@/lib/supabase/admin'

export async function isAlreadyProcessed(eventId: string): Promise<boolean> {
  const { data } = await adminClient
    .from('stripe_webhook_events')
    .select('event_id')
    .eq('event_id', eventId)
    .maybeSingle()
  return data !== null
}

export async function recordWebhookEvent(
  eventId: string,
  type: string,
  status: 'processed' | 'failed',
  error?: string,
): Promise<void> {
  await adminClient.from('stripe_webhook_events').upsert({
    event_id: eventId,
    type,
    status,
    error: error ?? null,
  })
}

3. El router type-safe

Una discriminated union mapea tipos de evento a handlers. TypeScript estrecha la forma del evento por cada case, así el handler recibe un payload totalmente tipado sin un cast en runtime.

// src/lib/stripe/router.ts
import type Stripe from 'stripe'
import { handleCheckoutCompleted } from './handlers/checkout-completed'
import { handleSubscriptionUpdated } from './handlers/subscription-updated'
import { handleInvoicePaymentFailed } from './handlers/invoice-payment-failed'
import { handleCustomerDeleted } from './handlers/customer-deleted'

export async function routeEvent(event: Stripe.Event): Promise<void> {
  switch (event.type) {
    case 'checkout.session.completed':
      return handleCheckoutCompleted(event.data.object)
    case 'customer.subscription.updated':
    case 'customer.subscription.created':
    case 'customer.subscription.deleted':
      return handleSubscriptionUpdated(event.data.object)
    case 'invoice.payment_failed':
      return handleInvoicePaymentFailed(event.data.object)
    case 'customer.deleted':
      return handleCustomerDeleted(event.data.object)
    default:
      // Los eventos no manejados devuelven 200 sin trabajo; la tabla los
      // registra igual así podemos auditar el tráfico sin perder el evento.
      return
  }
}

4. Un handler transaccional

El handler de subscription hace upsert de la fila canónica dentro de una sola transacción. O la base de datos coincide con Stripe, o la escritura hace rollback y Stripe reintenta.

// src/lib/stripe/handlers/subscription-updated.ts
import type Stripe from 'stripe'
import { adminClient } from '@/lib/supabase/admin'

export async function handleSubscriptionUpdated(
  subscription: Stripe.Subscription,
): Promise<void> {
  const tenantId =
    subscription.metadata.tenant_id ??
    (await lookupTenantByCustomer(subscription.customer as string))

  if (!tenantId) {
    throw new Error(`no tenant for subscription ${subscription.id}`)
  }

  const { error } = await adminClient.from('subscriptions').upsert({
    id: subscription.id,
    tenant_id: tenantId,
    status: subscription.status,
    current_period_end: new Date(
      subscription.current_period_end * 1000,
    ).toISOString(),
    cancel_at_period_end: subscription.cancel_at_period_end,
    price_id: subscription.items.data[0]?.price.id ?? null,
    quantity: subscription.items.data[0]?.quantity ?? 1,
    updated_at: new Date().toISOString(),
  })

  if (error) throw error
}

async function lookupTenantByCustomer(customerId: string): Promise<string | null> {
  const { data } = await adminClient
    .from('tenants')
    .select('id')
    .eq('stripe_customer_id', customerId)
    .maybeSingle()
  return data?.id ?? null
}

5. Qué te compra esto

Cuando Stripe reintenta un evento, el constraint unique sobre event_id cortocircuita el trabajo duplicado. Cuando la escritura en la base falla, todo el handler hace rollback y Stripe reintenta. Cuando sale un nuevo tipo de evento, añades un case en el router y TypeScript te dice la forma del payload. La tabla de auditoría registra cada evento que la aplicación haya visto, así el día que un cliente pregunta "¿por qué mi subscripción muestra el plan equivocado?", tienes los recibos.

El billing en producción no es un botón Checkout. Es la disciplina de tratar los eventos de Stripe como fuente de verdad y hacer que la base de datos vaya detrás, retry-safe, type-safe, cada vez.

Lecturas relacionadas

Preguntas frecuentes

Cuéntanos cómo factura tu producto

Una capa de billing en dos a cuatro semanas.