Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve handling of monetary values #1835

Closed
michaelbromley opened this issue Oct 11, 2022 · 2 comments
Closed

Improve handling of monetary values #1835

michaelbromley opened this issue Oct 11, 2022 · 2 comments
Labels
design 📐 This issue deals with high-level design of a feature @vendure/core
Milestone

Comments

@michaelbromley
Copy link
Member

Background

Currently in Vendure we store and transmit all monetary values as integers in the minor unit of the currency (cents, pennies etc). The reason for this is to avoid floating-point math errors when dealing with decimals, i.e. 0.1 + 0.2 = 0.30000000000000004.

This is a widely-used and recommended way of dealing with currencies and works well overall.

However, it does have a weakness in that when working with decimal multiplications or divisions - very common operations when applying taxes or promotional discounts - the lack of precision (fractions of cents) can result in unexpected results, off by one or a few cents.

Reference: Best way to store currency values in C++

Example: Calculating taxes

  • Let's say we have sell a Pen for $0.31.
  • Our tax rate is 20%.
  • Customer buys 10 pens.

Customer might expect to pay a total of 0.31 * 1.2 * 10, i.e. $3.72

The actual calculation done by Vendure will be

31 * 1.2 = 37.2
Math.round(37.2) =37
37 * 10 = 370

So customer will end up paying $3.70. Now in this case the customer might be happy! But in other cases they will end up paying more than expected.

Example: syncing prices from external PIM

We have an existing PIM system that stores prices ex. VAT, in fractions of a penny. When we sync to Vendure, we send this ex. VAT amount and then in Vendure it must get rounded in order to be stored as an integer. This means that sometimes our inc. VAT prices differ from those in the PIM, due to the rounding error.

  • we have a paint tube in the PIM listed at £5.37.
  • Our tax rate is 20%
  • The ex. vat price listed in the PIM is 5.37 / 1.2 = 4.475

So we cannot represent this in Vendure, as we would need to store it as 447.5, but we only work with integers so this gets rounded to 448. When we then calculate the inc. VAT price, we get

448 * 1.2 = 537.6
Math.round(537.6) = 538

So we now list the paint tube as £5.38, rather than £5.37 as it should be to match the PIM.

Solution

To solve these issues we need to be able to work with more precision when performing calculations.

Money storage data type

It is still desirable to expose integers via the API, but as some point lower in the stack we need to be able to have greater precision, which means either a non-integer data type, or adding extra significant figures to the integer representation, i.e. storing $1 as 100_000 rather than 100, giving us 3 extra digits of precision to work with.

Since we perform all these calculations on the server, it should still be safe to expose the same minor-unit integer via the GraphQL APIs, so there would not need to be any breaking change to client applications.

Money transmission data type

Currently we expose all monetary values as the Int GraphQL type. However, this leads the a problem of integer overflow since the max 32-bit integer is 2,147,483,647, which gives us an upper limit on representing a maximum of ~21 million major units. For certain industries and high-denomination currencies, this can become a problem:

Conversion

Somewhere in the system there will be a boundary, where the internal more-precise representation gets converted into the existing minor-unit integer. For example, we could use a custom GraphQL Scalar to take care of this (though this will leave a problem for values exposed not via GraphQL, e.g. a REST endpoint).

Strategy

I'd like to encapsulate all of the above in a strategy interface, which allows the developer to define:

  1. The level of precision required
  2. How the conversion happens in both directions

The default strategy would exactly replicate the existing behaviour probably, to reduce the chances of unexpected changes in behaviour. Business requirements and laws vary from place to place, so it makes the most sense to allow this to be configured as needed.

Next steps

This would be a major change that will touch virtually every part of Vendure core. I want to spend some time gathering feedback on this and experimenting with different designs to solve this in a way that:

  1. handles the problem
  2. is minimally-breaking
  3. does not significantly increase the complexity of working with monetary values for the developer building on Vendure
@michaelbromley michaelbromley added @vendure/core design 📐 This issue deals with high-level design of a feature 💥 DB Breaking labels Oct 11, 2022
@michaelbromley michaelbromley moved this to 🤔 Under consideration in Vendure OS Roadmap Oct 11, 2022
@martijnvdbrug
Copy link
Collaborator

martijnvdbrug commented Oct 11, 2022

Does this only occur with taxes, or are there other scenarios?

In my perception, the tax cases were handled by the channel.pricesIncludeTax setting:
pricesIncludeTax=true Means we only use prices including tax for calculations.
⬆️ This does still result in discrepancies in the priceWithoutTax. Most websites I see avoid this discrepancy this by:

  • Not displaying item prices without tax for consumers (channel.pricesIncludeTax=true)
  • Not displaying item prices with tax for B2B (channel.pricesIncludeTax=false)

✔️ I still agree that more precision is better though, as it prevents 90% of rounding errors that occur now 🥳 It's just that the change does sound very impactful...

Precision strategy
It feels like Vendure should decide this for its users, instead of a strategy:
Generally speaking, more precision is always better, right? There are no laws that prohibit correct calculations, I assume.
(dangerous assumption 🙃 ?)

I agree that migration of an existing project would be a pain, but maintaining another strategy also comes at a cost (support, questions, issues, testing, development)

@JMPJNS
Copy link

JMPJNS commented Oct 11, 2022

Databases like Postgres already support that with money types and Languages like c# have a native decimal type and c++ is getting one soon, sadly i think nobody has yet written a native nodejs extension that adds one to javascript, there is a stage 1 proposal but that probably won't be implemented in the next few years

@michaelbromley michaelbromley added this to the v2.0 milestone Nov 25, 2022
@michaelbromley michaelbromley moved this from 🤔 Under consideration to 🏗 In progress in Vendure OS Roadmap Feb 2, 2023
michaelbromley added a commit that referenced this issue Feb 21, 2023
Relates to #1835.

BREAKING CHANGE: The introduction of the new MoneyStrategy includes a new GraphQL `Money` scalar,
which replaces `Int` used in v1.x. In practice, this is still a `number` type and should not
break any client applications. One point to note is that `Money` is based on the `Float` scalar
and therefore can represent decimal values, allowing fractions of cents to be represented.
@michaelbromley michaelbromley moved this from 🏗 In progress to 🔖 Ready in Vendure OS Roadmap Feb 21, 2023
@michaelbromley michaelbromley moved this from 🔖 Ready to ✅ Done in Vendure OS Roadmap Jun 22, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
design 📐 This issue deals with high-level design of a feature @vendure/core
Projects
Archived in project
Development

No branches or pull requests

3 participants