Por que o primeiro pagamento real achou bugs que quase mil testes não viram?

997 testes verdes e o plano não subiu depois do primeiro pagamento. Quatro bugs de webhook, tempo, data e coluna, e por que os testes copiavam o erro do código.

Pedro Henrique Quadroatualizado em 5 min de leitura

Porque os testes comparavam o código com ele mesmo, e não com o mundo real. Em 8 de setembro de 2026, o primeiro pagamento real de um SaaS que estou construindo entrou (R$ 39 no Mercado Pago) e o plano não subiu, com 997 testes verdes. Os quatro bugs que encontrei nasceram de suposições que os testes repetiam: um formato de tempo, um tipo de dado, um campo do payload e uma coluna. Conferi as fontes em 10 de outubro de 2026.

O que a documentação diz

Webhook é o aviso que o gateway de pagamento manda para o seu sistema quando algo acontece. A documentação do Mercado Pago diz que a notificação traz um cabeçalho x-signature com um valor ts e um v1, e que o seu endpoint deve responder 200 ou 201. Se não houver resposta em 22 segundos, a notificação é tratada como não entregue e o Mercado Pago reenvia a cada 15 minutos, com intervalos crescentes depois da terceira tentativa. Sobre o ts, a página mostra um exemplo e não diz a unidade. Isso importa no primeiro bug.

Para assinaturas, a documentação lista o tópico subscription_authorized_payment, descrito como pagamento recorrente de uma assinatura. A página que consultei não detalha os campos nem os status. Isso importa no terceiro bug.

Os quatro bugs

1. O ts vinha em segundos. O código supunha milissegundos e comparava o relógio local multiplicado por 1.000 com um número de 10 dígitos. A diferença era de cerca de 1,79 trilhão contra um limite de 300 mil. Todo webhook legítimo voltava 401, e o Mercado Pago entregou cinco vezes. Como a documentação não fixa a unidade, o certo é aceitar as duas: 10 dígitos são segundos, 13 são milissegundos.

2. Texto numa coluna de data. O driver de banco que uso (asyncpg) infere o tipo pela consulta, e a documentação mapeia o date do PostgreSQL para datetime.date. Mandei texto, com .isoformat(), e ele recusou com "'str' object has no attribute 'toordinal'". Converter para texto parece certo e não é.

3. O status do topo era do ciclo, não do dinheiro. No pagamento autorizado de uma assinatura, o status do nível de cima vinha processed. O resultado real estava em payment.status, com approved. Ler o de cima fazia o sistema ignorar cobranças aprovadas. Na prática, toda renovação mensal seria ignorada e o plano venceria com o cliente pagando. Era o mais caro a longo prazo e o único que só apareceria no segundo mês. Não vi essa diferença entre os dois campos explicada na documentação que consultei: ela veio do payload real.

4. Uma coluna que nunca existiu. Uma consulta fazia JOIN users u ON u.id = w.owner_id, e workspaces.owner_id não estava em nenhuma migration. Era a única linha do código que usava essa coluna, e outro módulo já buscava o dono pelo caminho certo. A escrita do plano vinha antes do envio do e-mail, então o erro de coluna derrubava a requisição com o plano já aplicado. O gateway lia 500, tratava a entrega como falha e continuava reenviando, com intervalo que cresce depois da terceira tentativa.

Por que 997 testes verdes não pegaram nada

Cada um mediu o proxy, não o efeito.

  • O teste fabricava o ts em milissegundos, o mesmo formato errado que o código esperava. Teste e código concordavam entre si e discordavam do mundo.
  • O banco falso aceitava qualquer tipo, então texto numa coluna de data passava.
  • O banco falso respondia por correspondência de texto, sem schema, então uma coluna inventada devolvia o mesmo dicionário que uma coluna real.
  • Ninguém tinha o payload real (o corpo da notificação, como o gateway manda de verdade) para saber que o status de cima não era o do dinheiro.

Além dos testes, passaram sete jobs de CI (a esteira que roda os testes a cada mudança) e uma validação em sandbox (o ambiente de teste do gateway, com dinheiro de mentira). O sandbox cobria a API de pagamento avulso, e o produto cobra por assinatura.

O que fazer

  1. Guarde o payload literal da primeira resposta real de qualquer gateway e use como fixture, o dado fixo que os testes leem. Fabricar de novo reintroduz o erro.
  2. Faça o banco falso reprovar tipo errado. Um pool que exigisse datetime.date numa coluna date teria pego o bug 2 antes.
  3. Escreva uma checagem que leia o schema das migrations e confira contra o SQL do código. Ela acha coluna inexistente sem precisar de banco.
  4. Aceite as duas unidades de tempo em timestamps de terceiros.
  5. Reprocessar um webhook perdido é legítimo quando o evento é real e o segredo é seu. Não precisa esperar a reentrega do gateway.
  6. Trate o primeiro pagamento real como o único teste do caminho principal, e não como cerimônia de lançamento. Faça-o cedo, com valor baixo.

Para ver como eu monto cobrança e assinatura em um produto, veja sistemas e SaaS.

Fontes

  1. Mercado Pago: webhooks, assinatura x-signature com ts, espera de 22 segundos e reentrega a cada 15 minutos
  2. Mercado Pago: tópico subscription_authorized_payment para cobrança recorrente de assinatura
  3. asyncpg: o tipo date do PostgreSQL mapeia para datetime.date
Pedro Henrique QuadroEngenheiro de software e IA. Constrói aplicativos, sistemas, painéis e agentes de IA em produção, e tem código aceito no Supabase, no Kestra e no QuestDB.

Continue lendo