Il registro append-only per spese condivise esatte
Un saldo dovrebbe essere una proiezione che puoi buttare via e ricostruire, e questa è la meccanica che lo rende sicuro.
Un saldo che si incrementa è un saldo che va alla deriva. Dimesum non ne incrementa mai uno. Ogni spesa registra una scrittura in partita doppia le cui righe hanno somma esattamente zero, e il numero sulla schermata del tuo gruppo è una proiezione su quelle righe che possiamo cancellare e ricostruire con un solo comando.
Puoi verificare questa affermazione. Quando l'unico modo in cui il denaro si muove è una scrittura append-only, un saldo sbagliato smette di essere un mistero e diventa una query che possiamo eseguire.
I saldi sono una proiezione, non un numero che si incrementa
Il registro di Dimesum possiede tre tabelle: journals, postings e una proiezione balances indicizzata per gruppo, membro e valuta. Le righe portano la verità. Una riga è positiva quando un membro ha messo denaro nel gruppo e negativa quando ha consumato valore, quindi un saldo è un semplice SUM(amount_minor). La proiezione esiste per velocità, non per autorità: viene scritta all'interno della transazione stessa della scrittura, e la completezza delle righe la mantiene eliminabile.
Due livelli affermano lo stesso zero, e nessuno dei due basta da solo
In Go, buildPostings si rifiuta di aprire una transazione se l'insieme delle righe non ha somma zero. In Postgres, un trigger di vincolo differito ricontrolla SUM(amount_minor) = 0 per ogni scrittura al momento del commit, perché le righe vengono inserite una alla volta e un controllo riga per riga rifiuterebbe la prima riga di ogni scrittura. L'assert intercetta un bug nel calcolatore. Il trigger intercetta uno scrittore che non l'ha mai chiamato.
Un terzo livello è un permesso. UPDATE, DELETE e TRUNCATE sono revocati dal ruolo ledger_app su entrambe le tabelle, così il codice difettoso non può riscrivere la storia nemmeno quando ci prova.
| Classe di bug | Tabella di saldi incrementata | Registro append-only |
|---|---|---|
| Debitore addebitato, pagante mai accreditato | Sbagliato in silenzio per sempre | Il controllo a somma zero fallisce, scrittura rifiutata |
| Una quota cambia, il totale no | I saldi vanno alla deriva silenziosamente | La scrittura non può essere confermata se non è bilanciata |
| "Perché il mio saldo è €412?" | Senza risposta | Ogni centesimo risale a una scrittura |
| Una correzione rapida in produzione | Mutazione non tracciata | L'unica via è una nuova scrittura verificabile |
Una modifica registra uno storno, mai un UPDATE
Modificare una spesa non sovrascrive nulla della vecchia versione. Il registro consuma un evento expense.amended e registra due scritture in un'unica transazione: uno EXPENSE_REVERSAL le cui righe sono l'esatta negazione riga per riga della versione che viene sostituita, poi una nuova EXPENSE per quella nuova. Un'eliminazione si ferma dopo lo storno.
L'unica transazione conta quanto le due scritture. Se lo storno fosse confermato da solo, un gruppo per un istante non dovrebbe nulla per una spesa che ancora deve.
Entrambe le chiavi di idempotenza sono derivate dalla versione anziché generate: expense:<id>:v<n> per la versione, e la chiave della versione precedente più :reversal per la negazione. journals.idempotency_key è UNIQUE, quindi un evento riconsegnato trova le sue scritture già presenti e non fa nulla. La derivazione è ciò che rende sicura la consegna at-least-once: un nuovo tentativo calcola la chiave usata dal primo tentativo.
Un watermark in ordine di commit mantiene una modifica dietro la sua spesa
Ogni evento che Dimesum pubblica viene scritto in una tabella outbox nella stessa transazione della scrittura di business. Se la spesa viene confermata, l'annuncio esiste. Se viene annullata, lo è anche l'annuncio. Il registro non sente mai parlare di una spesa che non esiste.
L'ordine di invio è la metà più difficile. Gli id sono UUIDv7 e si ordinano per tempo, ma codificano quando l'id è stato generato, non quando la sua transazione è stata confermata. Un relay che legge in ordine di id può saltare oltre una transazione che ha preso un id precedente e si è confermata dopo, così una modifica supera la spesa che modifica.
La correzione è una colonna e un predicato. Ogni riga di outbox porta inserted_xid xid8 DEFAULT pg_current_xact_id(), e il relay legge solo le righe WHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot()), ordinate per quell'xid (vedi le funzioni per gli id di transazione di PostgreSQL). Un evento causalmente successivo porta sempre un xid successivo, perché per esistere ha dovuto leggere la riga precedente.
Il consumatore delle modifiche non dà per scontato quell'ordine. Una modifica il cui predecessore non ha una scrittura viene riconsegnata finché è recente, e messa da parte per un umano una volta superati due minuti di tolleranza.
Il denaro è in unità minori int64, e l'esponente non è sempre due
Ogni importo è un conteggio int64 di unità minori più un codice ISO 4217, quindi 1.234,56 rupie sono {Minor: 123456, Currency: "INR"}. Gli interi sono esatti per costruzione, non per disciplina: nessun valore rappresentabile è mezzo centesimo, quindi nessuna operazione può produrne uno di nascosto. Il sidecar Python legge il proprio AST e fa fallire un test se la parola float compare nel suo modulo degli importi.
Assumere che l'unità minore sia un centesimo è la trappola sottostante. JPY non ne ha affatto e KWD ha tre decimali, quindi un tasso quotato tra unità maggiori e applicato a unità minori è sbagliato di una potenza di dieci. Prendi 2.000 yen a 0,58: il prodotto ingenuo è 1160, che si legge come 11,60 rupie, quando la risposta è 1.160.
WRITE_OFF è un quinto tipo di scrittura perché le sue righe coincidono con quelle di un regolamento
Uno storno registra le stesse due righe di un regolamento: il debitore sale, il creditore scende, dello stesso importo. Accorparlo in SETTLEMENT è stato respinto esattamente per il motivo per cui le forme coincidono. "Asha ti ha pagato 500 euro" e "hai condonato ad Asha 500 euro" sono fatti diversi, e un feed che li confondesse direbbe che qualcuno ha pagato quando nessuno l'ha fatto.
Così WRITE_OFF si è unito al CHECK dei tipi di scrittura il 2026-08-21, con righe proprie. Nominarle era metà del punto, perché uno storno che riutilizzasse SETTLE_PAY renderebbe ogni query "quanto è stato effettivamente pagato" sbagliata in silenzio.
| Tipo di scrittura | Registrato quando | Righe |
|---|---|---|
EXPENSE | Creata, o una nuova versione ne sostituisce una | PAID, SHARE |
EXPENSE_REVERSAL | Modificata o eliminata | Le righe della scrittura precedente, negate |
SETTLEMENT | Viene affermato un rimborso | SETTLE_PAY, SETTLE_RECV |
SETTLEMENT_REVERSAL | La controparte lo contesta | Entrambe le righe di regolamento, negate |
WRITE_OFF | Un creditore rinuncia a un credito | WRITE_OFF_FORGIVEN, WRITE_OFF_GRANTED |
Due sottocomandi trasformano gli invarianti in un cron job
evenly verify-ledger ridimostra gli invarianti sull'intero schema ed esce con codice diverso da zero a ogni riscontro. Il suo report ha quattro campi, e ci si aspetta che ognuno sia vuoto: scritture che non hanno somma zero, gruppi che non hanno somma zero, righe di proiezione che divergono da un SUM(postings) ricalcolato, ed eventi messi da parte in attesa di un umano. Tutte le scansioni condividono un unico snapshot repeatable-read, così una scrittura che arriva a metà verifica non può fabbricare una discrepanza.
Ogni scansione raggruppa per valuta oltre che per id, e il fallimento che questo evita è un falso negativo. Una riga di 500 rupie e una di meno 500 yen hanno somma zero quando una query ignora la colonna della valuta, quindi un registro doppiamente corrotto risulterebbe pulito.
evenly rebuild-balances [group-id] è la riparazione e l'esercitazione. Azzera la proiezione e ricalcola ogni riga dalle righe di scrittura, marcando ciascuna con l'ultima scrittura che ha mosso quel membro, come fa la scrittura in tempo reale. L'uguaglianza riga per riga è la proprietà: quando una ricostruzione e la proiezione in tempo reale non concordano, il registro ha ragione.
Verifica, poi ricostruisci. Il report nomina il saldo memorizzato e quello ricalcolato per ogni riga alla deriva, e una ricostruzione sovrascrive il valore memorizzato, quindi ricostruire prima distrugge le prove.
Rendi il saldo derivabile e la deriva diventa una query
Un registro append-only guadagna la sua seconda scrittura solo se puoi dimostrarlo, quindi costruisci il verificatore e la ricostruzione prima della funzionalità che ne ha bisogno. Una ricostruzione che nessuno ha mai eseguito è una speranza, non una via di fuga. Scegli la tua proiezione di denaro più rischiosa questa settimana, scrivi la query che la ricalcola dalla fonte, e fatti avvisare quando le due non concordano.
Domande frequenti
Cos'è un registro append-only in un'app per dividere le spese?
Un registro append-only registra ogni evento di denaro come una scrittura di righe con somma zero, e non ne aggiorna né elimina mai una. In Dimesum, una spesa, una modifica, un regolamento, una contestazione e uno storno aggiungono ciascuno una nuova scrittura. I saldi vengono poi derivati sommando le righe, così ogni centesimo risale all'evento che l'ha mosso.
Come gestisce un registro append-only una spesa modificata?
Una modifica registra due scritture in un'unica transazione: uno EXPENSE_REVERSAL che nega l'originale riga per riga, poi una nuova EXPENSE che porta la versione sostitutiva. Nulla viene riscritto, e un'eliminazione registra solo lo storno. Entrambe le scritture prendono chiavi di idempotenza derivate dalla versione della spesa, così un evento riconsegnato le trova già presenti e non cambia nulla.
Perché memorizzare il denaro come interi invece che come float?
Le unità minori intere sono esatte per costruzione, mentre la virgola mobile binaria non può rappresentare esattamente 0,1 e va alla deriva lungo la storia di un gruppo. Dimesum memorizza ogni importo come un conteggio int64 di unità minori più un codice ISO 4217. L'esponente viene dalla valuta: JPY non ha alcuna unità minore, quindi assumere un centesimo è un errore di 100x.
Come faccio a sapere che i saldi delle spese condivise non sono andati alla deriva?
Esegui evenly verify-ledger, che ridimostra gli invarianti sull'intero schema ed esce con codice diverso da zero a ogni riscontro. Segnala scritture che non hanno somma zero, gruppi che non hanno somma zero, righe di proiezione che non concordano con un SUM(postings) ricalcolato, ed eventi messi da parte. Programmalo ogni notte con cron, e ripara una proiezione errata con evenly rebuild-balances.
Perché uno storno ha bisogno di un proprio tipo di scrittura?
Uno storno registra righe identiche a quelle di un regolamento, ed è esattamente per questo che il tipo di scrittura doveva differire. Solo quella parola separa 'Asha ti ha pagato 500 euro' da 'hai condonato ad Asha 500 euro', e un feed che li confondesse direbbe che qualcuno ha pagato quando nessuno l'ha fatto. Le sue righe si chiamano WRITE_OFF_FORGIVEN e WRITE_OFF_GRANTED così le query sui pagamenti restano corrette.
Articoli più letti
- Perché modificare una spesa ridefinisce la divisione9 min di lettura
- Sei bug di denaro nelle spese multivaluta9 min di lettura
- Saldare le spese di gruppo in pochi bonifici5 min di lettura
- Come dividere un conto con un piatto non condiviso9 min di lettura
- Come dividere l'affitto tra coinquilini in modo equo6 min di lettura