<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rodrigo Scharp</title>
    <description>The latest articles on DEV Community by Rodrigo Scharp (@rodrigoscharp).</description>
    <link>https://dev.to/rodrigoscharp</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4166734%2Fa0896c23-fbe6-43f4-a866-8c50835356ee.png</url>
      <title>DEV Community: Rodrigo Scharp</title>
      <link>https://dev.to/rodrigoscharp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rodrigoscharp"/>
    <language>en</language>
    <item>
      <title>Como garantir que um Pix seja creditado exatamente uma vez</title>
      <dc:creator>Rodrigo Scharp</dc:creator>
      <pubDate>Tue, 06 Oct 2026 14:53:35 +0000</pubDate>
      <link>https://dev.to/rodrigoscharp/como-garantir-que-um-pix-seja-creditado-exatamente-uma-vez-54be</link>
      <guid>https://dev.to/rodrigoscharp/como-garantir-que-um-pix-seja-creditado-exatamente-uma-vez-54be</guid>
      <description>&lt;p&gt;Todo sistema que recebe Pix depende de uma notificação assíncrona do PSP: o webhook. Em produção, esse webhook chega duplicado quando o PSP não recebe o &lt;code&gt;200&lt;/code&gt; a tempo, chega atrasado, chega fora de ordem (a devolução antes do crédito) e às vezes não chega. Mesmo assim, cada centavo precisa ser creditado &lt;strong&gt;exatamente uma vez&lt;/strong&gt;, e no fim do dia o saldo interno tem que bater com o extrato do PSP.&lt;/p&gt;

&lt;p&gt;Montei um laboratório para provar isso, o &lt;a href="https://github.com/rodrigoscharp/PixLab" rel="noopener noreferrer"&gt;PixLab&lt;/a&gt;: um PSP falso que injeta essas falhas de propósito, com seed, e um recebedor de referência que precisa sobreviver a todas. Seguem as cinco decisões que fazem a diferença.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Idempotência é uma constraint, não um &lt;code&gt;if&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;O erro clássico:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;pagamentoJaExiste&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e2eId&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;   &lt;span class="c1"&gt;// duas threads passam aqui juntas...&lt;/span&gt;
    &lt;span class="n"&gt;creditar&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e2eId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;               &lt;span class="c1"&gt;// ...e as duas creditam&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Com dois webhooks idênticos chegando no mesmo milissegundo, os dois &lt;code&gt;SELECT&lt;/code&gt; respondem "não existe" antes de qualquer &lt;code&gt;INSERT&lt;/code&gt;. A garantia tem que vir do banco:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;webhook_inbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'PIX'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;e2eId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;conflict&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="k"&gt;nothing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;O endpoint grava, responde &lt;code&gt;200&lt;/code&gt; e mais nada. No laboratório, &lt;strong&gt;1.000 entregas simultâneas do mesmo webhook geram exatamente 1 crédito&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Responda rápido, processe depois
&lt;/h2&gt;

&lt;p&gt;O PSP reenvia quando o seu &lt;code&gt;200&lt;/code&gt; demora. Então o endpoint não processa: valida a assinatura, grava na inbox e responde. Workers consomem a inbox com &lt;code&gt;SELECT … FOR UPDATE SKIP LOCKED&lt;/code&gt;. Vários workers em paralelo, nenhum pegando o mesmo evento, e a transição de estado, o lançamento no ledger e o evento da outbox na &lt;strong&gt;mesma transação&lt;/strong&gt;. Num teste de carga com 500 req/s e 30% de reentregas, o p99 do ack ficou em 16 ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Webhook é otimização, não fonte da verdade
&lt;/h2&gt;

