Stripe Billing Implementation: Subscriptions, Invoices, and Webhooks

Complete Stripe billing implementation: subscription checkout, invoice management, webhook event handling, refunds, failed payment recovery, and NestJS integration patterns.

E
ECOSIRE Research and Development Team
|19 de marzo de 202610 min de lectura2.3k Palabras|

Implementación de facturación de Stripe: suscripciones, facturas y webhooks

Stripe es el estándar de facto para la infraestructura de facturación, pero implementarlo correctamente (manejar webhooks de manera idempotente, administrar las transiciones del estado de suscripción y recuperarse de pagos fallidos) requiere mucho más cuidado de lo que sugiere la documentación inicial. Un sistema de pago que funciona el 99% del tiempo y falla silenciosamente el 1% restante es peor que uno que falla ruidosamente el 95%.

Esta guía cubre una implementación de producción de Stripe con 6 tipos de eventos de webhook, gestión del ciclo de vida de la suscripción, aprovisionamiento automático de licencias y gestión de reembolsos. Los ejemplos de código utilizan NestJS 11, pero los patrones se aplican a cualquier marco de backend.

Conclusiones clave

  • Nunca proporcione acceso según la redirección de pago; espere siempre el webhook checkout.session.completed
  • Verificar las firmas del webhook con stripe.webhooks.constructEvent() antes de procesar cualquier evento
  • Hacer que todos los controladores de webhooks sean idempotentes: los reintentos de Stripe fallaron en los webhooks durante hasta 72 horas
  • Almacene los ID de clientes, los ID de suscripción y los ID de precios de Stripe en su base de datos; no vuelva a consultar Stripe para todo.
  • Manejar invoice.payment_failed para activar correos electrónicos de reclamación y suspensión de suscripción
  • charge.refunded y checkout.session.completed juntos cubren el ciclo de vida completo de la compra
  • Utilice el campo metadata de Stripe para vincular objetos de Stripe a sus registros internos
  • Nunca registres cargas útiles de webhooks de Stripe sin procesar: contienen datos confidenciales de la tarjeta

Configuración del proyecto

pnpm add stripe
// billing/stripe.client.ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
  typescript: true,
});

Almacene estas variables de entorno:

STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_live_...

Creación de sesión de pago

El flujo de pago: su backend crea una sesión de Stripe Checkout, el frontend redirige a Stripe, Stripe cobra el pago y luego llama a su webhook. Nunca gestiones el pago tú mismo; redirige siempre a Stripe Checkout.

// billing/billing.service.ts
import Stripe from 'stripe';
import { Injectable, BadRequestException } from '@nestjs/common';
import { stripe } from './stripe.client';

@Injectable()
export class BillingService {
  async createCheckoutSession(
    userId: string,
    orgId: string,
    priceId: string,
    productId: string
  ) {
    // Get or create Stripe customer
    const customer = await this.getOrCreateCustomer(userId, orgId);

    const session = await stripe.checkout.sessions.create({
      customer: customer.stripeCustomerId,
      payment_method_types: ['card'],
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      mode: 'payment', // or 'subscription' for recurring
      success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.FRONTEND_URL}/services`,
      metadata: {
        userId,
        organizationId: orgId,
        productId,
      },
      allow_promotion_codes: true,
      invoice_creation: {
        enabled: true,
      },
    });

    return { url: session.url };
  }

  async createSubscriptionCheckout(
    userId: string,
    orgId: string,
    priceId: string,
    productId: string
  ) {
    const customer = await this.getOrCreateCustomer(userId, orgId);

    const session = await stripe.checkout.sessions.create({
      customer: customer.stripeCustomerId,
      payment_method_types: ['card'],
      line_items: [{ price: priceId, quantity: 1 }],
      mode: 'subscription',
      subscription_data: {
        metadata: {
          userId,
          organizationId: orgId,
          productId,
        },
        trial_period_days: 14,
      },
      success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.FRONTEND_URL}/pricing`,
      metadata: {
        userId,
        organizationId: orgId,
        productId,
      },
    });

    return { url: session.url };
  }

  private async getOrCreateCustomer(userId: string, orgId: string) {
    // Check if customer already exists in your DB
    const existing = await db.query.billingCustomers.findFirst({
      where: and(
        eq(billingCustomers.userId, userId),
        eq(billingCustomers.organizationId, orgId)
      ),
    });

    if (existing) return existing;

    // Fetch user details for Stripe customer creation
    const user = await db.query.users.findFirst({
      where: eq(users.id, userId),
    });

    const stripeCustomer = await stripe.customers.create({
      email: user.email,
      name: user.name,
      metadata: {
        userId,
        organizationId: orgId,
      },
    });

    const [customer] = await db
      .insert(billingCustomers)
      .values({
        userId,
        organizationId: orgId,
        stripeCustomerId: stripeCustomer.id,
      })
      .returning();

    return customer;
  }
}

