Strona główna / Blog / Inżynieria
InżynieriaDopisywalna księga dla dokładnych sald wydatków
Saldo powinno być projekcją, którą można wyrzucić i bezpiecznie odtworzyć z niezmiennego dziennika księgowań o sumie zero.
Saldo, które inkrementujesz, to saldo, które dryfuje. Dimesum nigdy żadnego nie inkrementuje. Każdy wydatek księguje dziennik podwójnego zapisu, którego księgowania sumują się dokładnie do zera, a liczba na ekranie grupy to projekcja tych księgowań, którą możemy usunąć i odtworzyć jednym poleceniem.
Możesz to sprawdzić. Gdy jedyną drogą przepływu pieniędzy jest dziennik tylko do dopisywania, błędne saldo przestaje być zagadką i staje się zapytaniem, które możemy uruchomić.
Salda to projekcja, a nie liczba, którą inkrementujesz
Księga Dimesum zawiera trzy tabele: journals, postings oraz projekcję balances kluczowaną po grupie, członku i walucie. Prawdę niosą księgowania. Księgowanie jest dodatnie, gdy członek wpłacił pieniądze do grupy, a ujemne, gdy skonsumował wartość, więc saldo to zwykłe SUM(amount_minor). Projekcja istnieje dla szybkości, nie dla autorytetu: jest zapisywana wewnątrz własnej transakcji dziennika, a kompletność księgowań sprawia, że można ją bezpiecznie wyrzucić.
Dwie warstwy zapewniają to samo zero i żadna nie wystarcza sama
W Go buildPostings odmawia otwarcia transakcji, dopóki zbiór księgowań nie sumuje się do zera. W Postgresie odroczony wyzwalacz ograniczenia ponownie sprawdza SUM(amount_minor) = 0 dla każdego dziennika przy commicie, ponieważ księgowania wstawiane są wiersz po wierszu, a sprawdzenie na poziomie wiersza odrzucałoby pierwszą nogę każdego dziennika. Asercja wychwytuje błąd w kalkulatorze. Wyzwalacz wychwytuje program zapisujący, który nigdy go nie wywołał.
Trzecia warstwa to uprawnienia. UPDATE, DELETE i TRUNCATE są odebrane roli ledger_app na obu tabelach, więc wadliwy kod nie może przepisać historii, nawet gdy próbuje.
| Klasa błędu | Inkrementowana tabela sald | Księga tylko do dopisywania |
|---|---|---|
| Dłużnik obciążony, płatnik nigdy nieuznany | Po cichu błędne na zawsze | Kontrola sumy zero zawodzi, zapis odrzucony |
| Udział się zmienia, suma nie | Salda po cichu dryfują | Dziennik nie może zostać zatwierdzony niezbilansowany |
| „Dlaczego mam saldo 412 zł?” | Bez odpowiedzi | Każdy grosz prowadzi do dziennika |
| Hotfix na produkcji | Nieśledzona mutacja | Jedyną drogą jest nowy, audytowany dziennik |
Edycja księguje storno, nigdy UPDATE
Edycja wydatku niczego nie nadpisuje na starej wersji. Księga konsumuje jedno zdarzenie expense.amended i księguje dwa dzienniki w jednej transakcji: EXPENSE_REVERSAL, którego księgowania są dokładną, nogę po nodze, negacją zastępowanej wersji, a następnie świeży EXPENSE dla nowej. Usunięcie kończy się po stornie.
Jedna transakcja liczy się tak samo jak dwa dzienniki. Gdyby storno zostało zatwierdzone samo, grupa przez chwilę nie byłaby nic winna za wydatek, który wciąż jest winna.
Oba klucze idempotencji są wyprowadzane z wersji, a nie generowane: expense:<id>:v<n> dla wersji oraz klucz poprzedniej wersji plus :reversal dla negacji. journals.idempotency_key jest UNIQUE, więc ponownie dostarczone zdarzenie zastaje swoje dzienniki już na miejscu i nie robi nic. To wyprowadzanie sprawia, że dostarczanie co najmniej raz jest bezpieczne: ponowna próba oblicza ten sam klucz, którego użyła pierwsza.
Znak wodny kolejności commitów utrzymuje poprawkę za jej wydatkiem
Każde zdarzenie publikowane przez Dimesum jest zapisywane do tabeli outbox w tej samej transakcji co zapis biznesowy. Jeśli wydatek zostaje zatwierdzony, ogłoszenie istnieje. Jeśli zostaje wycofany, wycofuje się i ogłoszenie. Księga nigdy nie słyszy o wydatku, który nie istnieje.
Kolejność wysyłki to trudniejsza połowa. Identyfikatory to UUIDv7 i sortują się po czasie, ale kodują moment wygenerowania identyfikatora, a nie moment zatwierdzenia jego transakcji. Przekaźnik czytający w kolejności identyfikatorów może pominąć transakcję, która wzięła wcześniejszy identyfikator, a zatwierdziła się później, więc poprawka wyprzedza wydatek, który poprawia.
Poprawka to jedna kolumna i jeden predykat. Każdy wiersz outbox niesie inserted_xid xid8 DEFAULT pg_current_xact_id(), a przekaźnik czyta tylko wiersze WHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot()), posortowane po tym xid (zobacz funkcje identyfikatorów transakcji PostgreSQL). Zdarzenie przyczynowo późniejsze zawsze niesie późniejszy xid, ponieważ aby zaistnieć, musiało odczytać wcześniejszy wiersz.
Konsument poprawek nie przyjmuje tej kolejności na wiarę. Poprawka, której poprzednik nie ma dziennika, jest dostarczana ponownie, dopóki jest młoda, i odkładana dla człowieka po przekroczeniu dwuminutowej karencji.
Pieniądze to jednostki podrzędne int64, a wykładnik nie zawsze wynosi dwa
Każda kwota to liczba jednostek podrzędnych typu int64 plus kod ISO 4217, więc 1 234,56 zł to {Minor: 123456, Currency: "PLN"}. Liczby całkowite są dokładne z konstrukcji, nie z dyscypliny: żadna reprezentowalna wartość nie jest połową grosza, więc żadna operacja nie może po cichu jej wytworzyć. Sidecar w Pythonie czyta własne AST i oblewa test, jeśli w jego module kwot pojawi się słowo float.
Założenie, że jednostka podrzędna to jedna setna, to pułapka leżąca u podstaw. JPY nie ma jej wcale, a KWD ma trzy miejsca po przecinku, więc kurs podany między jednostkami głównymi i zastosowany do jednostek podrzędnych jest błędny o rząd wielkości. Weź 2000 jenów po 0,58: naiwny iloczyn to 1160, co odczytuje się jako 11,60 zł, podczas gdy odpowiedź to 1160.
WRITE_OFF to piąty typ dziennika, ponieważ jego księgowania odpowiadają rozliczeniowym
Umorzenie księguje te same dwie nogi co rozliczenie: dłużnik rośnie, wierzyciel spada, o tę samą kwotę. Włączenie go do SETTLEMENT zostało odrzucone dokładnie z tego powodu, dla którego kształty się zgadzają. „Asha zapłaciła ci 500 zł” i „umorzyłeś Ashy 500 zł” to różne fakty, a strumień, który by je mylił, mówiłby, że ktoś zapłacił, gdy nikt tego nie zrobił.
Dlatego WRITE_OFF dołączył do CHECK typu dziennika 2026-08-21, z własnymi nogami. Ich nazwanie było połową sensu, ponieważ umorzenie ponownie używające SETTLE_PAY czyniłoby po cichu błędnym każde zapytanie „ile faktycznie zapłacono”.
| Typ dziennika | Księgowany gdy | Nogi |
|---|---|---|
EXPENSE | Utworzony lub nowa wersja zastępuje istniejącą | PAID, SHARE |
EXPENSE_REVERSAL | Edytowany lub usunięty | Nogi poprzedniego dziennika, zanegowane |
SETTLEMENT | Zgłoszona zostaje spłata | SETTLE_PAY, SETTLE_RECV |
SETTLEMENT_REVERSAL | Kontrahent to kwestionuje | Obie nogi rozliczenia, zanegowane |
WRITE_OFF | Wierzyciel rezygnuje z roszczenia | WRITE_OFF_FORGIVEN, WRITE_OFF_GRANTED |
Dwie podkomendy zmieniają niezmienniki w zadanie cron
evenly verify-ledger ponownie dowodzi niezmienników w całym schemacie i kończy się kodem różnym od zera przy każdym trafieniu. Jego raport ma cztery pola i każde powinno być puste: dzienniki, które nie sumują się do zera, grupy, które nie sumują się do zera, wiersze projekcji rozbieżne z przeliczonym SUM(postings) oraz odłożone zdarzenia czekające na człowieka. Wszystkie skany współdzielą jedną migawkę repeatable-read, więc dziennik pojawiający się w trakcie weryfikacji nie może sfabrykować niezgodności.
Każdy skan grupuje zarówno po walucie, jak i po identyfikatorze, a błąd, którego to unika, to fałszywy negatyw. Księgowanie 500 zł i jedno na minus 500 jenów sumują się do zera, gdy zapytanie ignoruje kolumnę waluty, więc podwójnie uszkodzona księga odczytałaby się jako czysta.
evenly rebuild-balances [group-id] to naprawa i ćwiczenie. Czyści projekcję i przelicza każdy wiersz z księgowań, stemplując każdy ostatnim dziennikiem, który poruszył danego członka, tak jak robi to zapis na żywo. Właściwością jest równość wiersz po wierszu: gdy przebudowa i projekcja na żywo się różnią, rację ma księga.
Najpierw weryfikuj, potem przebudowuj. Raport nazywa zapisane saldo i przeliczone dla każdego dryfującego wiersza, a przebudowa nadpisuje zapisaną wartość, więc przebudowanie najpierw niszczy dowody.
Uczyń saldo wyprowadzalnym, a dryf stanie się zapytaniem
Księga tylko do dopisywania zasługuje na swój drugi dziennik tylko wtedy, gdy potrafisz to udowodnić, więc zbuduj weryfikator i przebudowę przed funkcją, która ich potrzebuje. Przebudowa, której nikt nie uruchomił, to nadzieja, a nie wyjście awaryjne. Wybierz w tym tygodniu swoją najbardziej ryzykowną projekcję pieniędzy, napisz zapytanie, które przeliczy ją ze źródła, i wyślij sobie alert, gdy oba się różnią.
Często zadawane pytania
Czym jest księga tylko do dopisywania w aplikacji do dzielenia wydatków?
Księga tylko do dopisywania zapisuje każde zdarzenie pieniężne jako dziennik księgowań sumujących się do zera i nigdy żadnego nie aktualizuje ani nie usuwa. W Dimesum wydatek, edycja, rozliczenie, spór i umorzenie dopisują nowy dziennik. Salda są następnie wyprowadzane przez sumowanie księgowań, więc każdy grosz prowadzi z powrotem do zdarzenia, które go poruszyło.
Jak księga tylko do dopisywania obsługuje edytowany wydatek?
Edycja księguje dwa dzienniki w jednej transakcji: EXPENSE_REVERSAL, który neguje oryginał nogę po nodze, a następnie nowy EXPENSE niosący wersję zastępczą. Nic nie jest przepisywane, a usunięcie księguje tylko storno. Oba dzienniki przyjmują klucze idempotencji wyprowadzone z wersji wydatku, więc ponownie dostarczone zdarzenie zastaje je już na miejscu i nic nie zmienia.
Dlaczego przechowywać pieniądze jako liczby całkowite zamiast float?
Całkowitoliczbowe jednostki podrzędne są dokładne z konstrukcji, podczas gdy binarny zmiennoprzecinkowy nie potrafi dokładnie odwzorować 0,1 i dryfuje przez całą historię grupy. Dimesum przechowuje każdą kwotę jako liczbę jednostek podrzędnych typu int64 plus kod ISO 4217. Wykładnik pochodzi z waluty: JPY nie ma jednostki podrzędnej wcale, więc założenie jednej setnej to błąd 100x.
Skąd wiadomo, że salda dzielonych wydatków nie zdryfowały?
Uruchom evenly verify-ledger, który ponownie dowodzi niezmienników w całym schemacie i kończy się kodem różnym od zera przy każdym trafieniu. Raportuje dzienniki, które nie sumują się do zera, grupy, które nie sumują się do zera, wiersze projekcji niezgodne z przeliczonym SUM(postings) oraz odłożone zdarzenia. Ustaw je w cronie na noc i napraw wadliwą projekcję poleceniem evenly rebuild-balances.
Dlaczego umorzenie potrzebuje własnego typu dziennika?
Umorzenie księguje nogi identyczne jak rozliczenie, co jest dokładnie powodem, dla którego typ dziennika musiał się różnić. Tylko to słowo oddziela „Asha zapłaciła ci 500 zł” od „umorzyłeś Ashy 500 zł”, a strumień, który by je mylił, mówiłby, że ktoś zapłacił, gdy nikt tego nie zrobił. Jego nogi nazywają się WRITE_OFF_FORGIVEN i WRITE_OFF_GRANTED, aby zapytania o płatności pozostawały poprawne.
Popularne wpisy
- Edycja wydatku musi na nowo określić podział8 min czytania
- Sześć błędów walutowych w podziale wydatków8 min czytania
- Rozliczenie: wspólne wydatki w kilku przelewach4 min czytania
- Jak sprawiedliwie podzielić rachunek w restauracji8 min czytania
- Jak sprawiedliwie podzielić czynsz między współlokatorów5 min czytania