dimesum

Startsida / Blogg / Teknik

Teknik

Journalen som håller delade utgifter exakta

· 8 min läsning ·

Ett saldo bör vara en projektion du kan slänga och bygga om, och det är maskineriet som gör det säkert: nollsummejournaler, redigeringar som bokför rättelser och en outbox som levererar i commit-ordning.

Ett saldo som du räknar upp är ett saldo som glider. Dimesum räknar aldrig upp något. Varje utgift bokförs som en dubbel bokföringspost vars poster summerar till exakt noll, och siffran på din gruppskärm är en projektion över dessa poster som vi kan radera och bygga om med ett enda kommando.

Du kan pröva det påståendet. När det enda sättet som pengar rör sig på är en journal som bara kan läggas till, slutar ett felaktigt saldo att vara ett mysterium och blir i stället en fråga som vi kan köra.

Saldon är en projektion, inte en siffra du räknar upp

Dimesums liggare äger tre tabeller: journals, postings och en balances-projektion som nycklas på grupp, medlem och valuta. Posterna bär sanningen. En post är positiv när en medlem har lagt in pengar i gruppen och negativ när medlemmen har förbrukat värde, så ett saldo är en enkel SUM(amount_minor). Projektionen finns för snabbhet, inte för auktoritet: den skrivs inuti journalens egen transaktion, och att posterna är fullständiga håller den slängbar.

Två lager hävdar samma nolla, och inget av dem räcker ensamt

I Go vägrar buildPostings att öppna en transaktion om inte postuppsättningen summerar till noll. I Postgres kontrollerar en uppskjuten villkorstrigger på nytt SUM(amount_minor) = 0 per journal vid commit, eftersom poster läggs in en rad i taget och en kontroll per rad skulle avvisa varje journals första ben. Assert-kontrollen fångar ett fel i kalkylatorn. Triggern fångar en skrivare som aldrig anropade den.

Ett tredje lager är en behörighet. UPDATE, DELETE och TRUNCATE är återkallade från rollen ledger_app på båda tabellerna, så felaktig kod kan inte skriva om historiken ens när den försöker.

Vad en journal som bara kan läggas till gör omöjligt, jämfört med en balans-tabell som räknas upp
FelklassBalans-tabell som räknas uppJournal som bara kan läggas till
Gäldenären debiteras, betalaren krediteras aldrigTyst fel för alltidNollsummekontrollen misslyckas, skrivningen avvisas
En andel ändras, summan gör det inteSaldon glider tystJournalen kan inte committas obalanserad
"Varför är mitt saldo 412 kr?"Går inte att besvaraVarje öre kan spåras till en journal
En snabbfix i produktionOspårad ändringDen enda vägen är en ny, granskad journal

En redigering bokför en återföring, aldrig en UPDATE

Att redigera en utgift skriver ingenting över den gamla versionen. Liggaren konsumerar en expense.amended-händelse och bokför två journaler i en enda transaktion: en EXPENSE_REVERSAL vars poster är den exakta negeringen ben för ben av den version som ersätts, och sedan en ny EXPENSE för den nya. En radering stannar efter återföringen.

Att det är en transaktion spelar lika stor roll som de två journalerna. Om återföringen committades ensam skulle en grupp under en kort stund vara skyldig ingenting för en utgift som den fortfarande är skyldig.

En utgiftsredigering bokför en EXPENSE_REVERSAL som negerar version ett, plus en ny EXPENSE för version två, i en transaktion; balansprojektionen är en summa över alla posterna. en transaktion EXPENSE expense:7c1:v1 4 poster summa = 0 EXPENSE_REVERSAL expense:7c1:v1:reversal varje ben negerat summa = 0 EXPENSE expense:7c1:v2 5 poster summa = 0 balansprojektion = SUM(postings) per medlem, per valuta slängbar: evenly rebuild-balances tömmer den och spelar upp varje post på nytt
En redigering lägger till: en återföring namnger den version den negerar, och ersättningen bokförs bredvid den.

Båda idempotensnycklarna härleds ur versionen i stället för att myntas: expense:<id>:v<n> för versionen, och den föregående versionens nyckel plus :reversal för negeringen. journals.idempotency_key är UNIQUE, så en händelse som levereras på nytt hittar sina journaler redan där och gör ingenting. Härledningen är det som gör leverans minst en gång säker: ett nytt försök beräknar samma nyckel som det första försöket använde.

