# Ledger de partidas dobradas no Laravel e PostgreSQL: como modelar o saldo de uma conta digital

> Por que um campo saldo não aguarda uma fintech e como modelar contas, transações e lançamentos imutáveis que sempre fecham em zero, com Laravel e PostgreSQL.

Por Lucas Roberto · Arquitetura · publicado em 2026-10-09 · 9 min de leitura
Versão web: https://lucasroberto.com/blog/ledger-de-partidas-dobradas-no-laravel-e-postgresql-como-modelar-o-saldo-de-uma-conta-digital

Para modelar o saldo de uma conta digital de forma auditável, o caminho mais seguro é um ledger de partidas dobradas. Nele, o saldo nunca é editado diretamente. Ele é a consequência de lançamentos imutáveis, e cada movimentação debita uma ou mais contas e credita outras no mesmo valor, de modo que a soma de toda transação é sempre zero. No Laravel com PostgreSQL, isso se resume a três tabelas, uma constraint que o banco verifica no commit e um único serviço por onde passa todo dinheiro do sistema.

Trabalhando com maquininha e conta digital PF e PJ, essa foi uma das decisões de arquitetura que mais pesou lá na frente. Abaixo estão o modelo que eu uso, o código essencial e os trade-offs que aparecem quando o volume cresce.

## Por que um campo saldo não basta numa conta digital?

O primeiro modelo que quase todo mundo escreve é uma coluna `balance` na tabela de contas, com `increment` e `decrement` espalhados pelo código. Funciona até a primeira pergunta difícil:

1. **"Por que o saldo desse lojista é esse?"** Sem histórico estruturado, você reconstrói a resposta a partir de logs, tabela de pedidos, webhooks e boa vontade.
2. **"Para onde foi a diferença?"** Quando um `decrement` roda e o `increment` correspondente falha, o dinheiro some sem deixar rastro. Nenhuma regra do banco impede isso.
3. **"Quanto a plataforma deve aos clientes agora?"** Isso exige somar saldos de clientes, recebíveis do adquirente e receita de tarifa, e checar se tudo bate. Com colunas soltas, não bate nunca, e você não sabe por quê.

O problema de concorrência (duas requisições lendo o mesmo saldo ao mesmo tempo) é real, e eu tratei dele em detalhe no post sobre [como evitar race condition no saldo da conta digital com Laravel e PostgreSQL](/blog/como-evitar-race-condition-no-saldo-da-conta-digital-com-laravel-e-postgresql). Mas lock resolve só a corrida. Não resolve a falta de rastreabilidade nem a falta de uma regra que garanta que o dinheiro não aparece nem desaparece. Isso é papel do ledger.

## O que é um ledger de partidas dobradas, na prática?

Ledger é o livro-razão: o registro cronológico de todas as movimentações financeiras. Partidas dobradas é a regra contábil de que todo valor que sai de um lugar entra em outro. Débito é um lado do lançamento e crédito é o outro, e numa transação a soma dos débitos é igual à soma dos créditos.

No sistema isso vira três conceitos:

1. **Conta (account):** um lugar onde o dinheiro fica. Pode ser "saldo disponível do lojista X", "saldo a liberar do lojista X", "recebíveis do adquirente", "receita de MDR" ou "conta de liquidação Pix". MDR é a taxa de desconto cobrada sobre cada venda no cartão.
2. **Transação:** um fato de negócio, como uma venda, uma liquidação, um Pix enviado ou um estorno. É atômica.
3. **Lançamento (entry):** uma linha que movimenta uma conta dentro de uma transação. Uma transação tem dois ou mais lançamentos.

Eu uso a convenção de valor com sinal: débito positivo, crédito negativo. Assim a regra inteira cabe numa frase: **a soma dos lançamentos de uma transação é zero**. Cada conta tem também um tipo (ativo, passivo, receita, despesa) que define o lado "natural" do saldo. O saldo de um cliente é passivo para a plataforma, porque é dinheiro que ela deve a ele. Por isso o saldo exibido ao cliente é o inverso da soma bruta.

## Como modelar as tabelas do ledger no PostgreSQL?

