How to deal with money in software

Date 2022-08-22

Dealing with money in software is difficult and dangerous. This post contains an overview of the problems you will run into eventually when writing software that deals with money. It describes potential representations, relevant trade-offs, and recommends ways of correctly implementing common operations. The post is prescriptive, so that you can use it to write your own library for dealing with amounts of money.

This post is an update of my previous post about dealing with money in software. It newly includes an overview of possible representations, better recommendations, and an addendum of tests.

Problems with money

Money is a surprisingly complex, but more importantly very dangerous subject for programs to deal with. It is hard to get right, but worth paying close attention to.

There are all sorts of intuitive and unintuitive pitfalls that you will want to avoid. (This list of falsehoods is a good place to appreciating how complex of a topic money in software is.) Not all problems will become evident immediately, and some might even never bite you, but chances are you will run into each one at some point in the long term.

This section lists common problems that you may run into when performing common operations on money.

Creating or destroying money through errors

Numeric calculations produce erroneous results when choosing a finitely-sized representation. This is because arbitrary amounts of precision requires an arbitrary amount of time and space.

IEEE754 Floating-point numbers, for example, suffer from this problem. As an example, let us have a look at the value $0.1$, which cannot represented accurately using an IEEE754 floating point number. Indeed, it is represented as follows instead:

>>> "%.55f" % 0.1
'0.1000000000000000055511151231257827021181583404541015625'

As a result, the following example of the common addition operation produces a silent error:

λ> 0.1 + 0.1 + 0.1
0.30000000000000004

This means that when you get a dime from a customer three times, and store it as a floating number, you will have effectively created 0.00000000000000004 USD out of thin air. This might not seem like much (even though it does not need to be much, it is still wrong), but this error grows as the values in your calculations grow. There is an entire field of mathematics dedicated to thinking about what happens to the errors depending on what types of calculations you are performing. If you are not intimately familiar with this field, stay far away from representations that may have errors.

Silent Overflow and Underflow

This second problem is another form of numerical errors that stem from a finitely-sized representation. It can happen if you try to store amounts of money as a fixed-width integral number of dollar cents, and naively perform computations as computations on those numbers directly.

For example, consider a situation in which you are a bank and you store amounts of money as int32. A very wealthy client of yours has about twenty million dollars. You save this information as "this client has two billion cents". A bit later, he earns another two million dollars, but when the money is sent to him, suddenly the system says that he is millions in of dollars in debt. There are two big issues with this scenario: 1. The client has lost money and 2. The total amount of money has decreased:

λ> 2000000000 + 200000000 :: Int32
-2094967296

Underflow is a similar problem in which money disappears because an amount becomes too small:

λ> 0.5 ^ 1074
5.0e-324
λ> 0.5 ^ 1075
0.0

Silently wrong results must be avoided in software that deals with money.

Representing nonsense values of money

The IEEE754 Floating point number spec defines NaN, +Infinity and -Infinity. Rational numbers also have these, in the form of 0 / 0, 1 / 0 and -1 / 0. These make no sense when interpreting them as monetary values. If your system ever computes one of these, there is little you can do to undo that.

Minimal quantisations

The smallest denomination of US dollar that you can hold is 0.01 USD: a penny. Any amount of US dollars more granular than that does not exist in coins and cannot usually be transacted. (You can still potentially have more granular debts, as you will see below.)

Different currencies can also have different minimal quantisations. In Switzerland, where I am using my broker, the smallest coin of a Swiss Frank is 5 "rappen": 0.05 CHF. The transaction granularity goes down to 0.01 CHF in bank transactions but no lower than that either.

Representing amounts of money smaller than the minimal quantisation that you have chosen means you are dealing with amounts that make no sense as an amount of money. We must use a representation that cannot represent more granular amounts than the minimal quantisation of the currency at hand.

Arbitrary space and time

Given that fixed-sized representations can result in computational errors, one might be tempted to use arbitrarily-sized representations instead.

Sadly, such a representation only trades in the above issues for other ones. For example, consider the operation of halving an amount of money. After not-even-that-many halvings, the result should be pretty close to negligible. However, performing the halving operation with a representation that has arbitrary precision results in a representation that unboundedly increases in size.

For example, when using rational numbers, each halving operation requires at least one more bit to store the result:

Prelude Data.Ratio> (1 % 2 )^1000
1 % 10715086071862673209484250490600018105614048117055336074437503883703510511249361224931983788156958581275946729175531468251871452856923140435984577574698574803934567774824230985421074605062371141877954182153046474983581941267398767559165543946077062914571196477686542167660429831652624386837205668069376

