shopify-money

repository·main·Indexed 18 days ago

https://github.com/shopify/money

A Ruby library for handling monetary values with precision. It encapsulates amounts and currencies, providing safe arithmetic and advanced features for splitting and allocating money without losing subunits. The library includes support for custom and crypto currencies, subunit format converters, and ActiveRecord integration via the money_column method for storing monetary values in database decimal columns.

Tokens
8.3K
Snippets
31
Records
35
Agent score
62%

What's inside shopify-money

  1. Configure rounding strategies for allocation

    main

    When using Money::Allocator#allocate, you can specify a rounding strategy to determine how leftover subunits are distributed. Strategies include:

    • :roundrobin: Assigns leftover subunits from left to right.
    • :roundrobin_reverse: Assigns leftover subunits from right to left.
    • :nearest: Assigns leftover subunits to the nearest whole subunit.

    You can set a global default via Money.configure or pass the strategy as an argument to the allocate method.

    # Global default
    Money.configure do |config|
      config.default_allocation_strategy = :nearest
    end
    
    # Per-call strategy
    m = Money.new(10.55, "USD")
    m.allocate([0.25, 0.5, 0.25], :nearest)
    # Assigns leftover subunits left to right
    m = Money::Allocator.new(Money.new(10.55, "USD"))
    monies = m.allocate([0.25, 0.5, 0.25], :roundrobin)
    
    # Assigns leftover subunits right to left
    m = Money::Allocator.new(Money.new(10.55, "USD"))
    monies = m.allocate([0.25, 0.5, 0.25], :roundrobin_reverse)
    
    # Assigns leftover subunits to the nearest whole subunit
    m = Money::Allocator.new(Money.new(10.55, "USD"))
    monies = m.allocate([0.25, 0.5, 0.25], :nearest)
  2. Convert money between currencies

    main

    The library does not provide automatic exchange rate APIs. To convert a value from one currency to another, you must manually provide the exchange rate.

    Warning: Money.new(money * exchange_rate, "JPY") will raise an exception. Use one of the following patterns:

    # Option 1: Multiply the value directly
    Money.new(money.value * exchange_rate, "JPY")
    
    # Option 2: Use the convert_currency method
    money.convert_currency(exchange_rate, "JPY")
    # Convert to subunits using ISO4217 format (default)
    Money.new(1.00, 'USD').subunits                    # => 100
    Money.new(1.00, 'ISK').subunits                    # => 1
    
    # Convert to subunits using Stripe format
    Money.new(1.00, 'ISK').subunits(format: :stripe)   # => 100
    
    # Convert from subunits
    Money.from_subunits(100, 'ISK', format: :stripe)                    # => Money.new(1.00, 'ISK')
  3. Basic usage of the Money class

    main

    The Money class encapsulates a monetary value and its currency. You can initialize it with a decimal value and a currency string. You can access the value in subunits (e.g., cents for USD) or retrieve the Money::Currency object.

    require 'money'
    
    # Create 10.00 USD
    money = Money.new(10.00, "USD")
    
    # Access subunits (10.00 USD -> 1000 subunits)
    money.subunits     #=> 1000
    
    # Access currency object
    money.currency     #=> Money::Currency.new("USD")
    require 'money'
    
    # 10.00 USD
    money = Money.new(10.00, "USD")
    money.subunits     #=> 1000
    money.currency     #=> Money::Currency.new("USD")
  4. Integrate Money with ActiveRecord using money_column

    main

    To store money in a database, use a decimal column. The money_column method generates methods for ActiveRecord models to handle the conversion between the database decimal and Money objects.

    Database Requirement: money_column expects a DECIMAL(21,3) field.

    Implementation Example

    # Migration
    create_table :orders do |t|
      t.decimal :sub_total, precision: 21, scale: 3
      t.decimal :tax, precision: 21, scale: 3
      t.string :currency, limit: 3
    end
    
    # Model
    class Order < ApplicationRecord
      money_column :sub_total, :tax
    end

    money_column Options

    optiontypedescription
    currency_columnmethodcolumn from which to read/write the currency
    currencystringhardcoded currency value
    currency_read_onlybooleanif true, currency_column won't write the currency back to the db. Default: false
    coerce_nullbooleanif true, a nil value will be returned as Money.zero. Default: false
    create_table :orders do |t|
      t.decimal :sub_total, precision: 21, scale: 3
      t.decimal :tax, precision: 21, scale: 3
      t.string :currency, limit: 3
    end
    
    class Order < ApplicationRecord
      money_column :sub_total, :tax
    end
  5. Avoid using Money::NullCurrency directly

    main

    While Money::NullCurrency is available for backwards compatibility, it is a placeholder currency that behaves like a dollar. Using it can lead to unexpected behavior in database validations or GraphQL enums because its string representation may not be supported.

    Preferred Alternatives:

    1. For comparisons: If you are unsure of the currency, use Numeric predicate methods like #positive?, #negative?, #zero?, or #nonzero?. You can also use standard comparison operators (==, !=, <=, >=, <, >) against Numeric values.
    2. For zero values: Prefer using Money.new(0, currency) instead of using NullCurrency to ensure better currency safety and compatibility with external systems.

    Unique Behavior: Unlike standard currencies, performing arithmetic between a NullCurrency object and another currency will result in a Money object using the other currency.

    # Preferred comparison approach
    Money.new(1, 'CAD').positive? #=> true
    Money.new(2, 'CAD') >= 0      #=> true
    
    # Arithmetic with NullCurrency results in the other currency
    Money.new(0, Money::NULL_CURRENCY) + Money.new(5, 'CAD')
    #=> #<Money value:5.00 currency:CAD>
  6. Configure custom and crypto currencies

    main

    You can extend the supported currencies via configuration.

    Crypto Currencies

    Enable support for currencies defined in crypto.yml:

    Money.configure do |config|
      config.experimental_crypto_currencies = true
    end

    Custom Currencies

    Load custom currency definitions from a YAML file:

    Money.configure do |config|
      config.experimental_custom_currency_path = Rails.root.join("config/custom_currencies.yml")
    end

    Example custom_currencies.yml format:

    credits:
      iso_code: "CREDITS"
      name: "Loyalty Points"
      symbol: "CR"
      disambiguate_symbol: "CR"
      subunit_to_unit: 1
      smallest_denomination: 1
      decimal_mark: "."
    # Enable crypto
    Money.configure do |config|
      config.experimental_crypto_currencies = true
    end
    
    # Load custom YAML
    Money.configure do |config|
      config.experimental_custom_currency_path = Rails.root.join("config/custom_currencies.yml")
    end
  7. Split and allocate money

    main

    The library provides advanced methods for dividing money without losing pennies due to rounding errors.

    Splitting evenly

    split(n) divides money into n parts. If the division isn't perfect, leftover subunits are assigned to the first chunks.

    m = Money.new(1000, "USD")
    m.split(3).map(&:value) == [333.34, 333.33, 333.33]

    Proportional allocation

    allocate(proportions) distributes money based on an array of weights (numbers or Rationals).

    m = Money.new(1000, "USD")
    m.allocate([0.50, 0.25, 0.25]).map(&:value) #=> [500, 250, 250]

    Allocation up to a cutoff

    allocate_max_amounts(amounts) attempts to allocate up to the specified maximum for each recipient.

    m = Money.new(1000, "USD")
    m.allocate_max_amounts([500, 300, 300]).map(&:value) #=> [454.55, 272.73, 272.72]
    m = Money.new(1000, "USD")
    # Splitting money evenly
    m.split(2)              == [Money.new(500, "USD"), Money.new(500, "USD")]
    m.split(3).map(&:value) == [333.34, 333.33, 333.33]
    
    # Allocating money proportionally
    m.allocate([0.50, 0.25, 0.25]).map(&:value)               == [500, 250, 250]
    m.allocate([Rational(2, 3), Rational(1, 3)]).map(&:value) == [666.67, 333.33]
    
    # Allocating up to a cutoff
    m.allocate_max_amounts([500, 300, 200]).map(&:value) == [500, 300, 200]
    m.allocate_max_amounts([500, 300, 300]).map(&:value) == [454.55, 272.73, 272.72]
  8. Use custom Converters for subunit formats

    main

    If you need to handle specific subunit formats (e.g., for payment providers), you can implement a custom converter by subclassing Money::Converters::Converter.

    class MyCustomConverter < Money::Converters::Converter
      def subunit_to_unit(currency)
        1000
      end
    end
    
    # Register the converter
    Money::Converters.register(:my_format, MyCustomConverter)
    
    # Use the converter
    Money.new(1.00, 'USD').subunits(format: :my_format) #=> 1000
    class MyCustomConverter < Money::Converters::Converter
      def subunit_to_unit(currency)
        # Your custom logic here
        1000
      end
    end
    
    # Register your converter
    Money::Converters.register(:my_format, MyCustomConverter)
    
    # Use your converter
    Money.new(1.00, 'USD').subunits(format: :my_format) # => 1000
  9. Compare and perform arithmetic with Money objects

    main

    Money objects support standard comparison and arithmetic operators. Comparisons are currency-aware; objects with different currencies are not equal even if their values are the same.

    Comparisons

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

    Arithmetic

    Money.new(1000, "USD") + Money.new(500, "USD") == Money.new(1500, "USD")
    Money.new(1000, "USD") - Money.new(200, "USD") == Money.new(800, "USD")
    Money.new(1000, "USD") * 5                     == Money.new(5000, "USD")
    # Comparisons
    Money.new(1000, "USD") == Money.new(1000, "USD")   #=> true
    Money.new(1000, "USD") == Money.new(100, "USD")    #=> false
    Money.new(1000, "USD") == Money.new(1000, "EUR")   #=> false
    Money.new(1000, "USD") != Money.new(1000, "EUR")   #=> true
    
    # Arithmetic
    Money.new(1000, "USD") + Money.new(500, "USD") == Money.new(1500, "USD")
    Money.new(1000, "USD") - Money.new(200, "USD") == Money.new(800, "USD")
    Money.new(1000, "USD") * 5                     == Money.new(5000, "USD")
  10. Work with Money::Currency

    main

    Currencies are represented by Money::Currency instances. Most Money APIs accept either a String (ISO code) or a Money::Currency object.

    Currency Properties

    currency = Money.new(1000, "USD").currency
    currency.iso_code #=> "USD"
    currency.name     #=> "United States Dollar"
    currency.symbol   #=> "$"

    Setting Default Currency

    By default, Money uses Money::NullCurrency. You can change the global default:

    Money.configure do |config|
      config.default_currency = Money::Currency.new("USD")
    end

    In Rails, it is recommended to set the currency per-request using Money.with_currency(currency) { yield } within an around_action.

    Money.new(1000, "USD") == Money.new(1000, Money::Currency.new("USD"))
    Money.new(1000, "EUR").currency == Money::Currency.new("EUR")
    
    currency = Money.new(1000, "USD").currency
    currency.iso_code #=> "USD"
    currency.name     #=> "United States Dollar"
    currency.to_s     #=> 'USD'
    currency.symbol   #=> '$'
    currency.disambiguate_symbol #=> 'US$'
  11. Avoid using Money::Parser::Fuzzy#parse

    main

    The Money::Parser::Fuzzy.parse method is deprecated. It uses heuristics to guess decimal separators (like dots or commas) based on the input string and currency. Because these heuristics can be unreliable, they may result in parsed amounts being significantly larger or smaller than intended (e.g., 1000x error).

    Recommended Alternatives:

    • Use LocaleAware.parse for locale-specific parsing.
    • Use Simple.parse for straightforward parsing.