Implementar cobrança recorrente parece um problema resolvido. Você integra um gateway, agenda um job pra rodar todo dia e cobra quem vence hoje. Em uma tarde está "funcionando".
Aí o sistema vai pra produção, você escala pra duas instâncias, e numa madrugada o mesmo cliente é cobrado duas vezes. Ou pior: o pagamento confirma, mas o módulo que libera o acesso nunca fica sabendo — e o cliente paga sem receber.
A cobrança em si é a parte fácil. O que separa um protótipo de um billing de produção são quatro detalhes que quase ninguém trata no começo.
1. Concorrência: o job que roda duas vezes
O cenário clássico: um @Scheduled que busca assinaturas vencidas e cobra cada uma. Com uma instância, funciona. Com duas (deploy sem downtime, autoscaling), as duas leem a mesma assinatura no mesmo segundo e cobram em dobro.
A solução não é "garantir uma instância" — é tornar a leitura segura sob concorrência. No PostgreSQL:
SELECT * FROM assinatura
WHERE proxima_cobranca <= now() AND status = 'ATIVA'
FOR UPDATE SKIP LOCKED
LIMIT 50;
{data-source-line="212"}
FOR UPDATE SKIP LOCKED faz cada instância travar e processar um lote disjunto: ninguém espera, ninguém duplica. Escala horizontalmente sem coordenação externa.
2. Retry determinístico: falhar não pode ser improviso
Cartão recusado acontece o tempo todo. A pergunta é: quantas vezes tentar, com qual intervalo, e quando desistir? Se isso for "ad hoc", você terá clientes suspensos cedo demais e outros cobrados pra sempre.
Trate a falha como uma máquina de estados explícita:
- 1ª falha → reagenda +15 min
- 2ª falha → +60 min
- 3ª falha → suspende a assinatura e desliga a renovação
Cada tentativa fica registrada e auditável. O comportamento é previsível — para você e para o cliente.
3. Outbox: o evento que não pode se perder
O erro mais caro: cobrar com sucesso e, na sequência, publicar o evento PagamentoConfirmado num broker. Se a aplicação morre entre as duas operações, o pagamento aconteceu mas o resto do sistema nunca soube. Dinheiro entrou, acesso não foi liberado.
O padrão Outbox resolve: grave o evento na mesma transação da cobrança, numa tabela outbox. Um publicador lê essa tabela e entrega ao broker com retry/backoff, marcando como DEAD ao estourar o limite.
BEGIN;
UPDATE assinatura SET status = 'ATIVA', proxima_cobranca = ... WHERE id = ?;
INSERT INTO outbox (tipo, payload, status) VALUES ('PagamentoConfirmado', ?, 'PENDENTE');
COMMIT;
{data-source-line="239"}
Ou a cobrança e o evento são persistidos, ou nenhum dos dois. Sem estado inconsistente.
4. Idempotência no consumidor
O outbox garante entrega pelo menos uma vez — então o mesmo evento pode chegar duas vezes. O consumidor precisa ser idempotente. A forma mais simples e robusta é uma constraint única na chave do evento:
ALTER TABLE evento_processado ADD CONSTRAINT uq_evento UNIQUE (evento_id);
{data-source-line="249"}
Processou de novo? A inserção viola a constraint e você ignora com segurança. Sem efeito colateral duplicado.
O encanamento toma mais tempo que o produto
Nada disso é "difícil" isoladamente. O problema é que são semanas de encanamento — concorrência, retry, outbox, idempotência, métricas, testes com containers — antes de escrever uma linha da regra de negócio que importa pro seu SaaS. E é justamente a parte onde um bug custa cliente e reputação.
Atalho
Empacotei exatamente essa camada num starter kit em Java 21 + Spring Boot 3: renovação automática, retry determinístico, outbox com idempotência, suspensão por inadimplência, métricas Prometheus e testes com Testcontainers — tudo documentado e rodando via Docker Compose.
É o AssinaFlow. Se você vai construir cobrança recorrente, ele economiza as semanas chatas e te dá uma base que já nasce pronta pra produção.
Dúvidas sobre alguma das decisões acima? Comenta aí — respondo todas.
Top comments (0)