The important insight about this problem is that using arbitrary amounts of time or space makes a computation practically partial. In other words, it is no longer fair to assume that any given computation will complete on your machine, and that is dangerous.

Possible representations

This section explores different possible representations of amounts of money, and details the problems and tradeoffs with each.

Money as floating-point numbers

If you take away one thing from this blogpost, let it be this:

You must NOT store monetary values as IEEE 754 floating point numbers.

  1. Calculations involving floating point numbers usually have some amount of error, so you can create or destroy money using calculations:

λ> 0.1 + 0.1 + 0.1
0.30000000000000004
  1. Many floating point numbers do not make sense as a value of money. These values include NaN (0/0), Infinity (1/0), -Infinity (-1/0).

  2. Calculations can silently overflow or underflow and sometimes create such values:

λ> 8.98846567431158e307 * 2
Infinity
λ> 0.5 ^ 1075
0.0
  1. Floating point numbers do not support minimal quantisations. You can represent values that are more granular:

λ> 0.01 / 2
5.0e-3

Money as a fixed-size integral amount of minimal quantisations

Storing amounts of money as an integral number of minimal quantisations makes sense. The only problem you will have with using fixed-size integrals with it, is that you have to deal with overflow:

λ> (9223372036854775807 :: Int64) + 1
-9223372036854775808

This problem is fixable using overflow checks.

Money as an arbitrary-size integral amount of minimal quantisations

Storing amounts of money as an arbitrarily-sized integral number of minimal quantisations also makes sense because you don't have the overflow problem. Unfortunately this representation has the unfixable problem of unbounded resource requirements.

Prelude Data.Ratio> (1 % 2)^1000
1 % 10715086071862673209484250490600018105614048117055336074437503
883703510511249361224931983788156958581275946729175531468251871452
856923140435984577574698574803934567774824230985421074605062371141
877954182153046474983581941267398767559165543946077062914571196477
686542167660429831652624386837205668069376

Money as a fixed-size rational amount

You could store an amount of money as two numbers $a$ and $b$ such that they represent $a / b$.

If these numbers $a$ and $b$ are stored as finitely-sized integers, then you run into the following problems:

  1. Overflow and underflow:

Prelude Data.Int Data.Ratio> let r = 1 % 12 :: Ratio Int8
Prelude Data.Int Data.Ratio> r + r
3 % (-14)
> r * r
1 % (-112)
  1. Representing nonsense values of money

If $b$ are zero, $a/b$ does not represent a valid amount of money. All three of $1/0$, $0/0$, and $-1/0$ are nonsensical as amounts of money.

  1. Minimal quantisations

Rational values allow us to represent amounts of money that are more granular than the minimal quantisation. For example when the minimal quantisation is 0.01 USD, and we have a value where $a=1$ and $b=1000$.

Money as an arbitrary-size rational amount

If instead you use arbitrarily-sized numbers to represent $a$ and $b$, then computations using numbers like these can take unbounded amounts of time and space.

For example, consider the operation of halving an amount of money. This corresponds to doubling the $b$ in this representation. Performing this operation indefinitely involves an unbounded amount of time and memory, because every time the operation is performed, at least one more bit is needed to store the result.

Prelude Data.Ratio> (1 % 2 )^1000
1 % 10715086071862673209484250490600018105614048117055336074437503883703510511249361224931983788156958581275946729175531468251871452856923140435984577574698574803934567774824230985421074605062371141877954182153046474983581941267398767559165543946077062914571196477686542167660429831652624386837205668069376

This representation also suffers from the same two problems as the previous option:

  1. Minimal quantisations

  2. Representing nonsense values of money

Overview

Given the above problems and possible representations, here is an overview of the tradeoffs

  1. IEEE754 Floating point numbers such as Float and Double

  2. Fixed-size integral numbers of minimal quantisations such as Int64

  3. Arbitrarily-sized integral numbers of minimal quantisations such as Integer

  4. Fixed-size rational numbers of minimal quantisations such as Ratio Int64

  5. Arbitrarily-sized rational numbers of minimal quantisations such as Ratio Integer

Double Int64 Integer Ratio Int64 Ratio Integer
Creating or destroying money through errors 💣 👌 👌 👌 👌
Silent Overflow and Underflow 💣 💣 👌 💣 👌
Representing nonsense values of money 💣 👌 👌 💣 💣
Unbounded space and time usage 👌 👌 💣 👌 💣
Minimal quantisations 💣 👌 👌 💣 💣

In what follows, we will recommend option using 2: Int64 and dealing with the overflow and underflow problems in a library.

Solutions

Requirements

