Blog

O que é um core ledger e por que quase todo produto financeiro precisa de um.

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.

← Voltar pro blog