Skip to content

crystal-money/money

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

550 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

money CI Releases License

Hey there! πŸ‘‹ Welcome to money, a Crystal shard for handling money and currency conversion, inspired by RubyMoney.

Why Use This Library?

Here’s what you get out of the box:

  • A Money class to represent amounts and their currencies.
  • A flexible Money::Currency class for all your currency info needs.
  • A growing list of 200+ supported currencies (metals, fiat, cryptocurrencies).
  • BigDecimal-based values β€” i.e. no more floating point rounding headaches!
  • Easy APIs for currency exchange.
  • Multiple exchange rate providers (use built-in or roll your own).
  • Comprehensive support for formatting and parsing money values.
  • Mathematical operations on Money objects:
    • Arithmetic: addition, subtraction, multiplication, division, etc.
    • Rounding and truncation helpers.
    • Allocation and splitting.
  • Money ranges.
  • JSON/YAML serialization and deserialization support.
  • Extensible architecture of exchange rate providers and stores.

Installation

Add this to your application's shard.yml:

dependencies:
  money:
    github: crystal-money/money

Then run:

shards install

And require it in your project:

require "money"

Tip

If you wish to use YAML serialization, remember to require "yaml" before requiring money.

Quick Examples

Creating Money

Note

Money.new first positional argument will treat the given value as the fractional if it's an integer, and the amount otherwise.

money = Money.new(10_00, "USD")
money.amount        # => 10.0
money.fractional    # => 1000.0
money.currency.code # => "USD"

From fractional amount

Money.from_fractional(10_00.0, "USD")
Money.from_fractional(10_00, "USD")

Money.new(fractional: 10_00.0, currency: "USD")
Money.new(fractional: 10_00, currency: "USD")

Money.new(10_00, "USD")

From whole amount

Money.from_amount(10.0, "USD")
Money.from_amount(10, "USD")

Money.new(amount: 10.0, currency: "USD")
Money.new(amount: 10, currency: "USD")

Money.new(10.0, "USD")

Comparing Money

Note

Performs currency conversion if necessary.

Money.default_exchange.rate_store["EUR", "USD"] = 1

Money.new(11_00, "USD") < Money.new(33_00, "USD") # => true
Money.new(11_00, "USD") > Money.new(33_00, "EUR") # => false

Strict Comparison (== / !=)

Note

Does not perform currency conversion.

Money.new(11_00, "USD") == Money.new(11_00, "USD") # => true
Money.new(11_00, "USD") == Money.new(11_00, "EUR") # => false

Loose Comparison (=~ / !~)

Note

Performs currency conversion if necessary.

Money.new(11_00, "USD") =~ Money.new(11_00, "USD") # => true
Money.new(11_00, "USD") =~ Money.new(11_00, "EUR") # => true

Caution

Two Money objects with 0 amount are considered equal, regardless of their currency.

Money.zero("USD") =~ Money.zero("EUR") # => true

Arithmetic

Note

Performs currency conversion if necessary.

Money.new(10_00, "USD") + Money.new(5_00, "USD") # => Money(@amount=15.0, @currency="USD")
Money.new(22_00, "USD") - Money.new(2_00, "USD") # => Money(@amount=20.0, @currency="USD")
Money.new(22_00, "USD") / 2                      # => Money(@amount=11.0, @currency="USD")
Money.new(11_00, "USD") * 5                      # => Money(@amount=55.0, @currency="USD")

Unit/Subunit Conversions

Money.from_amount(5, "USD").fractional # => 500.0
Money.from_amount(5, "JPY").fractional # => 5.0
Money.from_amount(5, "TND").fractional # => 5000.0

Currency Conversion

In order to perform currency exchange, you need to set up a Money::Currency::Exchange::RateProvider or add the rates manually:

Money.default_exchange.rate_store["USD", "EUR"] = 1.24515
Money.default_exchange.rate_store["EUR", "USD"] = 0.80311

Then you can perform the exchange:

Money.new(1_00, "USD").exchange_to("EUR") # => Money(@amount=1.24, @currency="EUR")
Money.new(1_00, "EUR").exchange_to("USD") # => Money(@amount=0.8, @currency="USD")

Comparison and arithmetic operations work as expected:

Money.new(10_00, "EUR") =~ Money.new(10_00, "USD") # => false
Money.new(10_00, "EUR") + Money.new(10_00, "USD")  # => Money(@amount=22.45, @currency="EUR")

Formatting

Money.new(1_00, "USD").format # => "$1.00"
Money.new(1_00, "EUR").format # => "€1.00"
Money.new(1_00, "PLN").format # => "1,00 zΕ‚"

