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. 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.

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:

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.

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.

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. 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.

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.

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.