dimesum

Strona główna / Blog / Pieniądze

Pieniądze

Sześć błędów walutowych w podziale wydatków

· 8 min czytania ·

Audyt z 2026-08-21 wykrył sześć wad walutowych, przez które nasze testy przechodziły na zielono, a przyczyną było jedno zdanie w dokumentacji, które po cichu przestało być prawdziwe.

Jedno nieaktualne zdanie w dokumentacji kosztowało sześć błędów walutowych. Audyt wielowalutowy Dimesum z 2026-08-21 znalazł je w kodzie, przez który nasze zestawy testów przechodziły na zielono, w tym na stronie roszczenia, która pokazywała dług ¥6,000 jako -₹60.00. Zdanie brzmiało „grupy są przypięte do INR”. Prawdziwe do W7, fałszywe w chwili wdrożenia, i wciąż obecne w FOLLOWUPS.md tydzień później.

Nieaktualne zdanie jest groźniejsze niż brak dokumentacji. Późniejszy audyt mu wierzy i pomija ścieżki, które opisuje, więc błędny wiersz wyrządza szkodę, jakiej cisza nigdy by nie zdołała. Pusty dokument odsyła cię do źródła. Błędny odsyła cię zupełnie gdzie indziej.

Zrealizowana notatka jest gorsza niż otwarta

Dimesum odkłada odroczoną pracę w FOLLOWUPS.md, po jednym wierszu na decyzję wraz z powodem odłożenia. Wpis A17 mówił, że grupy są przypięte do INR, że wyjazdu za granicę nie da się zapisać w walucie, w której nastąpił, oraz że poprawka czeka na decyzję założyciela. W7 i tak wdrożyło obsługę wielu walut: wydatek może być w dowolnej walucie, a salda są kluczowane przez (group_id, member_id, currency) w migracji ledger/00005. Nikt nie skreślił tego wiersza.

Kolejny audyt przeczytał A17, uznał, że obszar nie został zbudowany, i nigdy nie otworzył kodu rozliczeń, roszczeń ani strony docelowej. Trzy ścieżki pieniężne ślepe na walutę wciąż trafiały na produkcję na podstawie jednego zdania. Żaden trudny problem nie stał na przeszkodzie. Docstring pliku app/amount.py niósł to samo twierdzenie, więc czytelnik usłyszał je dwa razy.

Zasada, którą przyjęliśmy

Skreśl notatkę w tym samym commicie, który ją zamyka. Wiersz opisujący kod, który przestał tak działać, to przekierowanie z dala od pliku, który trzeba było przeczytać, a to kosztuje więcej niż milczenie.

Ta sama zaszyta na stałe wartość 100 pojawiła się w trzech różnych plikach

Pieniądze to tutaj jednostki podrzędne int64 plus kod ISO 4217, a liczby zmiennoprzecinkowe nigdy ich nie dotykają. Liczby całkowite są z definicji dokładne, więc jedyna ryzykowna arytmetyka, jaka zostaje, to przeliczanie między jednostkami głównymi a podrzędnymi. Jednostka podrzędna nie zawsze jest setną częścią. JPY nie ma jej wcale, KWD ma trzy miejsca po przecinku, a każda zaszyta na stałe wartość 100 to zakład, że użytkownik został w domu.

Plik app/amount.py w pobocznym module w Pythonie zawierał pierwsze wystąpienie, usunięte przed audytem jako mina, a nie jako aktywna wada. Mnożył każdą liczbę odczytaną ze zdania przez 100, więc paragon ¥1,200 stawał się 120,000 jednostkami podrzędnymi. Odczytuje się to jako ¥120,000. W walucie bez jednostki podrzędnej stała zawyża czyjś rachunek stukrotnie, a funkcja teraz zamiast tego sprawdza wykładnik dla żądanej waluty.

Paragon na 1,200 jenów przeliczony przez zaszyty na stałe mnożnik 100 oraz przez tabelę wykładników ISO 4217 ¥1,200 waluta: JPY PRZED minor = major x 100 stała w module 120000 jednostek podrzędnych odczyt: ¥120,000 PO minor = major x 10^exp wykładnik JPY = 0 1200 jednostek podrzędnych odczyt: ¥1,200
Pułapka wykładnika na jednym obrazku. Stały mnożnik 100 jest poprawny dla INR i błędny sto razy dla JPY, które nie ma jednostki podrzędnej; ta sama stała myli się dziesięciokrotnie dla KWD, które ma trzy miejsca po przecinku.

Audyt znalazł tę samą stałą na stronie, na której osoba decyduje, czy zaakceptować saldo. formatMinor, obsługujący stronę roszczenia pod /m/{token}, dzielił przez 100 i doklejał z przodu znak rupii. Dług ¥6,000 wyświetlał się jako -₹60.00: zły symbol, setna część kwoty. Jego odpowiednik w JSON /v1/claims zawodził razem z nim, spłaszczając wiersze członka rozbite na waluty do jednego i zachowując tę walutę, którą mapa zapisała jako ostatnią.

