Voltar para o Blog
6 minutos de leitura·20 de outubro de 2026

Uma carteira que nunca credita duas vezes

Como eu desenhei a carteira da plataforma do Girowin para que retries de provedores, reentregas da fila e apostas concorrentes nunca virem saldo duplicado.

EC
Éverton Carvalho
Software Engineer | Software Architect | CTO
Uma carteira que nunca credita duas vezes
Estudo de Caso Relacionado
Girowin: uma plataforma de jogos com dinheiro real
Ver ADR

Numa plataforma de jogos com dinheiro real, o pior bug não é o que derruba o site: é o que credita o mesmo depósito duas vezes, ou deixa uma aposta sair de graça. Nenhum dos dois aparece nos logs como erro. Neste texto eu conto como estruturei a carteira da plataforma do Girowin para que isso não aconteça, e o que ainda está no caminho.

De onde vem a duplicidade

  • Provedores de jogos reenviam a mesma aposta, o mesmo ganho ou o mesmo cancelamento quando não recebem resposta a tempo.
  • O crédito de um depósito viaja por RPC no RabbitMQ. Se a resposta se perde depois do commit, quem chamou tenta de novo.
  • Duas apostas do mesmo jogador no mesmo milissegundo leem o mesmo saldo.
  • Jobs agendados rodam em mais de uma réplica.
  • Um processo pode cair no meio do fluxo: um serviço commita, o outro não chega a registrar.

Alguns desses casos aconteceram de verdade e moldaram o desenho, como um crédito que se repetia num retry porque acontecia antes do registro idempotente, e um crédito de depósito que falhava em silêncio.

A regra: idempotência no banco, não na disciplina

Em vez de confiar que cada handler lembre de checar se já processou um evento, eu coloquei a garantia numa constraint. Cada movimento da carteira tem uma chave única formada pela carteira, pelo tipo do movimento e pelo id externo de quem originou o movimento: o id do depósito, o id da transação do provedor, o id da aposta.

Quando um reenvio esbarra nessa chave, o erro de unicidade vira um erro de duplicidade explícito e a transação inteira volta, saldo incluído. O lançamento é gravado com o mesmo EntityManager que alterou o saldo, e o saldo novo sai de um UPDATE com RETURNING: o "antes" é derivado dele, nunca de uma leitura anterior que outra transação possa ter mudado.

No crédito de depósito, um reenvio não falha: o serviço devolve o recibo do lançamento original, com o saldo antes e depois, e recusa se usuário, marca ou valor não baterem.

O caminho de um depósito

O diagrama mostra o caminho de ponta a ponta. Cada etapa é idempotente por conta própria.

sequenceDiagram
  participant G as Gateway de pagamento
  participant C as casino
  participant Q as RabbitMQ
  participant W as gaming
  participant DB as PostgreSQL
  G->>C: retorno do pagamento (via fila)
  C->>C: confirma o depósito (status já confirmado = ignora)
  C->>C: evento interno deposit.confirmed
  C->>Q: RPC de crédito com o id do depósito como chave
  Q->>W: crédito
  W->>DB: saldo e lançamento na mesma transação
  alt reenvio do mesmo depósito
    DB-->>W: chave única já existe
    W-->>C: recibo do lançamento original, nada creditado
  else primeira vez
    W-->>C: saldo antes e depois
  end
  C->>C: marca CREDITED ou agenda retry com backoff
  • Callback: o mesmo status já aplicado é ignorado, um depósito confirmado nunca é rebaixado por um status tardio, e uma confirmação tardia depois de expirado prevalece.
  • Evento interno: o deposit.confirmed só é emitido na transição para confirmado; se o depósito já foi creditado, os efeitos que não são idempotentes, como promoção e afiliados, são pulados.
  • Crédito: o serviço relê o depósito do banco, pula se já foi creditado e envia o id do depósito como chave; se falhar, grava a falha e agenda um retry, sem lançar exceção.
  • Retry: o job usa o id do depósito como id, então nunca existem dois retries do mesmo depósito; são 5 tentativas com backoff exponencial a partir de 30 s, e o job falho fica guardado. Esgotadas as tentativas, o depósito vira um caso para uma pessoa olhar.
  • Expiração: um UPDATE condicional só expira o que ainda está pendente, sem atropelar uma confirmação concorrente.

Apostas e saques concorrentes

O débito de aposta lê a conta com FOR UPDATE e só então divide o valor entre saldo real e bônus. O saque usa um advisory lock por saque, trava a conta e grava um recibo de débito na mesma transação; um replay do mesmo saque devolve o recibo original depois de conferir usuário, marca, valor e carteira. Todos os caminhos seguem a mesma ordem de locks, para não criar deadlocks.

Dinheiro não é float

Todo o caminho do dinheiro usa decimal.js, e os valores vão para colunas numeric(18,8). No débito de aposta, o valor efetivo é derivado do saldo já arredondado para 8 casas, para que carteira, aposta e bônus concordem exatamente com o que o banco grava.

Conferir a cadeia

Cada lançamento guarda o saldo antes e depois e um número de sequência atribuído no INSERT, com o lock da conta em mãos. Uso a sequência e não a data porque now() marca o início da transação e inverteria mutações concorrentes.

Com isso, conferir a carteira vira uma consulta: o saldo depois de um lançamento tem de ser igual ao saldo antes do próximo, e o último lançamento tem de bater com a conta. Um job diário roda essa conferência sobre as últimas 25 horas, na réplica de leitura, e só detecta: nunca corrige. A correção de um problema real entra como um ajuste explícito, registrado por um funcionário, porque a cadeia é a evidência e não se edita.

Callbacks de provedores

Os callbacks são validados por HMAC-SHA256 sobre o corpo cru da requisição, com comparação em tempo constante. Re-serializar o JSON antes de validar quebraria a assinatura de forma intermitente. Cada tipo de movimento usa o id certo do provedor como chave: o da transação na aposta, o do resultado no ganho e o da aposta no estorno.

Jobs em várias réplicas

Os crons rodam num papel de worker separado da API, coordenados por um lease no Redis por marca, serviço e job, renovado a cada 20 segundos e liberado de forma atômica por scripts Lua. Isso não é exactly-once, e eu não finjo que é: a coordenação reduz a concorrência, e a idempotência de cada efeito garante o resultado.

Testes

A regressão da carteira roda no CI contra um PostgreSQL real em cada PR. Os testes de integração cobrem, entre outros, saldo e lançamento entrando juntos, id externo repetido sem mudar o saldo, a cadeia fechando, a recuperação de um crédito depois de o gaming commitar sem creditar duas vezes, oito apostas duplicadas concorrentes virando uma só, e cerca de 25 cenários de saque com resposta perdida e replays concorrentes.

O que ainda falta

  • O ledger está em implantação: já roda em staging e entra em produção por etapas, com o histórico antigo trazido por backfill explícito.
  • A chave única não protege lançamentos sem id externo, que ainda podem vir de produtores antigos.
  • Falhas de serialização e deadlocks no caminho do dinheiro ainda sobem como erro; quem tenta de novo é a fila ou o provedor.
  • A conferência diária só registra no log; falta transformá-la em alerta.
Artigo de autoria própria
Éverton Carvalho|Software Engineer | Software Architect | CTO

Construído com Next.js 15, NestJS 11, PostgreSQL e Prisma. Arquitetura desacoplada e pragmática.

© 2026 Éverton Carvalho. Todos os direitos reservados.

Portfolio V2 · Zero Shared Runtimes