O valor fica sempre em centavos, em `bigint`. Nada de `float` e nada de `decimal` trafegando como string pelo PHP sem necessidade.

```php
Schema::create('ledger_accounts', function (Blueprint $table) {
    $table->id();
    $table->string('code')->unique(); // ex: merchant:123:available
    $table->string('type');           // asset, liability, revenue, expense
    $table->nullableMorphs('owner');
    $table->char('currency', 3)->default('BRL');
    $table->bigInteger('balance')->default(0); // soma bruta com sinal
    $table->boolean('allow_negative')->default(false);
    $table->boolean('track_balance')->default(true);
    $table->timestamps();
});

Schema::create('ledger_transactions', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->string('idempotency_key')->unique();
    $table->string('type'); // card_sale, settlement, pix_out, refund...
    $table->uuid('reverses_id')->nullable()->index();
    $table->jsonb('metadata')->nullable();
    $table->timestampTz('occurred_at');
    $table->timestampTz('created_at')->useCurrent();
});

Schema::create('ledger_entries', function (Blueprint $table) {
    $table->id();
    $table->foreignUuid('transaction_id')->constrained('ledger_transactions');
    $table->foreignId('account_id')->constrained('ledger_accounts');
    $table->bigInteger('amount'); // débito > 0, crédito < 0
    $table->timestampTz('created_at')->useCurrent();
    $table->index(['account_id', 'id']);
});
```

Algumas decisões que valem explicar:

- **`idempotency_key` única na transação.** Uma chave de idempotência garante que repetir a mesma operação não gera efeito duplicado. Webhook reenviado, job reexecutado e retry de rede vão tentar lançar a mesma venda duas vezes. A chave costuma ser algo como `card_sale:{id_do_provedor}`.
- **Uma conta por estado do dinheiro, não uma por cliente.** "A liberar" e "disponível" são contas diferentes do mesmo lojista. Mover dinheiro entre elas é uma transação como outra qualquer, e isso deixa o fluxo de liquidação explícito.
- **`balance` cacheado na conta.** É uma otimização, não a fonte da verdade. A verdade é a soma dos lançamentos. Mais abaixo falo de quando vale manter esse cache.

## Como garantir no banco que toda transação fecha em zero?

Validar no PHP é obrigatório, mas não suficiente. Um script de correção rodado às pressas no tinker ou um seeder mal escrito ignora o serviço. Por isso coloco a regra no PostgreSQL com uma constraint trigger deferida, que só é avaliada no commit, depois que todos os lançamentos da transação foram inseridos:

```sql
CREATE FUNCTION ledger_check_balanced() RETURNS trigger AS $$
BEGIN
  IF (SELECT COALESCE(SUM(amount), 0)
        FROM ledger_entries
       WHERE transaction_id = NEW.transaction_id) <> 0 THEN
    RAISE EXCEPTION 'ledger transaction % is not balanced', NEW.transaction_id;
  END IF;
  RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE CONSTRAINT TRIGGER ledger_entries_balanced
  AFTER INSERT ON ledger_entries
  DEFERRABLE INITIALLY DEFERRED
  FOR EACH ROW EXECUTE FUNCTION ledger_check_balanced();

CREATE FUNCTION ledger_block_mutation() RETURNS trigger AS $$
BEGIN
  RAISE EXCEPTION 'ledger entries are append-only';
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER ledger_entries_immutable
  BEFORE UPDATE OR DELETE ON ledger_entries
  FOR EACH ROW EXECUTE FUNCTION ledger_block_mutation();
```

Coloco isso numa migration com `DB::unprepared()`. Também adiciono `CHECK (amount <> 0)` nos lançamentos, porque lançamento zerado só polui o histórico. O segundo trigger torna a tabela append-only, ou seja, só aceita inserção. Se o usuário da aplicação no banco nem tiver permissão de `UPDATE` e `DELETE` em `ledger_entries`, melhor ainda.

## Como lançar uma transação no Laravel sem corromper o saldo?

Todo dinheiro passa por um único serviço. Nenhum controller, job ou listener mexe em `ledger_entries` diretamente.

