Todo produto que mexe com dinheiro começa igual. Tem uma tabela users, e nela uma coluna balance. Alguém paga, você soma. Alguém saca, você subtrai. Funciona por meses.
O dia em que para de funcionar tem um roteiro previsível. Um cliente liga dizendo que o saldo está errado. Você abre o banco e vê o número. O número está lá, sozinho, sem história. Você não consegue dizer como ele chegou naquele valor, quais operações passaram por ali, nem qual delas errou. A coluna guarda o resultado e joga fora o raciocínio.
Core ledger é a resposta pra esse problema. E ele é bem mais simples do que o nome sugere.
A definição curta
Um core ledger é o registro autoritativo de todo movimento de valor dentro do sistema, guardado como uma sequência imutável de lançamentos, da qual o saldo é derivado.
Três palavras carregam o peso: autoritativo, imutável, derivado.
Autoritativo quer dizer que existe um lugar só que conta a verdade. Se o relatório financeiro, a tela do cliente e o extrato divergem, não é porque cada um tem sua versão. É porque alguém não leu do ledger.
Imutável quer dizer que lançamento não se edita nem se apaga. Errou? Você registra a correção. O erro continua lá, visível, com data. Isso incomoda muito engenheiro no começo, e é exatamente o ponto: a história é o produto.
Derivado quer dizer que saldo não é um campo que você escreve. É uma soma que você calcula, ou um cache que você reconstrói a partir dos lançamentos a qualquer momento. Se der divergência entre o cache e a soma, o cache está errado, sempre.
Por que partidas dobradas
Aqui a maioria dos devs revira os olhos, e eu revirei também. Contabilidade de 500 anos atrás, num sistema moderno, parece cerimônia.
Não é. É uma restrição de integridade que pega erro sozinha.
Na partida dobrada, dinheiro nunca aparece nem some: ele sai de algum lugar e entra em outro. Todo movimento tem no mínimo duas pernas, e a soma das pernas de uma transação é zero.
Cliente paga R$ 100 por um pedido:
débito caixa_gateway +10000
crédito conta_cliente_1234 -10000
------------------------------------
soma 0
Os valores estão em centavos, inteiros. Dinheiro em float é um bug com data marcada.
O que essa regra te dá de graça: qualquer bug que crie ou destrua dinheiro quebra a soma. Você adiciona uma checagem que roda periodicamente somando tudo e conferindo se dá zero. Se não der, alguma coisa quebrou, e você descobre pela verificação, não pelo cliente.
Sem partidas dobradas, dinheiro sumindo é indistinguível de dinheiro que nunca existiu.
O modelo mínimo
create table account (
id uuid primary key,
owner_type text not null, -- 'customer', 'platform', 'gateway'
owner_id uuid,
currency char(3) not null,
kind text not null, -- 'asset', 'liability', 'revenue', 'expense'
created_at timestamptz not null default now(),
unique (owner_type, owner_id, currency, kind)
);
create table ledger_transaction (
id uuid primary key,
idempotency_key text not null unique,
kind text not null, -- 'payment', 'refund', 'payout', 'fee'
occurred_at timestamptz not null,
recorded_at timestamptz not null default now(),
metadata jsonb not null default '{}'
);
create table ledger_entry (
id bigserial primary key,
transaction_id uuid not null references ledger_transaction(id),
account_id uuid not null references account(id),
amount_minor bigint not null, -- positivo entra, negativo sai
currency char(3) not null,
created_at timestamptz not null default now()
);
Duas coisas importam mais que o resto.
A idempotency_key é única no banco, não checada na aplicação. Gateway de pagamento reenvia webhook. Fila faz retry. Usuário clica duas vezes. Sem essa constraint você vai creditar duas vezes, e vai descobrir tarde.
E amount_minor é bigint, nunca numeric com casa decimal e jamais float. Você guarda centavos. Formatação é problema da camada de apresentação.
O balanceamento é verificável numa query:
select transaction_id, sum(amount_minor)
from ledger_entry
group by transaction_id
having sum(amount_minor) <> 0;
Essa query tem que voltar vazia. Sempre. Se voltar linha, você tem um incidente, e é melhor descobrir por um job de madrugada do que pelo contador em março.
Saldo: calcular ou materializar
A pergunta que sempre aparece: somar todos os lançamentos toda vez não fica lento?
Fica, eventualmente. Mas bem depois do que você imagina. Com índice em account_id e alguns milhões de linhas, o Postgres soma isso sem suar. Comece calculando.
Quando o volume justificar, o padrão é snapshot: uma tabela com o saldo de cada conta até um entry_id de corte, e o saldo corrente é o snapshot mais os lançamentos posteriores. O snapshot é cache, e cache reconstruível. Se ele divergir da soma completa, você regenera. Nunca o contrário.
O que não funciona é a coluna balance que você atualiza junto com o insert. Duas fontes de verdade, uma transação que pode falhar no meio, e nenhuma forma de saber qual das duas está certa.
Onde eu vejo isso dar errado
O erro mais comum é permitir UPDATE na tabela de lançamentos. Alguém vai precisar “só corrigir uma coisinha”. Bloqueie no banco, com regra ou permissão, não com combinado no code review.
O segundo é misturar moedas na mesma conta. Parece que economiza tabela. Custa uma migração dolorosa quando entra a segunda moeda, e ela sempre entra.
O terceiro é tratar o ledger como detalhe de implementação de um serviço. Ele é o núcleo. Se o serviço de pedido, o de assinatura e o de repasse cada um tem sua contabilidade, você não tem um ledger, tem três versões da verdade e uma reunião mensal pra decidir qual delas vale.
Isso não é só para fintech
Se o seu produto tem crédito, cota, ponto de fidelidade, minuto contratado ou qualquer unidade que o usuário acumula e gasta, você tem um ledger. A pergunta é se ele está desenhado ou se está espalhado em três colunas e um cron.
O custo de começar com um ledger de verdade é uma semana. O custo de migrar depois, com dinheiro de cliente dentro, é bem outro.
É um dos temas que eu levo pra palestra, e o tipo de arquitetura que a gente desenha no Singularity.