Controlador de webhook

El controlador de webhook es la parte más crítica de la integración de Stripe. Todo evento debe ser idempotente:

// billing/webhook.controller.ts
import {
  Controller,
  Post,
  Req,
  Headers,
  RawBodyRequest,
  HttpCode,
} from '@nestjs/common';
import { Request } from 'express';
import Stripe from 'stripe';
import { stripe } from './stripe.client';
import { Public } from '../auth/decorators/public.decorator';
import { BillingWebhookService } from './billing-webhook.service';

@Controller('billing/webhook')
export class WebhookController {
  constructor(private webhookService: BillingWebhookService) {}

  @Post()
  @Public()
  @HttpCode(200) // Always return 200 — Stripe retries on non-2xx
  async handleWebhook(
    @Req() req: RawBodyRequest<Request>,
    @Headers('stripe-signature') signature: string
  ) {
    let event: Stripe.Event;

    try {
      event = stripe.webhooks.constructEvent(
        req.rawBody!, // Requires raw body middleware
        signature,
        process.env.STRIPE_WEBHOOK_SECRET!
      );
    } catch (err) {
      // Invalid signature — do not process
      throw new Error(`Webhook signature verification failed: ${err.message}`);
    }

    // Route to specific handler
    switch (event.type) {
      case 'checkout.session.completed':
        await this.webhookService.handleCheckoutCompleted(
          event.data.object as Stripe.Checkout.Session
        );
        break;

      case 'invoice.paid':
        await this.webhookService.handleInvoicePaid(
          event.data.object as Stripe.Invoice
        );
        break;

      case 'invoice.payment_failed':
        await this.webhookService.handlePaymentFailed(
          event.data.object as Stripe.Invoice
        );
        break;

      case 'customer.subscription.deleted':
        await this.webhookService.handleSubscriptionDeleted(
          event.data.object as Stripe.Subscription
        );
        break;

      case 'customer.subscription.updated':
        await this.webhookService.handleSubscriptionUpdated(
          event.data.object as Stripe.Subscription
        );
        break;

      case 'charge.refunded':
        await this.webhookService.handleChargeRefunded(
          event.data.object as Stripe.Charge
        );
        break;

      default:
        // Ignore unhandled event types
        break;
    }

    return { received: true };
  }
}

Importante: NestJS requiere rawBody: true en NestFactory.create() para la verificación de firma de Stripe:

// main.ts
const app = await NestFactory.create(AppModule, {
  rawBody: true,
});

Controlador de pago completado

Este es el controlador de aprovisionamiento principal. Hazlo idempotente:

// billing/billing-webhook.service.ts
@Injectable()
export class BillingWebhookService {
  async handleCheckoutCompleted(session: Stripe.Checkout.Session) {
    // Check if already processed (idempotency)
    const existing = await db.query.orders.findFirst({
      where: eq(orders.stripeSessionId, session.id),
    });

    if (existing) {
      return; // Already processed — Stripe is retrying
    }

    const { userId, organizationId, productId } = session.metadata!;

    // Create order in transaction
    await db.transaction(async (tx) => {
      // Create order
      const [order] = await tx
        .insert(orders)
        .values({
          userId,
          organizationId,
          productId,
          stripeSessionId: session.id,
          stripePaymentIntentId: session.payment_intent as string,
          amount: session.amount_total! / 100, // Stripe uses cents
          currency: session.currency!,
          status: 'completed',
        })
        .returning();

      // Generate license key
      const licenseKey = generateLicenseKey(productId);

      // Create license
      await tx.insert(licenses).values({
        orderId: order.id,
        userId,
        organizationId,
        productId,
        licenseKey,
        status: 'active',
        issuedAt: new Date(),
      });
    });

    // Send confirmation email (non-blocking — don't fail the webhook)
    this.emailService
      .sendPurchaseConfirmation(userId, productId)
      .catch((err) => this.logger.error('Email send failed', err));
  }

  async handleInvoicePaid(invoice: Stripe.Invoice) {
    // For subscriptions — renewal
    if (!invoice.subscription) return;

    // Find existing subscription/license and extend it
    const license = await db.query.licenses.findFirst({
      where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
    });

    if (!license) return;

    await db
      .update(licenses)
      .set({
        status: 'active',
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // +30 days
      })
      .where(eq(licenses.id, license.id));

    // Log renewal in audit table
    await db.insert(licenseAuditLogs).values({
      licenseId: license.id,
      action: 'renewed',
      details: { invoiceId: invoice.id },
    });
  }

