Blog

Reprocessar lote financeiro sem duplicar lançamento.

Todo sistema financeiro tem processamento em lote. Cobrança de assinatura, repasse a vendedores, cálculo de comissão, aplicação de juros.

E todo lote um dia falha no meio.

O que acontece depois é sempre igual: alguém precisa rodar de novo, sob pressão, geralmente fora do horário, com uma decisão a tomar que ninguém preparou. Se o sistema não foi desenhado pra isso, a segunda execução duplica o que a primeira já fez.

A regra que resolve

Toda operação financeira precisa de uma chave natural de negócio, com constraint única no banco.

Natural quer dizer derivada do que a operação significa, não gerada no momento da execução.

-- 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);

-- o repasse de um vendedor num período, idem
alter table payout
  add constraint payout_period_unique
  unique (seller_id, period_start, period_end);

Com billing_cycle sendo 2026-10, rodar o lote de outubro quinze vezes produz a mesma cobrança uma vez só. A décima quinta execução viola a constraint e segue.

Isso é bem mais forte que qualquer chave técnica, porque sobrevive a alguém rodando o script errado com os parâmetros errados às três da manhã.

Item a item, não lote inteiro

O segundo erro estrutural é envolver o lote todo numa transação.

// frágil: 50 mil itens, um falha, tudo volta
await db.transaction(async (tx) => {
  for (const sub of subscriptions) await charge(tx, sub);
});

Parece seguro e produz o pior desfecho: uma transação enorme, com lock longo, que quando falha desfaz horas de trabalho, inclusive as chamadas externas que não voltam atrás.

O certo é transação por item:

for (const sub of subscriptions) {
  try {
    await db.transaction(async (tx) => {
      await tx.insert("subscription_charge", {
        subscription_id: sub.id,
        billing_cycle: cycle,          // a constraint decide
      });
      await postLedgerEntries(tx, sub);
    });
  } catch (e) {
    if (isUniqueViolation(e)) continue;      // já processado, segue
    await recordFailure(sub, e);             // registra e segue
  }
}

O lote passa a ser retomável por construção. Rodar de novo pula o que já foi e processa o que faltou.

Registre a execução

Lote sem registro é diagnóstico sem prontuário.

create table batch_run (
  id            uuid primary key,
  job_name      text not null,
  reference     text not null,       -- '2026-10'
  status        text not null,       -- 'running','completed','failed'
  total_items   int,
  processed     int not null default 0,
  skipped       int not null default 0,
  failed        int not null default 0,
  started_at    timestamptz not null default now(),
  finished_at   timestamptz,
  unique (job_name, reference, id)
);

Isso responde as perguntas que aparecem depois: quantas execuções teve esse ciclo, quantos itens cada uma processou, e quais falharam.

E skipped separado de processed importa. Uma segunda execução com 49.987 pulados e 13 processados é sinal de que tudo está certo. Se ela processar 50 mil de novo, a constraint não está funcionando.

O que fazer com o que falhou

Item que falhou não pode sumir. Ele vai pra uma fila com o erro registrado, e alguém decide.

Três destinos possíveis: reprocessar, porque foi falha transitória. Corrigir o dado e reprocessar. Ou marcar como não aplicável, com justificativa escrita.

O terceiro é o que costuma faltar. Sem ele, o item fica na fila pra sempre e a fila vira ruído, que é o mesmo mecanismo do alerta que dispara todo dia.

Quando o efeito é externo

O caso difícil: o lote chama um gateway. A constraint no seu banco não impede que a chamada externa aconteça duas vezes.

A chave de idempotência precisa ir junto, e ela precisa ser a mesma nas duas execuções:

const idempotencyKey = `charge:${sub.id}:${cycle}`;   // derivada, não gerada
await gateway.charge({ ...data, idempotencyKey });

Derivada do fato, não randomUUID(). Escrevi o mecanismo completo em idempotência em pagamentos.

E registre a intenção antes de chamar. Se o processo morrer entre a chamada e a gravação, você precisa saber que a chamada foi feita pra poder consultar o estado remoto em vez de repetir às cegas.

Simulação obrigatória

Todo lote financeiro deveria ter modo de simulação: calcula tudo, escreve nada, e devolve o resumo.

Ciclo 2026-10
  49.987 assinaturas elegíveis
  R$ 2.481.320,00 total
  13 com cartão expirado
  2 com valor divergente do ciclo anterior em mais de 50%

Aquelas duas últimas linhas são o motivo de existir. Elas pegam o erro de parâmetro antes de ele virar dinheiro movimentado, e erro de parâmetro em lote financeiro é a categoria mais cara que existe.

O limite de sanidade

Uma proteção que já salvou muita gente: o lote se recusa a rodar se o volume divergir demais do esperado.

const previous = await lastRunTotal(job, previousCycle);
if (Math.abs(total - previous) / previous > 0.3) {
  throw new BatchSanityError(
    `Total ${total} diverge ${pct}% do ciclo anterior. Rode com --force se for esperado.`
  );
}

Se o filtro de data estiver errado e o lote pegar seis meses em vez de um, isso para antes. A alternativa é descobrir pela reclamação dos clientes, e reverter cobrança em massa custa muito mais que o incômodo do --force.

← Voltar pro blog