En vattenstämpel i commit-ordning håller en ändring bakom sin utgift

Varje händelse som Dimesum publicerar skrivs till en outbox-tabell i samma transaktion som affärsskrivningen. Om utgiften committas finns tillkännagivandet. Om den rullas tillbaka gör tillkännagivandet det också. Liggaren får aldrig höra om en utgift som inte finns.

Leveransordningen är den svårare halvan. Id:n är UUIDv7 och sorteras efter tid, men de kodar när id:t myntades, inte när dess transaktion committades. En relä som läser i id-ordning kan hoppa förbi en transaktion som tog ett tidigare id och committades senare, så en ändring går om utgiften den ändrar.

Affärsskrivningen och dess outbox-rad committas i en transaktion; reläet levererar sedan bara outbox-rader vars insatta transaktions-id ligger under vattenstämpeln pg_snapshot_xmin, i transaktions-id-ordning. en transaktion INSERT utgiftsrad INSERT outbox-rad ämne id (uuidv7) inserted_xid expense.created 019a-7f3 4101 expense.amended 019a-4c1 4102 expense.created 019a-1a8 4103 skrivare pågår händelsebuss liggare konsument pg_snapshot_xmin(pg_current_snapshot()). Rader ovanför den skrevs av transaktioner som har committats utan att någon äldre fortfarande körs. Raden nedanför väntar på nästa genomgång. Att sortera efter id skulle leverera 019a-1a8 först, så en ändring skulle kunna landa före utgiften den ändrar. Att sortera efter inserted_xid kan inte det: en senare händelse har ett senare xid.
Outbox-raden committas med affärsskrivningen, och reläet levererar under vattenstämpeln i transaktions-id-ordning.

Lösningen är en kolumn och ett predikat. Varje outbox-rad bär inserted_xid xid8 DEFAULT pg_current_xact_id(), och reläet läser bara rader WHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot()), sorterade efter det xid:t (se PostgreSQLs funktioner för transaktions-id). En kausalt senare händelse bär alltid ett senare xid, eftersom den var tvungen att läsa den tidigare raden för att finnas.

Ändringskonsumenten tar inte den ordningen på förtroende. En ändring vars föregångare saknar journal levereras på nytt medan den är ung, och parkeras för en människa när den har passerat en respit på två minuter.

Pengar är int64 minorenheter, och exponenten är inte alltid två

Varje belopp är ett int64-antal minorenheter plus en ISO 4217-kod, så 1 234,56 kronor är {Minor: 123456, Currency: "SEK"}. Heltal är exakta genom sin konstruktion, inte genom disciplin: inget representerbart värde är ett halvt öre, så ingen operation kan tyst skapa ett. Python-sidovagnen läser sin egen AST och underkänner ett test om ordet float förekommer i dess beloppsmodul.

Att anta att minorenheten är en hundradel är fällan som ligger under. JPY har ingen alls och KWD har tre decimaler, så en kurs som anges mellan majorenheter och tillämpas på minorenheter blir fel med en tiopotens. Ta 2 000 yen till 0,58: den naiva produkten är 1160, vilket läses som 11,60 kronor, medan svaret är 1 160.

WRITE_OFF är en femte journaltyp eftersom dess poster är samma som en avräknings

En avskrivning bokför samma två ben som en avräkning gör: gäldenären stiger, borgenären faller, med samma belopp. Att slå ihop den med SETTLEMENT avvisades av exakt samma skäl som gör att formerna stämmer. ”Asha betalade dig 500 kronor” och ”du efterskänkte Asha 500 kronor” är olika fakta, och ett flöde som blandade ihop dem skulle påstå att någon betalade när ingen gjorde det.

WRITE_OFF anslöt sig till journaltypens CHECK den 2026-08-21, med egna ben. Att namnge dem var halva poängen, eftersom en avskrivning som återanvände SETTLE_PAY skulle göra varje fråga om ”hur mycket som faktiskt har betalats” tyst felaktig.

De fem journaltyperna i Dimesum-liggaren och de ben var och en bokför
JournaltypBokförs närBen
EXPENSESkapas, eller en ny version ersätter enPAID, SHARE
EXPENSE_REVERSALRedigeras eller raderasDen föregående journalens ben, negerade
SETTLEMENTEn återbetalning hävdasSETTLE_PAY, SETTLE_RECV
SETTLEMENT_REVERSALMotparten bestrider denBåda avräkningsbenen, negerade
WRITE_OFFEn borgenär ger upp ett anspråkWRITE_OFF_FORGIVEN, WRITE_OFF_GRANTED

