surfaai · parte 4 de 8

Postmortem: o crédito duplicado que um F5 criava

01 de outubro de 20265 min de leituraCesar Eduardo Sturmer

Este é um relato de incidente real do surfaai. O artigo seguinte da série generaliza o padrão; aqui eu conto o que aconteceu comigo, incluindo a parte em que meu diagnóstico inicial estava errado.

O sintoma

Um aluno comprou um pacote de créditos e ficou com o dobro do que pagou. Um pagamento, dois registros em user_credits, cada um com a quantidade cheia.

Não era erro de arredondamento nem bug de contagem. Eram duas linhas no banco, criadas com segundos de diferença, idênticas exceto pelo id.

Como estava construído

No desenho original, o fluxo de compra era assim:

  1. o front cria o PaymentIntent
  2. o aluno paga
  3. a Stripe devolve a confirmação ao front
  4. o front chama a rota que cria o crédito

O passo 4 é o problema, e levei um tempo para enxergar — porque o código do passo 4 estava correto. Ele validava o pagamento, checava o valor, criava o registro certo. Revisei várias vezes procurando o erro na lógica.

O erro não estava na lógica. Estava no fato de o passo 4 existir onde existia.

O que realmente acontecia

A página de confirmação disparava a criação do crédito ao montar. Se ela montasse duas vezes, o crédito era criado duas vezes.

E ela montava duas vezes com uma facilidade constrangedora:

  • o aluno aperta F5 na tela de confirmação
  • a conexão oscila, o navegador reenvia a requisição
  • o React remonta o componente

Nenhum desses casos é exótico. O primeiro é o que qualquer pessoa faz quando a tela demora.

Minha primeira hipótese foi condição de corrida entre duas requisições simultâneas, e cheguei a pensar em resolver com um lock. Estava olhando para o lugar errado: não era concorrência, era repetição. O cliente podia chamar a operação quantas vezes quisesse, e nada no sistema dizia que ela só podia valer uma vez.

Por que o cliente nunca deveria ter esse poder

Tem um ponto mais profundo aqui, e ele só ficou claro quando fui integrar PIX e boleto.

Cartão tem um momento em que o front "sabe" que deu certo. PIX e boleto não têm. O aluno fecha a aba, paga pelo app do banco quarenta minutos depois, e nunca mais volta ao site. Não existe nenhum instante no navegador em que esse pagamento confirma.

Ou seja: mesmo que o fluxo client-side funcionasse perfeitamente para cartão, ele era estruturalmente incapaz de atender metade dos meios de pagamento. O bug do F5 foi o sintoma que apareceu primeiro, mas a arquitetura já estava errada antes dele.

A correção

Crédito passou a nascer exclusivamente do evento payment_intent.succeeded do webhook da Stripe. O front não cria mais nada — ele só consulta o estado.

Isso muda a natureza do problema. A origem passa a ser única e confiável: a Stripe assina cada evento, e cada evento tem um id estável. Cartão, PIX e boleto convergem para o mesmo caminho, porque todos geram o mesmo evento.

Mas trocar o cliente pelo webhook não resolve a duplicação sozinho — e esse foi o segundo aprendizado. Webhooks são entregues com garantia at-least-once: a Stripe reenvia o evento se não receber 2xx rápido o bastante. Eu troquei "o usuário pode chamar duas vezes" por "a Stripe pode chamar duas vezes".

A diferença é que agora eu tinha com o que trabalhar: um identificador único por evento.

A primeira barreira ficou na entrada do handler:

const { data: existingEvent } = await db
  .from('stripe_webhook_events')
  .select('id, processed')
  .eq('stripe_event_id', event.id)
  .maybeSingle();

if (existingEvent?.processed) {
  return NextResponse.json({ success: true, message: 'Already processed' });
}

E a última, no banco, como rede de segurança — uma constraint de unicidade que falha se o crédito já existir:

if (creditError) {
  // 23505 = unique_violation: crédito já criado por webhook concorrente — OK
  if ((creditError as any).code === '23505') return;
  throw creditError;
}

Esse return silencioso no 23505 é deliberado. Se dois webhooks passarem pela primeira barreira ao mesmo tempo — e passam, porque entre o select e o insert existe uma janela —, o banco é o único árbitro confiável. O segundo apanha a violação e desiste sem erro, porque o resultado desejado já aconteceu.

Entre essas duas há uma terceira camada. O artigo seguinte destrincha as três, por que cada uma pega um caso diferente, e por que remover qualquer uma reabre o buraco.

O que eu faria diferente

Teria começado pelo webhook. Não por prever o bug, mas porque PIX e boleto já estavam no roadmap desde o início. Se eu tivesse desenhado para o meio de pagamento mais restritivo em vez do mais conveniente, o fluxo client-side nunca teria existido.

Teria colocado a constraint antes do código. A unicidade no banco não foi a primeira coisa que fiz — foi a última. Devia ter sido a primeira. Constraint de banco é a única garantia que não depende de eu ter pensado em todos os caminhos do código.

Teria desconfiado antes do "o código está certo". Passei tempo demais relendo uma função correta. A pergunta útil não era "esse código está certo?", era "o que acontece se isso rodar duas vezes?". Para qualquer operação que mexe com dinheiro, essa deveria ser a primeira pergunta, não a última.

A regra

Toda operação que cria, move ou destrói dinheiro precisa de uma origem única e de um identificador que permita reconhecê-la se ela voltar. O cliente nunca pode ser essa origem — não porque o código dele seja ruim, mas porque você não controla quantas vezes ele roda.

← Todos os artigos