In order to begin talking about solutions, let us clarify what needs to be possible in the system that we produce:

Addition

  1. We must be able to add up amounts of money.

This is useful to keep a running balance, for example.

The additions must happen in such a way that the representation of the sum equals the sum of the representations. Otherwise we might create or destroy money via addition.

For example, 0.1 + 0.1 + 0.1 must somehow equal 0.3 and not 0.30000000000000004.

Integral Multiplication

  1. We must be able to multiply amounts of money by an integer.

This is useful to find out how much multiple units of an item would cost, in the case of integral multiples.

You will notice that if you can solve the problem of addition, then you have also solved the problem of correct integral multiplication. However, ideally, an integral multiplication would take up the same amount of time as a single addition, not a linear multiple of it. We would like to have multiplication be efficient as well as correct.

For example, 3.33 x 3 must equal 9.99 and not 10.

Distribution into an integer number of chunks

  1. We must be able to distribute an amount into an integer number of chunks.

This is useful for dividing a payment into installments. Here it is important that the sum of the installments equals the amount that was divided. (Note that implementing integer division will always result in surprising (dangerous) behaviour, so we should use distribution instead.)

For example, 10 / 3 must be divided into three times 3.33 and an extra 0.01 somewhere: 3.34 + 3.33 + 3.33.

Fractional Multiplication

  1. We must be able to multiply amounts of money by a fraction.

This is useful for calculating interest rates, taxes and dividends, for example.

