Skip to content

sernamar/dinero

Repository files navigation

dinero

https://img.shields.io/clojars/v/io.github.sernamar/dinero.svg

dinero is a Clojure library designed for managing monetary amounts and currencies. It offers support for precise arithmetic operations, formatting, and parsing, all tailored to different locales and currencies.

This library supports two types of monetary amounts:

  • money: represents monetary amounts with arbitrary precision and scale, ensuring accurate numerical computations during arithmetic operations.
  • rounded money: represents monetary amounts that are automatically rounded after each arithmetic operation, ensuring consistent precision.

It also offers functionality to format monetary amounts and parse strings representing monetary amounts. It takes care of different formats and currencies based on the user’s locale settings, ensuring accurate and locale-sensitive representations.

In terms of currencies, it supports all ISO 4217 currencies by default, allowing for seamless handling of all internationally recognized currencies like the Euro (EUR) and British Pound (GBP). You can also extend support to non-ISO currencies, such as cryptocurrencies like Bitcoin (BTC), or even custom currencies specific to your domain, through an EDN configuration file.

Table of contents

Installation

Clojure CLI/deps.edn:

io.github.sernamar/dinero {:mvn/version "0.1.2"}

Leiningen/Boot:

[io.github.sernamar/dinero "0.1.2"]

Configuration

Before using this library, you can configure the default currency and default rounding mode using a configuration file named config.edn, which should be located in the root directory of the project.

For example, to set euros as the default currency and the half even method as the default rounding method, the config.edn file should have the following content:

{:default-currency :eur
 :default-rounding-mode :half-even}

Usage

Monetary amounts

This library support two types of monetary amounts:

  • money: represents monetary amounts with arbitrary precision and scale, ensuring accurate numerical computations during arithmetic operations.
  • rounded money: represents monetary amounts that are automatically rounded after each arithmetic operation.

Monetary amounts of type money

You can use money-of to create monetary amounts with arbitrary precision and scale. It accepts two arguments:

  • amount: can be either an integer, a floating-point number, a BigDecimal or a String.
  • currency: must be a keyword.

If the currency is not specified, the money-of function will use the default currency set in the configuration file. If a currency is provided, it will override the default and use the specified currency instead:

