Blog

Como funciona um core ledger por dentro.

Já escrevi sobre o que é um core ledger e por que ele existe. Aqui é a mecânica: o que acontece entre o cliente clicar em pagar e o saldo aparecer certo na tela.

As quatro camadas

Um ledger bem desenhado tem quatro camadas com responsabilidades que não se misturam. Quando um projeto começa a doer, quase sempre é porque duas delas se fundiram.

Interface de operação. O que o resto do sistema chama. Verbos de negócio: cobrar, estornar, repassar, aplicar taxa. Nunca inserir lançamento. Se o serviço de assinatura consegue escrever lançamento avulso, você não tem um ledger, tem uma tabela compartilhada.

Motor de postagem. Traduz operação de negócio em conjunto de lançamentos, valida que a soma dá zero, garante idempotência e grava atomicamente. É a única parte que escreve.

Consulta de saldo. Lê. Saldo corrente, extrato, saldo numa data. Nunca escreve.

Reconciliação. Roda periodicamente e confere que tudo continua coerente, internamente e contra o mundo externo.

O caminho de uma cobrança

async function chargeSubscription(cmd: ChargeCommand) {
  return db.transaction(async (tx) => {
    // 1. idempotência decidida pelo banco, não por SELECT antes
    const txn = await tx.insertOrConflict("ledger_transaction", {
      idempotency_key: cmd.idempotencyKey,
      kind: "subscription_charge",
      occurred_at: cmd.occurredAt,
      metadata: { subscriptionId: cmd.subscriptionId },
    });
    if (txn.conflicted) return replay(txn.existing);

    // 2. o negócio decide as pernas
    const entries = [
      { accountId: gatewayCash,    amount:  cmd.amountMinor },
      { accountId: customerLiab,   amount: -cmd.amountMinor + cmd.feeMinor },
      { accountId: platformFeeRev, amount: -cmd.feeMinor },
    ];

    // 3. invariante conferida antes de gravar
    const sum = entries.reduce((acc, e) => acc + e.amount, 0n);
    if (sum !== 0n) throw new UnbalancedTransactionError(sum);

    // 4. grava tudo junto
    await tx.insertMany("ledger_entry", entries.map((e) => ({
      ...e, transaction_id: txn.id, currency: cmd.currency,
    })));

    return txn;
  });
}

Quatro pontos que decidem se isso aguenta produção.

A idempotência vem primeiro e é resolvida por constraint única. Verificar com SELECT e depois inserir tem uma corrida no meio que aparece exatamente no momento em que o gateway reenvia o webhook em paralelo.

O balanceamento é conferido em memória antes de escrever. É barato e transforma um bug de lógica em exceção clara em vez de dado corrompido silencioso.

Todos os lançamentos entram na mesma transação. Meio lançamento gravado é a única categoria de erro que um ledger não consegue explicar depois.

E não existe atualização de saldo aqui. Nenhuma.

Saldo sem lock global

A pergunta que sempre vem: se ninguém escreve saldo, como consultar rápido?

Somando. Com índice em (account_id, id), o Postgres soma milhões de linhas mais rápido do que a intuição sugere. Comece assim e meça antes de complicar.

Quando o volume justificar, snapshot:

create table account_balance_snapshot (
  account_id      uuid not null,
  up_to_entry_id  bigint not null,
  balance_minor   bigint not null,
  created_at      timestamptz not null default now(),
  primary key (account_id, up_to_entry_id)
);

O saldo corrente é o último snapshot mais os lançamentos posteriores:

select s.balance_minor + coalesce(sum(e.amount_minor), 0)
from account_balance_snapshot s
left join ledger_entry e
  on e.account_id = s.account_id and e.id > s.up_to_entry_id
where s.account_id = $1
group by s.balance_minor
order by s.up_to_entry_id desc
limit 1;

O snapshot é cache. Se divergir da soma completa, o snapshot está errado e você regenera. Essa hierarquia precisa estar clara na cabeça de todo mundo do time, senão alguém vai “corrigir” o ledger pra bater com o snapshot.

O problema do saldo insuficiente

Aqui mora a única concorrência genuinamente difícil.

Duas transferências simultâneas de uma conta com R$ 100, de R$ 80 cada. As duas leem o saldo, as duas veem 100, as duas aprovam. A conta fica negativa.

Três soluções, com custos diferentes.

Lock por conta, com SELECT ... FOR UPDATE numa linha da tabela de contas. Simples, correto, e serializa tudo daquela conta. Suficiente na maioria dos casos, porque conta de cliente raramente tem concorrência alta.

Constraint de não negativo com a checagem dentro da mesma transação. Precisa de saldo materializado, o que reintroduz o problema que a gente estava evitando.

Aceitar negativo temporário e reconciliar. É o que sistema de cartão faz, porque a autorização acontece offline e o mundo real não espera. Nesse modelo, o limite é uma política de risco, não uma garantia técnica.

Escolha explicitamente. O erro é não escolher e descobrir a resposta em produção.

O que roda de madrugada

Três verificações. Elas são o que transforma um ledger em algo confiável.

Balanceamento por transação, que precisa voltar vazio sempre:

select transaction_id from ledger_entry
group by transaction_id having sum(amount_minor) <> 0;

Snapshot conferido contra a soma completa, numa amostra de contas.

E conciliação externa: o total que o gateway diz ter processado no dia contra o total das suas contas de caixa. É a única checagem que pega erro de premissa, aquele em que o seu sistema está internamente coerente e errado em relação ao mundo.

Divergência aqui não é para consertar sozinho. É para alertar humano.

O erro conceitual mais caro

Tratar o ledger como um serviço entre outros.

No momento em que assinatura tem sua contabilidade, marketplace tem a dele e repasse tem o terceiro, você não tem ledger. Tem três versões da verdade e uma reunião mensal pra decidir qual delas vale.

O ledger é o núcleo. Os outros serviços pedem operação a ele. Essa hierarquia é decisão de arquitetura, e é quase impossível de reverter depois que o sistema cresceu.

← Voltar pro blog