Przejdź do treści

Liczby, zaokrąglanie i pieniądze

  • Przydatne narzędzia
  • poziom: średni
  • aktualizacja: 16 sierpnia 2026

Pieniądze i typ double to zły związek: ułamki dziesiętne zapisywane są dwójkowo, więc 0.1 + 0.2 daje 0.30000000000000004, a 4.35 * 100 — 434.99999999999994. Przy jednym mnożeniu tego nie widać, ale po kilku pozycjach koszyka brakuje grosza, którego nie wytłumaczysz księgowej. Lekarstwem jest BigDecimal: liczba dziesiętna z jawną skalą, tworzona zawsze z tekstu. Ma jednak własne kolce — equals porównuje też skalę, a divide bez podanej skali potrafi rzucić wyjątkiem. Skrypt pokazuje jedno i drugie na żywo, a na koniec liczy ten sam koszyk z rabatem i VAT-em dwoma sposobami: źle i dobrze.

JAVA LiczbyIPieniadze.java 134 linii Pobierz .java
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.NumberFormat;
import java.util.List;
import java.util.Locale;

/**
 * Liczby, zaokrąglanie i pieniądze — skryptyJAVA.pl
 * Zapisz jako: LiczbyIPieniadze.java
 */
public final class LiczbyIPieniadze {

    private static final Locale POLSKI = Locale.forLanguageTag("pl-PL");
    private static final BigDecimal STAWKA_VAT = new BigDecimal("0.23");
    private static final BigDecimal RABAT = new BigDecimal("15");

    /** Pozycja koszyka: cena jednostkowa netto i liczba sztuk. */
    public record Pozycja(String nazwa, BigDecimal cenaNetto, int ilosc) {}

    /** Zaokrągla do pełnych groszy „w górę od połowy” — tak liczy większość faktur. */
    public static BigDecimal doGroszy(BigDecimal kwota) {
        return kwota.setScale(2, RoundingMode.HALF_UP);
    }

    /** Wartość pozycji po rabacie, jeszcze bez zaokrąglania — pełna dokładność mnożenia. */
    public static BigDecimal wartoscDokladna(Pozycja pozycja, BigDecimal rabatProcent) {
        BigDecimal mnoznik = BigDecimal.ONE.subtract(rabatProcent.movePointLeft(2));
        return pozycja.cenaNetto().multiply(BigDecimal.valueOf(pozycja.ilosc())).multiply(mnoznik);
    }

    /** Suma zaokrąglonych pozycji — zgadza się z kwotami wydrukowanymi w wierszach. */
    public static BigDecimal sumaPozycjiZaokraglonych(List<Pozycja> koszyk, BigDecimal rabat) {
        BigDecimal suma = BigDecimal.ZERO;
        for (Pozycja pozycja : koszyk) {
            suma = suma.add(doGroszy(wartoscDokladna(pozycja, rabat)));
        }
        return suma;
    }

    /** Suma wartości dokładnych, zaokrąglona dopiero na końcu — bywa o grosz inna. */
    public static BigDecimal sumaZaokraglonaNaKoncu(List<Pozycja> koszyk, BigDecimal rabat) {
        BigDecimal suma = BigDecimal.ZERO;
        for (Pozycja pozycja : koszyk) {
            suma = suma.add(wartoscDokladna(pozycja, rabat));
        }
        return doGroszy(suma);
    }

    /** Ta sama arytmetyka na double — celowo błędna, żeby było co porównać. */
    public static double sumaNaDouble(List<Pozycja> koszyk, double rabatProcent) {
        double suma = 0.0;
        for (Pozycja pozycja : koszyk) {
            double wartosc = pozycja.cenaNetto().doubleValue() * pozycja.ilosc()
                    * (1.0 - rabatProcent / 100.0);
            suma += Math.round(wartosc * 100) / 100.0;
        }
        return suma;
    }

