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 março de 202610 min de leitura2.2k Palavras|

Implementação do Stripe Billing: assinaturas, faturas e webhooks

Stripe é o padrão de fato para infraestrutura de faturamento, mas implementá-lo corretamente – lidar com webhooks de forma idempotente, gerenciar transições de estado de assinatura e recuperar-se de falhas de pagamento – exige muito mais cuidado do que a documentação de introdução sugere. Um sistema de pagamento que funciona 99% do tempo e falha silenciosamente nos outros 1% é pior do que aquele que falha ruidosamente em 95%.

Este guia cobre uma implementação de produção do Stripe com 6 tipos de eventos de webhook, gerenciamento do ciclo de vida da assinatura, provisionamento automático de licenças e tratamento de reembolsos. Os exemplos de código usam NestJS 11, mas os padrões se aplicam a qualquer estrutura de back-end.

Principais conclusões

  • Nunca provisione acesso com base no redirecionamento de checkout — sempre espere pelo webhook checkout.session.completed
  • Verifique as assinaturas do webhook com stripe.webhooks.constructEvent() antes de processar qualquer evento
  • Tornar todos os manipuladores de webhook idempotentes — Stripe tenta novamente webhooks com falha por até 72 horas
  • Armazene IDs de clientes, IDs de assinatura e IDs de preços do Stripe em seu banco de dados – não consulte tudo novamente no Stripe
  • Lidar com invoice.payment_failed para acionar e-mails de cobrança e suspensão de assinatura
  • charge.refunded e checkout.session.completed juntos cobrem todo o ciclo de vida da compra
  • Use o campo metadata do Stripe para vincular objetos Stripe aos seus registros internos
  • Nunca registre cargas úteis de webhook do Stripe — elas contêm dados confidenciais do cartão

Configuração do projeto

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,
});

Armazene estas variáveis de ambiente:

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

Criação de sessão de checkout

O fluxo de checkout: seu backend cria uma sessão Stripe Checkout, o frontend redireciona para Stripe, Stripe coleta o pagamento e depois chama seu webhook. Nunca cuide do pagamento sozinho – sempre redirecione para 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;
  }
}

Manipulador de webhook

O manipulador de webhook é a parte mais crítica da integração do Stripe. Todo evento deve 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 requer rawBody: true em NestFactory.create() para verificação de assinatura Stripe:

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

Manipulador de check-out concluído

Este é o manipulador de provisionamento principal. Torne-o 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 do Cliente

Permita que os clientes gerenciem suas próprias assinaturas sem entrar em contato com o suporte:

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 };
}

O portal do cliente permite que os usuários cancelem assinaturas, atualizem métodos de pagamento, baixem faturas e visualizem o histórico de faturamento – sem qualquer trabalho de interface personalizada.


Integração de front-end

// 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');
}

Testando webhooks localmente

O Stripe CLI é essencial para o desenvolvimento de webhook local:

# 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

A CLI fornece seu segredo do webhook local — use-o em seu .env.local:

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

Armadilhas e soluções comuns

Armada 1: provisionamento de acesso no redirecionamento de checkout em vez de webhook

O redirecionamento de sucesso de checkout pode ser acionado por: retrocesso/avançamento do navegador, guias duplicadas ou nova tentativa de rede. Sempre provisione com base no webhook checkout.session.completed, não no URL de redirecionamento.

Armadilha 2: não lidar com novas tentativas de webhook (idempotência)

Stripe tenta webhooks com falha por 72 horas. Sem verificações de idempotência, as novas tentativas criam pedidos, licenças e e-mails duplicados. Armazene o ID da sessão Stripe em sua tabela de pedidos com uma restrição exclusiva – inserções duplicadas falham normalmente.

Armadilha 3: Usar middleware de corpo bruto incorretamente

A verificação da assinatura Stripe requer o corpo da solicitação bruto e não analisado. Se o analisador de corpo JSON do Express for executado primeiro, o corpo bruto desaparecerá. Use rawBody: true no NestJS e acesse req.rawBody ou configure o middleware JSON para preservar o corpo bruto especificamente para a rota do webhook.

Armada 4: Lógica de repetição ausente para chamadas de API Stripe

As chamadas da API Stripe podem falhar temporariamente. Adicione lógica de nova tentativa para operações 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');
}

Perguntas frequentes

Devo usar o Stripe Checkout ou formulários de pagamento personalizados?

Sempre use o Stripe Checkout, a menos que você tenha requisitos específicos que tornem isso impossível (raro). O Checkout lida automaticamente com conformidade com PCI, autenticação 3D Secure, Apple Pay, Google Pay, SEPA e dezenas de outros métodos de pagamento. Os formulários de pagamento personalizados (usando Stripe Elements) exigem que você mesmo mantenha a conformidade com o PCI e implemente cada método de pagamento manualmente. Para 95% dos produtos SaaS, o Checkout é a escolha certa.

Como lidar com o modo de teste do Stripe versus o modo ao vivo?

Use configurações de ambiente separadas: STRIPE_SECRET_KEY=sk_test_... em desenvolvimento e STRIPE_SECRET_KEY=sk_live_... em produção. O modo de teste Stripe é completamente isolado do modo ao vivo – nenhuma cobrança de teste afeta o dinheiro real e nenhum dado ao vivo aparece no modo de teste. Use stripe.mode em seu código para verificar se você está usando a chave correta e adicione um indicador em destaque na interface do administrador mostrando em qual modo você está.

O que acontece se meu servidor de webhook estiver inativo e o Stripe não conseguir entregar?

O Stripe tenta novamente webhooks com falha em uma programação de espera exponencial por até 72 horas: 5 minutos, 30 minutos, 1 hora, 2 horas e intervalos crescentes. Após 72 horas, o evento é considerado falhado. Use o painel de eventos do Stripe para reenviar manualmente eventos com falha ou consulte stripe.events.list() e reproduza-os. Projete seus manipuladores para serem idempotentes, para que a reentrega seja segura.

Como implementar o faturamento medido/baseado no uso?

O Stripe Billing oferece suporte ao uso medido via stripe.subscriptionItems.createUsageRecord(). Reportar a utilização ao final de cada período de faturamento com a quantidade consumida. Preço dos planos medidos em seu painel do Stripe como preços "medidos". O principal detalhe da implementação: relatar o uso antes do término do período de faturamento, não depois - Stripe finaliza a fatura no final do período.

Como devo armazenar Stripe IDs em meu banco de dados?

Armazene todos os Stripe IDs como colunas text (não numéricas). Crie índices em stripe_customer_id, stripe_subscription_id e stripe_payment_intent_id — você procurará registros por eles com frequência. Adicione restrições exclusivas quando apropriado (um registro de cliente por usuário). Nunca armazene números de cartão de crédito ou dados brutos de pagamento em seu próprio banco de dados.


Próximas etapas

Implementar o faturamento do Stripe corretamente — com idempotência de webhook, gerenciamento do ciclo de vida da assinatura, provisionamento automático e tratamento adequado de reembolso — não é uma tarefa de engenharia trivial. ECOSIRE lida com 6 tipos de eventos de webhook Stripe em produção com provisionamento automático de licença e integração ao portal do cliente.

Se você precisar de integração Stripe, infraestrutura de cobrança de assinatura ou suporte completo ao desenvolvimento de SaaS, explore nossos serviços de desenvolvimento.

E

Escrito por

ECOSIRE Research and Development Team

Construindo produtos digitais de nível empresarial na ECOSIRE. Compartilhando insights sobre integrações Odoo, automação de e-commerce e soluções de negócios com IA.

Converse no WhatsApp