```php
final readonly class Posting
{
    public function __construct(public int $accountId, public int $amount)
    {
        if ($amount === 0) {
            throw new InvalidArgumentException('Posting amount cannot be zero.');
        }
    }
}

final class Ledger
{
    /** @param Posting[] $postings */
    public function post(string $key, string $type, array $postings, array $meta = []): LedgerTransaction
    {
        if (array_sum(array_map(fn (Posting $p) => $p->amount, $postings)) !== 0) {
            throw new UnbalancedTransaction($key);
        }

        try {
            return DB::transaction(fn () => $this->write($key, $type, $postings, $meta));
        } catch (UniqueConstraintViolationException) {
            // outra requisição lançou a mesma chave primeiro
            return LedgerTransaction::where('idempotency_key', $key)->firstOrFail();
        }
    }

    private function write(string $key, string $type, array $postings, array $meta): LedgerTransaction
    {
        if ($existing = LedgerTransaction::where('idempotency_key', $key)->first()) {
            return $existing;
        }

        $ids = array_unique(array_map(fn (Posting $p) => $p->accountId, $postings));

        // lock sempre na mesma ordem para não gerar deadlock
        $accounts = LedgerAccount::whereIn('id', $ids)
            ->where('track_balance', true)
            ->orderBy('id')
            ->lockForUpdate()
            ->get()
            ->keyBy('id');

        $tx = LedgerTransaction::create([
            'idempotency_key' => $key,
            'type' => $type,
            'metadata' => $meta,
            'occurred_at' => now(),
        ]);

        foreach ($postings as $p) {
            $tx->entries()->create(['account_id' => $p->accountId, 'amount' => $p->amount]);

            if ($account = $accounts->get($p->accountId)) {
                $account->balance += $p->amount;
            }
        }

        foreach ($accounts as $account) {
            if (! $account->allow_negative && $account->naturalBalance() < 0) {
                throw new InsufficientFunds($account->code);
            }
            $account->save();
        }

        return $tx;
    }
}
```

`naturalBalance()` é um método do model que devolve `-balance` para contas de passivo e receita e `balance` para ativo e despesa. O check de saldo insuficiente acontece depois de aplicar todos os lançamentos e antes do commit. Se falhar, a exceção desfaz tudo e nenhum lançamento parcial sobra. A `UniqueConstraintViolationException` existe desde o Laravel 10 e cobre a corrida em que duas requisições passam pelo `first()` ao mesmo tempo com a mesma chave.

Outro cuidado: se o lançamento dispara job (notificar o lojista, emitir comprovante), o dispatch precisa acontecer depois do commit. Senão o job pode rodar antes de o lançamento existir, ou existir por um lançamento que sofreu rollback. Esse é exatamente o [bug silencioso de jobs disparados dentro de transação com afterCommit](/blog/jobs-disparados-dentro-de-transacao-o-bug-silencioso-do-aftercommit-no-laravel).

## Como mapear venda na maquininha, Pix e estorno em lançamentos?

A melhor forma de validar o modelo é escrever os fluxos reais como tabela antes de codar. Exemplo de uma venda de R$ 100,00 no cartão com MDR de R$ 2,50:

| Transação | Conta | Tipo da conta | Lançamento (centavos) |
|---|---|---|---|
| card_sale | Recebíveis do adquirente | ativo | +10000 |
| card_sale | Lojista X: a liberar | passivo | -9750 |
| card_sale | Receita de MDR | receita | -250 |
| settlement | Lojista X: a liberar | passivo | +9750 |
| settlement | Lojista X: disponível | passivo | -9750 |
| pix_out | Lojista X: disponível | passivo | +5000 |
| pix_out | Liquidação Pix | ativo | -5000 |

Cada bloco soma zero. Ao final, o lojista tem R$ 47,50 disponíveis (o inverso de -9750 + 5000), a plataforma reconhece R$ 2,50 de receita e o ativo "recebíveis do adquirente" mostra quanto o adquirente ainda deve repassar. Quando o repasse cai na conta da plataforma, outra transação debita a conta bancária e credita os recebíveis.

Split de pagamento entra naturalmente: é uma transação com várias linhas de crédito, uma por recebedor. A parte difícil, que é dividir centavos sem sobra, eu detalhei no post sobre [split de pagamento sem perder centavos](/blog/split-de-pagamento-sem-perder-centavos-como-dividir-valores-e-estornos-no-laravel). O ledger só garante que, depois de dividido, nada fica de fora.