Trzeci plik to internal/ingestion/store.go, a znalazło go W8 podczas rozszerzania wykrywania duplikatów, nie audyt. Jego tolerancja „plus minus jedna rupia” była stałą 100, jedną rupią liczoną w paise, pasmem plus minus ¥100 tam, gdzie JPY nie ma żadnej jednostki podrzędnej. Wszystkie trzy odczytują teraz wykładnik.

Porównywanie gołych liczb całkowitych sprawiało, że jen wyglądał jak rupia

Warstwa deduplikacji Dimesum sprawdza nowy wpis względem ostatnich wydatków, aby zaimportowany paragon nie obciążył dwukrotnie zamówienia wpisanego ręcznie. Zapytanie obok tego pasma tolerancji nie miało w ogóle filtra waluty. Więc ¥1,000 pasowało do ₹1,000, a import proponował zastąpienie wydatku, z którym nie miał nic wspólnego. Grupy były wielowalutowe od ledger/00005, więc przypadek był osiągalny, a nie teoretyczny.

Gołe jednostki podrzędne nie są porównywalne między walutami, tolerancje również nie. Projekcja filtruje teraz po walucie i wyprowadza swoje pasmo z jednej jednostki głównej porównywanej waluty.

Dwa silniki odpowiadały na pytanie „kto komu płaci”, a drugi był ślepy na walutę

Kosztowna wada jest strukturalna, nie arytmetyczna. Ścieżka zapisu rozliczeń planowała na internal/platform/simplify, drugim silniku minimalnego przepływu gotówki, którego struktura Balance nie miała pola waluty. Zerowanie się salda w obrębie każdej waluty zeruje też sumę płaską, więc dług ¥300,000 i dług ₹500 znosiły się do zera i żaden strażnik nie odrzucił planu. Plan łączył wtedy wierzyciela w jenach z dłużnikiem w rupiach.

Zapis pogarszał sprawę. Usługa stemplowała Currency: g.DefaultCurrency na każdym rozliczeniu, więc dług ¥300,000 autoryzował wpis księgowy w INR, a długu w jenach nie dało się w ogóle rozliczyć.

Te same cztery salda kierowane przez ślepy na walutę silnik simplify oraz przez świadomy waluty silnik settle SALDA Asha +¥300,000 Bhavna -¥300,000 Chetan +₹500 Dev -₹500 simplify: Balance{MemberID, Minor} brak waluty w strukturze, więc wszystkie cztery sumują się płasko 300000 + (-300000) + 500 + (-500) = 0 plan: Bhavna płaci Chetanowi dłużnik w jenach wysłany do wierzyciela w rupiach settle: Balance{MemberID, money.Amount} podział według waluty, potem kierowanie wewnątrz każdej JPY: Bhavna płaci Ashy ¥300,000 INR: Dev płaci Chetanowi ₹500 rozliczenie podaje walutę, którą rozlicza simplify jest usunięty, nie naprawiony
Cztery salda, dwa silniki. Zerowanie się salda w obrębie każdej waluty implikuje płaską sumę zero, więc silnik ślepy na walutę widzi zrównoważoną grupę i pewnie kieruje płatność między dwie osoby, które nie są sobie nic winne.

simplify jest usunięty, a /settle-plan wycofany. internal/platform/settle to teraz jedyny silnik: dzieli waluty na wymienialne i niewymienialne, przelicza salda netto raz i kieruje każdą niewymienialną walutę w jej własnym nominale. Rozliczenie podaje walutę, którą rozlicza, a to pole jest wymagane, gdy grupa ma więcej niż jedną.

Limit równy zero odczytany jako brak limitu

Strażnik nadpłaty odrzuca płatność większą niż dług, który rozlicza. Strażnik czytał outstanding > 0 && amount > outstanding, więc limit równy zero całkowicie pomijał porównanie. Ktoś rejestrujący płatność wobec długu, który nie istnieje, to jedyny przypadek, dla którego reguła istnieje, i to właśnie ten przypadek przechodził bez przeszkód.

Poprawką jest typ, nie warunek. OutstandingMinor jest teraz *int64, więc „nikt tego nie policzył” nie da się zapisać tak samo jak „odpowiedź to zero”. Nil pomija sprawdzenie i oznacza naprawdę nieznane; każdy wywołujący z dostępem do księgi przekazuje prawdziwą liczbę.

Dlaczego zielony zestaw testów niczego nie dowodził

Każda atrapa w tych ścieżkach używała INR. Porównanie ślepe na walutę jest niewidoczne w teście jednowalutowym, ponieważ przy jednej walucie nie ma czego pomylić. Zestawy nie były słabe, były wąskie, a audyt, który by je poszerzył, został odprawiony przez A17.

