Hacker News new | comments | show | ask | jobs | submit login
Dinero.js – JavaScript library for working with monetary values (sarahdayan.github.io)
135 points by johnhenderson 6 months ago | hide | past | web | favorite | 77 comments



This library has some huge hazards.

    let added = Dinero({ amount: 0.1 }).add(Dinero({ amount: 0.2 }))
    let result = added.equalsTo(Dinero({ amount: 0.3 }))
`result` is false! I can't believe there's a currency library that doesn't do correct decimal arithmetic.

https://codepen.io/recursive/pen/VXogvN?editors=0010


I am guessing the author would say that you are supposed to only use integer amounts. Amounts are measured in cents, so $15 is `Dinero({amount: 1500, currency: 'USD'})`.

It is a serious problem that `Dinero({amount: 0.1})` doesn't immediately throw an exception, though—if you design a library that horribly breaks on non-integer inputs, you must reject integer inputs loudly and immediately.


How often are fractions of a cent calculated and managed? I always assumed that if you're doing some serious finance work you track into fractions of a penny.


IIRC you generally do not; accounting standards tend to expect that every "booking" is rounded, so only some intermediate results can ever be in fractions of a penny. This sometimes results in "adjustment" bookings to ensure that a bunch of deals match the required total, because the rounding introduces a difference - e.g. if you have interest accrual by day and the monthly total interest would be $xx.35, then you might accrue $yy.01 every day and $yy.05 on the last day - but not $yy.01166 every day.

Prices are a different issue, you're likely to have prices that are fractions of a penny; but any inventory valuations (either as stock or sale) would again be rounded after multiplying the price with the item quantity.

Perhaps this could use some integration with a general concept of unit dimensions, distinguishing values that are measured in dollars(cents) and values such as prices that are "dollars/item" and can become "dollars" only when multiplied by number of items.


I was working on a heavily government regulated horseracing/gambling mobile website/webapp maybe 7-8 years ago, and we had to prove every intermediate step of any calculation was done in 1/10,000ths of a cent (or perhaps dollar? It was quite some time ago), and any rounding only ever happened once at the very end. (Where by "prove",they meant "submit the source code" - the "easy" way to get this past the regulators was to have "ten thousandths of a cent" be the base unit of a "custom currency", and only ever convert to dollars/cents in the display code. (Nobody ever questioned the ajax/javascript interaction with the resulting "integer" currency numbers... shudder...)

I get the impression back then they everybody in the gaming/gambling industry did this all the time - to the extent that they knew how to organise source code and name variables and functions so the regulators auditing the code would just rubber stamp it.

(I was not "in the gaming industry", we were just doing a mobile friendly front end, in jQuery Mobile, to run on the hot new Samsung Galaxy S2... Now I think I need to go curl up in a dark corner and cry myself to sleep again... Project. From. Hell...)


The nature of odds would require that. Otherwise how would you calculate 1:3 odds?


Same argument applies to interest rates and many other financial calculations too... The thing I didn't know before that project was that there was a "standard" about how much precision you're required to calculate at. Not that ten thousandths of a cent is necessarily "the right answer", but when the regulators tell you that's the answer it takes a lot of discussion/argument away... (Surely even tenths of a cent would suffice???)


Sure, but using floats is still the wrong way to handle this. Much better to just decide how much accuracy you need and set up your fixed-point system to handle that. Additionally you might want e.g. rationals to handle any rate that multiplies amounts.


18 decimals "ought to be enough for anybody" in the bitcoin age :)


Good call, will fix.


The amounts must be specified in cents, as floats can’t be relied upon to perform calculations. That was my #1 reason to design the library using solely integers. I wrote all about it here: https://frontstuff.io/how-to-handle-monetary-values-in-javas...


By "cents", you really mean "minor currency unit" I assume?

In British Pounds, it would be pennies for example.