## Saldo cacheado ou calculado pela soma dos lançamentos?

Essa é a decisão com mais trade-off real do modelo.

| Critério | Saldo cacheado na conta | Saldo pela soma dos lançamentos |
|---|---|---|
| Leitura | O(1), uma linha | Cresce com o histórico (mitigável com snapshot) |
| Escrita | Exige lock na conta | Só insert, sem contenção |
| Checagem de saldo insuficiente | Simples e consistente | Exige lock ou soma dentro da transação |
| Risco de divergência | Existe, precisa de auditoria | Nenhum, é a própria verdade |

O que eu faço é misturar os dois. Contas de cliente usam saldo cacheado, porque toda saída precisa checar saldo e o lock por conta tem pouca contenção: é raro o mesmo lojista ter dezenas de operações simultâneas.

Contas de sistema são outra história. Receita de MDR e recebíveis do adquirente participam de quase toda transação. Se elas forem travadas com `lockForUpdate`, todas as vendas da plataforma passam a ser serializadas numa única linha. É o problema clássico de hot account (conta quente). Por isso essas contas ficam com `track_balance = false`: o serviço não as trava nem atualiza saldo, e o saldo delas é calculado por soma, com snapshots diários (o saldo fechado até certa data mais a soma dos lançamentos posteriores).

Como o cache pode divergir, um job agendado compara `balance` com `SUM(amount)` por conta e alerta em qualquer diferença. Em teoria nunca dispara. Na prática ele é o que te avisa que alguém fez um update manual onde não devia.

## Como corrigir um lançamento errado se nada pode ser alterado?

Com outro lançamento. Erro em ledger se corrige por estorno contábil: uma nova transação com os mesmos lançamentos de sinal invertido, apontando para a original via `reverses_id`, e depois, se for o caso, uma nova transação com os valores corretos.

```php
public function reverse(LedgerTransaction $original, string $reason): LedgerTransaction
{
    $postings = $original->entries
        ->map(fn ($e) => new Posting($e->account_id, -$e->amount))
        ->all();

    return $this->post(
        key: "reversal:{$original->id}",
        type: 'reversal',
        postings: $postings,
        meta: ['reverses_id' => $original->id, 'reason' => $reason],
    );
}
```

No código real, eu passo `reverses_id` como argumento do `post` e gravo na coluna, não só no metadata. Parece burocrático, mas é isso que permite responder qualquer auditoria: o histórico mostra o erro, a correção, quando ela aconteceu e por quê. Chargeback e estorno de venda seguem a mesma lógica, com a diferença de que são fatos de negócio com tipo próprio, não correções.

Esse histórico imutável também é a base da conciliação. Em vez de comparar o extrato do provedor com tabelas de pedidos, você compara com os lançamentos da conta de recebíveis e da conta de liquidação, que é o que descrevo em [conciliação de pagamentos no Laravel](/blog/conciliacao-de-pagamentos-no-laravel-como-bater-o-extrato-do-provedor-com-o-seu-banco-todo-dia).

## Conclusão: por onde começar se o sistema já tem um campo saldo

Se você já está em produção com uma coluna `balance`, não precisa reescrever tudo de uma vez. A ordem que funciona:

1. Crie as três tabelas e as triggers de balanceamento e imutabilidade.
2. Para cada conta existente, lance uma transação de abertura contra uma conta de sistema ("saldo de migração"), com o saldo atual.
3. Faça todos os novos fluxos passarem pelo `Ledger::post`, começando pelas saídas de dinheiro (Pix, TED, saque), que são as que mais doem quando erram.
4. Ative o job que compara saldo cacheado com a soma dos lançamentos e só desligue a coluna antiga depois de semanas sem divergência.

O custo é real: mais tabelas, mais linhas por operação e um modelo mental que o time precisa aprender. Mas, em sistema que guarda dinheiro de terceiros, a pergunta "por que esse saldo é esse?" vai aparecer, e o ledger é o que te deixa responder com uma query em vez de uma investigação.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