  async handlePaymentFailed(invoice: Stripe.Invoice) {
    if (!invoice.subscription) return;

    const license = await db.query.licenses.findFirst({
      where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
    });

    if (!license) return;

    // Grace period before suspension (e.g., 3 days)
    const gracePeriod = new Date();
    gracePeriod.setDate(gracePeriod.getDate() + 3);

    if (new Date() > gracePeriod) {
      await db
        .update(licenses)
        .set({ status: 'suspended' })
        .where(eq(licenses.id, license.id));
    }

    // Send dunning email
    this.emailService
      .sendPaymentFailedNotification(license.userId, invoice)
      .catch(console.error);
  }

  async handleChargeRefunded(charge: Stripe.Charge) {
    const order = await db.query.orders.findFirst({
      where: eq(orders.stripePaymentIntentId, charge.payment_intent as string),
    });

    if (!order) return;

    await db.transaction(async (tx) => {
      // Update order status
      await tx
        .update(orders)
        .set({ status: 'refunded' })
        .where(eq(orders.id, order.id));

      // Revoke associated licenses
      await tx
        .update(licenses)
        .set({ status: 'revoked', revokedAt: new Date() })
        .where(eq(licenses.orderId, order.id));
    });

    // Log audit trail
    await db.insert(licenseAuditLogs).values({
      licenseId: (await db.query.licenses.findFirst({
        where: eq(licenses.orderId, order.id),
      }))!.id,
      action: 'revoked',
      details: { reason: 'refund', chargeId: charge.id },
    });
  }

  async handleSubscriptionDeleted(subscription: Stripe.Subscription) {
    await db
      .update(licenses)
      .set({ status: 'cancelled', cancelledAt: new Date() })
      .where(eq(licenses.stripeSubscriptionId, subscription.id));
  }

  async handleSubscriptionUpdated(subscription: Stripe.Subscription) {
    const status = subscription.status === 'active' ? 'active' : 'suspended';

    await db
      .update(licenses)
      .set({ status })
      .where(eq(licenses.stripeSubscriptionId, subscription.id));
  }
}

Portal del cliente

Permita que los clientes administren sus propias suscripciones sin comunicarse con el soporte:

async createPortalSession(userId: string, orgId: string) {
  const customer = await db.query.billingCustomers.findFirst({
    where: and(
      eq(billingCustomers.userId, userId),
      eq(billingCustomers.organizationId, orgId)
    ),
  });

  if (!customer) {
    throw new NotFoundException('No billing account found');
  }

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: customer.stripeCustomerId,
    return_url: `${process.env.FRONTEND_URL}/dashboard/billing`,
  });

  return { url: portalSession.url };
}

El portal del cliente permite a los usuarios cancelar suscripciones, actualizar métodos de pago, descargar facturas y ver el historial de facturación, sin ningún trabajo de interfaz de usuario personalizado.


Integración frontal

// web: lib/billing.ts
'use client';

import { apiFetch } from '@/lib/api';

export async function initiateCheckout(priceId: string, productId: string) {
  const { url } = await apiFetch<{ url: string }>('/billing/checkout', {
    method: 'POST',
    body: JSON.stringify({ priceId, productId }),
  });

  // Redirect to Stripe Checkout
  window.location.href = url;
}

export async function openCustomerPortal() {
  const { url } = await apiFetch<{ url: string }>('/billing/portal', {
    method: 'POST',
  });

  window.open(url, '_blank');
}

Probar webhooks localmente

La CLI de Stripe es esencial para el desarrollo de webhooks locales:

# Install Stripe CLI
npm install -g stripe

# Listen and forward webhooks to localhost
stripe listen --forward-to localhost:3001/billing/webhook

# Trigger test events
stripe trigger checkout.session.completed
stripe trigger invoice.paid
stripe trigger charge.refunded
stripe trigger customer.subscription.deleted

# View event logs
stripe events list

La CLI proporciona el secreto de su webhook local; utilícelo en su .env.local:

STRIPE_WEBHOOK_SECRET=whsec_test_... # From `stripe listen` output

Errores y soluciones comunes

Error 1: proporcionar acceso en la redirección de pago en lugar de en el webhook

La redirección exitosa del pago puede activarse mediante: avance/retroceso del navegador, pestañas duplicadas o reintento de red. Aprovisione siempre basándose en el webhook checkout.session.completed, no en la URL de redireccionamiento.

Error 2: no gestionar los reintentos de webhook (idempotencia)

