Journalen som håller delade utgifter exakta
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.
| Felklass | Balans-tabell som räknas upp | Journal som bara kan läggas till |
|---|---|---|
| Gäldenären debiteras, betalaren krediteras aldrig | Tyst fel för alltid | Nollsummekontrollen misslyckas, skrivningen avvisas |
| En andel ändras, summan gör det inte | Saldon glider tyst | Journalen kan inte committas obalanserad |
| "Varför är mitt saldo 412 kr?" | Går inte att besvara | Varje öre kan spåras till en journal |
| En snabbfix i produktion | Ospårad ändring | Den 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.
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.
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.
Så 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.
| Journaltyp | Bokförs när | Ben |
|---|---|---|
EXPENSE | Skapas, eller en ny version ersätter en | PAID, SHARE |
EXPENSE_REVERSAL | Redigeras eller raderas | Den föregående journalens ben, negerade |
SETTLEMENT | En återbetalning hävdas | SETTLE_PAY, SETTLE_RECV |
SETTLEMENT_REVERSAL | Motparten bestrider den | Båda avräkningsbenen, negerade |
WRITE_OFF | En borgenär ger upp ett anspråk | WRITE_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.
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.
Populära inlägg
- Varför en ändrad utgift måste ange delningen på nytt8 min läsning
- Sex pengabuggar i utgiftsdelning med flera valutor8 min läsning
- Gör upp: reglera gruppens utgifter med färre överföringar5 min läsning
- Dela notan när en rätt inte delades9 min läsning
- Dela hyran rättvist med rumskompisar5 min läsning