Money Ranges

range = Money.new(1_00, "USD")..Money.new(3_00, "USD")
range.to_a(&.format)
# => ["$1.00", "$1.01", "$1.02", ..., "$2.99", "$3.00"]

Steppable Ranges

range = Money.new(1_00, "USD")..Money.new(3_00, "USD")
range
  .step(by: Money.new(1_00, "USD"))
  .to_a(&.format)
# => ["$1.00", "$2.00", "$3.00"]

Clamping

Money.new(10_00, "USD").clamp(
  min: Money.new(1_00, "USD"),
  max: Money.new(9_00, "USD"),
) # => Money(@amount=9.0, @currency="USD")

# or

Money.new(10_00, "USD").clamp(
  Money.new(1_00, "USD")..Money.new(9_00, "USD"),
) # => Money(@amount=9.0, @currency="USD")

Infinite Precision

By default, Money objects are rounded to the nearest cent and the extra precision is not preserved:

Money.new(2.34567, "USD").format # => "$2.35"

If you want to keep all the digits, you can enable infinite precision globally:

Money.infinite_precision = true
Money.new(2.34567, "USD").format # => "$2.34567"

Or use the block-scoped Money.with_infinite_precision:

Money.with_infinite_precision do
  Money.new(2.34567, "USD").format # => "$2.34567"
end

Currencies

A Money::Currency instance holds all the info about the currency:

currency = Money::Currency.find("USD")
currency.code         # => "USD"
currency.name         # => "United States Dollar"
currency.symbol       # => "$"
currency.fiat?        # => true
currency.historical?  # => false

Most APIs let you use a String, Symbol, or a Money::Currency:

# All of the following are equivalent:

Money.default_currency = Money::Currency.find("CAD")
Money.default_currency = "CAD"
Money.default_currency = :cad

Currency Lookup

Money::Currency.find and Money::Currency.[] methods let you find a currency by its code:

Money::Currency.find("USD") # => #<Money::Currency @code="USD">
Money::Currency[:usd]       # => #<Money::Currency @code="USD">
Money::Currency[:foo]       # raises Money::Currency::NotFoundError

There are also Money::Currency.find? and Money::Currency.[]? non-raising methods:

Money::Currency.find?("USD") # => #<Money::Currency @code="USD">
Money::Currency[:usd]?       # => #<Money::Currency @code="USD">
Money::Currency[:foo]?       # => nil

Currency Enumeration

Tip

Money::Currency class implements Enumerable module, so you can use all of its methods like each, map, find, select, etc.

For example, to find a currency by ISO 4217 numeric code:

Money::Currency.find(&.iso_numeric.==(978)) # => #<Money::Currency @code="EUR">

Or to select all the ISO currencies:

Money::Currency.select(&.iso?)
# => [#<Money::Currency @code="USD">, #<Money::Currency @code="EUR">, ...]

In addition, there are Money::Currency.metal, Money::Currency.fiat and Money::Currency.crypto methods to get all the currencies of a particular type:

Money::Currency.metal
# => [#<Money::Currency @code="XAG">, #<Money::Currency @code="XAU">, ...]
Money::Currency.fiat
# => [#<Money::Currency @code="USD">, #<Money::Currency @code="EUR">, ...]
Money::Currency.crypto
# => [#<Money::Currency @code="BTC">, #<Money::Currency @code="ETH">, ...]

# or
Money::Currency.reject(&.metal?)
# => [#<Money::Currency @code="USD">, #<Money::Currency @code="EUR">, ...]

To return an array of registered currencies (ordered by their priority), call Money::Currency.all or .to_a:

Money::Currency.all # => [#<Money::Currency @code="USD">, #<Money::Currency @code="EUR">, ...]

Historical Currencies

You can get all the historical (archived) currencies via Money::Currency.historical:

Money::Currency.historical
# => [#<Money::Currency @code="ANG">, #<Money::Currency @code="BYR">, ...]

You can check if a currency is historical via Money::Currency#historical?:

Money::Currency.find("ANG").historical? # => true
Money::Currency.find("ANG").archived_at # => 2025-04-01 00:00:00Z
Money::Currency.find("ANG").replaced_by # => "XCG"

Registering a New Currency

currency = Money::Currency.new(
  type:                :fiat,
  priority:            1,
  code:                "USD",
  iso_numeric:         840,
  name:                "United States Dollar",
  symbol:              "$",
  symbol_first:        true,
  subunit:             "Cent",
  subunit_to_unit:     100,
  decimal_mark:        ".",
  thousands_separator: ","
)