Los reintentos de Stripe fallaron en los webhooks durante 72 horas. Sin comprobaciones de idempotencia, los reintentos crean pedidos, licencias y correos electrónicos duplicados. Almacene el ID de sesión de Stripe en su tabla de pedidos con una restricción única: las inserciones duplicadas fallan sin problemas.

Error 3: utilizar incorrectamente el middleware de cuerpo sin formato

La verificación de la firma de Stripe requiere el cuerpo de la solicitud sin procesar y sin analizar. Si el analizador de cuerpo JSON de Express se ejecuta primero, el cuerpo sin formato desaparece. Utilice rawBody: true en NestJS y acceda a req.rawBody, o configure el middleware JSON para conservar el cuerpo sin formato para la ruta del webhook específicamente.

Error 4: falta lógica de reintento para llamadas a la API de Stripe

Las llamadas a la API de Stripe pueden fallar transitoriamente. Agregue lógica de reintento para operaciones idempotentes:

import Stripe from 'stripe';

async function retryStripeOperation<T>(
  operation: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await operation();
    } catch (err) {
      if (err instanceof Stripe.errors.StripeConnectionError && attempt < maxRetries) {
        await new Promise((r) => setTimeout(r, 1000 * attempt));
        continue;
      }
      throw err;
    }
  }
  throw new Error('Max retries exceeded');
}

Preguntas frecuentes

¿Debo utilizar Stripe Checkout o formularios de pago personalizados?

Utilice siempre Stripe Checkout a menos que tenga requisitos específicos que lo hagan imposible (poco común). Checkout gestiona automáticamente el cumplimiento de PCI, la autenticación 3D Secure, Apple Pay, Google Pay, SEPA y docenas de otros métodos de pago. Los formularios de pago personalizados (utilizando Stripe Elements) requieren que usted mismo mantenga el cumplimiento de PCI e implemente cada método de pago manualmente. Para el 95% de los productos SaaS, Checkout es la opción correcta.

¿Cómo manejo el modo de prueba de Stripe frente al modo en vivo?

Utilice configuraciones de entorno independientes: STRIPE_SECRET_KEY=sk_test_... en desarrollo y STRIPE_SECRET_KEY=sk_live_... en producción. El modo de prueba Stripe está completamente aislado del modo en vivo: ningún cargo de prueba afecta el dinero real y no aparecen datos en vivo en el modo de prueba. Use stripe.mode en su código para verificar que está usando la clave correcta y agregue un indicador destacado en su interfaz de usuario de administrador que muestre en qué modo se encuentra.

¿Qué sucede si mi servidor de webhook no funciona y Stripe no puede realizar la entrega?

Stripe reintenta webhooks fallidos en un cronograma de retraso exponencial de hasta 72 horas: 5 minutos, 30 minutos, 1 hora, 2 horas e intervalos crecientes. Pasadas las 72 horas, el evento se considera fallido. Utilice el panel de eventos de Stripe para volver a enviar eventos fallidos manualmente o consulte stripe.events.list() y reprodúzcalos. Diseñe sus controladores para que sean idempotentes, de modo que la reenvío sea segura.

¿Cómo implemento la facturación medida/basada en el uso?

Stripe Billing admite el uso medido a través de stripe.subscriptionItems.createUsageRecord(). Informar el uso al final de cada período de facturación con la cantidad consumida. Planes de precio medido en su panel de Stripe como precios "medidos". El detalle clave de la implementación: informar el uso antes de que finalice el período de facturación, no después: Stripe finaliza la factura al final del período.

¿Cómo debo almacenar los ID de Stripe en mi base de datos?

Almacene todos los ID de Stripe como columnas text (no numéricas). Cree índices en stripe_customer_id, stripe_subscription_id y stripe_payment_intent_id; buscará registros según estos con frecuencia. Agregue restricciones únicas cuando corresponda (un registro de cliente por usuario). Nunca almacene números de tarjetas de crédito ni datos de pago sin procesar en su propia base de datos.


Próximos pasos

Implementar correctamente la facturación de Stripe (con idempotencia de webhook, gestión del ciclo de vida de la suscripción, aprovisionamiento automático y gestión adecuada de reembolsos) es una tarea de ingeniería no trivial. ECOSIRE maneja 6 tipos de eventos de webhook de Stripe en producción con aprovisionamiento automático de licencias e integración del portal del cliente.

Si necesita integración de Stripe, infraestructura de facturación de suscripciones o soporte de desarrollo de SaaS completo, explore nuestros servicios de desarrollo.

E

Escrito por

ECOSIRE Research and Development Team

Construyendo productos digitales de nivel empresarial en ECOSIRE. Compartiendo perspectivas sobre integraciones Odoo, automatización de eCommerce y soluciones empresariales impulsadas por IA.

Chatea en whatsapp