Skip to main content
LadeStack LogoLadeStack
SaaS Architecture 10 min de leitura 28 de julho de 2024

Construindo Sistemas Robustos de Cobrança por Assinatura: Ciclo de Vida, Webhooks e Idempotência

Um guia completo de engenharia para arquitetar sistemas de cobrança por assinatura prontos para produção com Stripe, verificação de webhooks, máquinas de estado idempotentes e sincronização de permissões multi-inquilino.

Diagrama de Arquitetura de Sistemas de Cobrança por Assinatura

Introdução: A Complexidade da Engenharia de Faturamento

Na engenharia de software SaaS, a implementação de cobrança por assinatura costuma ser subestimada como a simples inclusão de um componente de checkout e o salvamento de um subscription_id no banco de dados. Na realidade, a infraestrutura de faturamento é um dos sistemas distribuídos mais críticos e sensíveis que uma organização mantém.

Um mecanismo de faturamento confiável precisa lidar graciosamente com confirmações de pagamento assíncronas, falhas transitórias de rede, eventos de webhook fora de ordem, expiração de cartões, upgrades de planos com cálculo proporcional (proration) e sincronização de limites de acesso multi-inquilino (multi-tenant). Uma única falha de lógica pode acarretar perda de receita, churn por revogação indevida de acesso ou não conformidade regulatória.

Este guia estabelece um modelo arquitetural para construir uma infraestrutura de faturamento por assinatura resiliente e escalável. Analisamos a máquina de estados completa de assinaturas, fluxos seguros de checkout, verificação criptográfica de webhooks, processamento idempotente de transações e sincronização de direitos de acesso.


1. Arquitetura em Alto Nível e Separação de Responsabilidades

Antes de escrever qualquer código, as equipes devem estabelecer uma fronteira rígida entre processamento de pagamentos e autorização de acesso no aplicativo:

[Navegador do Cliente]
       │
       │ 1. Iniciar Checkout / Atualizar Cartão
       ▼
[Servidor de Aplicação SaaS] ──── 2. Criar Sessão de Checkout / Cliente ───► [Stripe / API de Cobrança]
       │                                                                            │
       │ 4. Consultar Direitos (DB Local / Cache)                                   │
       ▼                                                                            │ 3. Envio Assíncrono
[Banco de Dados / Redis] ◄─── 5. Manipulador Idempotente de Webhooks ◄──────────────┘    de Eventos
(Inquilinos, Assinaturas, Faturas)

Regras Fundamentais de Design

  1. Nunca Toque em Dados Brutos de Pagamento: Em nenhuma hipótese números de cartão de crédito (PANs) ou códigos de segurança (CVVs) devem passar pelos seus servidores ou bancos de dados. Toda a coleta de dados de pagamento deve ocorrer via tokenização no lado do cliente (como Stripe Elements ou páginas hospedadas de checkout), mantendo a aplicação no escopo simplificado de conformidade PCI DSS SAQ A.
  2. Webhooks como Fonte Única da Verdade: Nunca libere permissões de acesso ou altere o status persistente de uma assinatura diretamente na resposta HTTP de uma chamada de API do navegador. Falhas de conexão ou fechamento da aba podem quebrar o fluxo. O status no banco de dados deve evoluir estritamente a partir de eventos de webhook verificados.
  3. Desacople o Status de Faturamento das Permissões de Domínio: Mantenha as regras de autorização do seu sistema independentes de identificadores tarifários de fornecedores. Sua aplicação deve verificar tenant.hasFeature('advanced_analytics'), e não stripe_price_id === 'price_123'.

2. A Máquina de Estados do Ciclo de Vida da Assinatura

Uma assinatura é uma entidade dinâmica que transita por diversos estados ao longo de sua existência:

                  ┌──────────────┐
                  │   trialing   │
                  └──────┬───────┘
                         │ Período de teste convertido
                         ▼