It should happen in such a way that the representation of the product equals the product of the representations. Failing that (because we'll see that this is impossible), it should at least not create or destroy money.

For example, 0.1 times 0.11 USD, using a minimal quantisation of 0.01 USD, should result in 0.01 USD. The representational error which mathematically equals 0.001 should be dealt with somehow.

Accurate Accounting

  1. We must be able to accurately account for every smallest unit of currency.

When earning or spending money, we must be able to account for every smallest unit of currency in play. No such units be created or destroyed, and no amounts smaller than the smallest unit may be accounted for.

Note that you can choose a different minimal quantisation of currency in your accounting system than is used for bank transactions. Even if banks use 0.01 CHF as a minimal transaction value, you can still use 0.0001 CHF as a minimal quantisation internally. The only real constraints are correctness: you do not create or destroy money and storage size. Dealing with more precise numbers requires more storage.

When you choose a different minimal quantisation of currency internally than the one you show to users, you have to somehow make that conversion. When you do, it is important that you don't add any mistakes back in.

Granular pricing

  1. We must be able to make calculations with values smaller than the smallest unit of currency.

The utility of this requirement may not be obvious. However, when dealing with an item of large volume and cost such as oil, we want to be able to reason about (and compete on) the (average) price of a small amount of it. In such a case it may be necessary to reason about currency in units smaller than its minimal quantisation.

For example, if I want to rent out digital storage for 0.000023 USD per kilobyte per month, that rate is like expressed in a more precise granularity then your system deals with. In that case we must still be able to rent out 2'000'000 kilobyte for a month for 46.00 USD.

Serialisations

  1. We must be able to losslessly send amounts of money between programs.

In a standard frontend/backend split, the backend must be able to send over information about amounts of money to the frontend and vice versa. This communication must be lossless, so that no miscommunications happen.

In other words, we need this law to hold:

decode(encode(amount)) == Success(amount)

Units

  1. We must not be able to perform dimensionally incorrect calculations.

An amount of money is qualitatively different from a number because it has a unit. When multiplying two amounts of money together, the result is not an amount of money but an amount of money squared. That means that we must not be able to produce an amount of money by multiplying together two amounts of money.

Implementation

The rest of this post will details instructions for how to implement a library that can satisfy the above requirements.

Representation

Looking at the overview table of representations and their problems, we will use fixed-size integer numbers as an underlying representation, and check for overflows.

This reduces the representation issues to none, as long as the fixed size of the integers is big enough. A library author can choose to provide separate types for "only positive" and "positive or negative" amounts if they want.

We use Int64 or Word64 for just about any currency because these are almost always big enough. Indeed, maxBound :: Int64 is 9223372036854775807. When choosing 0.0001 USD as the minimal quantisation of USD, this allows us to represent a quadrillion dollars. This is more than the current M1 money supply of USD.

Addition, subtraction, and integer multiplication

Addition and subtraction of amounts of money is relatively simple. The only thing we have to watch out for is overflow. This means that these operations must be able to fail loudly. We can do this naively by converting to arbitrarily sized integers, performing the computation, and checking for overflow before converting back.

Multiplication can be implemented naively using iterated addition, but this would not be a constant-time operation. Instead, we implement multiplication in the same way using conversion, computation, checking and conversion back.

Integer distribution

When distributing an amount of money into an integer number of chunks, we also have the problem of remainders. The remainder has to be distributed somehow. For example, when distributing 10 into 3 chunks, we have 1 chunk of 4 and 2 chunks of 3.

We could return a list of chunks, but then the result would not be a constant-size data structure. Instead we must return a special data structure that specifies the number of chunks and their sizes. This is a constant-size data structure because we can always distribute the amount into at most two differently sized chunks.

The procedure to compute the result works using integer division:

  1. Divide the total amount by the number of chunks to get the size of the smaller chunks.

  2. The remainder amount of minimal quantisations equals the number of larger chunks.

  3. The number of smaller chunks is the number of chunks minus the number of larger chunks.

  4. The size of the larger chunks is one minimal quantisation more than the size of the smaller chunks.

Fractional multiplication, accurate accounting and granular pricing

As long as we only perform addition and integer multiplication, the accurate accounting requirement is already fulfilled. It also turns out that if we can solve fractional multiplication in an accurate way, that we can get granular pricing for free.

The big issue with fractional multiplication is easily illustrated with currency conversions. For example, as of the time of writing, the conversion rate from EUR to CHF is 1 EUR = 1.072032 CHF. At a first glance this really does not make sense because 1.072032 CHF really does not mean anything if we choose a minimal quantisation greater than 0.0000001 CHF. (Note that there will always be rates that don't make sense for your minimal quantisation, so you have to deal with this problem.) There exists 1.05 CHF and 1.10 CHF but there can never be 1.072032 CHF using this minimal quantisation. However, you never really convert exactly one EUR either. This is the important piece of info that will allow us to solve the problem.

You cannot simply multiply 1 EUR by 1.072032 CHF/EUR to get a sensible value in CHF. Instead, a bit more infrastructure is needed. The way you should interpret such a ratio is a bit different from your intuition.

To exchange 10 000 EUR to CHF, we first make a hypothetical calculation of what would happen if currencies had no minimal quantisation:

10 000.00 EUR * 1.072032 CHF / EUR =  10 720.32 CHF

Next, we choose the closest meaningful value to the result. Recall that we chose a minimal quantisation of 0.05 CHF, so the closest meaningful value is 10 720.30 CHF. We represent this as the integer 214406 because 214406 * 0.05 CHF = 10 720.30 CHF.

You now change the rate that you give so that this matches nicely, and represent the rate as an integral fraction:

214 406 % 10 000 00 = 107 203 % 5 000 00

We represent only the conversion rate as a fraction, not amounts of money. Note that you have to represent this as two numbers in memory, because there is no guarantee that the result would not have unrepresentable repeating digits. Note also that the original rate only differs from this rate by 0.000002, and you can charge a higher rate if this is a problem for you. The 'error' also shrinks as transactions get bigger and the absolute (theoretical) error is never bigger than your chosen minimal quantisation so this is really no big deal in practice.

You can then safely use this rate to accurately convert 10 000.00 EUR to 10 720.32 CHF.

This means that the result of the fraction computation must be a tuple of the resulting amount and the real ratio that was used. We then compute these as follows:

  1. Compute the theoretical result as an arbitrary-precision fraction.

  2. Compute the rounded results as a number of minimal quantisations. (You can round in whichever direction is most appropriate for your application.)

  3. Compute the actual rate by dividing the rounded result by the input amount.

  4. Return the rounded result together with the actual rate.

Showing amounts to users

When you chose amounts to users, we need to make sure that they make sense. It should not look like we made any errors.

By far the simplest way to deal with this problem is to show amounts to users at the same level of granularity as we are storing them. Otherwise you may show incorrectly rounded values to users. You can still give users the option to show amounts in a more conventional way if they do not really care about these apparent mistakes.

Serialisation

You are probably (rightfully) expecting that round-tripping numbers "should" be easy. Sadly, when using JSON numbers, they do not roundtrip when parsed by JavaScript's standard JSON.parse function:

> JSON.parse("1234567891011121314")
1234567891011121400

(Note that this number is smaller than the maximum value of a Word64.)

While JSON does support representing arbitrarily large integer numbers accurately, many implementations use floating point numbers to parse them. This means that we cannot use JSON numbers to represent serialised amounts of money and instead are forced to use a different representation.

The least surprising safe representation will be JSON String's that represent numbers that represent an integral number of minimal quantisations. When using JavaScript, this string must then be passed to a BigInt constructor:

> BigInt(JSON.parse("\"1234567891011121314\""))
1234567891011121314n

Other formats will likely support either precise integers or strings as well, so we can choose the appropriate serialisation based on what is available.

Units

We must use the tools that the programming language provides to ensure that dimensionally incorrect computations cannot occur.

For example, addition like Amount(5) + Amount(6) should result in an Amount(11), but multiplication must not. Indeed, the following computation must fail, either at compile-time or at runtime (at least if it would result in an Amount):

Amount(5) * Amount(6)

We must also use the strongest possible tools to ensure that these errors do not occur. In some languages like JavaScript and Python, that means using runtime checks because that's all there is. In Haskell we could simply not provide an instance of Num for Amount, but we must go further still by poisoning that instance entirely and turning it into a type error.

instance TypeError ('Text "Amounts of money must not be an instance of Num") => Num Amount

Library recommendations

In Haskell I can only recommend the really-safe-money package. The comparison table in the README explains why. In short: every other library that I investigated uses a representation with a serious unfixable flaw and most have dangerous instances.

Conclusions

It is possible to deal with currency accurately, but it is not easy. Listen to experts on this matter, and test your implementation well. You can use property-based testing, and validity-based testing in particular, to help you find problems with your implementation.

Addendum: Tests

This section aims to describe examples that any implementation must handle correctly. We use pseudo code here, so some interpretation is required.

  • Representation and conversions

    • Turning an integer amount of minimal quantisations (cents, for example), must be able to fail.

      • It must succeed for:

        • 0

        • 1 minimal quantisation

        • -1 minimal quantisation

        • The maximal amount of minimal quantisations that your representation allows

          • On the positive end

          • On the negative end (that is usually not the same in absolute amount)

        • -0.0, but that should be normalised to 0 or disallowed by type

      • It must fail (either at runtime or by type) for:

        • Any fractional amount of minimal quantisations

        • NaN

        • +Infinity

        • -Infinity

    • For any amount of money, turning it into an integer amount of minimal quantisations and back into an amount of money should succeed and result in the same amount of money.

      toMoney(fromMoney(money)) == money
      

      Note that the same does not hold for floating point amounts of minimal quantisations. Indeed, multiplying by the quantisation factor is a calculation that can have floating-point calculation errors as well.

    • The inverse property must not hold, see the list of prescribed failures above.

    • Turning a floating point or rational amount into an amount of money should fail whenever it introduces an error.

  • Addition

    • Addition of two amounts must be able to fail.

      • It must succeed for:

        • 1 + 2, and equal 3

        • 0 + money for any money (and equal money).

        • money + 0 for any money (and equal money).

      • It must fail for:

        • maxBound + 1

        • maxBound + maxBound

        • minBound + minBound

        • minBound + (- 1)

    • Adding two amounts in different currencies must always fail.

    • It must be associative if both sides succeed: a + (b + c) == (a + b) + c Note that either side might fail separately because of overflow.

    • It must be commutative: a + b == b + a.

  • Subtraction

    • Subtraction of two amounts must be able to fail.

      • It must succeed for:

        • 3 - 2, and equal 1.

        • 0 - money for any money (and equal - money).

        • money - 0 for any money (and equal money).

      • It must fail for:

        • minBound - 1

        • maxBound - minBound

        • minBound - maxBound

        • maxBound - (- 1))

  • Summation

    • Summation must succeed for:

      • sum [1, 2, 3] == 6

    • Summation must fail for:

      • [maxBound, 1]

      • [maxBound, 1, -2] (in order to maintain bounded complexities per element).

  • Integer multiplication

    • It must succeed for:

      • 3 x 6, and equal 18

      • 1 x money, and equal money

      • 0 x money, and equal a zero amount

    • It must fail for:

      • 2 x maxBound

      • 3 x minBound

    • It must be distributive when both sides succeed: a x (b + c) == (a x b) + (a x c).

  • Integer distribution

    • It distributes 3 into 3 as 3 chunks of 1.

    • It distributes 5 into 3 as 2 chunks of 2 and 1 chunk of 1.

    • It distributes 10 into 4 as 2 chunks of 3 and 2 chunk of 2.

    • It distributes any amount into chunks that sum up to the input amount successfully.

  • Fractional multiplication

    • 100 minimal quantisations times 1/100 must equal 1 minimal quantisation.

    • 101 minimal quantisations times 1/100 must equal 1 minimal quantisation with the rate changed to 1/101.

    • It must produce results that can be multiplied back to the input amount successfully.

Previous
Automate your feedback loops using feedback

Looking for a lead engineer?

Hire me
Next
Announcing safe-coloured-text 0.2.0.0 with a quick primer on character encodings