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.