The doc doesn't explain how the library deals with the various different minor units out there (I've been bitten too many times by code that assumes that all currencies have a minor unit and that 100 minor units = 1 major unit so I'm careful these days).

For example, what about currencies that have a minor unit that's not effectively used in practice or that don't have a minor unit at all (e.g. JPY)? Are the amount supposed to be expressed in this unused / non-existent minor unit?

What happens with currencies that use a subdivision other than 100 for their minor unit (e.g. KWD)? Will the calculations and formatting be correct?


A cent is a minor currency unit; think procent, aka hundred in latin, or used in common international form one-hundreth.


The Kuwaiti Dinar (KWD) the parent mentioned is subdivided into 1000s, not 100s.


But there's no way to do so in JavaScript unless you implement a numeric type by hand, something that Dinero does not do.

In JavaScript every number is a IEEE754 floating point number, and removing decimal places will not change its representation in memory or its limitations.

The only situation when a JavaScript Number type is treated as an integer is a vendor specific optimization for small integers (e.g: Smi in v8).

You can still run into issues such as absorption problems if dealing with only "integers". e.g:

    > 1e16 -1
    10000000000000000 // wrong
    > 1e16 -2
    9999999999999998
    > 1e16 -3
    9999999999999996 // wrong
    > 1e16 -4
    9999999999999996
    > 1e16 -5
    9999999999999996 // wrong
    > 1e16 -6
    9999999999999994
    > 1e16 -7
    9999999999999992 // wrong
But this is just the beginning.


But there's no way to do so in JavaScript unless you implement a numeric type by hand

This is not true: with 64-bit floating-point numbers, calculations on numbers in a range from Number.MIN_SAFE_INTEGER (2^53-1) to Number.MAX_SAFE_INTEGER (-2^53-1) are safe to do. If you go outside this range, the numbers are no longer exact, so you'll have to check for overflow, but it's the same when you use native machine integer types (e.g. uint64) in most languages that have them.

The only situation when a JavaScript Number type is treated as an integer is a vendor specific optimization for small integers (e.g: Smi in v8).

A number is integer if it doesn't have a fractional component regardless of how it's represented by a computer.

There are also operations in JavaScript that treat their inputs as 32-bit integers (numbers are taken modulo 2^32), such as bitwise operations and Math.imul. But if you don't use them, integers can be as large or as small as described above.


Some numbers do not have an exact representation in IEEE754. This leads to problems such as:

    > 1.03 - 0.42
    0.6100000000000001


These are not integers! Integers are exactly represented from -2^53-1 to 2^53-1.


You claim that as long as numbers fit in a double precision IEEE754 floating point number significand, which is what JS implements, you are safe. Fine.

But what happens after successive operations? the risk increases. Is this a good idea when dealing with currency? No. Does this library warn you about those cases or throw an error? no. Does this library provide unit tests for those cases? no.


I agree that this library doesn't looks good — a proper implementation would use a better precision (e.g. 1/10000 of a currency unit) rather than "cents" (1/100), and of course, check ranges and the absence of fractions (Number.isSafeInteger).


You may have designed it using only integers, but it's very easy to use it with non-integers, which will result in hard-to-diagnose errors. It should probably throw if a non-integer is encountered. Failing that, a huge blinking red banner at the top of the docs should explain the problem.


Annnnd that's why I like types :D


I've read the post and I do get the point BUT we currently support 25 languages, use accounting.js (I know, it's just formatting) and the backend returns float values and we still get by. I'm aware of the risks and not happy about it, but probably good will and magic holds it together (plus backend has a veto over the price with the final, canonical value on their side).

Would it be possible to rewrite this library to use "floatish" numbers with initial multiplication and flooring then using decimal.js? I understand that non-internal arithmetics are slow as hell, but most of the time people just add products to a cart or sometimes apply a percentage discount.

On the other hand, please do throw for non-integer values :)


It's not (solely) an issue of speed.

