I have an application that handles products and sales in different currencies. So every row in the same table in the database can store prices in different currencies. How to correctly do that?
The most straightforward way is to define a numeric price_amount
column and varchar price_currency
oolumn, but I feel that two technically independent columns for essentially single value (price
) is wrong. Like physical measurements are meaningless numbers without units, amount of money is also meaningless without their units–currency.
In my opinion money should be a single value containing both amount and currency within itself.
I started searching and got surprised a bit that there are no ready solutions or good articles in search results. There is pg-currency extension that does what I want, but it was abandoned almost 10 years ago.
I created the following composite datatype as a starting point:
CREATE TYPE true_money AS (
currency varchar,
amount numeric
);
And then starting to write supporting things for it: validations, arithmetic, aggregates… And realized that this rabbit hole is truly deep.
All my current (partial) results over this composite type can be found here for reference: https://gist.github.com/Envek/780b917e72a86c123776ee763b8dd986?fbclid=IwAR2GxGUVPg5FtN3SSPhQv2uFA7oPNNjbZeTYWRix-ZijYaJFRec15chWLA8#file-true_money-sql
And now I can do following things:
INSERT INTO "products" ("title", "price") VALUES ('Гравицапа', ('RUB',100500));
INSERT INTO "products" ("title", "price") VALUES ('Эцих с гвоздями', ('RUB',12100.42));
INSERT INTO "products" ("title", "price") VALUES ('Gravizapa', ('USD',19999.99));
-- You can access its parts if you need to extract them or do filtering or grouping
SELECT SUM(price) FROM test_monetaries WHERE (price).currency = 'RUB';
-- (RUB,112600.42)
-- And if you forget filtering/grouping then custom types can save you from nonsense results
SELECT SUM(price) FROM test_monetaries;
ERROR: (USD,19999.99) can not be added to (RUB,112600.42) - currencies do not match
Is it a correct approach? How to do it right?
A bit of context: in our app, users (sellers) can manage their stock (products) in any currency they want (e.g., USD, EUR, JPY, RUB, whatever). The app will convert currencies and publish products in local sites (like British or Australian). Buyers will also buy these goods in their local currency (GBP, AUD, etc.) that will eventually be converted to seller currency and paid to them (except fees). So in many places in the application, almost any supported currency can appear. And finally, the seller should be able to change their currency and all products should be converted to new currency in batches (single update within transaction can't be used by some reasons). So we can't say “keep only numeric values in the products
table and JOIN with the sellers
table to get currency” (which is anti-pattern per se, I believe).
Yes, creating your own type is quite a lot of work if you want to integrate it seamlessly with PostgreSQL.
If an item can be sold in different countries and has a different price everywhere, you should model the data accordingly. Having an exchange rate is not good enough, because the same item might be more expensive in Japan than in China.
If you are only interested in the current price, that could look like this:
CREATE TABLE currency (
currency_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
denomination text CHECK (length(denomination) = 3) NOT NULL
);
CREATE TABLE exchange (
from_curr_id bigint REFERENCES currency NOT NULL,
to_curr_id bigint REFERENCES currency NOT NULL,
rate numeric(10,5) NOT NULL
);
CREATE TABLE country (
country_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text UNIQUE NOT NULL,
currency_id bigint REFERENCES currency NOT NULL
);
CREATE TABLE product (
product_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
);
CRATE TABLE price (
country_id bigint REFERENCES country NOT NULL,
product_id bigint REFERENCES product NOT NULL,
amount numeric(10,2) NOT NULL,
PRIMARY KEY (product_id, country_id)
);
CREATE INDEX ON price (country_id); -- for the foreign key
This way, each product can have a certain price in each country, and the price is associated with a currency via the country.
Of course, the real world might be still more complicated:
The main thing is that you can always follow a chain of foreign keys that leads you to the desired amount and currency unambiguously.
For converting between currencies
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With