Handling Money In Elixir
Mix.install([
{:decimal, "~> 2.0"}
])
About Me
Karlo Smid
Zagreb
Elixir Developer @Yolo
Elixir Zagreb Meetup
walking, stand up comedy, Rubik Cube, The Worst Chess Player In The World (TWCPITW)
contact: https://github.com/karlosmid
Context
Richard Pryor earns a Ferrari in Superman 3
In each paycheck half a cent is rounded to the nearest lowest value.
134,505 => 134,50.
That is what Richard collects. 0.5 cents floats around somewhere in the computer.
And that was first smart contract!
False Positives
You can store a price in a floating point variable.
All currencies are subdivided in 1/100th units like US dollar/cents, euro/eurocents.
What about :BTC?
All currencies are subdivided in decimal units (like dinar/fils)
Madagascar MGA, where 1 ariary = 5 iraimbilanja
All currencies are subdivided.
Japanese Yen :JPY
Prices can’t have more precision than the smaller sub-unit of the currency.
AWS t2.small/spot $0.0069/hour
EUR to HRK conversion rate on date 1.1.2023 is 7.53450
How much is 200 HRK? 26.5445616829 :EUR
For any currency you can have a price of 1.
Zimbabwean dollar ZWL
Creating and destroying money through error
Elixir hexdoc for float nicely explains the problem. As we store by IEEE 754 possible infinitive decimal values into finit binary, all float calcuations are aproximation.
0.1 * 0.1
0.1 * 0.1 - 0.01
0.1 + 0.1 + 0.1
0.1 + 0.1 + 0.1 - 0.3
We created money with calculation error out of the thin air.
0.5 ** 1075
And this was an example of destroying money through the error.
Underflow
Money as Integer with Minimal Quantisation - Precision
two_milion_dollars_and_99_cents = 2_000_000.99
us_dolar_precission = 100
as_integer =
(two_milion_dollars_and_99_cents * us_dolar_precission)
|> Kernel.round()
What to do with smaller precisions?
us_dollar_precission = 100
1.23 * 1.23
us_dollar_precission = 100
1.23 * 1.23 * us_dollar_precission
us_dollar_precission = 100
(1.23 * 1.23 * us_dollar_precission)
|> Kernel.round()
How much did we lose? 0.0029 us$
With :BTC currency, 0.0029 is a lot of bitcoins.
Overflow
Elixir limit on MAX INTEGER is your computer memory, there is no language restrictions, like C 64 bit integer.
integer_64_bit = 9_223_372_036_854_775_807
integer_64_bit + 1
So with Elixir, is Overflow an issue?
What about greedy Money?
precision = 10 ** 8
income = 36457.12345678
income_as_integer = Kernel.round(income * precision)
dividend_as_integer = Kernel.round(0.2333333 * precision)
profit_as_integer = dividend_as_integer * income_as_integer
[
profit: String.length(Integer.to_string(profit_as_integer)),
income: String.length(Integer.to_string(income_as_integer)),
dividend: String.length(Integer.to_string(dividend_as_integer))
]
Solution
Decimal
example = Decimal.new("23.456")
[sign: example.sign, coeficient: example.coef, exponent: example.exp]
sign coefficient 10 ^ exponent
Decimal.Context.get()
precision is number of digits in coeficient
Requirements
Addition
Decimal.from_float(0.1)
|> Decimal.add(Decimal.from_float(0.1))
|> Decimal.add(Decimal.from_float(0.1))
Decimal.from_float(0.1) + Decimal.from_float(0.1) + Decimal.from_float(0.1)
Multiplication With Integer
Decimal.mult(Decimal.from_float(0.1), 3)
Division
Decimal.div(10, 3)
Decimal.Context.with(%Decimal.Context{precision: 3}, fn -> Decimal.div(100, 3) end)
Decimal.Context.with(%Decimal.Context{precision: 8}, fn ->
Decimal.div(Decimal.from_float(12_345_678.123456789), 1)
end)
Here we have to agree on precision. What to do with:
precision_28 = Decimal.div(10, 3)
precision_3 = Decimal.Context.with(%Decimal.Context{precision: 3}, fn -> Decimal.div(10, 3) end)
Decimal.sub(precision_28, precision_3)
Set the “precisions in one central point”
"USDC": {
"code": "USDC",
"precision": 5,
"units": {
"USDC": {
"code": "USDC",
"symbol": "",
"name": "USDC",
"shift": 0,
"displayPrecision": 2,
"inputPrecision": 4
}
}
}
Fractional Multiplication
multi = fn -> Decimal.mult("0.1", "0.11") end
precision_28 = multi.()
precision_1 = Decimal.Context.with(%Decimal.Context{precision: 1}, multi)
[coeficient: precision_28.coef, exponent: precision_28.exp]
[coeficient: precision_1.coef, exponent: precision_1.exp]
As for Division, in Fractional Multiplication we have to agree on precision.
What We Do?
How To Move Between Systems?
As Integers, with central (almost) Precisions
"BTC": {
"code": "BTC",
"precision": 8,
"units": {
"BTC": {
"code": "BTC",
"symbol": "₿",
"name": "Bitcoin",
"displayPrecision": 8,
"inputPrecision": 8,
"shift": 0
},
"mBTC": {
"code": "mBTC",
"symbol": "m₿",
"name": "Milli-bitcoin",
"displayPrecision": 4,
"inputPrecision": 5,
"shift": 3
},
"uBTC": {
"code": "uBTC",
"symbol": "μ₿",
"name": "Bits",
"displayPrecision": 2,
"inputPrecision": 2,
"shift": 6
},
"sat": {
"code": "sat",
"symbol": "₿",
"name": "Satoshi",
"displayPrecision": 0,
"inputPrecision": 0,
"shift": 8
}
}
}
Over REST API
Create BTC
from_wire = "100"
Decimal.div(Decimal.new(from_wire), 10 ** 8)
from_database = Decimal.new("0.0000000007")
Decimal.mult(from_database, 10 ** 8)
|> Decimal.to_integer()
from_database = Decimal.new("0.0000000007")
Decimal.mult(from_database, 10 ** 8)
|> Decimal.round(0, :down)
|> Decimal.to_integer()
What happened? Value that is less than precision, is safe in database, but client will see 0 amount.
This is the money that “floats around” in the computer.
Protobuf
message UMoney {
UDecimal amount = 1;
CurrencyCodeValue currency_code = 2;
}
message UDecimal {
uint64 coef = 1;
int32 exp = 2;
}
# @spec truncate(Decimal.t()) :: Decimal.t()
defmodule MoneyInElixir do
@max_coef 2 ** 64
def max_decimal_places(), do: (to_string(@max_coef) |> String.length()) - 1
def truncate(nil), do: nil
def truncate(%{coef: coef} = rate) when coef < @max_coef, do: rate
def truncate(rate) do
Decimal.Context.set(%Decimal.Context{precision: max_decimal_places()})
Decimal.round(rate, max_decimal_places())
end
end
MoneyInElixir.max_decimal_places()
2 ** 64
MoneyInElixir.truncate(Decimal.new("18446744073709551616.1"))
How to move from Memory to Database?
Ecto Schema
schema "accounts" do
field :balance, :decimal
Ecto Migration
def up do
create table(:accounts) do
add :balance, :float
timestamps()
end
Postgres type
numeric with precision 28, same as Decimal defult Context precision value.
Recap
Use Decimal lib for calculations
Agree on currency calculation precision
How to store decimal as integer
Know protobuf limits
Use in Ecto as decimal and float
References
If you want to create your own library
https://cs-syd.eu/posts/2022-08-22-how-to-deal-with-money-in-software
Decimal
https://hexdocs.pm/decimal/Decimal.html#content
Float Problem
https://hexdocs.pm/elixir/1.13/Float.html