    /** Rabat 0–100% na groszach, z symetrycznym HALF_UP i kontrolą przepełnienia. */
    public static long rabatWGroszach(long groszy, int rabatProcent) {
        if (rabatProcent < 0 || rabatProcent > 100) {
            throw new IllegalArgumentException("Rabat musi mieścić się w zakresie 0–100%.");
        }
        long mnoznik = 100L - rabatProcent;
        long cale = Math.multiplyExact(groszy / 100, mnoznik);
        long reszta = Math.multiplyExact(groszy % 100, mnoznik);
        return Math.addExact(cale, (reszta + Long.signum(reszta) * 50) / 100);
    }

    /** Kwota w polskim formacie, np. 12,34 zł. */
    public static String pln(BigDecimal kwota) {
        return NumberFormat.getCurrencyInstance(POLSKI).format(kwota);
    }

    // ── PRZYKŁADY ───────────────────────────────────────────
    public static void main(String[] args) {
        System.out.println("— czego double nie potrafi —");
        System.out.println("0.1 + 0.2  = " + (0.1 + 0.2));
        System.out.println("4.35 * 100 = " + (4.35 * 100));

        System.out.println("\n— BigDecimal buduj z tekstu, nigdy z double —");
        System.out.println("new BigDecimal(0.1)   = " + new BigDecimal(0.1));
        System.out.println("new BigDecimal(\"0.1\") = " + new BigDecimal("0.1"));

        BigDecimal dwa = new BigDecimal("2.0");
        BigDecimal dwaZero = new BigDecimal("2.00");
        System.out.println("2.0.equals(2.00)    = " + dwa.equals(dwaZero) + "  (skala się liczy)");
        System.out.println("2.0.compareTo(2.00) = " + dwa.compareTo(dwaZero) + "  (0 znaczy równe)");

        System.out.println("\n— dzielenie —");
        BigDecimal trzy = new BigDecimal("3");
        try {
            System.out.println(BigDecimal.ONE.divide(trzy));
        } catch (ArithmeticException blad) {
            System.out.println("1/3 bez skali rzuca wyjątek: " + blad.getMessage());
        }
        System.out.println("1/3 ze skalą 4: " + BigDecimal.ONE.divide(trzy, 4, RoundingMode.HALF_UP));

        System.out.println("\n— HALF_UP, HALF_EVEN i to, co po cichu wybiera pln() —");
        for (String tekst : List.of("2.345", "2.355", "-2.345")) {
            BigDecimal kwota = new BigDecimal(tekst);
            System.out.printf("%-7s HALF_UP=%-6s HALF_EVEN=%-6s pln=%s%n", tekst,
                    kwota.setScale(2, RoundingMode.HALF_UP),
                    kwota.setScale(2, RoundingMode.HALF_EVEN), pln(kwota));
        }

        System.out.println("\n— koszyk: rabat 15%, VAT 23% —");
        List<Pozycja> koszyk = List.of(
                new Pozycja("Kubek termiczny", new BigDecimal("2.10"), 3),
                new Pozycja("Zeszyt A5", new BigDecimal("4.30"), 1),
                new Pozycja("Długopis", new BigDecimal("1.50"), 7));

        for (Pozycja pozycja : koszyk) {
            BigDecimal dokladna = wartoscDokladna(pozycja, RABAT);
            System.out.printf("%-16s %8s -> %s%n", pozycja.nazwa(), dokladna, pln(doGroszy(dokladna)));
        }

        BigDecimal netto = sumaPozycjiZaokraglonych(koszyk, RABAT);
        System.out.println("Suma zaokrąglonych pozycji: " + pln(netto));
        System.out.println("Zaokrąglona suma dokładna:  " + pln(sumaZaokraglonaNaKoncu(koszyk, RABAT)));
        System.out.println("To samo policzone na double: " + sumaNaDouble(koszyk, 15.0));

        // VAT naliczamy od podstawy, czyli od sumy już zaokrąglonych pozycji.
        BigDecimal podatek = doGroszy(netto.multiply(STAWKA_VAT));
        System.out.println("VAT 23%: " + pln(podatek) + ",  brutto: " + pln(netto.add(podatek)));

        System.out.println("\n— alternatywa: kwoty w groszach jako long —");
        long groszy = rabatWGroszach(1050L, 15);
        System.out.println("1050 gr - 15% = " + groszy + " gr = " + pln(BigDecimal.valueOf(groszy, 2)));
        System.out.println("-1050 gr - 15% = " + rabatWGroszach(-1050L, 15)
                + " gr (symetryczne HALF_UP)");
    }
}