(require '[dinero.core :as dinero])

(dinero/money-of 1)
;; => {:amount 1M, :currency :eur}

(dinero/money-of 1 :gbp)
;; => {:amount 1M, :currency :gbp}

For convenience, you can use the with-currency macro to bind the default currency in the macro’s body. This way, if the default currency isn’t set in the configuration file, you won’t need to specify the currency when creating monetary amounts:

(dinero/with-currency :gbp
  (dinero/money-of 1))
;; => {:amount 1M, :currency :gbp}

If none the amount nor the currency is specified, it uses 0 as the amount, and the default currency as the currency:

(dinero/money-of)
;; => {:amount 0M, :currency :eur}

Although you can use either an integer, a floating-point number, a BigDecimal or a String as the amount argument, they are all parsed and stored as BigDecimals:

(dinero/money-of 1 :eur)
;; => {:amount 1M, :currency :eur}

(dinero/money-of 1.23 :eur)
;; => {:amount 1.23M, :currency :eur}

(dinero/money-of 1.23M :eur)
;; => {:amount 1.23M, :currency :eur}

(dinero/money-of "1.23" :eur)
;; => {:amount 1.23M, :currency :eur}

WARNING:

Be aware that using floating-point numbers can lead to rounding errors in certain calculations, as Clojure reads them as Doubles, which are 64-bit IEEE 754 floating point numbers. This means you get double precision, with about 15-17 significant decimal digits of precision.

So for accurate numerical computations, especially in financial applications, don’t use floating-point numbers; use BigDecimals or Strings instead, as they will be parsed accurately.

;;; Not safe, as it uses a floating-point number, losing precision when parsing
(dinero/money-of 0.123456789123456789123456789 :eur)
;; => {:amount 0.12345678912345678M, :currency :eur}

;;; Safe, as it uses BigDecimal or String
(dinero/money-of 0.123456789123456789123456789M :eur)
;; => {:amount 0.123456789123456789123456789M, :currency :eur}
(dinero/money-of "0.123456789123456789123456789" :eur)
;; => {:amount 0.123456789123456789123456789M, :currency :eur}
  

Monetary amounts of type rounded money

You can use rounded-money-of to create monetary amounts that are automatically rounded after each arithmetic operation. This function accepts up to 4 arguments:

  • amount: can be an integer, a floating-point number, a BigDecimal or a String.
  • currency: must be a keyword.
  • scale: the number of decimal places to which the amount will be rounded.
  • rounding-mode: the rounding mode to use when rounding the amount.

If currency is not specified, the rounded-money-of function will use the default currency from the configuration file. If scale is not provided, the minor units of the given currency will be used. If rounding-mode is not specified, the default rounding mode will be applied (or :half-even if the default rounding mode is not set in the configuration file):

(dinero/rounded-money-of 1234.5678 :eur)
;; => {:amount 1234.57M, :currency :eur, :scale 2, :rounding-mode :half-even}

(dinero/rounded-money-of 1234.5678 :eur 0)
;; => {:amount 1235M, :currency :eur, :scale 0, :rounding-mode :half-even}

(dinero/rounded-money-of 1234.5678 :eur 0 :down)
;; => {:amount 1234M, :currency :eur, :scale 0, :rounding-mode :down}

Amount, currency, and rounding information

Given a monetary amount, you can get its amount and currency using the get-amount and get-currency functions:

(let [money (dinero/money-of 1 :eur)]
  (dinero/get-amount money))
;; => 1M
(let [money (dinero/money-of 1 :eur)]
  (dinero/get-currency money))
;; => :eur

For rounded monetary amounts, you can also use the get-scale and get-rounding-mode functions to retrieve the scale and rounding mode applied during the rounding process:

(let [money (dinero/rounded-money-of 1 :eur)]
  (dinero/get-scale money))
;; => 2

(let [money (dinero/rounded-money-of 1 :eur)]
  (dinero/get-rounding-mode money))
;; => :half-even

Currencies

This library supports all ISO 4217 currencies by default, providing seamless handling of all internationally recognized currencies like the Euro (EUR) and British Pound (GBP). Additionally, you can extend support to non-ISO currencies, such as cryptocurrencies like Bitcoin (BTC), or even custom currencies specific to your domain, by editing the resources/currencies.edn file. The format for defining currencies is as follows:

{:eur {:type :iso-4217, :currency-code "EUR", :minor-units 2},
 :gbp {:type :iso-4217, :currency-code "GBP", :minor-units 2},
 :btc {:type :crypto, :currency-code "BTC", :symbol "", :minor-units 8}}

For ISO 4217 currencies, the :symbol key should not be used, as the library automatically relies on the symbol defined by the locale. For example, the British Pound (GBP) is represented as £ in java.util.Locale/UK, while in java.util.Locale/FRANCE, it appears as £GB. This ensures that the correct symbol is displayed based on the user’s locale settings.

For non-ISO currencies, such as Bitcoin, the :symbol key is required because they are not supported by java.util.Locale. Since their symbols are not locale-specific, we define a single symbol in the resources/currencies.edn file, which is used consistently across all locales.

This approach provides flexibility in handling both standardized and custom currencies, allowing your application to adapt to a wide range of monetary systems.

Formatting

As already mentioned, monetary amounts could be stored internally with more decimal places than the smallest unit of the currency. Although this may be important for accurate numerical computations, you might be interested in displaying amounts in a user-friendly format.

To display monetary amounts in a user-friendly format, you can use the format-money function. This function will convert the internal representation of the monetary amount into a string with a more readable format.

The format-money function accepts a map of configuration options as its second argument. The available options are:

  • locale
  • rounding-mode
  • decimal-places
  • symbol-style: accepts either :symbol (default) or :code.

For example:

(require '[dinero.core :as dinero]
         '[dinero.format :as format])

(let [m1 (dinero/money-of 1234.5678 :eur)
      germany java.util.Locale/GERMANY]
  (println (format/format-money m1 {:locale germany}))
  (println (format/format-money m1 {:locale germany :symbol-style :code}))
  (println (format/format-money m1 {:locale germany :rounding-mode :down :symbol-style :code}))
  (println (format/format-money m1 {:locale germany :rounding-mode :down :decimal-places 0 :symbol-style :code})))
;; 1.234,57 €
;; 1.234,57 EUR
;; 1.234,56 EUR
;; 1.234 EUR

You can also use the format-money-with-pattern function, which uses the given formatting pattern to format the monetary amount. This function also accepts a map of configuration options as its third argument, supporting these options:

  • locale
  • rounding-mode

For example:

(let [m1 (dinero/money-of 1234.5678 :eur)
      germany java.util.Locale/GERMANY]
  (println (format/format-money-with-pattern m1 "#,##0.00 ¤" {:locale germany}))
  (println (format/format-money-with-pattern m1 "#,##0.00 ¤¤" {:locale germany}))
  (println (format/format-money-with-pattern m1 "#,##0.00 euros" {:locale germany}))
  (println (format/format-money-with-pattern m1 "#,##0.000 ¤" {:locale germany}))
  (println (format/format-money-with-pattern m1 "#,##0 ¤" {:locale germany}))
  (println (format/format-money-with-pattern m1 "#,##0 ¤" {:locale germany :rounding-mode :down})))
;; 1.234,57 €
;; 1.234,57 EUR
;; 1.234,57 euros
;; 1.234,568 €
;; 1.235 €
;; 1.234 €

Parsing

This library supports parsing strings with both ISO 4217 currencies (e.g., Euro) and non-ISO 4217 currencies (e.g., Bitcoin), whether they use currency symbols (e.g., or ) or currency codes (e.g., EUR or BTC).

To parse a string representing a monetary amount, use the parse-string function, which accepts a map of configuration options as its second argument. The available options are:

  • :locale: a java.util.Locale object used for parsing. If NIL, the default locale is used.
  • :currencies: a sequence of currencies to attempt during parsing. If NIL, it defaults to either the configured currency or the locale’s default currency.
  • :try-all-currencies?: a boolean flag. If TRUE, the function will attempt to parse the string using all currencies available in resources/currencies.edn if the provided currencies fail. Defaults to FALSE.
(require '[dinero.parse :as parse])

(parse/parse-string "1.234,56 €" {:locale java.util.Locale/GERMANY})
;; => {:amount 1234.56M, :currency :eur}

(parse/parse-string "1.234,56 EUR" {:locale java.util.Locale/GERMANY})
;; => {:amount 1234.56M, :currency :eur}

(parse/parse-string "1.234,56 £" {:locale java.util.Locale/GERMANY :currencies [:eur :gbp]})
;; => {:amount 1234.56M, :currency :gbp}

(parse/parse-string "1.234,56 GBP" {:locale java.util.Locale/GERMANY :currencies [:eur :gbp]})
;; => {:amount 1234.56M, :currency :gbp}

(parse/parse-string "1.234,56 £" {:locale java.util.Locale/GERMANY :try-all-currencies? true})
;; => {:amount 1234.56M, :currency :gbp}

(parse/parse-string "1.234,56 ₿" {:locale java.util.Locale/GERMANY :currencies [:btc]})
;; => {:amount 1234.56M, :currency :btc}

(parse/parse-string "1.234,56 BTC" {:locale java.util.Locale/GERMANY :currencies [:btc]})
;; => {:amount 1234.56M, :currency :btc}

(parse/parse-string "1.234,56 ₿" {:locale java.util.Locale/GERMANY :try-all-currencies? true})
;; => {:amount 1234.56M, :currency :btc}

(parse/parse-string "1.234,56 ₿" {:locale java.util.Locale/GERMANY :currencies [:eur :gbp] :try-all-currencies? true})
;; => {:amount 1234.56M, :currency :btc}

The parse-string function is capable of differentiating between the same currency symbol used in different locales. For example, the dollar sign ($) represents both US dollars (USD) and Canadian dollars (CAD), depending on the locale:

(parse/parse-string "$1,234.56" {:locale java.util.Locale/US})
;; => {:amount 1234.56M, :currency :usd}

(parse/parse-string "$1,234.56" {:locale java.util.Locale/CANADA})
;; => {:amount 1234.56M, :currency :cad}

If parse-string cannot recognize the format or the currency in the string, it throws a java.text.ParseException:

;; unrecognized format for java.util.Locale/GERMANY
(parse/parse-string "1,234.56 €" {:locale java.util.Locale/GERMANY})
;; Unhandled java.text.ParseException
;; Unparseable number: "1,234.56 €"

;; unrecognized currency for java.util.Locale/GERMANY
(parse/parse-string "1.234,56 £" {:locale java.util.Locale/GERMANY})
;; Unhandled java.text.ParseException
;; Unparseable number: "1.234,56 £"

;; unrecognized currency for any java.util.Locale
(parse/parse-string "1.234,56 ₿" {:locale java.util.Locale/GERMANY})
;; Unhandled java.text.ParseException
;; Unparseable number: "1.234,56 ₿"

Equality and comparison

You could use the following functions to do equality and comparison operations on monetary amounts: =, not=, money<, money<=, money>, money>=, money-zero?, money-pos?, and money-neg?.

For example:

(require '[dinero.core :as dinero])

(let [m1 (dinero/money-of 1 :eur)
      m2 (dinero/money-of 1 :eur)]
  (= m1 m2))
;; => true

(let [m1 (dinero/money-of 1 :eur)
      m2 (dinero/money-of 1 :gbp)]
  (= m1 m2))
  ;; => false

(let [m1 (dinero/money-of 1 :eur)
      m2 (dinero/money-of 2 :eur)]
  (not= m1 m2))
;; => true

(let [m1 (dinero/money-of 1 :eur)
      m2 (dinero/money-of 2 :eur)]
  (dinero/money< m1 m2))
;; => true

(let [m1 (dinero/money-of 1 :eur)
      m2 (dinero/money-of 2 :eur)]
  (dinero/money> m1 m2))
;; => false

(let [money (dinero/money-of 0 :eur)]
  (dinero/money-zero? money))
;; => true

(let [money (dinero/money-of -1 :eur)]
  (dinero/money-pos? money))
;; => false

(let [money (dinero/money-of -1 :eur)]
  (dinero/money-neg? money))
;; => true

Arithmetic operations

You can use add, substract, multiply, and divide to perform arithmetic operations on monetary amounts:

(require '[dinero.core :as dinero])

(let [m1 (dinero/money-of 1 :eur)
      m2 (dinero/money-of 1 :eur)]
  (dinero/add m1 m2))
;; => {:amount 2M, :currency :eur}

(let [m1 (dinero/money-of 1 :eur)
      m2 (dinero/money-of 1 :eur)]
  (dinero/subtract m1 m2))
;; => {:amount 0M, :currency :eur}

(let [money (dinero/money-of 1 :eur)
      factor 2]
  (dinero/multiply money factor))
;; => {:amount 2M, :currency :eur}

(let [money (dinero/money-of 2 :eur)
      divisor 2]
  (dinero/divide money divisor))
;; => {:amount 1M, :currency :eur}

Note that add and substract can be used to add and substract more than two monetary amounts:

(let [m1 (dinero/money-of 1 :eur)
      m2 (dinero/money-of 2 :eur)
      m3 (dinero/money-of 3 :eur)]
  (dinero/add m1 m2 m3))
;; => {:amount 6M, :currency :eur}

(let [m1 (dinero/money-of 3 :eur)
      m2 (dinero/money-of 2 :eur)
      m3 (dinero/money-of 1 :eur)]
  (dinero/subtract m1 m2 m3))
;; => {:amount 0M, :currency :eur}

Adding or substracting monetary amounts with different currencies throws an ExceptionInfo exception:

(let [m1 (dinero/money-of 1 :eur)
      m2 (dinero/money-of 1 :gbp)]
  (dinero/add m1 m2))
;; clojure.lang.ExceptionInfo
;; Currencies do not match
;; {:currencies (:eur :gbp)}

Rounding

As previously mentioned, money amounts could be stored internally with more decimal places than the smallest unit of the currency. But some applications might require operating with amounts rounded to the smallest unit of currency. In such cases, you can use a monetary amount of type rounded, but you can also use the round function to adjust the monetary amount accordingly.

By default, the round function rounds amounts to the smallest unit of the currency, using the default rounding mode specified in the configuration file (if no rounding mode is configured, it defaults to :half-even):

(require '[dinero.core :as dinero]
         '[dinero.rounding :as rounding])

(let [m1 (dinero/money-of 1.555 :eur)
      m2 (dinero/money-of 1.555 :eur)]
  (dinero/add m1 m2))
;; => {:amount 3.110M, :currency :eur}

(let [m1 (dinero/money-of 1.555 :eur)
      m2 (dinero/money-of 1.555 :eur)
      m1-rounded (rounding/round m1)
      m2-rounded (rounding/round m2)]
  (dinero/add m1-rounded m2-rounded))
;; => {:amount 3.12M, :currency :eur}

But you can also speficy the number of decimal places and the rounding mode you want to use when rounding. For example:

(let [m1 (dinero/money-of 1.555 :eur)
      m2 (dinero/money-of 1.555 :eur)
      m1-rounded (rounding/round m1 0 :half-even)
      m2-rounded (rounding/round m2 0 :half-even)]
  (dinero/add m1-rounded m2-rounded))
;; => {:amount 4M, :currency :eur}

If necessary, you can also call round with two arguments, which are the monetary amount and a custom rounding funtion to use to round the monetary amount. This allows you to specify different rounding rules for certain cases.

For example, the Swiss Franc (CHF) uses unique rounding rules because the smallest unit of currency in Switzerland is the 5-centime (0.05 CHF) coin. To handle the specific rounding requirements for Swiss Francs, you can use the chf-rounding-fn function, which containins a rounding function tailored to CHF:

(let [money (dinero/money-of 1.024 :chf)]
  (rounding/round money rounding/chf-rounding-fn))
;; => {:amount 1.00M, :currency :chf}

(let [money (dinero/money-of 1.025 :chf)]
  (rounding/round money rounding/chf-rounding-fn))
;; => {:amount 1.05M, :currency :chf}

This approach is also useful when formatting currencies with special rounding requirements. For instance, when formatting Swiss Francs, you might want to round the amount before using the format function to ensure the displayed value matches the currency’s rounding conventions:

(let [money (dinero/money-of 1.025 :chf)]
  (format/format-money money {:locale (java.util.Locale. "de" "CH")}))
;; => "CHF 1.02"

(let [money (dinero/money-of 1.025 :chf)
      rounded-money (rounding/round money rounding/chf-rounding-fn)]
  (format/format-money rounded-money {:locale (java.util.Locale. "de" "CH")}))
;; => "CHF 1.05"

Currency conversion

This library provides several functions to convert monetary amounts between currencies using various sources for exchange rates.

The simplest function is convert-using-exchange-rate, where you provide the exchange rate for the conversion:

(require '[dinero.core :as dinero]
         '[dinero.conversion.core :as conversion])

(let [money (dinero/money-of 1 :eur)]
  (conversion/convert-using-exchange-rate money :gbp 0.8))
;; => {:amount 0.8M, :currency :gbp}

In addition to this, you can use other functions designed for specific use cases, whether you’re retrieving exchange rates from external providers or custom databases.

For example, to perform currency conversion using a database, use convert-using-db. This function requires, besides the monetary amount and target currency for the conversion (and optionally, the date), a database connection along with schema details (such as the table name, fields for the base and target currencies, the exchange rate field, and the date field if needed):

(require '[next.jdbc :as jdbc])

(defonce db (jdbc/get-datasource {:dbtype "h2:mem" :dbname "readme-db"}))
(jdbc/execute-one! db ["CREATE TABLE exchange_rate (from_currency VARCHAR(3), to_currency VARCHAR(3), rate DOUBLE, date DATE)"])
(jdbc/execute-one! db ["INSERT INTO exchange_rate (from_currency, to_currency, rate, date) VALUES ('EUR', 'GBP', 0.80, '2024-09-08')"])

(let [money (dinero/money-of 1 :eur)]
    (conversion/convert-using-db money :gbp db "exchange_rate" "from_currency" "to_currency" "rate"))
;; => {:amount 0.8M, :currency :gbp}

(let [money (dinero/money-of 1 :eur)
      date (java.time.LocalDate/parse "2024-09-08")]
    (conversion/convert-using-db money :gbp date db "exchange_rate" "from_currency" "to_currency" "rate" "date"))
;; => {:amount 0.8M, :currency :gbp}

Additionally, you can retrieve exchange rates from external providers. Currently, the library supports exchange rates from the European Central Bank (ECB) for both current and historical (up to 90 days) data, as well as from Coinbase for both current and historical Bitcoin exchange rates:

(let [money (dinero/money-of 1 :eur)]
  (conversion/convert-using-ecb money :gbp))
;; => {:amount 0.84375M, :currency :gbp}

(let [money (dinero/money-of 1 :eur)
      date (java.time.LocalDate/of 2024 9 11)]
  (conversion/convert-using-ecb money :gbp date))
;; => {:amount 0.84375M, :currency :gbp}

(let [money (dinero/money-of 1 :btc)]
  (conversion/convert-using-coinbase money :eur))
;; => {:amount 52394.05M, :currency :eur}

(let [money (dinero/money-of 1 :btc)
      date (java.time.LocalDate/of 2024 9 11)]
  (conversion/convert-using-coinbase money :eur date))
;; => {:amount 52314.756527447545254192M, :currency :eur}

License

Copyright © 2024 Sergio Navarro

Distributed under the Apache License, Version 2.0.