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.