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:

  1. O impacto é contável. Você consegue, e precisa, dizer quantos centavos e quantas contas foram afetados.
  2. 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.
  3. 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:

  1. Qualquer divergência de saldo, mesmo de um centavo, que chegou a um cliente.
  2. Dinheiro movimentado em duplicidade ou não movimentado quando deveria.
  3. Indisponibilidade de fluxo de pagamento (checkout, Pix, saque) acima do tempo que você tolera.
  4. Incidente detectado pelo cliente antes do seu monitoramento.
  5. 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:

  1. 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.
  2. 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:

  1. Por que o saldo foi creditado duas vezes? Porque o job processou o mesmo evento duas vezes.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

  1. 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.
  2. Rodar em dry-run por padrão. O comando só altera dados com uma flag explícita.
  3. Ser idempotente. Rodar duas vezes não pode estornar duas vezes. A chave de referência do estorno garante isso.
  4. 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:

  1. 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.
  2. 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.
  3. 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:

  1. Estanque o sangramento (desligue a feature, pause a fila, bloqueie saque) antes de investigar.
  2. Escreva a query de impacto e rode com janela ampla. Ela vai para o documento.
  3. Monte a linha do tempo a partir dos logs filtrados por ID, não da memória.
  4. Faça os porquês até sair de "alguém errou" e chegar em "o sistema permitiu".
  5. Corrija os dados com um comando versionado, idempotente e com dry-run, usando lançamentos compensatórios.
  6. Escreva o teste que reproduz o bug e a constraint que o impede.
  7. 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.