Num marketplace, um webhook repetido não é um caso raro: é o comportamento normal dos gateways, que reenviam até receber uma resposta de sucesso. Se cada reenvio virar um crédito, o vendedor recebe duas vezes; se virar um estorno, perde duas vezes. No marketplace da PokerNetic eu escrevi a maior parte do backend e trabalho no serviço de pagamentos. Aqui conto como a confirmação de pagamento ficou idempotente e o que vem a seguir.
sequenceDiagram
participant B as Comprador
participant M as marketplace
participant Q as RabbitMQ
participant P as payment
participant G as Gateway
participant DB as PostgreSQL
B->>M: checkout
M->>Q: pedido de link de pagamento
Q->>P: gera a sessão no gateway
P-->>M: link de pagamento (resposta do RPC)
M-->>B: link de pagamento
B->>G: paga
G->>P: webhook assinado
P->>DB: registra o evento (gateway, id) se ainda não existe
alt evento repetido
DB-->>P: já registrado, nada a fazer
else primeira vez
P->>Q: pedido pago, na fila do serviço certo
Q->>M: confirma o pedido
P->>DB: crédito do vendedor no ledger (gateway interno)
end
O pedido e o link de pagamento
O checkout recalcula preços e cupons, separa assinaturas de produtos avulsos, gera o número do pedido numa transação com lock e calcula a comissão da plataforma e o valor líquido da loja. Em seguida pede o link de pagamento ao serviço de payment por RPC no RabbitMQ. O payment cria a sessão no gateway e registra a transação com o id do gateway como chave única.
Um pedido não pago expira sozinho: uma fila de jobs agenda a expiração com um id por pedido, e agendar de novo cancela o job anterior do mesmo pedido.
Gateways atrás de uma abstração
Cada gateway implementa a mesma interface: consultar o status, estornar, gerar o link e mapear os status do gateway para os da plataforma, com um padrão seguro para status desconhecidos. O serviço escolhe a implementação por nome, e registrar duas vezes o mesmo nome é erro.
- Stripe Connect: cobrança direta na conta do vendedor, com a taxa da plataforma descontada na própria cobrança.
- Gateway interno: a plataforma como merchant of record: todo o valor entra na conta da plataforma, o split vai nos metadados da sessão e é liquidado no ledger do vendedor.
- Mercado Pago e mock: implementados atrás da mesma interface e hoje desativados: os caminhos ativos são o Stripe Connect e o gateway interno.
Webhooks idempotentes
A assinatura de cada webhook é validada sobre o corpo cru, com um segredo por tipo de integração. A idempotência é um claim no banco: antes de processar, o serviço tenta inserir o par gateway e id do evento numa tabela com índice único. Se a linha já existe, o evento é um reenvio e nada acontece. Se o processamento falha depois do claim, o claim é desfeito e o serviço responde erro, para o gateway reenviar.
Isso nasceu de um problema real: retries de webhook creditavam o vendedor duas vezes e um estorno chegou a debitar duas vezes.
O dinheiro do vendedor
O saldo do vendedor fica num ledger por vendedor e moeda, com saldo pendente e disponível. Créditos e débitos rodam em transação com lock pessimista e cada lançamento guarda o saldo antes e depois, além da data em que o valor fica disponível depois do período de retenção. Payouts seguem a frequência configurada pelo vendedor, com valor mínimo, e as faturas da comissão são geradas por período.
Moedas internas sem saldo negativo
Comprar com as moedas da plataforma segue reservar, aprovar ou liberar: o pedido reserva o valor por RPC, a aprovação transforma a reserva em gasto e uma rejeição devolve. Toda mutação de saldo roda em transação com lock pessimista, e uma constraint de saldo maior ou igual a zero no banco é a última barreira.
A fila que entregava no lugar errado
Um bug ensinou bastante: uma única fila de transações era consumida por quatro serviços diferentes. O RabbitMQ distribui as mensagens em rodízio, sem olhar o tipo, então confirmações de pagamento caíam no serviço errado e eram descartadas em silêncio. A correção foi uma fila de transações por serviço, roteada pelo tipo do pedido, e uma fila legada que avisa quando aparece um tipo sem consumidor.
Na mesma linha, o cliente do RabbitMQ ganhou um listener de erro no canal, porque um erro sem listener na reconexão derrubava o processo, e passou a usar uma conexão compartilhada por processo, com heartbeat e reconexão.
Testes
Os testes cobrem o serviço de idempotência dos webhooks, os handlers de Stripe e do gateway interno, o Stripe Connect, as transações de pagamento, o ledger do vendedor, os payouts e as regras do pedido, do checkout e dos descontos, além do saldo de moedas.
O próximo passo
A garantia mais forte está na fronteira do webhook: um evento nunca é processado duas vezes. O próximo passo é levar a mesma garantia de ponta a ponta para dentro do pedido.