Money::Currency.register(currency)

Currency Attributes

  • :archived_at β€” date (in ISO 8601 format) when the currency was archived
  • :replaced_by β€” currency code which replaced this currency
  • :type β€” a Money::Currency::Type - either Metal, Fiat or Crypto
  • :priority β€” a numerical value you can use to sort/group the currency list
  • :code β€” the international 3-letter code as defined by the ISO 4217 standard
  • :iso_numeric β€” the international 3-digit code as defined by the ISO 4217 standard
  • :name β€” the currency name
  • :symbol β€” the currency symbol (UTF-8 encoded)
  • :symbol_first β€” whether a money symbol should go before the amount
  • :subunit β€” the name of the fractional monetary unit
  • :subunit_to_unit β€” the proportion between the unit and the subunit
  • :decimal_mark β€” character between the whole and fraction amounts
  • :thousands_separator β€” character between each thousands place
  • :format β€” a format string passed to Money#format

All attributes except :code and :subunit_to_unit are optional.

Priority

You can use the priority attribute to sort or group currencies:

# Returns an array of currencies where priority is less than 10
def major_currencies(currencies)
  currencies.take_while(&.priority.try(&.<(10)))
end

major_currencies(Money::Currency)
# => [#<Money::Currency @code="USD">, #<Money::Currency @code="EUR">, ...]

Default Currency

By default, Money does not have a default currency. You can set it like so:

Money.default_currency = :xag

Currency Exponent

The exponent of a money value is the number of digits after the decimal separator (which separates the major unit from the minor unit). See e.g. ISO 4217 for more information.

Money::Currency.find("USD").exponent # => 2
Money::Currency.find("JPY").exponent # => 0
Money::Currency.find("MGA").exponent # => 1

Currency Exchange

Exchanging money is performed through a Money::Currency::Exchange object. This is done by fetching the exchange rate from a #rate_store first. If the rate is not available (or stale), it is then fetched from a #rate_provider.

The default Money::Currency::Exchange object uses Memory rate store in conjunction with Null rate provider, which requires one to manually specify the exchange rate.

Here's an example of how it works:

Money.default_exchange.rate_store["USD", "EUR"] = 1.24515
Money.default_exchange.rate_store["EUR", "USD"] = 0.80311

Money.new(1_00, "USD").exchange_to("EUR") # => Money(@amount=1.24, @currency="EUR")
Money.new(1_00, "EUR").exchange_to("USD") # => Money(@amount=0.8, @currency="USD")

Exchange Rate Stores

The default exchange uses an in-memory store:

Money.default_exchange = Money::Currency::Exchange.new(
  rate_store: Money::Currency::RateStore::Memory.new
)

Rate stores can be configured with Time::Span controlling the time-to-live (TTL) of the exchange rates:

Money.default_exchange = Money::Currency::Exchange.new(
  rate_store: Money::Currency::RateStore::Memory.new(ttl: 1.hour)
)

Or use your own store (database, file, cache, etc):

Money.default_exchange.rate_store = MyCustomStore.new

The store can be used directly:

# Add to the underlying store
Money.default_exchange.rate_store["USD", "CAD"] = 0.9

# Retrieve from the underlying store
Money.default_exchange.rate_store["USD", "CAD"] # => 0.9

As long as the store holds the exchange rates, Money will use them.

Money.new(10_00, "USD").exchange_to("CAD")        # => Money(@amount=9.0 @currency="CAD")
Money.new(10_00, "CAD") + Money.new(10_00, "USD") # => Money(@amount=19.0 @currency="CAD")

Exchange Rate Providers

By default, the exchange uses a Null provider, which returns nil for all rates.

Money.default_exchange = Money::Currency::Exchange.new(
  rate_provider: Money::Currency::RateProvider::Null.new
)

There are multiple providers available under the Money::Currency::RateProvider namespace which can be used OOTB to fetch exchange rates from different sources.

You can choose one of them, roll your own, or combine them with the Compound provider:

Money.default_exchange.rate_provider =
  Money::Currency::RateProvider::Compound.new([
    Money::Currency::RateProvider::ECB.new,
    Money::Currency::RateProvider::FloatRates.new,
    Money::Currency::RateProvider::UniRateAPI.new(
      api_key: "valid-api-key"
    ),
  ])

Tip

Compound rate provider takes an array of Money::Currency::RateProvider instances which are used in order to fetch the exchange rate.

Disabling Currency Conversion

If you want to prevent automatic currency conversion, you can do so globally:

Money.disallow_currency_conversion!

Or use the block-scoped version:

Money.disallow_currency_conversion do
  # ...
