Skip to content
← Wszystkie wpisy
5 min czytania Dawid Skłodowski

Projektowanie wielowalutowego modelu danych e-commerce

Sprzedaż w więcej niż jednej walucie brzmi jak problem wyświetlania, a okazuje się problemem modelowania danych. Deep-dive o poprawnym reprezentowaniu pieniędzy, przechowywaniu cen w wielu walutach, kursach wymiany, zaokrąglaniu i błędach kosztujących prawdziwe pieniądze.

„Sprzedawajmy w euro oprócz funtów” brzmi jak zmiana formatowania — przyklej inny symbol do ceny i przelicz. Nie jest. Wielowalutowość to jedna z tych funkcji, które wyglądają na sprawę wyświetlania, a okazują się sięgać aż w głąb twojego modelu danych, zaokrąglania i rozumienia, czym w ogóle jest „cena”. Zrób to źle, a błędy nie są kosmetyczne — to rozbieżności o grosz, które kumulują się w prawdziwe pieniądze i nieszczęśliwych księgowych. Oto jak to modelujemy, na podstawie budowy systemów handlu sprzedających na wielu rynkach.

Reguła zero: nigdy nie przechowuj pieniędzy jako float

Przede wszystkim: pieniądze nigdy nie są liczbą zmiennoprzecinkową. Floaty nie potrafią reprezentować większości ułamków dziesiętnych dokładnie, więc 0.1 + 0.2 != 0.3, a przez tysiące transakcji te maleńkie błędy kumulują się w rozbieżności, które będziesz ścigać dniami. Uniwersalną odpowiedzią jest przechowywanie pieniędzy jako liczby całkowitej najmniejszej jednostki — centów, pensów, groszy — razem z walutą:

# NIE
add_column :products, :price, :float        # 19.99 to kłamstwo czekające, by się stać

# TAK — całkowite jednostki podrzędne + jawna waluta
add_column :products, :price_cents, :integer, null: false
add_column :products, :price_currency, :string, null: false, default: "EUR"

19,99 zł jest przechowywane jako 1999 groszy. Cała arytmetyka dzieje się w liczbach całkowitych (dokładnie), a dzielisz przez 100 tylko do wyświetlania. W Ruby gem money-rails opakowuje dokładnie ten wzorzec — obiekt Money parujący całkowitą kwotę z walutą, z bezpieczną arytmetyką i formatowaniem — a monetize :price_cents daje ci product.price, który jest prawdziwym typem pieniężnym, a nie gołą liczbą.

Cena to para z walutą, nie liczba

Głębszym uświadomieniem jest to, że cena jest bez znaczenia bez swojej waluty. „19,99” to nie cena; „19,99 EUR” to cena. Gdy to przyswoisz, pytanie modelowe staje się: jak ceny odnoszą się między walutami? Są dwie fundamentalnie różne odpowiedzi, a wybór poprawnej to sedno projektu.

Opcja A — przeliczaj z waluty bazowej. Przechowuj każdą cenę raz, w walucie bazowej, i licz pozostałe z żywych kursów wymiany w czasie wyświetlania. Proste w utrzymaniu (jedna cena na produkt), ale każda wyświetlona cena to ruchomy cel, i musisz zdecydować, jak zaokrąglać przeliczenia — i czy klient, który widział wczoraj 18,50 €, jest mu to dziś należne.

Opcja B — ustaw jawną cenę na walutę. Przechowuj osobną cenę dla każdej waluty, w której sprzedajesz, ustawioną świadomie przez biznes. Więcej danych do utrzymania, ale daje kontrolę: możesz wycenić produkt na 20 £ i 25 € (a nie 23,40 €-po-przeliczeniu), bo okrągłe, dopasowane do rynku ceny lepiej się sprzedają, a cena jest stabilna — dokładnie to, co pobierasz, a nie funkcja dzisiejszego kursu.

# Opcja B: ceny należą do produktu, jedna na walutę
class Price < ApplicationRecord
  belongs_to :product
  monetize :amount_cents
  # unikalny indeks na [product_id, amount_currency]
end

W praktyce poważny handel detaliczny skłania się ku Opcji B — biznesy chcą wybierać swoją cenę na każdym rynku, a nie dziedziczyć cokolwiek wyprodukuje kurs wymiany. Domyślnie stosujemy jawne ceny per waluta, z przeliczeniem jako fallback dla walut, które nie zostały wycenione ręcznie. Lekcją jest to, że model danych koduje decyzję biznesową („czy nasze ceny są ustalane, czy wyprowadzane?”), i musisz na nią odpowiedzieć przed napisaniem schematu, nie po.

Kursy wymiany: kiedy i zamrożone we właściwym momencie