Kompleksowy weryfikator księgi dzielił tę ślepotę, i to jest część warta zapamiętania. Weryfikator sumował księgowania na członka bez grupowania według waluty, więc podnosił fałszywy alarm na zdrowej grupie dwuwalutowej i sumował do zera na grupie zepsutej dwukrotnie. Fałszywy negatyw to niebezpieczny kierunek. Kontroler zbudowany na tym samym założeniu co kod zawsze zgodzi się z kodem.

Każda wada znaleziona przez audyt wielowalutowy z 2026-08-21, co dostawała grupa w jenach i co trafiło na produkcję.
WadaGdzieCo dostawała grupa JPYPoprawka
Struktura Balance bez walutyplatform/simplifywierzyciel w jenach połączony z dłużnikiem w rupiachsimplify usunięty; settle dzieli według waluty
Domyślna waluta grupy stemplowana na każdym rozliczeniuusługa rozliczeńdług ¥300,000 autoryzował wpis księgowy w INRrozliczenie podaje walutę, którą rozlicza
outstanding > 0 w strażniku nadpłatyusługa rozliczeńlimit równy zero stawał się brakiem limitu*int64: nil oznacza nieznane, zero oznacza zero
Dzielenie przez 100 ze znakiem rupiibramka formatMinordług ¥6,000 wyświetlany jako -₹60.00symbol i miejsca po przecinku z waluty
Salda rozbite na waluty spłaszczone do jednego wiersza/v1/claimsta waluta, którą mapa zapisała jako ostatniąjeden wpis na walutę, zgodnie z GET /balances
Księgowania sumowane na członka, waluta pominiętakompleksowy weryfikator księgigrupa zepsuta dwukrotnie raportowana jako zrównoważonagrupowanie według członka i waluty

Przeszukaj swój kod pieniężny grepem pod kątem literału 100

Przeszukaj go pod kątem tej stałej oraz pod kątem każdego porównania, które zestawia dwie kwoty obok siebie bez waluty przy nich. Potem napraw tańszą rzecz, tę, która zapobiega kolejnym sześciu: skreśl notatkę w tym samym commicie, który ją zamyka. I usuń zduplikowany silnik zamiast go naprawiać. Dwie odpowiedzi na pytanie „kto komu płaci” to sposób, w jaki jedna z nich pozostaje błędna.

Często zadawane pytania

Na czym polega wielowalutowe dzielenie wydatków?

Wielowalutowe dzielenie wydatków zapisuje każdy wydatek w walucie, w której nastąpił, i utrzymuje osobne saldo dla każdej waluty, zamiast przeliczać wszystko na jedną. Dimesum kluczuje salda według grupy, członka i waluty, więc dług w jenach i dług w rupiach nigdy nie zlewają się w jedną liczbę. Przeliczenie to widok używany do planu rozliczenia, nigdy przechowywana kwota.

Dlaczego zaszyta na stałe wartość 100 jest groźna w kodzie pieniężnym?

Zaszyta na stałe wartość 100 zakłada, że każda waluta ma dwa miejsca po przecinku, a kilka nie ma. JPY nie ma jednostki podrzędnej, więc mnożenie przez 100 zamienia paragon ¥1,200 w 120,000 jednostek podrzędnych, czyli stukrotne zawyżenie, a nie błąd zaokrąglenia. KWD ma trzy miejsca po przecinku, więc ta sama stała myli się dziesięciokrotnie. Zamiast tego odczytaj wykładnik ISO 4217.

Jak powstrzymać dwa silniki rozliczeń przed rozbieżnością?

Usuń jeden z dwóch silników zamiast je uzgadniać, ponieważ druga odpowiedź na pytanie, kto komu płaci, to sposób, w jaki pierwsza pozostaje błędna. Dimesum uruchamiał simplify i settle obok siebie, dopóki audyt nie wykrył, że pierwszy nie miał waluty w typie salda, co pozwalało planowi łączyć wierzyciela w jenach z dłużnikiem w rupiach. simplify został usunięty, a /settle-plan wycofany zamiast łatany.

Dlaczego zestaw testów pozostawał zielony mimo sześciu błędów walutowych?

Zestaw testów pozostawał zielony, ponieważ każda atrapa w dotkniętych ścieżkach używała jednej waluty, a porównanie ślepe na walutę nie może zawieść, gdy jest tylko jedna waluta. Kompleksowy weryfikator księgi trzymał to samo założenie: sumował księgowania na członka bez grupowania według waluty, więc raportował grupę zepsutą dwukrotnie jako zrównoważoną. Kontroler zbudowany na własnym założeniu kodu zgadza się z kodem.

Co zrobić z notatką o odroczonej pracy, gdy funkcja trafi na produkcję?

Skreśl notatkę w tym samym commicie, który zamyka opisaną przez nią pracę. Notatka, która po cichu się zrealizowała, jest gorsza niż otwarta, ponieważ kieruje późniejszego czytelnika z dala od kodu, który kiedyś opisywała. Dimesum zostawił wpis mówiący, że grupy są przypięte do INR, przez tydzień po wdrożeniu obsługi wielu walut, a kolejny audyt pominął trzy ścieżki pieniężne na jego podstawie.