Guardar dinheiro em cêntimos inteiros | Excelsi
[T-05-pt]2026-10-15•Excelsi

Guardar dinheiro em cêntimos inteiros

Porque migrámos as colunas de dinheiro de decimais para inteiros, o que o USING round(x*100) faz, como o ledger encadeado sobreviveu, e a surpresa que rebentou 47 testes.

⏱5 min de leitura

Dinheiro não é um número. É uma unidade. E durante demasiado tempo o nosso sistema guardou dinheiro como decimais em euros, o que é a mesma coisa que guardar comprimentos sem dizer se são metros ou pés.

O problema de fundo

Um DECIMAL(12,2) em euros parece inofensivo. E é, enquanto há uma só convenção. O problema começa quando o sistema cresce e passa a falar duas unidades ao mesmo tempo.

A dada altura, parte dos campos de dinheiro do nosso backend já estavam em cêntimos e outros continuavam em euros. Havia uma conversão de fronteira explícita em cada rota – funções para ir de euros a cêntimos e voltar – e, pior, um mesmo objeto de resposta misturava as duas: uns campos em euros, outros em cêntimos. O consumidor tinha de saber, campo a campo, qual era qual. Isso não é um sistema com uma convenção. É um sistema com memória de trabalho.

Decidimos que a unidade canónica seria o cêntimo, sempre, em todo o lado: domínio, API e base de dados. Este artigo é sobre a parte mais chata e mais perigosa dessa decisão – mexer nas colunas.

Só três colunas

O primeiro passo foi o inventário, e foi aí que percebemos que o problema era menor do que temíamos. As colunas de dinheiro novas já estavam todas em cêntimos (inteiros). Só três continuavam em euros: transactions.amount e ledger_postings.debit/credit – números do nosso projeto, claro.

Todas as outras conversões que existiam no código eram adaptadores para atravessar exatamente essa fronteira. Fechá-la era fechar os adaptadores.

A migração, e porque é que o round importa

Migrámos as três colunas para BIGINT em cêntimos. O núcleo é este:

ALTER TABLE "transactions"
  ALTER COLUMN "amount" TYPE BIGINT USING round("amount" * 100)::bigint;

O USING é onde vive a lógica. Ele não é um detalhe de sintaxe: é a regra de conversão, escrita em SQL, aplicada a cada linha. O round arredonda para o inteiro mais próximo, meio para longe do zero – 500.00 vira 50000, -148.86 vira -14886, 0 vira 0. Não há passo de backfill separado porque a conversão é o backfill, e não há valores a perder porque o arredondamento é total: para um Decimal de duas casas, multiplicar por 100 é sempre exato.

Duas coisas tornaram esta migração segura de escrever:

Idempotência. O prisma migrate deploy corre a cada arranque do contentor. Se a migração não fosse idempotente, o segundo arranque rebentava. Por isso o ALTER está dentro de um bloco que primeiro pergunta se a coluna já é bigint; se for, não faz nada. Reaplicar é um no-op.

A alternativa é pior. A tentação é escrever à mão um backfill em código de aplicação. Não fizemos: em código, dinheiro é o sítio onde é mais fácil escrever uma conversão plausível e errada. Em SQL, a regra fica num só lugar e é a mesma para todas as linhas.

O ledger e o trigger que não dispara

O segundo conjunto de colunas vive numa tabela especial: o ledger. ledger_postings é append-only – há um trigger BEFORE UPDATE OR DELETE que rejeita qualquer alteração, e a aplicação nem sequer tem permissão de UPDATE/DELETE sobre ela. É a espinha dorsal da nossa capacidade de provar o que aconteceu.

Um ALTER COLUMN ... TYPE faz um rewrite da tabela. A pergunta óbvia era: isto dispara o trigger append-only e a migração falha? A resposta, que provámos num clone antes do release, é não: o trigger é de nível de linha, para UPDATE/DELETE de dados, e um rewrite de tipo é DDL – não passa por ele.

Menciono isto porque é o tipo de conflito que só se descobre a correr. Uma invariante forte a proteger dados e uma migração estrutural podem colidir, e a única forma de saber é testar em dados reais, não em teoria.

Vale também dizer o que o ledger é, para contexto: cada evento encadeia-se ao anterior por um digest, sha256(prevHash || canonicalJSON). O primeiro evento arranca com um prev_hash de 64 zeros. A escrita é serializada por um lock de transação e idempotente pelo id. Há um endpoint que recomputa a cadeia toda e devolve a primeira inconsistência, e outro que exporta os eventos com os hashes. O que isto prova é consistência interna: uma alteração acidental é detetada. O que não prova – e dizemo-lo aos auditores – é contra quem tenha acesso total à base de dados e recompute a cadeia; para isso seria preciso ancorar o hash da ponta num sítio externo, que ainda não temos.

A surpresa: 47 testes vermelhos

A migração passou. O que partiu foi a seguir.

O Prisma devolve uma coluna BIGINT como bigint do JavaScript, não como number. E há uma coisa que o bigint não suporta: JSON.stringify lança TypeError: Do not know how to serialize a BigInt. Não devolve null, não avisa – lança.

Isso rebentou 47 testes de uma vez: o harness de avaliação serializava os registos de trace com JSON.stringify direto, e assim que uma ferramenta devolvia uma transação em bigint, o harness inteiro caía. Pior: o mesmo TypeError subia dentro do percurso de um pedido real, o que fazia um endpoint de ferramentas de IA devolver 500 em produção – não só nos testes.

A correção foi criar um normalizador canónico de JSON seguro para bigint e usá-lo em todos os pontos onde serializávamos valores que podiam ser cêntimos: sete sítios, entre eles dois críticos de produção que nunca teriam aparecido se só olhássemos para os testes. A convenção ficou explícita: bigint no Prisma vira number no wire; Decimal vira number; o resto preserva-se. E o normalizador passou a ser um só, em vez das duas cópias que já existiam espalhadas pelo código.

A lição

Mudar a unidade do dinheiro não é uma migração de dados. É uma migração de contrato.

O compilador não vê unidades. number é number quer sejam euros quer sejam cêntimos; bigint é o que é e explode no JSON.stringify só quando corre. Não há verificação estática que apanhe “este número está na unidade errada”. O que apanha é disciplina: um inventário honesto das fronteiras antes de começar, uma convenção única escrita no sítio mais baixo possível, e testes que corram o caminho todo – porque metade dos problemas só apareceu depois de a coluna já estar migrada.