Jak użyć tego skryptu

  1. Plik musi nazywać się LiczbyIPieniadze.java, bo tak samo nazywa się klasa publiczna w środku. Wklej kod, wywołaj java LiczbyIPieniadze.java i czytaj wydruk od góry: najpierw wpadki double, potem pułapki BigDecimal, na końcu ten sam koszyk policzony trzema drogami.
  2. Kwoty wprowadzaj do BigDecimal z napisu: new BigDecimal("24.90") albo wprost z tekstu wpisanego przez użytkownika. Liczbę sztuk zamieniaj przez BigDecimal.valueOf(ilosc), a kwotę zapisaną w groszach jako long — przez BigDecimal.valueOf(893, 2), co od razu daje 8.93.
  3. Ustal jedną zasadę zaokrąglania i trzymaj się jej w całym dokumencie. sumaPozycjiZaokraglonych zaokrągla każdą pozycję i dopiero sumuje (17,95 zł), sumaZaokraglonaNaKoncu robi odwrotnie (17,94 zł). Na fakturze zwykle wygrywa ta pierwsza, bo podsumowanie musi zgadzać się z kwotami widocznymi w wierszach.
  4. Przy dzieleniu zawsze podawaj skalę i tryb: kwota.divide(dzielnik, 2, RoundingMode.HALF_UP). Wariant jednoargumentowy zadziała tylko wtedy, gdy wynik ma skończone rozwinięcie dziesiętne — przy dzieleniu na trzy rzuci ArithmeticException, co widać w przykładach.

Warto wiedzieć

  • new BigDecimal(0.1) nie jest błędem składni, tylko cichą katastrofą: konstruktor dostaje już zepsutą liczbę double i wiernie przepisuje wszystkie jej cyfry, więc w wydruku zobaczysz 0.1000000000000000055511151231257827021181583404541015625. Taka wartość liczy się dokładnie tak samo źle jak double, tylko wolniej. Jeśli musisz przyjąć double z cudzego API, użyj BigDecimal.valueOf(x) — idzie przez Double.toString i da 0.1, ale nie odzyska tego, co zgubiło się we wcześniejszych rachunkach.
  • equals i compareTo nie zgadzają się ze sobą, a to rozjeżdża kolekcje. HashSet, HashMap i List.contains pytają o equals, więc 2.0 i 2.00 są dla nich dwiema różnymi kwotami; TreeSet i TreeMap pytają o compareTo i uznają je za jedną. Zanim wrzucisz kwoty do kolekcji albo porównasz je w teście, ujednolić skalę przez setScale(2, RoundingMode.HALF_UP).
  • Metoda pln tylko formatuje, ale przy okazji zaokrągla po swojemu i nikogo o tym nie uprzedza: NumberFormat.getCurrencyInstance startuje z trybem HALF_EVEN, więc niezaokrąglone 2.345 wyświetli jako 2,34 zł, choć setScale(2, RoundingMode.HALF_UP) dałoby 2,35 — w przykładach obie kolumny stoją obok siebie. Formatuj więc kwoty już zaokrąglone tam, gdzie je liczysz, albo rzutuj wynik na DecimalFormat i wywołaj setRoundingMode jawnie; inaczej sumy w bazie i na wydruku rozjadą się o grosz.
  • rabatWGroszach pokazuje tańszą drogę — kwoty jako long w groszach. Rozdziela liczbę na pełne setki i resztę, dzięki czemu mnożenie nie przepełnia się nawet dla Long.MIN_VALUE i Long.MAX_VALUE, a poprawka o 50 ma znak reszty, więc HALF_UP działa symetrycznie dla zwrotów. Metoda jawnie przyjmuje rabat tylko od 0 do 100%; inne reguły biznesowe waliduj osobno.
#bigdecimal #zaokrąglanie #pieniądze #formatowanie #locale

Podobne skrypty