Två underkommandon gör invarianterna till ett cron-jobb

evenly verify-ledger bevisar invarianterna på nytt över hela schemat och avslutas med skilt från noll vid varje träff. Dess rapport har fyra fält, och vart och ett förväntas vara tomt: journaler som inte summerar till noll, grupper som inte summerar till noll, projektionsrader som avviker från en omräknad SUM(postings), och parkerade händelser som väntar på en människa. Alla genomsökningar delar en enda repeatable-read-ögonblicksbild, så en journal som landar mitt i verifieringen kan inte hitta på en avvikelse.

Varje genomsökning grupperar efter valuta lika väl som efter id, och felet som det undviker är ett falskt negativt. En post på 500 kronor och en på minus 500 yen summerar till noll när en fråga ignorerar valutakolumnen, så en dubbelt korrupt liggare skulle framstå som ren.

evenly rebuild-balances [group-id] är både reparationen och övningen. Den tömmer projektionen och räknar om varje rad från posterna, och stämplar var och en med den senaste journalen som rörde den medlemmen, precis som den direkta skrivningen gör. Likhet rad för rad är egenskapen: när en ombyggnad och den direkta projektionen inte stämmer överens är liggaren den som har rätt.

Kör dem i den här ordningen

Verifiera, sedan bygg om. Rapporten namnger det lagrade saldot och det omräknade för varje rad som glider, och en ombyggnad skriver över det lagrade värdet, så att bygga om först förstör bevisen.

Gör saldot härledbart så blir glidning en fråga

En journal som bara kan läggas till förtjänar sin andra journal bara om du kan bevisa den, så bygg verifieraren och ombyggnaden före funktionen som behöver dem. En ombyggnad som ingen har kört är ett hopp, inte en nödutgång. Välj din mest riskfyllda pengaprojektion den här veckan, skriv frågan som räknar om den från källan, och larma dig själv när de två inte stämmer överens.

Vanliga frågor

Vad är en append-only-liggare i en app för att dela utgifter?

En append-only-liggare registrerar varje penninghändelse som en journal av poster som summerar till noll, och uppdaterar eller raderar aldrig någon. I Dimesum lägger en utgift, en redigering, en avräkning, en tvist och en avskrivning var och en till en ny journal. Saldon härleds sedan genom att summera poster, så varje öre kan spåras tillbaka till händelsen som flyttade det.

Hur hanterar en append-only-liggare en redigerad utgift?

En redigering bokför två journaler i en transaktion: en EXPENSE_REVERSAL som negerar den ursprungliga ben för ben, och sedan en ny EXPENSE som bär ersättningsversionen. Ingenting skrivs om, och en radering bokför bara återföringen. Båda journalerna tar idempotensnycklar som härleds ur utgiftens version, så en händelse som levereras på nytt hittar dem redan där och ändrar ingenting.

Varför lagra pengar som heltal i stället för flyttal?

Heltalsminorenheter är exakta genom sin konstruktion, medan binära flyttal inte kan representera 0,1 exakt och glider genom en grupps historik. Dimesum lagrar varje belopp som ett int64-antal minorenheter plus en ISO 4217-kod. Exponenten kommer från valutan: JPY har ingen minorenhet alls, så att anta en hundradel är ett 100x-fel.

Hur vet man att saldon för delade utgifter inte har glidit?

Kör evenly verify-ledger, som bevisar invarianterna på nytt över hela schemat och avslutas med skilt från noll vid varje träff. Den rapporterar journaler som inte summerar till noll, grupper som inte summerar till noll, projektionsrader som inte stämmer med en omräknad SUM(postings), och parkerade händelser. Lägg den i cron varje natt, och reparera en dålig projektion med evenly rebuild-balances.

Varför behöver en avskrivning en egen journaltyp?

En avskrivning bokför ben som är identiska med en avräknings, vilket är precis därför journaltypen måste skilja sig. Bara det ordet skiljer ”Asha betalade dig 500 kronor” från ”du efterskänkte Asha 500 kronor”, och ett flöde som blandade ihop dem skulle påstå att någon betalade när ingen gjorde det. Dess ben heter WRITE_OFF_FORGIVEN och WRITE_OFF_GRANTED så att betalningsfrågor förblir korrekta.