# RubyMoney - Money-Rails [![Gem Version](https://badge.fury.io/rb/money-rails.svg)](https://rubygems.org/gems/money-rails) [![Ruby](https://github.com/RubyMoney/money-rails/actions/workflows/ruby.yml/badge.svg)](https://github.com/RubyMoney/money-rails/actions/workflows/ruby.yml) [![License](https://img.shields.io/:license-mit-green.svg?style=flat)](https://opensource.org/licenses/MIT) ## Introduction This library provides integration of the [money](https://github.com/Rubymoney/money) gem with Rails. Use `monetize` to specify which fields you want to be backed by Money objects and helpers provided by the [money](https://github.com/Rubymoney/money) gem. Currently, this library is in active development mode, so if you would like to have a new feature, feel free to open a new issue [here](https://github.com/RubyMoney/money-rails/issues). You are also welcome to contribute to the project. ## Installation Add it to your application’s Gemfile using: ```sh bundle add money-rails ``` Or install it yourself using: ```sh $ gem install money-rails ``` You can also use the money configuration initializer: ```sh $ bin/rails generate money_rails:initializer ``` There, you can define the default currency value and set other configuration parameters for the Rails app. Without Rails in rack-based applications, call during initialization: ```ruby MoneyRails::Hooks.init ``` ## Usage ### ActiveRecord #### Usage example For example, we create a Product model which has an integer column called `price_cents` and we want to handle it using a `Money` object instead: ```ruby class Product < ActiveRecord::Base monetize :price_cents end ``` Now each Product object will also have an attribute called `price` which is a `Money` object, and can be used for money comparisons, conversions etc. In this case the name of the money attribute is created automagically by removing the `_cents` suffix from the column name. If you are using another database column name, or you prefer another name for the money attribute, then you can provide an `as` argument with a string value to the `monetize` macro: ```ruby monetize :discount_subunit, as: "discount" ``` Now the model objects will have a `discount` attribute which is a `Money` object, wrapping the value of the `discount_subunit` column with a Money instance. #### Migration helpers If you want to add a money field to a product model you can use the `add_monetize` helper. This helper can be customized inside a `MoneyRails.configure` block. You should customize the `add_monetize` helper to match the most common use case and utilize it across all migrations. ```ruby class MonetizeProduct < ActiveRecord::Migration def change add_monetize :products, :price # OR change_table :products do |t| t.monetize :price end end end ``` Another example, where the currency column is not included: ```ruby class MonetizeItem < ActiveRecord::Migration def change add_monetize :items, :price, currency: { present: false } end end ``` Notice: Default value of currency field, generated by migration’s helper, is USD. To override these defaults, you need change the `default_currency` in an initializer and run migrations. The `add_monetize` helper is reversible, so you can use it inside `change` migrations. If you’re writing separate `up` and `down` methods, you can use the `remove_monetize` helper. #### Allow nil values If you want to allow `nil` and/or blank values to a specific monetized field, you can use the `:allow_nil` parameter: ```ruby # in Product model monetize :optional_price_cents, allow_nil: true # in Migration def change add_monetize :products, :optional_price, amount: { null: true, default: nil }, currency: { null: true, default: nil } end # now blank assignments are permitted product.optional_price = nil product.save # returns without errors product.optional_price # => nil product.optional_price_cents # => nil ``` #### Allow large numbers If you foresee that you will be saving large values (range is -2147483648 to +2147483647 for Postgres), increase your integer column limit to `bigint`: ```ruby def change change_column :products, :price_cents, :integer, limit: 8 end ``` #### Numericality validation options You can also pass along [numericality validation options](https://guides.rubyonrails.org/active_record_validations.html#numericality) such as this: ```ruby monetize :price_in_a_range_cents, allow_nil: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 10000 } ``` Or, if you prefer, you can skip validations entirely for the attribute. This is useful if chosen attributes are aggregate methods and you wish to avoid executing them on every record save. ```ruby monetize :price_in_a_range_cents, disable_validation: true ``` You can also skip validations independently from each other by simply passing `false` to the validation you are willing to skip, like this: ```ruby monetize :price_in_a_range_cents, numericality: false ``` And you can also use `subunit_numericality` for subunit: ```ruby monetize :price_in_a_range_cents, allow_nil: true, subunit_numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100_00 } ``` ### Mongoid `Money` is available as a field type to supply during a field definition: ```ruby class Product include Mongoid::Document field :price, type: Money end obj = Product.new # => # obj.price # => nil obj.price = Money.new(100, 'EUR') # => # obj.price #=> # obj.save # => true obj # => # obj.price #=> # ## You can access the money hash too: obj[:price] # => {cents: 100, currency_iso: "EUR"} ``` The usual options on `field` as `index`, `default`, ..., are available. ### Method conversion Method return values can be monetized in the same way attributes are monetized. For example: ```ruby class Transaction < ActiveRecord::Base monetize :price_cents monetize :tax_cents monetize :total_cents def total_cents price_cents + tax_cents end end ``` Now each `Transaction` object has a method called `total` which returns a `Money` object. ### Currencies money-rails supports a set of options to handle currencies for your monetized fields. The default option for every conversion is to use the global default currency of the Money library, as given in the configuration initializer of money-rails: ```ruby # config/initializers/money.rb MoneyRails.configure do |config| # set the default currency config.default_currency = :usd end ``` For a complete list of available currencies: [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) If you need to set the default currency on a per-request basis, such as in a multi-tenant application, you may use a lambda to lazy-load the default currency from a field in a configuration model called `Tenant` in this example: ```ruby # config/initializers/money.rb ActiveSupport::Reloader.to_prepare do MoneyRails.configure do |config| # set the default currency based on client configuration config.default_currency = -> { Tenant.current.default_currency } end end ``` In many cases this is not enough, so there are some other options to meet your needs. #### Model Currency You can override the global default currency within a specific ActiveRecord model using the `register_currency` macro: ```ruby # app/models/product.rb class Product < ActiveRecord::Base # Use EUR as model level currency register_currency :eur monetize :discount_subunit, as: "discount" monetize :bonus_cents end ``` Now `product.discount` and `product.bonus` will return a `Money` object using EUR as their currency, instead of the default USD. (This is not available in Mongoid). #### Attribute Currency (`:with_currency`) By passing the option `:with_currency` to the `monetize` macro call, with a currency code (symbol or string) or a callable object (object that responds to the `call` method) that returns a currency code, as its value, you can define a currency in a more granular way. This will let you attach the given currency only to the specified monetized model attribute (allowing you to, for example, monetize different attributes of the same model with different currencies). This allows you to override both the model level and the global default currencies: ```ruby # app/models/product.rb class Product < ActiveRecord::Base # Use EUR as the model level currency register_currency :eur monetize :discount_subunit, as: "discount" monetize :bonus_cents, with_currency: :gbp end ``` In this case `product.bonus` will return a Money object with GBP as its currency, whereas `product.discount.currency.to_s # => EUR` As mentioned earlier you can use an object that responds to the method `call` and accepts the model instance as a parameter. That means you can use a `Proc` or `lambda` (we would recommend `lambda` over `Proc` because of their [different control flow characteristics](https://stackoverflow.com/questions/1740046/whats-the-difference-between-a-proc-and-a-lambda-in-ruby)) or even define a separate `class` with an instance or class method (maybe even a `module`) to return the currency code: ```ruby class DeliveryFee def call(product) # some logic here that will return a currency code end end module OptionalPrice def self.call(product) # some logic here that will return a currency code end end class Product < ActiveRecord::Base monetize :price_cents, with_currency: ->(_product) { :gbp } monetize :delivery_fee_cents, with_currency: DeliveryFee.new monetize :optional_price_cents, with_currency: OptionalPrice end ``` #### Instance Currencies All the previous options do not require any extra model fields to hold the currency values. If the currency of a field will vary from one model instance to another, then you should add a column called `currency` to your database table and pass the option `with_model_currency` to the `monetize` macro. money-rails will use this knowledge to override the model level and global default values. Non-nil instance currency values also override attribute currency values, so they have the highest precedence. ```ruby class Transaction < ActiveRecord::Base # Use model level currency register_currency :gbp monetize :amount_cents, with_model_currency: :currency monetize :tax_cents, with_model_currency: :currency end # Now instantiating with a specific currency overrides # the model and global currencies t = Transaction.new(amount_cents: 2500, currency: "CAD") t.amount == Money.new(2500, "CAD") # true ``` ### Configuration parameters You can handle a bunch of configuration params through `money.rb` initializer: ```ruby MoneyRails.configure do |config| # To set the default currency # # config.default_currency = :usd # Set default bank object # # Example: # config.default_bank = EuCentralBank.new # Add exchange rates to current money bank object. # (The conversion rate refers to one direction only) # # Example: # config.add_rate "USD", "CAD", 1.24515 # config.add_rate "CAD", "USD", 0.803115 # To handle the inclusion of validations for monetized fields # The default value is true # # config.include_validations = true # Default ActiveRecord migration configuration values for columns: # # config.amount_column = { # prefix: '', # column name prefix # postfix: '_cents', # column name postfix # column_name: nil, # full column name (overrides prefix, postfix and accessor name) # type: :integer, # column type # present: true, # column will be created # null: false, # other options will be treated as column options # default: 0 # } # # config.currency_column = { # prefix: '', # postfix: '_currency', # column_name: nil, # type: :string, # present: true, # null: false, # default: 'USD' # } # Register a custom currency # # Example: # config.register_currency = { # priority: 1, # iso_code: "EU4", # name: "Euro with subunit of 4 digits", # symbol: "€", # symbol_first: true, # subunit: "Subcent", # subunit_to_unit: 10000, # thousands_separator: ".", # decimal_mark: "," # } # Specify a rounding mode # Any one of: # # BigDecimal::ROUND_UP, # BigDecimal::ROUND_DOWN, # BigDecimal::ROUND_HALF_UP, # BigDecimal::ROUND_HALF_DOWN, # BigDecimal::ROUND_HALF_EVEN, # BigDecimal::ROUND_CEILING, # BigDecimal::ROUND_FLOOR # # set to BigDecimal::ROUND_HALF_EVEN by default # # config.rounding_mode = BigDecimal::ROUND_HALF_UP # Set default money format globally. # Default value is nil meaning "ignore this option". # Example: # # config.default_format = { # no_cents_if_whole: nil, # symbol: nil, # sign_before_symbol: nil # } # Set whether an error should be raised when parsing money values # This includes assigning to a monetized field with the wrong currency # Default value is false # # config.raise_error_on_money_parsing = true end ``` * `default_currency`: Set the default (application wide) currency (USD is the default) * `include_validations`: Permit the inclusion of a `validates_numericality_of` validation for each monetized field (the default is true) * `register_currency`: Register one custom currency. This option can be used more than once to set more custom currencies. The value should be a hash of all the necessary key/value pairs (important keys: `:priority`, `:iso_code`, `:name`, `:symbol`, `:symbol_first`, `:subunit`, `:subunit_to_unit`, `:thousands_separator`, `:decimal_mark`). * `add_rate`: Provide custom exchange rate for currencies in one direction only! This rate is added to the attached bank object. * `default_bank`: The default bank object holding exchange rates etc. (https://github.com/RubyMoney/money#currency-exchange) * `default_format`: Force `Money#format` to use these options for formatting. * `amount_column`: Provide values for the amount column (holding the fractional part of a money object). * `currency_column`: Provide default values or even disable (`present: false`) the currency column. * `rounding_mode`: Set `Money.rounding_mode` to one of the BigDecimal constants. * `raise_error_on_money_parsing`: Set whether errors should be raised when parsing money values ### Helpers _For examples below, `@money_object == `_ | Helper | Result | |:--- |:--- | | `currency_symbol` | `$` | | `humanized_money @money_object` | 6.50 | | `humanized_money_with_symbol @money_object` | $6.50 | | `money_without_cents @money_object` | 6 | | `money_without_cents_and_with_symbol @money_object` | $6 | | `money_only_cents @money_object` | 50 | #### `no_cents_if_whole` configuration param `humanized_money` and `humanized_money_with_symbol` will not render the cents part if it contains only zeros, unless `config.no_cents_if_whole` is set to `false` in the `money.rb` configuration (default: true). Note that the `config.default_format` will be overwritten by `config.no_cents_if_whole`. So `humanized_money` will ignore `config.default_format = { no_cents_if_whole: false }` if you don't set `config.no_cents_if_whole = false`. ### Testing The test helpers work with both RSpec and Minitest. #### RSpec If you use RSpec, just require the test helpers in `spec_helper.rb`: ```ruby require "money-rails/test_helpers" ``` The helpers will automatically be included in your RSpec tests. #### Minitest If you use Minitest, require the test helpers and include them in your test class: ```ruby require "money-rails/test_helpers" class ProductTest < Minitest::Test include MoneyRails::TestHelpers def test_monetizes_price matcher = monetize(:price_cents) assert matcher.matches?(Product) end end ``` #### The `monetize` matcher ```ruby is_expected.to monetize(:price) ``` This will ensure that a column called `price_cents` is being monetized. ```ruby is_expected.to monetize(:price).allow_nil ``` By using `allow_nil` you can specify money attributes that accept nil values. ```ruby is_expected.to monetize(:price).as(:discount_value) ``` By using `as` chain you can specify the exact name to which a monetized column is being mapped. ```ruby is_expected.to monetize(:price).with_currency(:gbp) ``` By using the `with_currency` chain you can specify the expected currency for the chosen money attribute. (You can also combine all the chains.) ```ruby is_expected.to monetize(:price).with_model_currency(:currency) ``` By using the `with_model_currency` chain you can specify the attribute that contains the currency to be used for the chosen money attribute. For examples on using the test_helpers look at [test_helpers_spec.rb](https://github.com/RubyMoney/money-rails/blob/master/spec/test_helpers_spec.rb) ## Supported ORMs/ODMs * ActiveRecord (>= 7.0) * Mongoid (>= 7.x) ## Supported Ruby interpreters * MRI Ruby >= 3.1 You can see a full list of the currently supported interpreters in [ruby.yml](https://github.com/RubyMoney/money-rails/blob/main/.github/workflows/ruby.yml) ## Contributing ### Steps 1. Fork the repo 2. Run the tests 3. Make your changes 4. Test your changes 5. Create a Pull Request ### How to run the tests Our tests are executed with several ORMs - see `Rakefile` for details. To install all required gems run `rake spec:all`. That command will take care of installing all required gems for all the different Gemfiles and then running the test suite with the installed bundle. You can also run the test suite against a specific ORM or Rails version, `rake -T` will give you an idea of the possible task (take a look at the tasks under the spec: namespace). If you are testing against mongoid, make sure to have a MongoDB server running before executing the suite (e.g. `sudo mongod --quiet` or `docker run --rm -p 27017:27017 mongo:latest`).