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ą
Moneyobsł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.