Nawet z jawnymi cenami potrzebujesz gdzieś kursów wymiany — do fallbackowego przeliczania, do raportowania, do rozliczania. Dwie dyscypliny mają znaczenie:

  • Kursy to dane szeregów czasowych, nie pojedyncza wartość. Przechowuj kursy ze znacznikiem czasu i trzymaj historię. „Jaki był kurs GBP→EUR, gdy złożono to zamówienie?” to pytanie, które zostanie ci zadane, przez spór z klientem albo audytora, a „nie wiem, trzymam tylko bieżący kurs” nie jest akceptowalną odpowiedzią.
  • Zamroź kurs i przeliczoną kwotę na zamówieniu w czasie zakupu. To najważniejsza reguła w całej domenie. Zamówienie musi zapisać, co faktycznie pobrano, w faktycznej walucie, po faktycznym użytym kursie — skopiowane na zamówienie, nigdy nieprzeliczane później. Jeśli ponownie wyprowadzisz total starego zamówienia z dzisiejszych cen i kursów, liczba zdryfuje, a twoje zapisy będą kłamać. Zamówienie to niezmienny zapis finansowy; denormalizuj pieniądze na nim świadomie.
class Order < ApplicationRecord
  monetize :total_cents
  # uchwycone przy checkoucie, nigdy nieprzeliczane:
  #   total_cents, total_currency,
  #   exchange_rate_used, base_total_cents  (do raportowania w walucie macierzystej)
end

Zaokrąglanie to polityka, nie przypadek

Zaokrąglanie wydaje się trywialne, dopóki nie kosztuje cię pieniędzy przy skali. Gdy przeliczasz albo stosujesz podatek czy rabat, produkujesz ułamkowe jednostki podrzędne, a jak je zaokrąglasz to decyzja o finansowych konsekwencjach. Reguły, których się trzymamy:

  • Zaokrąglaj raz, w zdefiniowanym punkcie, w zdefiniowanym trybie. Zdecyduj, gdzie dzieje się zaokrąglanie (zwykle per pozycja albo per total zamówienia — wybierz jedno i bądź konsekwentny) i jakiego trybu zaokrąglania używasz, i stosuj go tak samo wszędzie.
  • Zaokrąglaj do faktycznej precyzji waluty. Większość walut ma 2 miejsca po przecinku, ale nie wszystkie — JPY ma 0, niektóre mają 3. Wpisanie na sztywno „dziel przez 100” to błąd czekający na twoją pierwszą sprzedaż w jenach. Sama waluta definiuje swoją precyzję podjednostki; uszanuj ją (to kolejna rzecz, którą Money obsługuje za ciebie).
  • Niech totale się zgadzają. Suma zaokrąglonych pozycji musi równać się zaokrąglonemu totalowi, którym klient jest obciążany. Zaokrąglanie każdej pozycji niezależnie, a potem osobne zaokrąglenie sumy, może wyprodukować rozbieżność o grosz — klasyczny błąd „liczby się nie zgadzają”. Zdecyduj swoją regułę alokacji i przetestuj ją.

Podatek dodaje kolejny wymiar

Wielowalutowość zwykle przychodzi obok wielorynkowości, co oznacza podatek — stawki VAT różniące się krajem, ceny pokazywane z VAT na jednych rynkach i bez na innych, oraz pytanie, czy twoja przechowywana cena jest brutto czy netto. To kumuluje się z walutą: ten sam produkt może być 20 £ z VAT w UK i 25 € bez VAT gdzie indziej. Modeluj jawnie, czy przechowywana cena zawiera podatek, przechowuj stawkę podatku zastosowaną na zamówieniu (zamrożoną, jak kurs wymiany) i nigdy nie próbuj jej wstecznie liczyć z bieżących kursów.

Werdykt

Wielowalutowość to problem modelowania danych w ubraniu problemu wyświetlania. Nienegocjowalne: przechowuj pieniądze jako całkowite jednostki podrzędne plus walutę, nigdy jako float; traktuj cenę jako parę z walutą i zdecyduj świadomie, czy twoje ceny są ustalane per waluta, czy wyprowadzane z bazy; trzymaj kursy wymiany jako historię szeregów czasowych; i — przede wszystkim — zamroź faktycznie pobraną kwotę, walutę i kurs na każdym zamówieniu jako niezmienny zapis, nigdy nieprzeliczany. Dodaj spójną politykę zaokrąglania, która szanuje precyzję każdej waluty i sprawia, że totale się zgadzają, i obsłuż podatek jako osobny, zamrożony wymiar, którym jest. Zrób to, a sprzedaż na wielu rynkach jest solidna. Potraktuj to jako warstwę formatowania nad floatem, a będziesz uzgadniać tajemnicze groszowe rozbieżności latami.