┌──────────────┐ Pagamento ┌──────────────┐  Falha de   ┌──────────────┐
│  incomplete  ├──────────►│    active    │◄───────────┤   past_due   │
└──────┬───────┘ Sucesso   └──────┬───────┘ Pagamento   └──────┬───────┘
       │                          │                            │
       │ Falha Permanente         │ Cancelamento               │ Tentativas
       │ de Pagamento             │ ou Downgrade               │ Esgotadas
       ▼                          ▼                            ▼
┌──────────────┐           ┌──────────────┐             ┌──────────────┐
│  incomplete_ │           │   canceled   │             │    unpaid    │
│   expired    │           └──────────────┘             └──────────────┘
└──────────────┘

Definições de Estados e Estratégia de Ação

  • incomplete: Estado inicial quando o pagamento exige autenticação 3D Secure (3DS/SCA) antes da ativação inicial.
  • active: Pagamento processado com sucesso; todos os recursos do plano contratado estão desbloqueados.
  • past_due: Ocorreu uma falha no pagamento da renovação (cartão expirado, limite insuficiente). Não revogue o acesso imediatamente. Inicie fluxos de régua de cobrança (dunning) e exiba alertas na interface durante este período de carência.
  • canceled: Assinatura encerrada pelo cliente ou pelo sistema por inadimplência. Geralmente o acesso deve ser mantido até o término do ciclo de cobrança vigente (cancel_at_period_end: true).
  • unpaid: Estado final caso as tentativas automáticas se esgotem sem regularização. Conforme a política da empresa, migre o inquilino para um plano gratuito ou modo somente leitura.

3. Verificação Segura de Webhooks e Processamento Idempotente

Os webhooks do Stripe são entregues via requisições HTTP POST. Como o transporte pela internet é inerentemente sujeito a falhas, o Stripe utiliza tentativas automáticas com recuo exponencial. Esse mecanismo impõe duas realidades arquiteturais:

  1. Entrega ao Menos Uma Vez (At-Least-Once Delivery): Você pode receber exatamente a mesma carga de dados de webhook múltiplas vezes.
  2. Entrega Fora de Ordem (Out-of-Order Delivery): Um evento invoice.payment_succeeded pode chegar antes de customer.subscription.created devido a caminhos de rede concorrentes.

Verificação Criptográfica de Assinatura

Para repelir ataques de personificação (spoofing), cada webhook recebido deve ser validado contra o segredo compartilhado de endpoint por meio do cabeçalho Stripe-Signature.

Exemplo de implementação em TypeScript com Express:

import express, { Request, Response } from 'express';
import Stripe from 'stripe';
import { db } from './db';
import { processWebhookEvent } from './billingService';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
  apiVersion: '2024-06-20',
});

const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET as string;

// IMPORTANTE: O endpoint deve receber o Buffer bruto sem parsing para validar a assinatura
export const webhookHandler = async (req: Request, res: Response): Promise<void> => {
  const sig = req.headers['stripe-signature'];

  if (!sig || typeof sig !== 'string') {
    res.status(400).send('Cabeçalho Stripe-Signature ausente ou inválido');
    return;
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
  } catch (err: any) {
    console.error(`Falha na validação do webhook: ${err.message}`);
    res.status(400).send(`Erro no Webhook: ${err.message}`);
    return;
  }

  try {
    await processWebhookEvent(event);
    res.status(200).json({ received: true });
  } catch (err: any) {
    console.error(`Erro ao processar evento ${event.id}:`, err);
    // Retornar 500 sinaliza ao Stripe para tentar enviar o evento novamente
    res.status(500).json({ error: 'Falha no processamento interno' });
  }
};

4. Esquema de Banco de Dados e Registro de Idempotência

Para evitar efeitos colaterais duplicados (como criar cobranças em dobro ou disparar múltiplos recibos por e-mail), implemente uma Tabela de Idempotência no PostgreSQL:

-- 1. Tabela de Auditoria e Idempotência de Webhooks
CREATE TABLE billing_webhook_events (
    id VARCHAR(255) PRIMARY KEY, -- ID do Evento Stripe (ex: evt_1Oxxxx)
    event_type VARCHAR(100) NOT NULL,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    payload JSONB NOT NULL
);

