Blog

Idempotência em pagamentos: o problema de cobrar duas vezes.

Já escrevi sobre o mecanismo geral de idempotência. Pagamento merece um texto próprio porque o fluxo tem quatro pontos de entrada de duplicata, e cada um pede uma defesa diferente.

Tratar os quatro com a mesma chave de idempotência é o erro que faz o time achar que resolveu.

Ponto 1: o usuário clica duas vezes

O mais banal e o mais frequente. Botão sem estado de carregamento, conexão lenta, dedo ansioso.

Desabilitar o botão é experiência do usuário, não proteção. A requisição já saiu, e mesmo que não saísse, quem quiser mandar duas vezes manda pela API.

A defesa é a chave de idempotência gerada quando a intenção de pagamento nasce, não quando o botão é clicado:

// no momento em que o checkout é montado, não no submit
const [idempotencyKey] = useState(() => crypto.randomUUID());

Gerar no submit produz uma chave nova por clique, que é exatamente o que você não quer.

Ponto 2: o retry do seu próprio cliente HTTP

Timeout de leitura configurado em cinco segundos, o gateway responde em seis, sua biblioteca tenta de novo.

Do outro lado, a primeira cobrança foi processada. A segunda também vai ser, se você não mandou chave de idempotência pro gateway. E aqui a chave tem que ser a mesma nas duas tentativas, o que significa que ela não pode ser gerada dentro da função de retry.

// errado: cada tentativa vira uma cobrança diferente
await retry(() => gateway.charge({ ...data, idempotencyKey: uuid() }));

// certo: a chave pertence à intenção
const key = uuid();
await retry(() => gateway.charge({ ...data, idempotencyKey: key }));

Regra geral que vale pra tudo: chave por intenção, nunca por tentativa.

E revise o timeout. Operação de pagamento é lenta por natureza, porque envolve adquirente e bandeira. Timeout curto demais transforma sucesso em retry desnecessário.

Ponto 3: o webhook reenviado

O gateway avisa que o pagamento foi aprovado. Se não receber 200 rápido, reenvia. Até seis ou sete vezes, dependendo do provedor, e às vezes em paralelo quando a fila dele acumulou.

Aqui a defesa é constraint no banco, usando o identificador do evento que o provedor manda:

alter table payment_event
  add constraint payment_event_provider_unique unique (provider, provider_event_id);

Verificar antes com SELECT e depois inserir tem uma corrida no meio, e essa corrida acontece de verdade quando dois reenvios chegam juntos. Deixe o banco arbitrar.

O handler responde rápido e empurra o trabalho pra fila:

@Post("webhooks/:provider")
async receive(@Param("provider") provider: string, @Body() body: unknown,
              @Headers("signature") sig: string) {
  this.verifySignature(provider, body, sig);           // sempre, antes de tudo
  const { created, id } = await this.events.insertIgnore(provider, body);
  if (created) await this.queue.add("process-payment-event", { id });
  return { received: true };                            // 200 em milissegundos
}

Repare que a resposta é 200 mesmo quando o evento já existia. Devolver erro pra um reenvio faz o gateway tentar de novo, e você entra num laço que só termina quando o provedor desiste e marca a integração como quebrada.

Ponto 4: o reprocessamento em lote

Esse é o mais perigoso, porque é operado por gente sob pressão.

Um lote falhou no meio. Alguém roda de novo. Os itens que já tinham sido processados são cobrados outra vez.

A defesa não é chave de idempotência: é o item do lote carregar um identificador natural e estável, e o processamento verificar antes de agir.

-- a cobrança de uma assinatura num ciclo só pode existir uma vez
alter table subscription_charge
  add constraint subscription_charge_cycle_unique
  unique (subscription_id, billing_cycle);

billing_cycle sendo algo como 2026-08. Rodar o lote de agosto quinze vezes produz a mesma cobrança uma vez só.

Esse tipo de constraint derivada do negócio é bem mais forte que qualquer chave técnica, porque ela sobrevive a alguém rodando o script errado com os parâmetros errados.

A parte que não é idempotência

Duas coisas costumam ser confundidas com esse problema e pedem outra solução.

Estorno não deve ser idempotente pela chave do pagamento. Ele é uma operação própria, com sua chave e seu registro. Modelar estorno como “desfazer o pagamento” leva a apagar história, e história é o produto num ledger.

E cobrança recorrente legítima não é duplicata. O mesmo cliente, mesmo valor, mesmo cartão, um mês depois, é uma cobrança nova. Se a sua proteção for baseada em comparar valor e cliente numa janela de tempo, você vai bloquear cobrança válida. Por isso a chave é explícita e o ciclo entra na constraint.

Como testar

Um teste que vale por dez:

it("processa o mesmo webhook duas vezes sem duplicar crédito", async () => {
  const event = paymentApprovedFixture();

  await Promise.all([postWebhook(event), postWebhook(event)]);
  await drainQueue();

  const entries = await ledger.entriesFor(event.subscriptionId);
  expect(entries).toHaveLength(3);   // as pernas de uma transação só
});

O Promise.all é o detalhe importante. Chamar duas vezes em sequência passa mesmo em código com corrida. Em paralelo é onde o bug aparece.

Se você nunca rodou esse teste no seu fluxo de pagamento, vale mais que ler o resto deste texto.

← Voltar pro blog