dimesum

Strona główna / Blog / Inżynieria

Inżynieria

Dopisywalna księga dla dokładnych sald wydatków

· 8 min czytania ·

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.

Co księga tylko do dopisywania czyni niemożliwym, w zestawieniu z inkrementowaną tabelą sald
Klasa błęduInkrementowana tabela saldKsięga tylko do dopisywania
Dłużnik obciążony, płatnik nigdy nieuznanyPo cichu błędne na zawszeKontrola sumy zero zawodzi, zapis odrzucony
Udział się zmienia, suma nieSalda po cichu dryfująDziennik nie może zostać zatwierdzony niezbilansowany
„Dlaczego mam saldo 412 zł?”Bez odpowiedziKażdy grosz prowadzi do dziennika
Hotfix na produkcjiNieśledzona mutacjaJedyną 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.

Edycja wydatku księguje EXPENSE_REVERSAL, który neguje wersję pierwszą, oraz nowy EXPENSE dla wersji drugiej, w jednej transakcji; projekcja sald to suma po wszystkich księgowaniach. jedna transakcja EXPENSE expense:7c1:v1 4 księgowania sum = 0 EXPENSE_REVERSAL expense:7c1:v1:reversal każda noga zanegowana sum = 0 EXPENSE expense:7c1:v2 5 księgowań sum = 0 projekcja sald = SUM(postings) na członka, na walutę jednorazowa: evenly rebuild-balances czyści ją i odtwarza każde księgowanie
Edycja dopisuje: storno nazywa wersję, którą neguje, a zastąpienie księgowane jest obok niego.

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.

Zapis biznesowy i jego wiersz outbox zatwierdzają się w jednej transakcji; przekaźnik wysyła następnie tylko te wiersze outbox, których identyfikator wstawiającej transakcji jest poniżej znaku wodnego pg_snapshot_xmin, w kolejności identyfikatorów transakcji. jedna transakcja INSERT wiersza wydatku INSERT wiersza outbox temat id (uuidv7) inserted_xid expense.created 019a-7f3 4101 expense.amended 019a-4c1 4102 expense.created 019a-1a8 4103 zapisujący w locie szyna zdarzeń księga konsument pg_snapshot_xmin(pg_current_snapshot()). Wiersze powyżej zostały zapisane przez transakcje, które zatwierdziły się i żadna starsza wciąż nie działa. Wiersz poniżej czeka na następny przebieg. Sortowanie po id wysłałoby najpierw 019a-1a8, więc poprawka mogłaby dotrzeć przed wydatkiem, który poprawia. Sortowanie po inserted_xid nie może: późniejsze zdarzenie ma późniejszy xid.
Wiersz outbox zatwierdza się razem z zapisem biznesowym, a przekaźnik wysyła poniżej znaku wodnego w kolejności identyfikatorów transakcji.

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”.

Pięć typów dzienników w księdze Dimesum oraz nogi, które każdy księguje
Typ dziennikaKsięgowany gdyNogi
EXPENSEUtworzony lub nowa wersja zastępuje istniejącąPAID, SHARE
EXPENSE_REVERSALEdytowany lub usuniętyNogi poprzedniego dziennika, zanegowane
SETTLEMENTZgłoszona zostaje spłataSETTLE_PAY, SETTLE_RECV
SETTLEMENT_REVERSALKontrahent to kwestionujeObie nogi rozliczenia, zanegowane
WRITE_OFFWierzyciel rezygnuje z roszczeniaWRITE_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.

Uruchamiaj je w tej kolejności

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.