http://weitz.de/ieee/

Plug in 0.11 and 0.1 and click `+`. You expect 0.21, but since 1/10 isn't cleanly represented in base 2 using a finite number of bits... (just like 1/3 isn't cleanly represented in base 10 ~0.33..).

And when it comes to currency, rounding and "good enough" is probably not going to be good enough.


It may be totally silly (I consider myself lucky for getting by with good enough so far:)), but would `(Math.floor(0.11x10xx2)+Math.floor(0.1x10xx2))` divided by 100 work?

Probably I'd like to do the last step with a custom arithmetic and not store the final value as a float, but like I said, I'm not sure. I get that the lowest denominator route is good, but it feels complicated and the chance of migrating a shoddy "good enough" project is zero in that case.

(For fuck's sake, in 2018 formatting text or using utf8 on hacker news is too much to ask for, it's even worse than slashdot)


I don't know enough of IEEE 754, but I don't think binary representation of numbers are guaranteed to be strictly greater i.e. I think you at least want Math.round instead of Math.floor.

It might be fine, depending on the use case. I think dealing with any complicated currency arithmetic and throwing floating point arithmetic on top is throwing gas on a fire.

That said, I consider this to be similar to the phone number representation. As in, what is the correct way to store and manipulate a phone number? 1 555 555 5555 looks a lot like a number, but does it make sense to increment by 1 or divide by 5?

Similarly, $1.12 looks a lot like a float, but would you ever have $pi? In practice currency acts more like an integer with possibly infinite digits i.e. probably a candidate for something like Java's BigInteger. But that's just my $0.02 :)


A third alternative is to directly store values in cents, relative to the unit. If you need to store 10 cents, you won’t store 0.1 nor 10, but 100.

I'm missing something here? Do you mean you're storing mills (₥)?


Simple typo, fixed :)


Do you have any suggestions on how to deal with money where you need more precision then cents? For example financials and inventory markets?


If you can find an atomic amount, (e.g. PicoDollars) I'd suggest using that. Javascript doesn't have an infinite precision integer type.

In cases where that isn't possible, try an infinite precision javascript library. https://github.com/MikeMcl/big.js

You could _probably_ find a way to shim big.js into OP's library :)


Basically, you store everything as integers.You can specify whatever precision you want, just by saying x digits are before the decimal. E.g. 1.234566 is 1234566. Rounding when needed(just be consistent), but you never round until the last step which is when you're going to be displaying/storing the final value.


