O livro-razão append-only que mantém saldos exatos
Um saldo deveria ser uma projeção que você pode descartar e reconstruir, e esta é a maquinaria que torna isso seguro: diários de soma zero, edições que lançam correções e um outbox que despacha na ordem do commit.
Um saldo que você incrementa é um saldo que desvia. O Dimesum nunca incrementa um. Toda despesa lança um diário de partidas dobradas cujos lançamentos somam exatamente zero, e o número na tela do seu grupo é uma projeção sobre esses lançamentos que podemos apagar e reconstruir com um único comando.
Você pode testar essa afirmação. Quando a única forma de o dinheiro se mover é um diário append-only, um saldo errado deixa de ser um mistério e vira uma consulta que podemos rodar.
Saldos são uma projeção, não um número que você incrementa
O livro-razão do Dimesum tem três tabelas: journals, postings e uma projeção balances chaveada por grupo, membro e moeda. Os lançamentos carregam a verdade. Um lançamento é positivo quando um membro pôs dinheiro no grupo e negativo quando consumiu valor, então um saldo é um simples SUM(amount_minor). A projeção existe por velocidade, não por autoridade: ela é gravada dentro da própria transação do diário, e o fato de os lançamentos estarem completos a mantém descartável.
Duas camadas afirmam o mesmo zero, e nenhuma basta sozinha
Em Go, buildPostings se recusa a abrir uma transação a menos que o conjunto de lançamentos some zero. No Postgres, um gatilho de restrição deferido reverifica SUM(amount_minor) = 0 por diário no commit, porque os lançamentos inserem uma linha por vez e uma checagem por linha rejeitaria a primeira perna de todo diário. O assert pega um bug na calculadora. O gatilho pega um escritor que nunca a chamou.
Uma terceira camada é uma concessão. UPDATE, DELETE e TRUNCATE são revogados do papel ledger_app nas duas tabelas, então código com bug não consegue reescrever a história nem quando tenta.
| Classe de bug | Tabela de saldos incrementada | Livro-razão append-only |
|---|---|---|
| Devedor debitado, pagador nunca creditado | Silenciosamente errado para sempre | Checagem de soma zero falha, escrita rejeitada |
| Uma parcela muda, o total não | Os saldos desviam silenciosamente | O diário não pode dar commit desbalanceado |
| "Por que meu saldo é de R$ 412?" | Sem resposta | Cada centavo remonta a um diário |
| Um hotfix em produção | Mutação não rastreada | O único caminho é um novo diário auditado |
Uma edição lança um estorno, nunca um UPDATE
Editar uma despesa não escreve nada por cima da versão antiga. O livro-razão consome um evento expense.amended e lança dois diários em uma única transação: um EXPENSE_REVERSAL cujos lançamentos são a negação exata, perna a perna, da versão que está sendo substituída, e depois um novo EXPENSE para a nova. Uma exclusão para depois do estorno.
Uma transação importa tanto quanto os dois diários. Se o estorno desse commit sozinho, um grupo ficaria por um instante devendo nada por uma despesa que ainda deve.
As duas chaves de idempotência são derivadas da versão em vez de geradas: expense:<id>:v<n> para a versão, e a chave da versão anterior mais :reversal para a negação. journals.idempotency_key é UNIQUE, então um evento reentregue encontra seus diários já ali e não faz nada. A derivação é o que torna a entrega ao-menos-uma-vez segura: uma retentativa calcula a mesma chave que a primeira tentativa usou.
Uma marca-d'água de ordem de commit mantém uma emenda atrás da sua despesa
Todo evento que o Dimesum publica é gravado em uma tabela outbox na mesma transação da escrita de negócio. Se a despesa der commit, o anúncio existe. Se ela der rollback, o anúncio também. O livro-razão nunca fica sabendo de uma despesa que não existe.
A ordem de despacho é a metade mais difícil. Os ids são UUIDv7 e ordenam por tempo, mas codificam quando o id foi gerado, não quando sua transação deu commit. Um relay lendo em ordem de id pode passar por cima de uma transação que pegou um id anterior e deu commit depois, então uma emenda ultrapassa a despesa que ela emenda.
A correção é uma coluna e um predicado. Cada linha de outbox carrega inserted_xid xid8 DEFAULT pg_current_xact_id(), e o relay lê apenas as linhas WHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot()), ordenadas por esse xid (veja as funções de id de transação do PostgreSQL). Um evento causalmente posterior sempre carrega um xid posterior, porque teve de ler a linha anterior para existir.
O consumidor de emendas não confia cegamente nessa ordenação. Uma emenda cujo predecessor não tem diário é reentregue enquanto está nova, e estacionada para um humano assim que passa de uma carência de dois minutos.
Dinheiro é int64 em unidades menores, e o expoente nem sempre é dois
Todo valor é uma contagem int64 de unidades menores mais um código ISO 4217, então 1.234,56 rupias é {Minor: 123456, Currency: "INR"}. Inteiros são exatos por construção, não por disciplina: nenhum valor representável é meio centavo, então nenhuma operação consegue produzir um sorrateiramente. O sidecar em Python lê seu próprio AST e falha um teste se a palavra float aparecer em seu módulo de valores.
Supor que a unidade menor é um centésimo é a armadilha por baixo. O JPY não tem nenhuma e o KWD tem três casas decimais, então uma taxa cotada entre unidades maiores e aplicada a unidades menores erra por uma potência de dez. Pegue 2.000 ienes a 0,58: o produto ingênuo é 1160, que se lê como 11,60 rupias, quando a resposta é 1.160.
WRITE_OFF é um quinto tipo de diário porque seus lançamentos batem com os de uma quitação
Uma baixa lança as mesmas duas pernas que uma quitação: o devedor sobe, o credor cai, no mesmo valor. Juntá-la em SETTLEMENT foi rejeitado exatamente pela razão de os formatos baterem. "A Asha te pagou R$ 500" e "você perdoou R$ 500 da Asha" são fatos diferentes, e um feed que os confundisse diria que alguém pagou quando ninguém pagou.
Então WRITE_OFF entrou no CHECK de tipo de diário em 2026-08-21, com pernas próprias. Nomeá-las era metade do ponto, porque uma baixa reutilizando SETTLE_PAY tornaria toda consulta de "quanto de fato foi pago" silenciosamente errada.
| Tipo de diário | Lançado quando | Pernas |
|---|---|---|
EXPENSE | Criada, ou uma nova versão substitui outra | PAID, SHARE |
EXPENSE_REVERSAL | Editada ou excluída | As pernas do diário anterior, anuladas |
SETTLEMENT | Um pagamento é declarado | SETTLE_PAY, SETTLE_RECV |
SETTLEMENT_REVERSAL | A contraparte a contesta | As duas pernas de quitação, anuladas |
WRITE_OFF | Um credor desiste de uma cobrança | WRITE_OFF_FORGIVEN, WRITE_OFF_GRANTED |
Dois subcomandos transformam os invariantes em um job de cron
evenly verify-ledger reprova os invariantes sobre todo o schema e sai com código diferente de zero em qualquer ocorrência. Seu relatório tem quatro campos, e espera-se que todos estejam vazios: diários que não somam zero, grupos que não somam zero, linhas de projeção que divergem de um SUM(postings) recalculado, e eventos estacionados esperando um humano. Todas as varreduras compartilham um mesmo snapshot repeatable-read, então um diário que chega no meio da verificação não consegue fabricar uma divergência.
Cada varredura agrupa por moeda além de por id, e a falha que isso evita é um falso negativo. Um lançamento de 500 rupias e um de menos 500 ienes somam zero quando uma consulta ignora a coluna de moeda, então um livro-razão duplamente corrompido pareceria limpo.
evenly rebuild-balances [group-id] é o reparo e o treino. Ele limpa a projeção e recalcula cada linha a partir dos lançamentos, carimbando cada uma com o último diário que moveu aquele membro, como faz a escrita ao vivo. A igualdade linha a linha é a propriedade: quando um rebuild e a projeção ao vivo discordam, o livro-razão é quem está certo.
Verifique, depois reconstrua. O relatório nomeia o saldo armazenado e o recalculado para cada linha que desviou, e um rebuild sobrescreve o valor armazenado, então reconstruir primeiro destrói a evidência.
Torne o saldo derivável e o desvio vira uma consulta
Um livro-razão append-only merece seu segundo diário só se você conseguir prová-lo, então construa o verificador e o rebuild antes da funcionalidade que precisa deles. Um rebuild que ninguém rodou é uma esperança, não uma saída de emergência. Escolha sua projeção de dinheiro mais arriscada esta semana, escreva a consulta que a recalcula a partir da origem, e acione a si mesmo quando as duas discordarem.
Perguntas frequentes
O que é um livro-razão append-only em um app de divisão de contas?
Um livro-razão append-only registra cada evento financeiro como um diário de lançamentos que somam zero e nunca atualiza ou apaga um deles. No Dimesum, uma despesa, uma edição, uma quitação, uma contestação e uma baixa acrescentam, cada uma, um novo diário. Os saldos são então derivados pela soma dos lançamentos, então cada centavo remonta ao evento que o moveu.
Como um livro-razão append-only trata uma despesa editada?
Uma edição lança dois diários em uma transação: um EXPENSE_REVERSAL que anula o original perna a perna e, em seguida, um novo EXPENSE com a versão substituta. Nada é reescrito, e uma exclusão lança apenas o estorno. Os dois diários recebem chaves de idempotência derivadas da versão da despesa, então um evento reentregue os encontra já ali e não muda nada.
Por que guardar valores monetários como inteiros em vez de floats?
Unidades menores inteiras são exatas por construção, enquanto o ponto flutuante binário não consegue representar 0,1 com exatidão e acumula desvio ao longo do histórico de um grupo. O Dimesum guarda cada valor como uma contagem int64 de unidades menores mais um código ISO 4217. O expoente vem da moeda: o JPY não tem unidade menor alguma, então supor um centésimo é um erro de 100x.
Como saber se os saldos de uma divisão de despesas não desviaram?
Rode evenly verify-ledger, que reprova os invariantes sobre todo o schema e sai com código diferente de zero em qualquer ocorrência. Ele reporta diários que não somam zero, grupos que não somam zero, linhas de projeção que discordam de um SUM(postings) recalculado e eventos estacionados. Coloque no cron toda noite e conserte uma projeção ruim com evenly rebuild-balances.
Por que uma baixa precisa do próprio tipo de diário?
Uma baixa lança pernas idênticas às de uma quitação, e é exatamente por isso que o tipo de diário precisava ser diferente. Só essa palavra separa 'a Asha te pagou R$ 500' de 'você perdoou R$ 500 da Asha', e um feed que os confundisse diria que alguém pagou quando ninguém pagou. Suas pernas se chamam WRITE_OFF_FORGIVEN e WRITE_OFF_GRANTED para que as consultas de pagamento continuem corretas.
Posts populares
- Por que editar uma despesa deve refazer a divisão9 min de leitura
- Seis bugs de dinheiro em despesas multimoeda9 min de leitura
- Acerto de contas: menos transferências no grupo5 min de leitura
- Como dividir uma conta detalhada de restaurante10 min de leitura
- Como dividir o aluguel de forma justa6 min de leitura