Um bom postmortem de incidente em fintech responde a quatro perguntas com dados: quanto dinheiro foi afetado e de quem, o que aconteceu em que ordem, por que o sistema permitiu e o que muda para não acontecer de novo. Ele não procura culpado. Procura a falha de processo, de código ou de monitoramento que deixou o erro chegar ao saldo do cliente. Se o documento termina com "o dev vai prestar mais atenção", ele falhou.
Escrever postmortem parece tarefa de gestão, mas em sistema financeiro é trabalho técnico pesado. É SQL para dimensionar o estrago, log para reconstruir a linha do tempo, script para corrigir os dados e teste para provar que o bug não volta. A forma como você conduz isso diz muito sobre sua senioridade, mais do que qualquer feature bonita que você entregue.
O que é um postmortem blameless e por que em fintech ele é diferente?
Postmortem é o documento escrito depois de um incidente para registrar o que aconteceu e o que vai mudar. Blameless significa que ele parte do princípio de que as pessoas agiram de forma razoável com a informação que tinham, e que o problema está no sistema que permitiu o erro.
Em um SaaS comum, um incidente costuma ser "ficou fora do ar por 40 minutos". Em fintech, o incidente típico deixa rastro em dados: um crédito duplicado, um split calculado errado, um estorno que não chegou ao lojista, um Pix marcado como pago que nunca saiu. Isso muda três coisas:
- O impacto é contável. Você consegue, e precisa, dizer quantos centavos e quantas contas foram afetados.
- A correção tem duas partes. Corrigir o código não corrige o saldo de quem já foi afetado. Os dados precisam de reparo próprio.
- Há terceiros na conversa. Cliente final, lojista, adquirente, gateway, às vezes compliance. O postmortem vira base para comunicação externa.
Quando vale a pena escrever um postmortem?
Nem todo bug merece documento. Eu uso critérios objetivos para não depender de humor:
- Qualquer divergência de saldo, mesmo de um centavo, que chegou a um cliente.
- Dinheiro movimentado em duplicidade ou não movimentado quando deveria.
- Indisponibilidade de fluxo de pagamento (checkout, Pix, saque) acima do tempo que você tolera.
- Incidente detectado pelo cliente antes do seu monitoramento.
- Correção manual em banco de produção, por menor que seja.
O item 4 é o mais importante. Se o cliente avisou antes do alerta, existe uma ação de detecção para registrar, mesmo que o impacto tenha sido pequeno.
Qual estrutura usar em um postmortem de incidente financeiro?
Uso sempre o mesmo esqueleto. Repetir a estrutura faz o documento sair mais rápido e permite comparar incidentes ao longo do tempo.
| Seção | O que entra | Erro comum |
|---|---|---|
| Resumo | 3 frases: o que, quanto, status atual | Virar narrativa longa |
| Impacto | Contas, transações e valor afetados, com a query usada | Estimar "por cima" |
| Linha do tempo | Eventos com horário, do primeiro sintoma ao reparo | Reconstruir de memória |
| Causa raiz e fatores contribuintes | O mecanismo técnico e o que deixou ele passar | Parar em "erro humano" |
| Reparo de dados | O que foi corrigido, como, e como foi verificado | Corrigir com UPDATE manual |
| Ações | Detecção, prevenção e mitigação, com dono e prazo | Ações vagas sem dono |
Vou usar como fio condutor um incidente típico em quem processa pagamentos: o mesmo webhook de "pagamento aprovado" é processado duas vezes e o saldo do lojista recebe crédito em dobro.
Como medir o impacto financeiro de um incidente com SQL?
A primeira coisa que faço não é corrigir o código, é dimensionar. Sem número, a discussão vira opinião e a prioridade fica errada. Se você modela saldo como um ledger de partidas dobradas, essa etapa é uma query, porque cada movimentação tem uma linha com origem e referência externa.
-- Créditos de webhook lançados mais de uma vez para o mesmo evento SELECT external_event_id, account_id, COUNT(*) AS vezes, SUM(amount) - MIN(amount) AS excedente_centavos FROM ledger_entries WHERE source = 'webhook' AND direction = 'credit' AND created_at BETWEEN :inicio AND :fim GROUP BY external_event_id, account_id HAVING COUNT(*) > 1 ORDER BY excedente_centavos DESC;
Dessa query saem os três números do resumo: quantas contas, quantos eventos e quanto em reais. Coloco a query no próprio postmortem. Isso permite que outra pessoa revise o critério e que você rode de novo depois do reparo para provar que o resultado ficou vazio.
Dois cuidados aqui:
- Amplie a janela. O primeiro sintoma raramente é o primeiro caso. Rodo a query com uma janela bem maior do que a suspeita inicial para descobrir quando o problema realmente começou, o que normalmente coincide com um deploy.
- Separe o que já saiu do sistema. Crédito duplicado em saldo parado é um problema. Crédito duplicado que já virou saque é outro, porque o reparo vira cobrança de um cliente. Essa divisão muda a comunicação inteira.
Se você já tem uma rotina de conciliação diária com o extrato do provedor, ela é a melhor fonte para validar o número: o total do provedor não muda, então a diferença entre o seu lado e o dele é exatamente o excedente.
Como montar a linha do tempo sem depender da memória?
Linha do tempo de memória é sempre errada. As pessoas lembram da ordem em que entenderam as coisas, não da ordem em que elas aconteceram. A solução é deixar o sistema escrever a linha do tempo por você, com logs que carregam contexto.
No Laravel 11 em diante, a facade Context resolve boa parte disso: o que você adiciona nela entra em todo log da requisição e é propagado para os jobs disparados a partir dela.
use Illuminate\Support\Facades\Context; class WebhookController { public function __invoke(Request $request, string $provider) { Context::add([ 'provider' => $provider, 'event_id' => $request->input('id'), 'trace_id' => (string) Str::uuid(), ]); Log::info('webhook.recebido'); ProcessarWebhook::dispatch($provider, $request->all()); return response()->noContent(); } }
Com isso, filtrar os logs por event_id mostra recebimento, cada tentativa do job e cada lançamento no ledger, com horário. A linha do tempo do postmortem vira uma tabela montada a partir dessa busca, cruzada com os horários de deploy e de alertas.
Um detalhe que faz diferença: registre também quando o time percebeu e quando agiu. A distância entre o primeiro caso real e o primeiro alerta é o seu tempo de detecção, e é dele que saem as ações mais valiosas.
Quando o incidente é de webhooks rejeitados, por exemplo depois de uma troca de segredo no painel do provedor, a linha do tempo precisa mostrar exatamente a partir de quando a assinatura passou a falhar. Para confirmar com um payload real se o HMAC bate com o segredo novo ou o antigo, uso o testador de webhook antes de mexer em qualquer configuração.
Como chegar na causa raiz sem parar no "erro humano"?
Uso uma versão adaptada dos "5 porquês", mas com uma regra: toda resposta que termina em uma pessoa precisa de mais um porquê. No exemplo do crédito duplicado:
- Por que o saldo foi creditado duas vezes? Porque o job processou o mesmo evento duas vezes.
- Por que processou duas vezes? Porque o provedor reenviou o webhook após um timeout da nossa resposta, e o job não verificava se o evento já tinha sido aplicado.
- Por que não verificava? Porque a checagem de idempotência ficava em uma consulta antes do lançamento, sem constraint no banco, e duas execuções concorrentes passaram pela checagem ao mesmo tempo.
- Por que isso passou no review? Porque o teste cobria o reenvio sequencial, não o concorrente, e o checklist de review não pedia constraint única.
- Por que só descobrimos pelo cliente? Porque não havia alerta para lançamento duplicado por
external_event_id.
Repare que "o dev esqueceu a constraint" aparece no meio, mas não é a causa raiz. A causa raiz é a idempotência depender de uma checagem em aplicação sem garantia no banco. Os fatores contribuintes são o teste incompleto, o review sem critério explícito e a ausência de alerta. Cada um vira uma ação.
O padrão de correção para esse caso específico está no post sobre webhooks duplicados e fora de ordem. No postmortem, eu linko a solução em vez de reexplicar.
Como corrigir os dados afetados com segurança?
Aqui mora o risco de transformar um incidente em dois. A tentação é abrir o cliente do PostgreSQL e rodar um UPDATE ou DELETE. Em sistema financeiro, não faço isso. O reparo é código: versionado, revisado, idempotente e com modo de simulação.
As regras que sigo:
- Nunca apagar lançamento. O crédito duplicado existiu e o cliente pode ter visto. O reparo é um lançamento de estorno compensatório, com referência ao incidente.
- Rodar em dry-run por padrão. O comando só altera dados com uma flag explícita.
- Ser idempotente. Rodar duas vezes não pode estornar duas vezes. A chave de referência do estorno garante isso.
- Verificar com a mesma query do impacto. Depois do reparo, a query de duplicados precisa voltar vazia.
class EstornarCreditosDuplicados extends Command { protected $signature = 'incidente:estornar-duplicados {incidente : identificador do postmortem} {--executar : aplica os estornos; sem a flag, só simula}'; public function handle(Ledger $ledger): int { $duplicados = DB::table('ledger_entries') ->select('external_event_id', 'account_id') ->selectRaw('SUM(amount) - MIN(amount) AS excedente') ->where('source', 'webhook') ->where('direction', 'credit') ->groupBy('external_event_id', 'account_id') ->havingRaw('COUNT(*) > 1') ->get(); foreach ($duplicados as $d) { $referencia = "postmortem:{$this->argument('incidente')}:{$d->external_event_id}"; $this->line("{$d->account_id} | {$d->external_event_id} | {$d->excedente} centavos"); if (! $this->option('executar')) { continue; } // O ledger rejeita referência repetida (unique), então rodar de novo não estorna em dobro $ledger->debitar( contaId: $d->account_id, centavos: (int) $d->excedente, referencia: $referencia, descricao: 'Estorno de crédito duplicado', ); } $this->info($this->option('executar') ? 'Estornos aplicados.' : 'Simulação concluída.'); return self::SUCCESS; } }
O comando passa por PR como qualquer outro código, e a saída do dry-run vai anexada ao postmortem. Para contas em que o crédito já foi sacado, o débito deixaria saldo negativo; isso exige decisão de negócio (absorver, negociar, cobrar) e não deveria ser resolvido pelo script. Por isso o comando real que eu escreveria pularia esses casos e listaria à parte.
Como transformar o postmortem em correção que não volta?
Ação boa tem dono, prazo e um jeito de verificar que foi feita. Classifico em três tipos, e um postmortem decente tem pelo menos uma de cada:
| Tipo | Pergunta que responde | Exemplo no caso do crédito duplicado |
|---|---|---|
| Prevenção | Como impedir que aconteça? | Constraint única em (source, external_event_id) no ledger |
| Detecção | Como saber em minutos, não em dias? | Job agendado que roda a query de duplicados e alerta se houver linha |
| Mitigação | Como reduzir o estrago se acontecer? | Retenção de saque por período curto após crédito via webhook |
A prevenção sempre começa com um teste que reproduz o incidente. Ele precisa falhar no código antigo e passar no novo; se passa nos dois, não reproduz nada.
it('não credita duas vezes o mesmo evento de webhook', function () { $conta = Conta::factory()->create(); $payload = webhookPagamentoAprovado(eventId: 'evt_123', contaId: $conta->id, centavos: 10_000); (new ProcessarWebhook('pagarme', $payload))->handle(app(Ledger::class)); (new ProcessarWebhook('pagarme', $payload))->handle(app(Ledger::class)); expect(LedgerEntry::where('external_event_id', 'evt_123')->count())->toBe(1) ->and($conta->fresh()->saldoEmCentavos())->toBe(10_000); });
O teste sequencial não prova a ausência de corrida, então a constraint no banco é o que de fato garante. O teste documenta a regra; a constraint a impõe. Também levo o aprendizado para o processo: no caso acima, o item "idempotência garantida por constraint, não só por consulta" entra no checklist de code review para PR que mexe com dinheiro. É assim que um incidente melhora o time inteiro, e não só aquele arquivo.
Ações que não aceito no documento: "ter mais cuidado", "revisar melhor", "monitorar de perto". Nenhuma delas tem critério de pronto.
Como comunicar o incidente para cliente, parceiro e time?
O postmortem interno é a fonte, mas cada público recebe uma versão diferente:
- Cliente afetado: o que aconteceu com a conta dele, em valores, o que já foi corrigido e se ele precisa fazer algo. Sem jargão, sem nome de tabela.
- Parceiro (gateway, adquirente): só quando o incidente envolve a integração. Linha do tempo com horários e IDs de evento ajuda o suporte deles a investigar.
- Time ou sócios: o documento completo, incluindo os números e as ações.
Publicar o postmortem interno para o time, mesmo sendo pequeno, cria o hábito de discutir falha sem constrangimento. Se você trabalha sozinho, ainda vale escrever: daqui a seis meses, você é outra pessoa e não vai lembrar por que aquela constraint existe. Isso conversa com a forma como organizo vários SaaS em produção sendo dev solo, em que o histórico escrito substitui o colega que eu não tenho.
Conclusão: o que fazer no próximo incidente
No próximo incidente que mexer com dinheiro, siga esta ordem e resista a pular etapas:
- Estanque o sangramento (desligue a feature, pause a fila, bloqueie saque) antes de investigar.
- Escreva a query de impacto e rode com janela ampla. Ela vai para o documento.
- Monte a linha do tempo a partir dos logs filtrados por ID, não da memória.
- Faça os porquês até sair de "alguém errou" e chegar em "o sistema permitiu".
- Corrija os dados com um comando versionado, idempotente e com dry-run, usando lançamentos compensatórios.
- Escreva o teste que reproduz o bug e a constraint que o impede.
- Saia com pelo menos uma ação de prevenção, uma de detecção e uma de mitigação, cada uma com dono e prazo.
Deixe um template com essas seções no repositório, em docs/postmortems/, antes de precisar dele. Na hora do incidente ninguém tem cabeça para inventar formato, e o dev que sabe conduzir esse processo com calma e com dados é exatamente quem as empresas de pagamento procuram quando falam em sênior.