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.