end

Rounding

By default, Money rounds to the nearest cent:

Money.new(2.34567, "USD").format # => "$2.35"

You can change the rounding precision:

Money.new(2.34567, "USD").round(1).format # => "$2.30"

You can change the rounding mode:

Money.new(2.34567, "USD").round(1, :to_positive).format # => "$2.40"
Money.new(2.34567, "USD").round(1, :to_negative).format # => "$2.30"

To keep extra digits, enable infinite precision:

Money.infinite_precision = true

Money.new(2.34567, "USD").format                    # => "$2.34567"
Money.new(2.34567, "USD").round(4).format           # => "$2.3457"
Money.new(2.34567, "USD").round(4, :to_zero).format # => "$2.3456"

# or

Money.with_rounding_mode(:to_zero) do
  Money.new(2.34567, "USD").round(4).format         # => "$2.3456"
end

Nearest Cash Value

If you want to round to the nearest cash value, use Money#round_to_nearest_cash_value:

Money.new(10_07, "CHF").round_to_nearest_cash_value
# => Money(@amount=10.05, @currency="CHF")

Money.new(10_08, "CHF").round_to_nearest_cash_value
# => Money(@amount=10.1, @currency="CHF")

JSON/YAML Serialization

Money, Money::Currency, Money::Currency::Exchange, Money::Currency::Rate, Money::Currency::RateStore and Money::Currency::RateProvider implements JSON::Serializable and YAML::Serializable:

Money

Money.new(10_00, "USD").to_json # => "{\"amount\":10.0,\"currency\":\"USD\"}"
Money.new(10_00, "USD").to_yaml # => "---\namount: 10.0\ncurrency: USD\n"

Money.from_json(%({"amount": 10.0, "currency": "USD"}))
# => Money(@amount=10.0, @currency="USD")

Money.from_yaml("{ amount: 10.0, currency: USD }")
# => Money(@amount=10.0, @currency="USD")

Money::Currency

# Serialize existing `Money::Currency`

Money::Currency.find("USD").to_json # => "{\"code\":\"USD\", ...}"
Money::Currency.find("USD").to_yaml # => "---\ncode: USD\n ..."

# Instantiate new `Money::Currency`

Money::Currency.from_json(%({"code": "FOO", ...})) # => #<Money::Currency @code="FOO">
Money::Currency.from_yaml("{ code: FOO, ... }")    # => #<Money::Currency @code="FOO">

# Lookup existing `Money::Currency`

Money::Currency.from_json(%("USD")) # => #<Money::Currency @code="USD">
Money::Currency.from_yaml("USD")    # => #<Money::Currency @code="USD">

Using with JSON::Serializable and YAML::Serializable

In order to (de)serialize Money::Currency instances solely from/to currency codes, you need to add a JSON/YAML::Field annotation with the Money::Currency::Converter converter.

class FooWithCurrency
  include JSON::Serializable
  include YAML::Serializable

  @[JSON::Field(converter: Money::Currency::Converter)]
  @[YAML::Field(converter: Money::Currency::Converter)]
  property currency : Money::Currency

  def initialize(@currency)
  end
end

foo = FooWithCurrency.from_yaml("{ currency: USD }")
foo.currency.code # => "USD"

Money::Currency::Exchange

exchange = Money::Currency::Exchange.from_yaml <<-YAML
  rate_store:
    name: File
    options:
      path: ~/.cache/money/currency-rates.json
      ttl: 1 hour, 11 minutes

  rate_provider:
    name: Compound
    options:
      providers:
      - name: ECB
      - name: FloatRates
      - name: UniRateAPI
        options:
          api_key: valid-api-key
  YAML

Money::Currency::Rate

rate = Money::Currency::Rate.new(
  Money::Currency.find("USD"),
  Money::Currency.find("EUR"),
  1.25.to_big_d,
  Time.parse_utc("2025-05-22", "%F"),
)

rate.to_json # => "{\"base\":\"USD\",\"target\":\"EUR\",\"value\":1.25,\"updated_at\":\"2025-05-22T00:00:00.000Z\"}"
rate.to_yaml # => "---\nbase: USD\ntarget: EUR\nvalue: 1.25\nupdated_at: 2025-05-22\n"

Money::Currency::RateStore

You can use .from_json and .from_yaml methods to deserialize generic rate store instances providing the name (in CamelCase or snake_case) and options - optional hash that's being passed to the store initializer.

store = Money::Currency::RateStore.from_yaml <<-YAML
  name: File
  options:
    path: ~/.cache/money/currency-rates.json
    ttl: 1 hour, 11 minutes
  YAML