-- 2. Tabela de Assinaturas de Inquilinos
CREATE TABLE tenant_subscriptions (
    tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
    stripe_customer_id VARCHAR(255) UNIQUE NOT NULL,
    stripe_subscription_id VARCHAR(255) UNIQUE,
    stripe_price_id VARCHAR(255),
    plan_tier VARCHAR(50) NOT NULL DEFAULT 'free',
    status VARCHAR(50) NOT NULL DEFAULT 'active',
    current_period_start TIMESTAMPTZ,
    current_period_end TIMESTAMPTZ,
    cancel_at_period_end BOOLEAN DEFAULT FALSE,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_tenant_subscriptions_customer ON tenant_subscriptions(stripe_customer_id);

Processador Transacional e Idempotente de Eventos

import { db } from './db';
import Stripe from 'stripe';

export async function processWebhookEvent(event: Stripe.Event): Promise<void> {
  const client = await db.getClient();

  try {
    await client.query('BEGIN');

    // 1. Verificar se o evento já foi processado anteriormente
    const existing = await client.query(
      'SELECT id FROM billing_webhook_events WHERE id = $1 FOR UPDATE',
      [event.id]
    );

    if (existing.rows.length > 0) {
      console.log(`Evento ${event.id} já processado. Ignorando.`);
      await client.query('ROLLBACK');
      return;
    }

    // 2. Registrar identificador no registro de idempotência
    await client.query(
      'INSERT INTO billing_webhook_events (id, event_type, payload) VALUES ($1, $2, $3)',
      [event.id, event.type, JSON.stringify(event)]
    );

    // 3. Executar lógica de negócio correspondente ao tipo de evento
    switch (event.type) {
      case 'customer.subscription.created':
      case 'customer.subscription.updated': {
        const subscription = event.data.object as Stripe.Subscription;
        await syncSubscriptionState(client, subscription);
        break;
      }
      case 'customer.subscription.deleted': {
        const subscription = event.data.object as Stripe.Subscription;
        await handleSubscriptionCanceled(client, subscription);
        break;
      }
      case 'invoice.payment_failed': {
        const invoice = event.data.object as Stripe.Invoice;
        await handleFailedPayment(client, invoice);
        break;
      }
      default:
        console.log(`Tipo de evento não gerenciado: ${event.type}`);
    }

    await client.query('COMMIT');
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

5. Sincronização do Estado da Assinatura e Cache de Direitos

Sempre que a assinatura for modificada, os dados do Stripe devem ser mapeados para as camadas de planos da aplicação e armazenados em cache no Redis:

import { PoolClient } from 'pg';
import Stripe from 'stripe';
import { redisClient } from './redis';

const PRICE_TIER_MAP: Record<string, string> = {
  'price_pro_monthly': 'pro',
  'price_pro_yearly': 'pro',
  'price_enterprise_monthly': 'enterprise',
};

async function syncSubscriptionState(client: PoolClient, sub: Stripe.Subscription): Promise<void> {
  const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
  const priceId = sub.items.data[0]?.price.id;
  const tier = PRICE_TIER_MAP[priceId] || 'free';

  // Atualizar banco relacional
  const result = await client.query(
    `UPDATE tenant_subscriptions
     SET stripe_subscription_id = $1,
         stripe_price_id = $2,
         plan_tier = $3,
         status = $4,
         current_period_start = to_timestamp($5),
         current_period_end = to_timestamp($6),
         cancel_at_period_end = $7,
         updated_at = NOW()
     WHERE stripe_customer_id = $8
     RETURNING tenant_id`,
    [
      sub.id,
      priceId,
      tier,
      sub.status,
      sub.current_period_start,
      sub.current_period_end,
      sub.cancel_at_period_end,
      customerId,
    ]
  );

  if (result.rows.length === 0) {
    throw new Error(`Inquilino não encontrado para o cliente Stripe ${customerId}`);
  }

  const tenantId = result.rows[0].tenant_id;

  // Atualizar cache de direitos no Redis
  const cacheKey = `entitlements:tenant:${tenantId}`;
  const entitlementData = {
    tier,
    status: sub.status,
    canAccessAI: tier === 'pro' || tier === 'enterprise',
    seatLimit: tier === 'enterprise' ? 100 : tier === 'pro' ? 10 : 1,
    currentPeriodEnd: sub.current_period_end,
  };

  await redisClient.set(cacheKey, JSON.stringify(entitlementData), 'EX', 86400); // Expira em 24h
}

6. Proporcionalidade (Proration), Upgrades e Alterações de Plano

Quando um cliente altera seu plano no meio do ciclo de faturamento, o Stripe calcula o valor proporcional por padrão:

  1. Faturamento Imediato (proration_behavior: 'always_invoice'):
    • Ideal para upgrades de plano (ex: de Starter para Pro).
    • Emite imediatamente uma fatura cobrando a diferença residual do período.
    • Os novos recursos são liberados instantaneamente.
  2. Proration ao Término do Ciclo (proration_behavior: 'create_prorations'):
    • O saldo credor ou devedor é lançado na próxima fatura periódica do cliente.
    • Evita cobranças repetidas de baixo valor no cartão de crédito.
  3. Sem Cálculo Proporcional (proration_behavior: 'none'):
    • Usado quando a alteração de plano passa a vigorar estritamente no início do próximo ciclo.
// Exemplo: Upgrade de assinatura com fatura imediata de proporcionalidade
export async function upgradeSubscription(
  subscriptionId: string,
  newPriceId: string
): Promise<Stripe.Subscription> {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);

  return await stripe.subscriptions.update(subscriptionId, {
    items: [
      {
        id: subscription.items.data[0].id,
        price: newPriceId,
      },
    ],
    proration_behavior: 'always_invoice',
    payment_behavior: 'error_if_incomplete',
  });
}

7. Tratamento de Pagamentos Com Falha e Régua de Cobrança (Dunning)

Falhas na renovação são inevitáveis devido a cartões vencidos ou bloqueios de segurança bancária. Um evento invoice.payment_failed sem tratamento adequado pode causar interrupções indevidas de serviço:

  1. Utilize o Stripe Smart Retries: Ative o sistema inteligente de novas tentativas baseado em aprendizado de máquina no painel do Stripe.
  2. Régua de E-mails de Cobrança: Configure alertas para os eventos invoice.payment_action_required ou customer.subscription.updated para enviar links de atualização de dados de pagamento.
  3. Período de Carência: Mantenha o status past_due por 7 a 14 dias sem interromper as operações centrais do inquilino. Bloqueie os recursos apenas quando o estado mudar para unpaid ou canceled.

8. Erros Críticos de Arquitetura

  • Armazenamento de Dados de Cartão: Jamais salve números de cartão ou códigos de segurança em seus servidores. Utilize exclusivamente PaymentMethods e identificadores de Customer.
  • Liberação Síncrona: Não libere acesso diretamente na URL de redirecionamento após o pagamento. O usuário pode fechar o navegador antes que a chamada de redirecionamento seja concluída.
  • Corpo da Requisição Alterado em Middlewares: Middlewares comuns como express.json() alteram o fluxo bruto da requisição, invalidando a checagem criptográfica. Empregue sempre express.raw({ type: 'application/json' }) nas rotas de webhooks.
  • Dessincronização de Relógio: Certifique-se de que os servidores estejam sincronizados via NTP para evitar falhas de validação de timestamp nas assinaturas.

Conclusão e Próximos Passos

Um sistema de faturamento por assinatura resiliente resolve fundamentalmente um desafio de sincronização de estados entre sistemas distribuídos. Ao implementar a verificação criptográfica de webhooks, uma tabela de registro de idempotência e o desacoplamento de planos e permissões, sua plataforma SaaS poderá escalar com total estabilidade financeira.

Guias de Arquitetura Recomendados

More Engineering Articles