On the other hand, it has 100% coverage and zero vulnerabilities! We really should teach people how to test properly and not just use metrics to sell a library :-(.


I think the inability to write effective tests is single biggest contributor to technical debt over the past decade. Ineffective tests are arguably more dangerous than no tests at all, due to what you pointed out: the "100% Coverage" banner is about as effective as those Verisign website seals that were so popular in the early 2000s.


Unit test coverage calcify your code. People will resist change because "it breaks unit tests".

The best are acceptance tests. But here the use of SaaS systems with no sandbox or locally deployable mocks mean this is hard to automate.


Like the Stripe model, amounts should be in cents (integers), don't use decimals.

Functions such as allocate() are super useful:

    // returns an array of two Dinero objects
    // the first one with an amount of 502
    // the second one with an amount of 501
    Dinero({ amount: 1003 }).allocate([50, 50])


I assume you're supposed to use a smaller denomination of your choice to avoid decimals. For example, the submitted page tells you to use cents for EUR values and there's presumably nothing that prevents you from using tenths or hundreths of cents as the base denomination if you want.

Perhaps it should not allow you to create an instance with a non-integral `amount` ?


My favourite money library is safe- money in Haskell. It basically catches 99% of the most common money bugs at compile time https://ren.zone/articles/safe-money


Wow, this is really well designed and the article is a pleasure to read.

I wonder if it might be even better for the ExchangeRate constructor to take two different Dense values, something like:

exchangeRate (1 :: Dense "EUR") (12 % 10 Dense "USD")

Then the type system could automatically prevent you from making an error by accidentally entering the reciprocal exchange rate from what is expected.


I once heard a joke about a team in an unnamed bank that was interfacing with the mainframe using the latest and greatest JavaScript framework of the time. Once they were full speed in production, bug reports started to pop up. They tried solving them one by one until some brave soul reminded them that JavaScript has only one way to represent numbers and it happens that the JavaScript way is totally unfit for monetary calculations.

P.S. As much as I hate DEC64, it is a much better way to handle money. Someone please add DEC64 to JavaScript! We have Set and generator functions. Why not another way to store floating point numbers!


You can easily store arbitrary precision numbers in an ArrayBuffer, there are several arbitrary-precision libraries for JS, for example http://crunch.js.org

I'm sure you're aware, but it should be mentioned that all languages using single-precision FP by default behave the same. Ruby, Python, Perl, PHP (or any other if you use float instead of double).


Ruby has distinct integer and float classes (Fixnum/Bignum and Float, respectively).


This post by the author discusses "How to Handle Monetary Values in JavaScript" and how this library relates to that issue https://frontstuff.io/how-to-handle-monetary-values-in-javas...


You can "cast" a JavaScript number to int:

  nr = nr | 0
Then use the cents instead of decimal dollars.

But it might be a good idea to use a bigint lib. And remember different countries might have different rounding rules.


JavaScript numbers can hold integers without losing precision up to Number.MAX_SAFE_INTEGER (9007199254740991, or 2⁵³-1), can be checked with Number.isSafeInteger(). Bitwise arithmetics, though, works on 32-bit numbers, so "casting" with | 0 will actually result in converting the number to 32-bit signed integer:

   2**53-1 | 0 = -1
   2**53-2 | 0 = -2
Which is modular arithmetic:

   (2**53-2) % 2**32 = 4294967294
   2**53-2 >>> 0 = 4294967294
          ( >>> 0 makes unsigned, 4294967294 | 0 = -2)


Using | will cast it to a 32 bit integer!

   2147483648 | 0 = -2147483648
  -2147483649 | 0 = 2147483647
   4294967296 | 0 = 0
  -4294967296 | 0 = 0
   6442450944 | 0 = -2147483648


My code review comment on this would be...

Don't be "clever". Use parseInt or parseFloat.


parseInt() and parseFloat() are made for strings, not for numbers. I think you are looking for one of these:

Math.round() Math.ceil() Math.floor()


That’s a terrible idea:

  parseInt(0.000001) // 0
  parseInt(0.0000001) // 1


For anyone who is wondering why:

parseInt() only accepts strings as input. So JavaScript calls .toString() on the number before passing it as an argument.

    (0.000001).toString() // "0.000001"
    (0.0000001).toString() // "1e-7"
By the way, TypeScript or Flow would have caught this error during compile time.


...and different industries in a given country, and different firms in a given industry, and different departments in a given firm, and different contracts used by a given department...


I wanted to see how it deals with decimal percentages so I opened the dev console and... Dinero isn't loaded on the page. That's the one thing I love about libs like decimal.js and moment.js - you can open the console and try it right in the documentation.


Good call, I’ll fix that


One way you can try out npm packages is to use RunKit: https://npm.runkit.com/dinero.js

npm pages for packages all have a "test in your browser" link that goes to RunKit.


I think that's a great start and your docs look good too. Unfortunately the native locale format functions are not always accurate for certain currencies and locales (e.g. the rupee). My team put together https://osrec.github.io/currencyFormatter.js/ to help format currencies more accurately. You may want to give your users the option to use this library instead of toLocaleString for formatting.


Thanks for this, I was about to comment the same thing! I don't really see the point of setting the locale if it doesn't automatically format the values or output currency's symbol.


Read the faq and the divide documentation, couldn’t figure out how it handles fractional cents? Does it?


Great point, I was about to say the same.

Looks like it does lose cents and can't be used in production.

from the source:

    divide(a, b) {
      return a / b
},


Hey! You need to use the allocate method for this. It will redistribute fractional cents and return an array of Dinero objects.


Currency + JavaScript Number type = bad idea

The Number type in JavaScript is a IEEE754 floating point number. Someone explained why floating point numbers are not good to represent currency:

https://stackoverflow.com/questions/3730019/why-not-use-doub...

In fact, even a simple operation like equality in floating point numbers becomes harder. e.g:

    Math.abs(x - y) < Number.EPSILON
I would be very careful about this since it becomes problematic for large numbers.


accounting.js [0] handles this by multiplying the number out by a few thousand before doing the maths, ensuring rounding works as expected.

Source: Gordon Zhu’s excellent introduction to JS course, Watch and Code [1]. I haven’t used accounting.js myself.

0: http://openexchangerates.github.io/accounting.js/

1: http://watchandcode.com


That is not enough.

Converting an amount to cents, e.g: $1.99 becomes 199 cents, will help you only briefly.

As numbers become larger or you have successive divisions, numbers will start to suffer from issues such as absorption. Then you may also suffer from issues when comparing numbers, since floating point equality is different.

e.g:

    > 1e16 -1
    10000000000000000
Note that this code should output 9999999999999999. But it doesn't because of absorption. In this case, "multiplying by a few thousands" makes this situation more likely.


A 64 bit floating point number can still represent exactly all integers smaller than 2^53 so it’s possible to use that for constructing proper rationals or doing proper exact comparisons and currency calculations within certain bounds e.g by using 1/100000 dollars as the unit you can fit any amount under 2^48.

It’s terrible to not even have proper integers but that doesn’t make the problem go away. People still have to do financial calculations in JS.


It's not being validated. Validation and tests involving large numbers need to be added.


I didn’t look at the details of this specific library (which I expect is even more complex than just using the exact integers of ieee754). But yes - in order to properly use the exact integers, the number would have to be encapsulated in an object and validated (range, dropping decimals, returning optional values for things that might fail etc).

You can not just take two “Number” and use for proper decimal math without enforcing some invariants.


>People still have to do financial calculations in JS.

No, they don't.


Why? Big.js does this and much more and is pretty battle tested.


No discussion of fixed point math?


I'm surprised it's not built on the decimal or bignum lib.


or big.js


I wonder if this library can be used to safely manipulate crypto currency amounts: Bitcoin uses 8 decimals, ethereum uses 18 decimals.

For whoever says "you don't manipulate money in js", there's nodejs and many devs actually have to manage them.


It looks like the linked library expects you to provide integer values only. You can do the same with cryptocurrencies (satoshis in the case of Bitcoin), but not all cryptocurrencies have the same number of decimal places (Monero with 12 instead of 8).

I've used the following library in the past:

* https://mikemcl.github.io/bignumber.js/

It's also worth noting that the maximum integer value that can be safely stored by the Number type in JavaScript is 2^53 (9007199254740992). So if you expect to be working with integers larger than this, you should use a library for working with large integers (private keys in cryptocurrencies). The one linked above works, but there are others as well.


IANAL, but I feel like a disclaimer about not being liable for issues using this code in production might be a good idea...


That's already taken care by the MIT license: https://github.com/sarahdayan/dinero.js/blob/master/LICENSE....


Especially since it's using floating point arithmetic.


For some reason, the tone of the documentation site was very soft and warm. Then I looked at the author it was a woman and just like most women are well mannered, she was too. If I had written it, I would annoy the user very quickly!


Warm, like the answers here and your downvotes :D


Yup. Expected! :)




Guidelines | FAQ | Support | API | Security | Lists | Bookmarklet | Legal | Apply to YC | Contact

Search: