Blog

Ledger multi-moeda: câmbio, arredondamento e dor de cabeça.

Todo ledger começa com uma moeda. A segunda entra quando alguém vende pra fora, ou quando a empresa recebe investimento em dólar, ou quando um fornecedor cobra em euro.

E aí três coisas que funcionavam param de funcionar.

Premissa 1: soma faz sentido

Num ledger de uma moeda, somar os lançamentos de uma conta dá o saldo.

Com duas moedas na mesma conta, a soma vira um número sem significado. R$ 100 mais US$ 50 não é 150 de nada.

A regra que resolve, e é inegociável: uma conta, uma moeda.

create table account (
  id         uuid primary key,
  owner_type text not null,
  owner_id   uuid,
  currency   char(3) not null,
  kind       text not null,
  unique (owner_type, owner_id, currency, kind)
);

Um cliente que opera em duas moedas tem duas contas. Isso parece burocracia e é o que mantém toda consulta de saldo correta sem nenhum cuidado especial.

E adicione a verificação no banco, porque combinado não segura:

alter table ledger_entry add constraint entry_currency_matches_account
  check (currency = (select currency from account where id = account_id));

Como CHECK com subconsulta não é permitido no Postgres, na prática isso vira um gatilho ou uma coluna redundante mantida por chave estrangeira composta. Vale o trabalho: lançamento na moeda errada é o tipo de erro que só aparece meses depois.

Premissa 2: a soma da transação dá zero

Numa transação de câmbio, as pernas estão em moedas diferentes.

Cliente converte R$ 500 em US$ 92 (taxa 5.4321):

  conta_cliente_BRL    -50000   (centavos de real)
  conta_cliente_USD     +9204   (centavos de dólar)

A soma aritmética é -40796, que não é zero e não deveria ser: são unidades diferentes.

A verificação de balanceamento precisa ser por moeda, dentro da transação:

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

E agora aparece o problema: por moeda, essa transação também não fecha. Faltam 50000 do lado do real e sobram 9204 do lado do dólar.

O que fecha é ter contas de posição de câmbio. A operação vira duas transações balanceadas ou uma transação com quatro pernas:

  conta_cliente_BRL       -50000
  posicao_cambio_BRL      +50000     -> fecha em BRL

  posicao_cambio_USD       -9204
  conta_cliente_USD        +9204     -> fecha em USD

A conta de posição de câmbio acumula o descasamento, e o saldo dela nas duas moedas é exatamente a exposição cambial da empresa. Isso deixa de ser um detalhe contábil e vira informação de gestão de risco que ninguém tinha antes.

Premissa 3: arredondamento é detalhe

Com taxa de câmbio, o arredondamento deixa de ser exceção e vira regra: quase toda conversão sobra centavo.

A taxa precisa ser guardada como inteiro com escala explícita, nunca como decimal solto:

create table fx_rate (
  id           uuid primary key,
  base         char(3) not null,
  quote        char(3) not null,
  rate_scaled  bigint not null,     -- 5.432167 guardado como 5432167
  scale        int not null,        -- 6
  source       text not null,       -- de onde veio
  valid_at     timestamptz not null,
  unique (base, quote, valid_at, source)
);

E o cálculo multiplica antes de dividir:

const converted = (amountMinor * rateScaled) / BigInt(10 ** scale);

Inverter a ordem perde precisão antes da multiplicação, e o erro fica visivelmente maior. Como escrevi em dinheiro nunca deve ser float, tudo isso em inteiro.

O que sobra vai pra uma conta de ajuste de arredondamento, explicitamente. Nunca desaparece.

A taxa usada precisa ficar gravada

Esse é o item que mais falta em sistema real.

A transação precisa registrar qual taxa foi aplicada, de qual fonte e de qual momento. Não basta ter a tabela de taxas: a transação aponta pra linha específica.

Sem isso, ninguém consegue reproduzir a conversão seis meses depois, e reproduzir é exatamente o que a auditoria vai pedir. “Usamos a taxa do dia” não é resposta quando existem cinco fontes e a taxa muda a cada minuto.

Qual taxa usar

Decisão de negócio disfarçada de detalhe técnico.

A taxa do momento da operação, a de fechamento do dia anterior, a média do período, ou a taxa comercial mais um spread. Cada uma produz números diferentes e cada uma é defensável em um contexto.

O que a engenharia precisa garantir é que a escolha seja explícita, esteja registrada por transação, e que exista uma fonte com histórico. Fonte que só devolve a taxa atual, sem histórico, inviabiliza recálculo e conciliação.

Reavaliação

A parte contábil que surpreende quem vem só da tecnologia.

Se a empresa tem saldo em dólar e a moeda de reporte é o real, o valor em real daquele saldo muda todo dia sem nenhuma transação acontecer. Isso gera ganho ou perda cambial que precisa ser reconhecida.

Tecnicamente, é um job periódico que calcula a diferença entre o valor registrado e o valor à taxa atual, e gera lançamentos numa conta de resultado. Não é opcional em empresa que fecha balanço.

O conselho prático

Se existe qualquer chance de entrar uma segunda moeda, modele com currency desde o primeiro dia, mesmo com uma moeda só.

A coluna custa nada agora. A migração depois envolve separar contas, reprocessar histórico e explicar pro financeiro por que os saldos mudaram durante a migração. Já vi esse projeto consumir um trimestre.

← Voltar pro blog