Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

PostgreSQL: How to store money with multiple currencies?

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).

like image 352
Envek Avatar asked Jun 23 '20 17:06

Envek


1 Answers

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:

  • you could have more than one currency per country
  • you might want to keep historical price information

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

like image 82
Laurenz Albe Avatar answered Nov 02 '22 22:11

Laurenz Albe