(2.55).toFixed(1) daje 2,5, a (2.35).toFixed(1) daje 2,4. Tak w wygenerowanym kodzie powstaje grosz różnicy
Koszyk pokazuje 1 230,00 zł, faktura 1 229,99 zł. Gdzie w wygenerowanym kodzie ginie grosz: float, toFixed, przecinek z formularza i typ kolumny w bazie.
Koszyk pokazuje 1 230,00 zł, a faktura wystawiona z tego samego zamówienia 1 229,99 zł. Jeden grosz, i reklamacja trafia do wykonawcy strony, nie do księgowości. Kwota w takim projekcie jest liczona w trzech miejscach — w przeglądarce, w bazie i przy formatowaniu do wyświetlenia — a w kodzie wygenerowanym przez model każde z tych trzech miejsc domyślnie dostaje typ, który dla pieniędzy nie daje powtarzalnego wyniku.
Wyniki JavaScriptu w tym tekście pochodzą z Node 22 i powtórzysz je wklejką do konsoli przeglądarki. Zajmują pół minuty, a decydują o tym, czy w ogóle masz problem.
Dziesiąte miejsce po przecinku, którego nie ma w cenniku
Liczby w JavaScripcie to podwójna precyzja według IEEE 754, czyli zapis binarny. Ułamki dziesiętne takie jak 0,1 czy 0,07 nie mają w nim dokładnej reprezentacji, więc wynik działania na cenach bywa o kilkanaście miejsc po przecinku obok wartości, którą widzisz w cenniku.
0.1 + 0.2 // 0.30000000000000004
0.07 * 100 // 7.000000000000001
0.07 * 100 === 7 // false
Ostatnia linia jest tą kosztowną. Warunek na progu darmowej dostawy, sprawdzenie, czy wpłata pokrywa zamówienie, porównanie sumy pozycji z sumą faktury — wszystko to są porównania kwot, a przy liczbach zmiennoprzecinkowych wychodzą fałszywie. Co gorsza, nie wychodzą tak zawsze: 19.99 * 3 daje w Node dokładnie 59.97, bez ogona. Błąd pojawia się więc na części danych, przechodzi przegląd kodu i wychodzi u klienta na którymś zamówieniu w trzecim tygodniu.
Przecinek z klawiatury zjada grosze bez żadnego błędu
Osobne źródło rozjazdu siedzi na wejściu, w miejscu, gdzie kwota jest jeszcze tekstem z formularza. Polski użytkownik wpisuje kwotę z przecinkiem, a wygenerowany kod parsuje ją najczęściej przez parseFloat, bo tak wygląda większość przykładów w sieci. parseFloat czyta tekst do pierwszego znaku, którego nie rozumie, i zwraca to, co udało mu się odczytać — bez wyjątku i bez ostrzeżenia.
parseFloat('33,34') // 33
parseFloat('1 230,00') // 1
Number('33,34') // NaN
Pierwsza linia to utrata 34 groszy na pozycji, druga to zamówienie na 1 zł zamiast 1230 zł, trzecia to jedyny przypadek, który ma szansę zostać zauważony, bo NaN popsuje widok. Najgorzej zachowuje się ten wariant, który wygląda na działający: formularz przyjmuje kwotę, nic nie protestuje, a w bazie ląduje liczba obcięta na przecinku. Minimalna poprawka to normalizacja wejścia przed parsowaniem i odrzucenie tego, co nie jest kwotą, zamiast cichego przyjęcia części tekstu.
const naGrosze = (tekst) => {
const s = String(tekst).replace(/\s/g, '').replace(',', '.');
if (!/^-?\d+(\.\d{1,2})?$/.test(s)) throw new Error('kwota: ' + tekst);
return Math.round(Number(s) * 100);
};
naGrosze('33,34'); // 3334
naGrosze('1 230'); // 123000
toFixed zaokrągla tę samą cyfrę w dwie różne strony
Model najczęściej domyka ten problem przez toFixed(2) i to wygląda na rozwiązanie, bo w interfejsie pojawiają się dwa miejsca po przecinku. Zaokrąglenie jednak nie działa na liczbie, którą wpisałeś, tylko na najbliższej reprezentowalnej liczbie podwójnej precyzji. Dokumentacja MDN pokazuje to na parze przykładów: (2.35).toFixed(1) zwraca '2.4', a (2.55).toFixed(1) zwraca '2.5', bo najbliższa reprezentowalna liczba dla 2,55 leży poniżej tej wartości.
| Wyrażenie | Wynik w Node 22 | Wynik oczekiwany przez księgowość |
|---|---|---|
(2.35).toFixed(1) |
'2.4' |
2,4 |
(2.55).toFixed(1) |
'2.5' |
2,6 |
(1.005).toFixed(2) |
'1.00' |
1,01 |
(8.575).toFixed(2) |
'8.57' |
8,58 |
Math.round(1.005 * 100) / 100 |
1 |
1,01 |
Math.round(2.5) |
3 |
3 |
Math.round(-2.5) |
-2 |
-3 |
Dwa ostatnie wiersze to osobna pułapka. Math.round przy połówce zaokrągla w stronę plus nieskończoności, więc kwoty dodatnie idą w górę, a ujemne w stronę zera. Dopóki liczysz tylko sprzedaż, nie zobaczysz tego nigdy. Zobaczysz przy pierwszej korekcie, zwrocie albo rabacie zapisanym jako pozycja ujemna — i to będzie grosz w drugą stronę niż na dokumencie pierwotnym.
Mechanizm tych siedmiu wierszy jest wspólny: przekazanie do zaokrąglenia liczby, która już wcześniej przestała być dokładna. Poprawka nie polega więc na zamianie toFixed na Math.round ani odwrotnie — obie funkcje dostają ten sam zepsuty argument.
Ta sama kwota, dwa różne zaokrąglenia w bazie
Druga warstwa to kolumna, w której trzymasz cenę. Tu różnica jest udokumentowana i łatwa do sprawdzenia: dokumentacja PostgreSQL podaje, że dla typu numeric połówki zaokrągla się z dala od zera, a dla double precision zachowanie przy połówce zależy od platformy, przy czym najczęstszą regułą jest zaokrąglenie do najbliższej liczby parzystej.
SELECT round(2.5::numeric); -- 3, deterministycznie
SELECT round(2.5::double precision); -- zaleznie od platformy, zwykle 2
To znaczy, że ten sam raport uruchomiony na innym hostingu może zwrócić inną sumę, jeśli kwoty siedzą w kolumnie zmiennoprzecinkowej. A siedzą tam częściej, niż się wydaje, bo tak wychodzi domyślny schemat: w dokumentacji Prismy typ Float mapuje się w PostgreSQL na double precision, a dziesiętny Decimal na decimal(65,30). Model proszony o model danych sklepu napisze price Float, bo cena to w potocznym rozumieniu liczba z przecinkiem. Migracja tego pola po wejściu zamówień na produkcję jest już osobnym zleceniem.
// wygenerowane
model Product { id Int @id @default(autoincrement()) price Float }
// typ dziesietny z jawna skala
model Product { id Int @id @default(autoincrement()) price Decimal @db.Decimal(12, 2) }
Rabat, VAT, suma: kolejność zmienia wynik
Trzecia warstwa to kolejność działań, a tu kod z modelu nie ma skąd znać zasady, której nie widać w żadnym API. Kwoty na fakturze zaokrągla się do pełnych groszy — reguła z art. 106e ust. 11 ustawy o VAT, omawiana z przykładami w opracowaniach branżowych, mówi o pomijaniu końcówek poniżej 0,5 grosza i zaokrąglaniu końcówek od 0,5 grosza w górę. Zaokrąglenie jest więc wymagane, a pytanie brzmi: w którym momencie rachunku.
Weź trzy pozycje po 33,34 zł netto przy stawce 23 proc. Podatek liczony od pozycji to 33.34 * 0.23, czyli 7,6682, po zaokrągleniu 7,67 zł; trzy pozycje dają 23,01 zł. Podatek liczony od sumy netto to 100.02 * 0.23, czyli 23,0046, po zaokrągleniu 23,00 zł. Ten sam koszyk, te same stawki, grosz różnicy — i oba wyniki są poprawne arytmetycznie.
// VAT od pozycji
const vatPozycja = (netto) => Math.round(netto * 23) / 100; // grosze
// 3 x 33,34 zl -> 7,67 + 7,67 + 7,67 = 23,01 zl
// VAT od sumy netto
// 100,02 zl -> 23,00 zl
Wybór metody nie jest decyzją front-endu. Rozstrzyga ją księgowość klienta albo system, który wystawia faktury, a zadaniem kodu jest policzyć tak samo w koszyku i na dokumencie. Grosz różnicy z początku tego tekstu powstaje najczęściej właśnie tutaj: koszyk liczy od sumy, bo tak prościej wyświetlić, a moduł faktur od pozycji, bo tak zwraca integracja.
Grosze jako liczby całkowite albo typ dziesiętny
Dwa rozwiązania mają sens, a różnią się kosztem wdrożenia. Pierwsze: trzymaj kwoty w najmniejszej jednostce jako liczby całkowite i zaokrąglaj dokładnie raz, na końcu rachunku. Cena 33,34 zł to 3334, podatek to Math.round(3334 * 23 / 100), czyli 767. Żadne porównanie nie zawiedzie, bo nie ma ułamka binarnego.
const vatGrosze = (nettoGrosze, stawka) =>
Math.round(nettoGrosze * stawka / 100);
vatGrosze(3334, 23); // 767
3334 * 3 === 10002; // true
Drugie: typ dziesiętny na całej drodze — kolumna numeric w bazie, biblioteka dziesiętna w aplikacji, kwota przesyłana w API jako łańcuch znaków, a nie jako liczba JSON-a. To rozwiązanie wygrywa, gdy w grę wchodzą przeliczenia walut, raty albo stawki z większą liczbą miejsc po przecinku.
Formatowanie zostaw osobno i na samym końcu. Dokumentacja Intl.NumberFormat podaje, że domyślnym trybem zaokrąglania jest halfExpand, czyli połówki z dala od zera, a liczbę miejsc dla waluty bierze z listy ISO 4217; tryb bankierski halfEven trzeba wskazać jawnie. Formatowanie to warstwa prezentacji i nie ma prawa wpływać na kwotę zapisaną w zamówieniu — jeśli jedyne zaokrąglenie w projekcie siedzi w Intl, to suma w bazie jest inna niż ta, którą widział klient.
const pln = new Intl.NumberFormat('pl-PL', {
style: 'currency', currency: 'PLN',
});
pln.format(1229.995); // '1230,00 zl' <- zaokraglil formatter, nie logika
Siedem przypadków testowych, które to wyłapią
0.07 * 100 === 7— test na porównania kwot. Jeśli przechodzi, w kodzie nie ma już liczb zmiennoprzecinkowych w roli pieniędzy.- Pozycja 1,005 zł — sprawdza, czy zaokrąglenie idzie w górę, czy w dół. Oba wyniki da się obronić dopiero wtedy, gdy są udokumentowane.
- Trzy pozycje po 33,34 zł netto, stawka 23 proc. — koszyk i faktura muszą pokazać tę samą kwotę podatku.
- Korekta na kwotę ujemną, końcówka 0,5 grosza — wyłapuje asymetrię
Math.roundz tabeli wyżej. - Rabat procentowy na pozycji z groszem nieparzystym, na przykład 10 proc. od 0,05 zł.
- Suma stu pozycji po 0,01 zł — ma dać równo 1,00 zł, a nie 1,0000000000000007, które zwraca zsumowanie ich jako liczb zmiennoprzecinkowych.
- Ten sam raport na bazie z kolumną
numerici z kolumnądouble precision— różnica potwierdza, że typ w schemacie jest źródłem problemu.
Co sprawdzić w kodzie, który dotyka kwot
- Przeszukaj projekt pod
toFixed. Każde wystąpienie poza warstwą prezentacji jest kandydatem na błąd. - Przejrzyj schemat bazy pod typy zmiennoprzecinkowe w polach cen, rabatów, podatku i kwot wpłat.
- Sprawdź, czy API zwraca kwotę jako liczbę JSON-a. Jeśli tak, klient i serwer liczą na dwóch różnych reprezentacjach.
- Znajdź w kodzie stałą
1.23albo0.23. Stawka wpisana na sztywno w komponencie oznacza, że przy zmianie stawki poprawka pójdzie przez cały front. - Ustal, czy podatek liczy się od pozycji, czy od sumy, i zapisz to w dokumentacji przekazania. To pytanie wróci przy pierwszej kontroli u klienta.
- Dodaj test na porównanie kwot, nie tylko na formatowanie. Test formatujący przechodzi także wtedy, gdy w bazie jest 1229,995.
Która warstwa trzyma typ pieniądza
Przed oddaniem projektu odpowiedz na jedno pytanie: która warstwa jest właścicielem typu kwoty. Jeśli to baza z kolumną numeric, a API oddaje łańcuch znaków, komponent nie ma czym pomnożyć ceny i problem nie ma gdzie powstać. Jeśli właścicielem jest komponent, bo tam zapadła decyzja o toFixed, każda kolejna integracja będzie dokładać własne zaokrąglenie do tej samej kwoty.
Ta odpowiedź jest tańsza przed wdrożeniem niż po nim, bo po wdrożeniu dochodzi migracja kolumny i uzgodnienie historycznych zamówień. W dokumentacji przekazania wystarczą trzy zdania: w jakim typie i jakiej jednostce trzymane są kwoty, w którym miejscu zapada jedyne zaokrąglenie i czy podatek liczony jest od pozycji, czy od sumy netto. Następny wykonawca przeczyta to w minutę, a bez tego zacznie od grepa po toFixed — tak jak ty na początku tego tekstu.