dimesum

Início / Blog / Engenharia

Engenharia

O livro-razão append-only que mantém saldos exatos

· 9 min de leitura ·

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.

O que um livro-razão append-only torna impossível, comparado a uma tabela de saldos incrementada
Classe de bugTabela de saldos incrementadaLivro-razão append-only
Devedor debitado, pagador nunca creditadoSilenciosamente errado para sempreChecagem de soma zero falha, escrita rejeitada
Uma parcela muda, o total nãoOs saldos desviam silenciosamenteO diário não pode dar commit desbalanceado
"Por que meu saldo é de R$ 412?"Sem respostaCada centavo remonta a um diário
Um hotfix em produçãoMutação não rastreadaO ú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.

Uma edição de despesa lança um EXPENSE_REVERSAL que anula a versão um, mais um novo EXPENSE para a versão dois, em uma transação; a projeção de saldos é uma soma sobre todos os lançamentos. uma transação EXPENSE expense:7c1:v1 4 lançamentos soma = 0 EXPENSE_REVERSAL expense:7c1:v1:reversal cada perna anulada soma = 0 EXPENSE expense:7c1:v2 5 lançamentos soma = 0 projeção de saldos = SUM(postings) por membro, por moeda descartável: evenly rebuild-balances a limpa e reexecuta cada lançamento
Uma edição acrescenta: um estorno nomeia a versão que anula, e a substituta é lançada ao lado.

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 escrita de negócio e sua linha de outbox dão commit em uma transação; o relay então despacha apenas as linhas de outbox cujo id de transação de inserção está abaixo da marca-d'água pg_snapshot_xmin, em ordem de id de transação. uma transação INSERT linha de despesa INSERT linha de outbox tópico id (uuidv7) inserted_xid expense.created 019a-7f3 4101 expense.amended 019a-4c1 4102 expense.created 019a-1a8 4103 escritor em voo barramento de eventos livro-razão consumidor pg_snapshot_xmin(pg_current_snapshot()). As linhas acima dela foram escritas por transações que deram commit sem nenhuma mais antiga ainda em execução. A linha abaixo espera a próxima passada. Ordenar por id despacharia 019a-1a8 primeiro, então uma emenda poderia chegar antes da despesa que ela emenda. Ordenar por inserted_xid não pode: um evento posterior tem um xid posterior.
A linha de outbox dá commit junto com a escrita de negócio, e o relay despacha abaixo da marca-d'água em ordem de id de transação.

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.

Os cinco tipos de diário no livro-razão do Dimesum e as pernas que cada um lança
Tipo de diárioLançado quandoPernas
EXPENSECriada, ou uma nova versão substitui outraPAID, SHARE
EXPENSE_REVERSALEditada ou excluídaAs pernas do diário anterior, anuladas
SETTLEMENTUm pagamento é declaradoSETTLE_PAY, SETTLE_RECV
SETTLEMENT_REVERSALA contraparte a contestaAs duas pernas de quitação, anuladas
WRITE_OFFUm credor desiste de uma cobrançaWRITE_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.

Rode-os nesta ordem

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.