typeof(store) # => Money::Currency::RateStore
store.class   # => Money::Currency::RateStore::File

For specific stores you pass the options directly:

file_store = Money::Currency::RateStore::File.from_yaml <<-YAML
  path: ~/.cache/money/currency-rates.json
  ttl: 1 hour, 11 minutes
  YAML

Using with JSON::Serializable and YAML::Serializable

In order to (de)serialize generic Money::Currency::RateStore instances, you need to add a JSON/YAML::Field annotation with a custom converter β€” Money::Currency::RateStore::Converter.

class FooWithGenericStore
  include JSON::Serializable
  include YAML::Serializable

  @[JSON::Field(converter: Money::Currency::RateStore::Converter)]
  @[YAML::Field(converter: Money::Currency::RateStore::Converter)]
  property store : Money::Currency::RateStore

  def initialize(@store)
  end
end

foo = FooWithGenericStore.from_yaml <<-YAML
  store:
    name: File
    options:
      path: ~/.cache/money/currency-rates.json
      ttl: 1 hour, 11 minutes
  YAML

foo.store.class # => Money::Currency::RateStore::File

Money::Currency::RateProvider

You can use .from_json and .from_yaml methods to deserialize generic rate provider instances providing the name (in CamelCase or snake_case) and options - optional hash that's being passed to the provider initializer.

provider = Money::Currency::RateProvider.from_yaml <<-YAML
  name: Compound
  options:
    providers:
    - name: ECB
    - name: FloatRates
    - name: UniRateAPI
      options:
        api_key: valid-api-key
  YAML

typeof(provider) # => Money::Currency::RateProvider
provider.class   # => Money::Currency::RateProvider::Compound

For specific providers you pass the options directly:

compound_provider = Money::Currency::RateProvider::Compound.from_yaml <<-YAML
  providers:
  - name: ECB
  - name: FloatRates
  YAML

compound_provider.providers << Money::Currency::RateProvider::UniRateAPI.from_yaml <<-YAML
  api_key: valid-api-key
  YAML

compound_provider.providers.size # => 3

Using with JSON::Serializable and YAML::Serializable

In order to (de)serialize generic Money::Currency::RateProvider instances, you need to add a JSON/YAML::Field annotation with a custom converter β€” Money::Currency::RateProvider::Converter.

class FooWithGenericProvider
  include JSON::Serializable
  include YAML::Serializable

  @[JSON::Field(converter: Money::Currency::RateProvider::Converter)]
  @[YAML::Field(converter: Money::Currency::RateProvider::Converter)]
  property provider : Money::Currency::RateProvider

  def initialize(@provider)
  end
end

foo = FooWithGenericProvider.from_yaml <<-YAML
  provider:
    name: Compound
    options:
      providers:
      - name: ECB
      - name: FloatRates
      - name: UniRateAPI
        options:
          api_key: valid-api-key
  YAML

foo.provider.class # => Money::Currency::RateProvider::Compound

Working with Fibers

Global settings are being kept in a single, fiber-local Money.context object, and are not shared between fibers by default.

Use this to spawn a fiber with the same settings as the current one:

Money.default_currency = "EUR"

Money.spawn_with_same_context do
  Money.default_currency.code # => "EUR"
end

All of the Money APIs and classes are (or at least should be) fiber-safe.

Caution

Money.spawn_with_same_context duplicates the Money.context instance, by calling #dup on it and thus only the values are being duplicated, references are shared.

Formatting

There are several formatting rules for when Money#format is called. For more info, check out the formatting module source, or the docs.

Here are some examples:

money = Money.new(1_23, "USD")    # => Money(@amount=1.23 @currency="USD")
money.format                      # => "$1.23"
money.format(sign_positive: true) # => "+$1.23"
money.format(no_cents: true)      # => "$1"
money.format(disambiguate: true)  # => "US$1.23"
money.to_s                        # => "1.23 USD"

If you want to format money according to the EU's Rules for expressing monetary units:

money = Money.new(1_23, "GBP")               # => Money(@amount=1.23 @currency="GBP")
money.format("%{currency} %{sign}%{amount}") # => "GBP 1.23"

Parsing

You can parse a string with an amount and currency code or symbol:

Money.parse("$12.34")    # => Money(@amount=12.34, @currency="USD")
Money.parse("12.34 USD") # => Money(@amount=12.34, @currency="USD")

Contributors

  • Sija Sijawusz Pur Rahnama (creator & maintainer)

About

πŸ’° Crystal shard for dealing with money and currency conversion

Topics

Resources

License

Stars

Watchers

Forks

Contributors