&lt;p&gt;Se o webhook nunca chega, quem descobre? Um job consulta periodicamente &lt;code&gt;GET /pix?inicio&amp;amp;fim&lt;/code&gt;, em janelas que se sobrepõem, e injeta cada Pix &lt;strong&gt;na mesma inbox&lt;/strong&gt;. Como a inbox é idempotente, reler o mesmo Pix cem vezes é inofensivo. Existe um único caminho de entrada (webhook, consulta ativa, reparo da conciliação), e é isso que torna a garantia verdadeira em todos os casos.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Eventos fora de ordem esperam, não quebram
&lt;/h2&gt;

&lt;p&gt;A devolução chegou antes do crédito? O evento volta para a inbox com backoff exponencial até o crédito aparecer. Se nunca aparecer, vai para quarentena, à vista de todos. Nunca fica preso: todo evento termina &lt;code&gt;PROCESSED&lt;/code&gt; ou &lt;code&gt;QUARANTINED&lt;/code&gt;. A soma das devoluções nunca passa do valor pago, garantida com lock na linha do pagamento. Um teste de propriedade com jqwik embaralha MED, crédito, falhas e duplicatas em 300 ordens diferentes, e o invariante vale em todas.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Concilie contra o extrato, e ajuste por lançamento
&lt;/h2&gt;

&lt;p&gt;No fim, o extrato do PSP é a verdade sobre o dinheiro que entrou. Um job Spring Batch, reiniciável, compara extrato × ledger e classifica cada item: &lt;code&gt;OK&lt;/code&gt;, &lt;code&gt;FALTA_NO_LEDGER&lt;/code&gt; (reinjeta), &lt;code&gt;FALTA_NO_PSP&lt;/code&gt; (alerta de crédito fantasma), &lt;code&gt;VALOR_DIVERGENTE&lt;/code&gt;, &lt;code&gt;SEM_COBRANCA&lt;/code&gt;, &lt;code&gt;DUPLICADO&lt;/code&gt;. Divergência de valor não edita o passado: vira um &lt;strong&gt;lançamento de ajuste&lt;/strong&gt; contra uma conta de suspense, e um caso para análise. Depois de cada execução, Σ &lt;code&gt;psp:pix:liquidar&lt;/code&gt; = Σ extrato, em centavos.&lt;/p&gt;

&lt;h2&gt;
  
  
  O que o caos encontrou
&lt;/h2&gt;

&lt;p&gt;O mais valioso foram os bugs que eu não teria achado lendo o código:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deadlock no dispatcher.&lt;/strong&gt; Ele segurava &lt;code&gt;FOR UPDATE&lt;/code&gt; enquanto threads de envio tentavam atualizar as mesmas linhas por outra conexão. Corrigi com uma reserva curta (lease) e nenhuma transação aberta durante o HTTP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extrato sobrescrito.&lt;/strong&gt; Reaplicar a mesma seed no mesmo minuto regerou um e2eId, e o &lt;code&gt;save()&lt;/code&gt; do JPA fez &lt;em&gt;merge&lt;/em&gt; por cima do Pix antigo em vez de falhar. A correção foi insert estrito, e o mesmo bug apareceu depois no rtrId das devoluções.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Referência ambígua no ledger.&lt;/strong&gt; O id de uma devolução só é único dentro do Pix. Usar só o id como referência misturava devoluções de Pix diferentes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Todos foram encontrados por testes com seed, e cada um virou teste de regressão. É esse o argumento do laboratório: se a falha é reproduzível, ela vira teste, e o teste vira garantia.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Para levar:&lt;/strong&gt; constraint &lt;code&gt;UNIQUE&lt;/code&gt; em vez de &lt;code&gt;SELECT&lt;/code&gt;; ack rápido mais inbox; um único caminho de entrada; eventos fora de ordem com backoff e quarentena; conciliação que corrige por lançamento. E caos determinístico para provar tudo isso.&lt;/p&gt;

&lt;p&gt;Código, testes e a demo ao vivo: &lt;strong&gt;&lt;a href="https://github.com/rodrigoscharp/PixLab" rel="noopener noreferrer"&gt;https://github.com/rodrigoscharp/PixLab&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>fintech</category>
      <category>distributedsystems</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
