diff --git a/.gitignore b/.gitignore index 6357e05e25..ff65878dbe 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ Gemfile.lock .rvmrc .rbenv-version .ruby-version +.ruby-gemset # dummy dbs *.sqlite3 diff --git a/Gemfile b/Gemfile index ebddec7259..ea6797abf5 100644 --- a/Gemfile +++ b/Gemfile @@ -9,7 +9,7 @@ platforms :jruby do end platforms :ruby do - gem "sqlite3" + gem "sqlite3", "~> 1.4" end platform :mri do diff --git a/Rakefile b/Rakefile index 9bc1f480ec..f9ab5c851f 100644 --- a/Rakefile +++ b/Rakefile @@ -38,8 +38,7 @@ def run_with_gemfile(gemfile) Bundler.with_original_env do begin sh "BUNDLE_GEMFILE='#{gemfile}' bundle install --quiet" - Rake.application['app:db:create'].invoke - Rake.application['app:db:test:prepare'].invoke + Rake.application['prepare_test_env'].invoke sh "BUNDLE_GEMFILE='#{gemfile}' bundle exec rake spec" ensure Rake.application['app:db:drop:all'].execute diff --git a/lib/money-rails/active_model/validator.rb b/lib/money-rails/active_model/validator.rb index 6ce396ad3e..5b8bb48e60 100644 --- a/lib/money-rails/active_model/validator.rb +++ b/lib/money-rails/active_model/validator.rb @@ -9,23 +9,44 @@ def abs_raw_value def decimal_pieces @decimal_pieces ||= abs_raw_value.split(decimal_mark) end + + def has_too_many_decimal_points? + decimal_pieces.length > 2 + end + + def thousand_separator_after_decimal_mark? + return false unless thousands_separator.present? + + decimal_pieces.length == 2 && decimal_pieces[1].include?(thousands_separator) + end + + def invalid_thousands_separation? + pieces_array = decimal_pieces[0].split(thousands_separator.presence) + + return false if pieces_array.length <= 1 + return true if pieces_array[0].length > 3 + + pieces_array[1..-1].any? do |thousands_group| + thousands_group.length != 3 + end + end + + # Remove thousands separators, normalize decimal mark, + # remove whitespaces and _ (E.g. 99 999 999 or 12_300_200.20) + def normalize + raw_value.to_s + .gsub(thousands_separator, '') + .gsub(decimal_mark, '.') + .gsub(/[\s_]/, '') + end end def validate_each(record, attr, _value) - subunit_attr = record.class.monetized_attributes[attr.to_s] currency = record.public_send("currency_for_#{attr}") # WARNING: Currently this is only defined in ActiveRecord extension! before_type_cast = :"#{attr}_money_before_type_cast" - raw_value = record.try(before_type_cast) - - # If raw value is nil and changed subunit is nil, then - # nil is a assigned value, else we should treat the - # subunit value as the one assigned. - if raw_value.nil? && record.public_send(subunit_attr) - subunit_value = record.public_send(subunit_attr) - raw_value = subunit_value.to_f / currency.subunit_to_unit - end + raw_value = record.try(before_type_cast) || record.public_send(attr) return if options[:allow_nil] && raw_value.nil? @@ -44,17 +65,16 @@ def validate_each(record, attr, _value) # Cache abs_raw_value before normalizing because it's used in # many places and relies on the original raw_value. details = generate_details(raw_value, currency) - normalized_raw_value = normalize(details) + normalized_raw_value = details.normalize super(record, attr, normalized_raw_value) return unless stringy return if record_already_has_error?(record, attr, normalized_raw_value) - add_error!(record, attr, details) if - value_has_too_many_decimal_points(details) || - thousand_separator_after_decimal_mark(details) || - invalid_thousands_separation(details) + add_error!(record, attr, details) if details.has_too_many_decimal_points? || + details.thousand_separator_after_decimal_mark? || + details.invalid_thousands_separation? end private @@ -87,37 +107,6 @@ def add_error!(record, attr, details) ) end - def value_has_too_many_decimal_points(details) - ![1, 2].include?(details.decimal_pieces.length) - end - - def thousand_separator_after_decimal_mark(details) - details.thousands_separator.present? && - details.decimal_pieces.length == 2 && - details.decimal_pieces[1].include?(details.thousands_separator) - end - - def invalid_thousands_separation(details) - pieces_array = details.decimal_pieces[0].split(details.thousands_separator.presence) - - return false if pieces_array.length <= 1 - return true if pieces_array[0].length > 3 - - pieces_array[1..-1].any? do |thousands_group| - thousands_group.length != 3 - end - end - - # Remove thousands separators, normalize decimal mark, - # remove whitespaces and _ (E.g. 99 999 999 or 12_300_200.20) - def normalize(details) - details.raw_value - .to_s - .gsub(details.thousands_separator, '') - .gsub(details.decimal_mark, '.') - .gsub(/[\s_]/, '') - end - def lookup(key, currency) if locale_backend locale_backend.lookup(key, currency) || DEFAULTS[key] diff --git a/lib/money-rails/active_record/monetizable.rb b/lib/money-rails/active_record/monetizable.rb index 4b6b5febf9..8c5124d7eb 100644 --- a/lib/money-rails/active_record/monetizable.rb +++ b/lib/money-rails/active_record/monetizable.rb @@ -12,11 +12,9 @@ module ClassMethods def monetized_attributes monetized_attributes = @monetized_attributes || {}.with_indifferent_access - if superclass.respond_to?(:monetized_attributes) - monetized_attributes.merge(superclass.monetized_attributes) - else - monetized_attributes - end + return monetized_attributes unless superclass.respond_to?(:monetized_attributes) + + monetized_attributes.merge(superclass.monetized_attributes) end def monetize(*fields) @@ -32,7 +30,7 @@ def monetize(*fields) ":with_currency or :with_model_currency") end - name = options[:as] || options[:target_name] || nil + name = options[:as] || options[:target_name] # Form target name for the money backed ActiveModel field: # if a target name is provided then use it @@ -46,34 +44,38 @@ def monetize(*fields) if name == subunit_name raise ArgumentError, "monetizable attribute name cannot be the same as options[:as] parameter" end - - elsif subunit_name =~ /#{MoneyRails::Configuration.amount_column[:postfix]}$/ - name = subunit_name.sub(/#{MoneyRails::Configuration.amount_column[:postfix]}$/, "") else - raise ArgumentError, "Unable to infer the name of the monetizable attribute for '#{subunit_name}'. " \ - "Expected amount column postfix is '#{MoneyRails::Configuration.amount_column[:postfix]}'. " \ - "Use :as option to explicitly specify the name or change the amount column postfix in the initializer." + amount_column_postfix = MoneyRails::Configuration.amount_column[:postfix] + + if subunit_name =~ /#{amount_column_postfix}$/ + name = subunit_name.sub(/#{amount_column_postfix}$/, "") + else + raise ArgumentError, "Unable to infer the name of the monetizable attribute for '#{subunit_name}'. " \ + "Expected amount column postfix is '#{amount_column_postfix}'. " \ + "Use :as option to explicitly specify the name or change the amount column postfix in the initializer." + end end # Optional accessor to be run on an instance to detect currency instance_currency_name = options[:with_model_currency] || - options[:model_currency] || - MoneyRails::Configuration.currency_column[:column_name] + options[:model_currency] || + MoneyRails::Configuration.currency_column[:column_name] # Infer currency column from name and postfix - if !instance_currency_name && MoneyRails::Configuration.currency_column[:postfix].present? - instance_currency_name = "#{name}#{MoneyRails::Configuration.currency_column[:postfix]}" - end + currency_column_postfix = MoneyRails::Configuration.currency_column[:postfix] - instance_currency_name = instance_currency_name && instance_currency_name.to_s + if instance_currency_name + instance_currency_name = instance_currency_name.to_s + elsif currency_column_postfix.present? + instance_currency_name = "#{name}#{currency_column_postfix}" + end # This attribute allows per column currency values # Overrides row and default currency - field_currency_name = options[:with_currency] || - options[:field_currency] || nil + field_currency_name = options[:with_currency] || options[:field_currency] # Create a reverse mapping of the monetized attributes - track_monetized_attribute name, subunit_name + track_monetized_attribute(name, subunit_name) # Include numericality validations if needed. # There are two validation options: @@ -97,48 +99,46 @@ def monetize(*fields) # # To disable validation entirely, use :disable_validation, E.g: # monetize :price_in_a_range_cents, disable_validation: true - if (validation_enabled = MoneyRails.include_validations && !options[:disable_validation]) - - # This is a validation for the subunit - if (subunit_numericality = options.fetch(:subunit_numericality, true)) - validates subunit_name, { - allow_nil: options[:allow_nil], - numericality: subunit_numericality - } + validation_enabled = MoneyRails.include_validations && !options[:disable_validation] + + if validation_enabled + validate_subunit_numericality = options.fetch(:subunit_numericality, true) + validate_numericality = options.fetch(:numericality, true) + + if validate_subunit_numericality + validates subunit_name, allow_nil: options[:allow_nil], + numericality: validate_subunit_numericality end # Allow only Money objects or Numeric values! - if (numericality = options.fetch(:numericality, true)) - validates name.to_sym, { - allow_nil: options[:allow_nil], - 'money_rails/active_model/money' => numericality - } + if validate_numericality + validates name.to_sym, allow_nil: options[:allow_nil], + 'money_rails/active_model/money' => validate_numericality end end - # Getter for monetized attribute define_method name do |*args, **kwargs| - read_monetized name, subunit_name, options, *args, **kwargs + read_monetized(name, subunit_name, options, *args, **kwargs) end # Setter for monetized attribute define_method "#{name}=" do |value| - write_monetized name, subunit_name, value, validation_enabled, instance_currency_name, options + write_monetized(name, subunit_name, value, validation_enabled, instance_currency_name, options) end if validation_enabled # Ensure that the before_type_cast value is cleared when setting # the subunit value directly define_method "#{subunit_name}=" do |value| - instance_variable_set "@#{name}_money_before_type_cast", nil + instance_variable_set("@#{name}_money_before_type_cast", nil) write_attribute(subunit_name, value) end end # Currency getter define_method "currency_for_#{name}" do - currency_for name, instance_currency_name, field_currency_name + currency_for(name, instance_currency_name, field_currency_name) end attr_reader "#{name}_money_before_type_cast" @@ -146,7 +146,7 @@ def monetize(*fields) # Hook to ensure the reset of before_type_cast attr # TODO: think of a better way to avoid this after_save do - instance_variable_set "@#{name}_money_before_type_cast", nil + instance_variable_set("@#{name}_money_before_type_cast", nil) end end end @@ -185,12 +185,12 @@ def read_monetized(name, subunit_name, options = nil, *args, **kwargs) kwargs = {} end - if kwargs.any? - amount = public_send(subunit_name, *args, **kwargs) - else - # Ruby 2.x does not allow empty kwargs - amount = public_send(subunit_name, *args) - end + amount = if kwargs.any? + public_send(subunit_name, *args, **kwargs) + else + # Ruby 2.x does not allow empty kwargs + public_send(subunit_name, *args) + end return if amount.nil? && options[:allow_nil] # Get the currency object @@ -218,7 +218,8 @@ def read_monetized(name, subunit_name, options = nil, *args, **kwargs) end if MoneyRails::Configuration.preserve_user_input - value_before_type_cast = instance_variable_get "@#{name}_money_before_type_cast" + value_before_type_cast = instance_variable_get("@#{name}_money_before_type_cast") + if errors.has_key?(name.to_sym) result.define_singleton_method(:to_s) { value_before_type_cast } result.define_singleton_method(:format) { |_| value_before_type_cast } @@ -230,23 +231,21 @@ def read_monetized(name, subunit_name, options = nil, *args, **kwargs) def write_monetized(name, subunit_name, value, validation_enabled, instance_currency_name, options) # Keep before_type_cast value as a reference to original input - instance_variable_set "@#{name}_money_before_type_cast", value + instance_variable_set("@#{name}_money_before_type_cast", value) # Use nil or get a Money object if options[:allow_nil] && value.blank? money = nil + elsif value.is_a?(Money) + money = value else - if value.is_a?(Money) - money = value - else - begin - money = value.to_money(public_send("currency_for_#{name}")) - rescue NoMethodError - return nil - rescue Money::Currency::UnknownCurrency, Monetize::ParseError => e - raise MoneyRails::Error, e.message if MoneyRails.raise_error_on_money_parsing - return nil - end + begin + money = value.to_money(public_send("currency_for_#{name}")) + rescue NoMethodError + return nil + rescue Money::Currency::UnknownCurrency, Monetize::ParseError => e + raise MoneyRails::Error, e.message if MoneyRails.raise_error_on_money_parsing + return nil end end @@ -273,26 +272,38 @@ def write_monetized(name, subunit_name, value, validation_enabled, instance_curr public_send("#{instance_currency_name}=", money_currency.iso_code) else current_currency = public_send("currency_for_#{name}") + if current_currency != money_currency.id - raise ReadOnlyCurrencyException.new("Can't change readonly currency '#{current_currency}' to '#{money_currency}' for field '#{name}'") if MoneyRails.raise_error_on_money_parsing + if MoneyRails.raise_error_on_money_parsing + raise ReadOnlyCurrencyException, + "Can't change readonly currency '#{current_currency}' to '#{money_currency}' for field '#{name}'" + end + return nil end end end # Save and return the new Money object - instance_variable_set "@#{name}", money + instance_variable_set("@#{name}", money) end def currency_for(name, instance_currency_name, field_currency_name) - if instance_currency_name.present? && respond_to?(instance_currency_name) && - Money::Currency.find(public_send(instance_currency_name)) - - Money::Currency.find(public_send(instance_currency_name)) - elsif field_currency_name.respond_to?(:call) - Money::Currency.find(field_currency_name.call(self)) - elsif field_currency_name - Money::Currency.find(field_currency_name) + if instance_currency_name.present? && respond_to?(instance_currency_name) + currency_name = public_send(instance_currency_name) + currency = Money::Currency.find(currency_name) + + return currency if currency + end + + if field_currency_name + currency_name = if field_currency_name.respond_to?(:call) + field_currency_name.call(self) + else + field_currency_name + end + + Money::Currency.find(currency_name) elsif self.class.respond_to?(:currency) self.class.currency else diff --git a/spec/active_record/monetizable/currency_for_spec.rb b/spec/active_record/monetizable/currency_for_spec.rb new file mode 100644 index 0000000000..ae92684092 --- /dev/null +++ b/spec/active_record/monetizable/currency_for_spec.rb @@ -0,0 +1,55 @@ +# encoding: utf-8 + +require 'spec_helper' + +require_relative 'money_helpers' + +if defined? ActiveRecord + describe MoneyRails::ActiveRecord::Monetizable do + include MoneyHelpers + + describe "#currency_for" do + context "when detecting currency based on different conditions" do + it "detects currency based on instance currency name" do + product = Product.new(sale_price_currency_code: 'CAD') + currency = product.send(:currency_for, :sale_price, :sale_price_currency_code, nil) + + expect_to_be_a_currency_instance(currency) + expect_currency_iso_code(currency, 'CAD') + end + + it "detects currency based on currency passed as a block" do + product = Product.new + currency = product.send(:currency_for, :lambda_price, nil, ->(_) { 'CAD' }) + + expect_to_be_a_currency_instance(currency) + expect_currency_iso_code(currency, 'CAD') + end + + it "detects currency based on currency passed explicitly" do + product = Product.new + currency = product.send(:currency_for, :bonus, nil, 'CAD') + + expect_to_be_a_currency_instance(currency) + expect_currency_iso_code(currency, 'CAD') + end + end + + context "when falling back to a default or registered currency" do + it "falls back to a registered currency" do + product = Product.new + currency = product.send(:currency_for, :amount, nil, nil) + + expect_equal_currency(currency, Product.currency) + end + + it "falls back to a default currency" do + transaction = Transaction.new + currency = transaction.send(:currency_for, :amount, nil, nil) + + expect_equal_currency(currency, Money.default_currency) + end + end + end + end +end diff --git a/spec/active_record/monetizable_spec.rb b/spec/active_record/monetizable/monetize_spec.rb similarity index 62% rename from spec/active_record/monetizable_spec.rb rename to spec/active_record/monetizable/monetize_spec.rb index 85dd378fbe..0ddf4d2cbb 100644 --- a/spec/active_record/monetizable_spec.rb +++ b/spec/active_record/monetizable/monetize_spec.rb @@ -2,18 +2,16 @@ require 'spec_helper' +require_relative 'money_helpers' +require_relative 'shared_contexts' + class Sub < Product; end if defined? ActiveRecord describe MoneyRails::ActiveRecord::Monetizable do - let(:product) do - Product.create(price_cents: 3000, discount: 150, - bonus_cents: 200, optional_price: 100, - sale_price_amount: 1200, delivery_fee_cents: 100, - restock_fee_cents: 2000, - reduced_price_cents: 1500, reduced_price_currency: :lvl, - lambda_price_cents: 4000) - end + include MoneyHelpers + + include_context "monetizable product setup" describe ".monetize" do let(:service) do @@ -28,81 +26,61 @@ def update_product(*attributes) end end - context ".monetized_attributes" do - - class InheritedMonetizeProduct < Product - monetize :special_price_cents - end - - it "should be inherited by subclasses" do - assert_monetized_attributes(Sub.monetized_attributes, Product.monetized_attributes) - end - - it "should be inherited by subclasses with new monetized attribute" do - assert_monetized_attributes(InheritedMonetizeProduct.monetized_attributes, Product.monetized_attributes.merge(special_price: "special_price_cents")) - end - - def assert_monetized_attributes(monetized_attributes, expected_attributes) - expect(monetized_attributes).to include expected_attributes - expect(expected_attributes).to include monetized_attributes - expect(monetized_attributes.size).to eql expected_attributes.size - monetized_attributes.keys.each do |key| - expect(key.is_a? String).to be_truthy - end - end - end - it "attaches a Money object to model field" do - expect(product.price).to be_an_instance_of(Money) - expect(product.discount_value).to be_an_instance_of(Money) - expect(product.bonus).to be_an_instance_of(Money) + expect_to_have_money_attributes(product, :price, :discount_value, :bonus) end it "attaches Money objects to multiple model fields" do - expect(product.delivery_fee).to be_an_instance_of(Money) - expect(product.restock_fee).to be_an_instance_of(Money) + expect_to_have_money_attributes(product, :delivery_fee, :restock_fee) end it "returns the expected money amount as a Money object" do - expect(product.price).to eq(Money.new(3000, "USD")) + expect_equal_money_instance(product.price, amount: 3000, currency: "USD") end it "assigns the correct value from a Money object" do product.price = Money.new(3210, "USD") + expect(product.save).to be_truthy - expect(product.price_cents).to eq(3210) + expect_money_attribute_cents_value(product, :price, 3210) end it "assigns the correct value from a Money object using create" do - product = Product.create(price: Money.new(3210, "USD"), discount: 150, - bonus_cents: 200, optional_price: 100) + product = Product.create( + price: Money.new(3210, "USD"), + discount: 150, + bonus_cents: 200, + optional_price: 100 + ) + expect(product.valid?).to be_truthy - expect(product.price_cents).to eq(3210) + expect_money_attribute_cents_value(product, :price, 3210) end it "correctly updates from a Money object using update_attributes" do expect(update_product(price: Money.new(215, "USD"))).to be_truthy - expect(product.price_cents).to eq(215) + expect_money_attribute_cents_value(product, :price, 215) end it "assigns the correct value from params" do params_clp = { amount: '20000', tax: '1000', currency: 'CLP' } product = Transaction.create(params_clp) + expect(product.valid?).to be_truthy expect(product.amount.currency.subunit_to_unit).to eq(1) - expect(product.amount_cents).to eq(20000) + expect_money_attribute_cents_value(product, :amount, 20000) end # TODO: This is a slightly controversial example, btu it reflects the current behaviour it "re-assigns cents amount when subunit/unit ratio changes preserving amount in units" do transaction = Transaction.create(amount: '20000', tax: '1000', currency: 'USD') - expect(transaction.amount).to eq(Money.new(20000_00, 'USD')) + expect_equal_money_instance(transaction.amount, amount: 20000_00, currency: 'USD') transaction.currency = 'CLP' - expect(transaction.amount).to eq(Money.new(20000, 'CLP')) - expect(transaction.amount_cents).to eq(20000) + expect_equal_money_instance(transaction.amount, amount: 20000, currency: 'CLP') + expect_money_attribute_cents_value(transaction, :amount, 20000) end it "raises an error if trying to create two attributes with the same name" do @@ -141,12 +119,12 @@ class SubProduct < Product sub_product = SubProduct.new(discount: 100) - expect(sub_product.discount_price).to be_an_instance_of(Money) + expect_to_be_a_money_instance(sub_product.discount_price) expect(sub_product.discount_price.currency.id).to equal :gbp end it "respects :as argument" do - expect(product.discount_value).to eq(Money.new(150, "USD")) + expect_equal_money_instance(product.discount_value, amount: 150, currency: "USD") end it "uses numericality validation" do @@ -402,14 +380,16 @@ class SubProduct < Product it "passes validation when amount contains spaces (999 999.99)" do product.price = "999 999.99" + expect(product).to be_valid - expect(product.price_cents).to eq(99999999) + expect_money_attribute_cents_value(product, :price, 99999999) end it "passes validation when amount contains underscores (999_999.99)" do product.price = "999_999.99" + expect(product).to be_valid - expect(product.price_cents).to eq(99999999) + expect_money_attribute_cents_value(product, :price, 99999999) end it "passes validation if money value has correct format" do @@ -429,12 +409,14 @@ class SubProduct < Product it "uses i18n currency format when validating" do old_locale = I18n.locale - I18n.locale = "en-GB" Money.default_currency = Money::Currency.find('EUR') - expect("12.00".to_money).to eq(Money.new(1200, :eur)) + + expect_equal_money_instance("12.00".to_money, amount: 1200, currency: :eur) + transaction = Transaction.new(amount: "12.00", tax: "13.00") - expect(transaction.amount_cents).to eq(1200) + + expect_money_attribute_cents_value(transaction, :amount, 1200) expect(transaction.valid?).to be_truthy # reset locale setting @@ -482,115 +464,121 @@ class SubProduct < Product end it "uses Money default currency if :with_currency has not been used" do - expect(service.discount.currency).to eq(Money::Currency.find(:eur)) + expect_money_currency_is(service.discount, :eur) end it "overrides default currency with the currency registered for the model" do - expect(product.price.currency).to eq(Money::Currency.find(:usd)) + expect_money_currency_is(product.price, :usd) end it "overrides default currency with the value of :with_currency argument" do - expect(service.charge.currency).to eq(Money::Currency.find(:usd)) - expect(product.bonus.currency).to eq(Money::Currency.find(:gbp)) + expect_money_currency_is(service.charge, :usd) + expect_money_currency_is(product.bonus, :gbp) end it "uses currency postfix to determine attribute that stores currency" do - expect(product.reduced_price.currency).to eq(Money::Currency.find(:lvl)) + expect_money_currency_is(product.reduced_price, :lvl) end it "correctly assigns Money objects to the attribute" do product.price = Money.new(2500, :USD) + expect(product.save).to be_truthy - expect(product.price.cents).to eq(2500) - expect(product.price.currency.to_s).to eq("USD") + expect_money_cents_value(product.price, 2500) + expect_money_currency_code(product.price, "USD") end it "correctly assigns Fixnum objects to the attribute" do product.price = 25 expect(product.save).to be_truthy - expect(product.price.cents).to eq(2500) - expect(product.price.currency.to_s).to eq("USD") + expect_money_cents_value(product.price, 2500) + expect_money_currency_code(product.price, "USD") service.discount = 2 expect(service.save).to be_truthy - expect(service.discount.cents).to eq(200) - expect(service.discount.currency.to_s).to eq("EUR") + expect_money_cents_value(service.discount, 200) + expect_money_currency_code(service.discount, "EUR") end it "correctly assigns String objects to the attribute" do product.price = "25" expect(product.save).to be_truthy - expect(product.price.cents).to eq(2500) - expect(product.price.currency.to_s).to eq("USD") + expect_money_cents_value(product.price, 2500) + expect_money_currency_code(product.price, "USD") service.discount = "2" expect(service.save).to be_truthy - expect(service.discount.cents).to eq(200) - expect(service.discount.currency.to_s).to eq("EUR") + expect_money_cents_value(service.discount, 200) + expect_money_currency_code(service.discount, "EUR") end it "correctly assigns objects to a accessor attribute" do product.accessor_price = 1.23 + expect(product.save).to be_truthy - expect(product.accessor_price.cents).to eq(123) - expect(product.accessor_price_cents).to eq(123) + expect_money_cents_value(product.accessor_price, 123) + expect_money_attribute_cents_value(product, :accessor_price, 123) end it "overrides default, model currency with the value of :with_currency in fixnum assignments" do product.bonus = 25 expect(product.save).to be_truthy - expect(product.bonus.cents).to eq(2500) - expect(product.bonus.currency.to_s).to eq("GBP") + expect_money_cents_value(product.bonus, 2500) + expect_money_currency_code(product.bonus, "GBP") service.charge = 2 expect(service.save).to be_truthy - expect(service.charge.cents).to eq(200) - expect(service.charge.currency.to_s).to eq("USD") + expect_money_cents_value(service.charge, 200) + expect_money_currency_code(service.charge, "USD") end it "overrides default, model currency with the value of :with_currency in string assignments" do product.bonus = "25" expect(product.save).to be_truthy - expect(product.bonus.cents).to eq(2500) - expect(product.bonus.currency.to_s).to eq("GBP") + expect_money_cents_value(product.bonus, 2500) + expect_money_currency_code(product.bonus, "GBP") service.charge = "2" expect(service.save).to be_truthy - expect(service.charge.cents).to eq(200) - expect(service.charge.currency.to_s).to eq("USD") + expect_money_cents_value(service.charge, 200) + expect_money_currency_code(service.charge, "USD") product.lambda_price = "32" expect(product.save).to be_truthy - expect(product.lambda_price.cents).to eq(3200) - expect(product.lambda_price.currency.to_s).to eq("CAD") + expect_money_cents_value(product.lambda_price, 3200) + expect_money_currency_code(product.lambda_price, "CAD") end it "overrides default currency with model currency, in fixnum assignments" do product.discount_value = 5 + expect(product.save).to be_truthy - expect(product.discount_value.cents).to eq(500) - expect(product.discount_value.currency.to_s).to eq("USD") + expect_money_cents_value(product.discount_value, 500) + expect_money_currency_code(product.discount_value, "USD") end it "overrides default currency with model currency, in string assignments" do product.discount_value = "5" + expect(product.save).to be_truthy - expect(product.discount_value.cents).to eq(500) - expect(product.discount_value.currency.to_s).to eq("USD") + expect_money_cents_value(product.discount_value, 500) + expect_money_currency_code(product.discount_value, "USD") end it "falls back to default currency, in fixnum assignments" do service.discount = 5 + expect(service.save).to be_truthy - expect(service.discount.cents).to eq(500) - expect(service.discount.currency.to_s).to eq("EUR") + expect_money_cents_value(service.discount, 500) + expect_money_currency_code(service.discount, "EUR") end it "falls back to default currency, in string assignments" do service.discount = "5" + expect(service.save).to be_truthy - expect(service.discount.cents).to eq(500) - expect(service.discount.currency.to_s).to eq("EUR") + expect_money_cents_value(service.discount, 500) + expect_money_currency_code(service.discount, "EUR") end it "sets field to nil, in nil assignments if allow_nil is set" do @@ -617,20 +605,23 @@ class SubProduct < Product it "writes the subunits to the original (unaliased) column" do pending if Rails::VERSION::MAJOR < 4 product.renamed = "$10.00" - expect(product.aliased_cents).to eq 10_00 + + expect_money_attribute_cents_value(product, :aliased, 10_00) end end context "for column with model currency:" do it "has default currency if not specified" do product = Product.create(sale_price_amount: 1234) - product.sale_price.currency.to_s == 'USD' + + expect_money_currency_code(product.sale_price, 'USD') end it "is overridden by instance currency column" do product = Product.create(sale_price_amount: 1234, sale_price_currency_code: 'CAD') - expect(product.sale_price.currency.to_s).to eq('CAD') + + expect_money_currency_code(product.sale_price, 'CAD') end it 'can change currency of custom column' do @@ -642,14 +633,14 @@ class SubProduct < Product sale_price_currency_code: 'USD' ) - expect(product.sale_price.currency.to_s).to eq('USD') + expect_money_currency_code(product.sale_price, 'USD') product.sale_price = Money.new 456, 'CAD' product.save product.reload - expect(product.sale_price.currency.to_s).to eq('CAD') - expect(product.discount_value.currency.to_s).to eq('USD') + expect_money_currency_code(product.sale_price, 'CAD') + expect_money_currency_code(product.discount_value, 'USD') end end @@ -681,65 +672,64 @@ class SubProduct < Product end it "overrides default currency with the value of row currency" do - expect(transaction.amount.currency).to eq(Money::Currency.find(:usd)) + expect_money_currency_is(transaction.amount, :usd) end it "overrides default currency with the currency registered for the model" do - expect(dummy_product_with_nil_currency.price.currency).to eq( - Money::Currency.find(:gbp) - ) + expect_money_currency_is(dummy_product_with_nil_currency.price, :gbp) end it "overrides default currency with the currency registered for the model if currency is invalid" do - expect(dummy_product_with_invalid_currency.price.currency).to eq( - Money::Currency.find(:gbp) - ) + expect_money_currency_is(dummy_product_with_invalid_currency.price, :gbp) end it "overrides default and model currency with the row currency" do - expect(dummy_product.price.currency).to eq(Money::Currency.find(:usd)) + expect_money_currency_is(dummy_product.price, :usd) end it "constructs the money attribute from the stored mapped attribute values" do - expect(transaction.amount).to eq(Money.new(2400, :usd)) + expect_equal_money_instance(transaction.amount, amount: 2400, currency: :usd) end it "correctly instantiates Money objects from the mapped attributes" do t = Transaction.new(amount_cents: 2500, currency: "CAD") - expect(t.amount).to eq(Money.new(2500, "CAD")) + + expect_equal_money_instance(t.amount, amount: 2500, currency: "CAD") end it "correctly assigns Money objects to the attribute" do transaction.amount = Money.new(2500, :eur) + expect(transaction.save).to be_truthy - expect(transaction.amount.cents).to eq(Money.new(2500, :eur).cents) - expect(transaction.amount.currency.to_s).to eq("EUR") + expect_equal_money_cents(transaction.amount, Money.new(2500, :eur)) + expect_money_currency_code(transaction.amount, "EUR") end it "uses default currency if a non Money object is assigned to the attribute" do transaction.amount = 234 - expect(transaction.amount.currency.to_s).to eq("USD") + + expect_money_currency_code(transaction.amount, "USD") end it "constructs the money object from the mapped method value" do - expect(transaction.total).to eq(Money.new(3000, :usd)) + expect_equal_money_instance(transaction.total, amount: 3000, currency: :usd) end it "constructs the money object from the mapped method value with arguments" do - expect(transaction.total(1, bar: 2)).to eq(Money.new(3003, :usd)) + expect_equal_money_instance(transaction.total(1, bar: 2), amount: 3003, currency: :usd) end it "allows currency column postfix to be blank" do allow(MoneyRails::Configuration).to receive(:currency_column) { { postfix: nil, column_name: 'currency' } } - expect(dummy_product_with_nil_currency.price.currency).to eq(Money::Currency.find(:gbp)) + expect_money_currency_is(dummy_product_with_nil_currency.price, :gbp) end it "updates inferred currency column based on currency column postfix" do product.reduced_price = Money.new(999_00, 'CAD') product.save - expect(product.reduced_price_cents).to eq(999_00) - expect(product.reduced_price_currency).to eq('CAD') + expect_money_attribute_cents_value(product, :reduced_price, 999_00) + expect_money_attribute_currency_value(product, :reduced_price, 'CAD') end context "and field with allow_nil: true" do @@ -810,257 +800,23 @@ class SubProduct < Product transaction.amount = "$123" expect(transaction.valid?).to be_truthy end - end - end - end - end - - describe ".register_currency" do - it "attaches currency at model level" do - expect(Product.currency).to eq(Money::Currency.find(:usd)) - expect(DummyProduct.currency).to eq(Money::Currency.find(:gbp)) - end - end - - describe "#read_monetized" do - it "returns monetized attribute's value" do - reduced_price = product.read_monetized(:reduced_price, :reduced_price_cents) - - expect(reduced_price).to be_an_instance_of(Money) - expect(reduced_price).to eq(Money.new(product.reduced_price_cents, product.reduced_price_currency)) - end - - context "memoize" do - it "memoizes monetized attribute's value" do - product.instance_variable_set '@reduced_price', nil - reduced_price = product.read_monetized(:reduced_price, :reduced_price_cents) - - expect(product.instance_variable_get('@reduced_price')).to eq(reduced_price) - end - - it "resets memoized attribute's value if amount has changed" do - reduced_price = product.read_monetized(:reduced_price, :reduced_price_cents) - product.reduced_price_cents = 100 - - expect(product.read_monetized(:reduced_price, :reduced_price_cents)).not_to eq(reduced_price) - end - - it "resets memoized attribute's value if currency has changed" do - reduced_price = product.read_monetized(:reduced_price, :reduced_price_cents) - product.reduced_price_currency = 'CAD' - - expect(product.read_monetized(:reduced_price, :reduced_price_cents)).not_to eq(reduced_price) - end - end - - context "with preserve_user_input set" do - around(:each) do |example| - MoneyRails::Configuration.preserve_user_input = true - example.run - MoneyRails::Configuration.preserve_user_input = false - end - - it "has no effect if validation passes" do - product.price = '14' - - expect(product.save).to be_truthy - expect(product.read_monetized(:price, :price_cents).to_s).to eq('14.00') - end - - it "preserves user input if validation fails" do - product.price = '14,0' - - expect(product.save).to be_falsy - expect(product.read_monetized(:price, :price_cents).to_s).to eq('14,0') - end - end - - context "with a monetized attribute that is nil" do - let(:service) do - Service.create(discount_cents: nil) - end - let(:default_currency_lambda) { double("Default Currency Fallback") } - subject { service.read_monetized(:discount, :discount_cents, options) } - - around(:each) do |example| - service # Instantiate instance which relies on Money.default_currency - original_default_currency = Money.default_currency - Money.default_currency = -> { default_currency_lambda.read_currency } - example.run - Money.default_currency = original_default_currency - end - - context "when allow_nil options is set" do - let(:options) { { allow_nil: true } } - it "does not attempt to read the fallback default currency" do - expect(default_currency_lambda).not_to receive(:read_currency) - subject - end - end - end - end - - describe "#write_monetized" do - let(:value) { Money.new(1_000, 'LVL') } - - it "sets monetized attribute's value to Money object" do - product.write_monetized :price, :price_cents, value, false, nil, {} - - expect(product.price).to be_an_instance_of(Money) - expect(product.price_cents).to eq(value.cents) - # Because :price does not have a column for currency - expect(product.price.currency).to eq(Product.currency) - end - - it "sets monetized attribute's value from a given Fixnum" do - product.write_monetized :price, :price_cents, 10, false, nil, {} - - expect(product.price).to be_an_instance_of(Money) - expect(product.price_cents).to eq(1000) - end - - it "sets monetized attribute's value from a given Float" do - product.write_monetized :price, :price_cents, 10.5, false, nil, {} - - expect(product.price).to be_an_instance_of(Money) - expect(product.price_cents).to eq(1050) - end - - it "resets monetized attribute when given blank input" do - product.write_monetized :price, :price_cents, nil, false, nil, { allow_nil: true } - - expect(product.price).to eq(nil) - end - - it "sets monetized attribute to 0 when given a blank value" do - currency = product.price.currency - product.write_monetized :price, :price_cents, nil, false, nil, {} - - expect(product.price.amount).to eq(0) - expect(product.price.currency).to eq(currency) - end - - it "does not memoize monetized attribute's value if currency is read-only" do - product.write_monetized :price, :price_cents, value, false, nil, {} - - price = product.instance_variable_get('@price') - - expect(price).to be_an_instance_of(Money) - expect(price.amount).not_to eq(value.amount) - end - - describe "instance_currency_name" do - it "updates instance_currency_name attribute" do - product.write_monetized :sale_price, :sale_price_amount, value, false, :sale_price_currency_code, {} - - expect(product.sale_price).to eq(value) - expect(product.sale_price_currency_code).to eq('LVL') - end - - it "memoizes monetized attribute's value with currency" do - product.write_monetized :sale_price, :sale_price_amount, value, false, :sale_price_currency_code, {} - - expect(product.instance_variable_get('@sale_price')).to eq(value) - end - - it "ignores empty instance_currency_name" do - product.write_monetized :sale_price, :sale_price_amount, value, false, '', {} - - expect(product.sale_price.amount).to eq(value.amount) - expect(product.sale_price.currency).to eq(Product.currency) - end - it "ignores instance_currency_name that model does not respond to" do - product.write_monetized :sale_price, :sale_price_amount, value, false, :non_existing_currency, {} + it "is valid when the monetize field is set" do + transaction.amount = 5_000 + transaction.currency = :eur - expect(product.sale_price.amount).to eq(value.amount) - expect(product.sale_price.currency).to eq(Product.currency) - end - end - - describe "error handling" do - let!(:old_price_value) { product.price } - - it "ignores values that do not implement to_money method" do - product.write_monetized :price, :price_cents, [10], false, nil, {} - - expect(product.price).to eq(old_price_value) - end - - context "raise_error_on_money_parsing enabled" do - before { MoneyRails.raise_error_on_money_parsing = true } - after { MoneyRails.raise_error_on_money_parsing = false } - - it "raises a MoneyRails::Error when given an invalid value" do - expect { - product.write_monetized :price, :price_cents, '10-50', false, nil, {} - }.to raise_error(MoneyRails::Error) - end - - it "raises a MoneyRails::Error error when trying to set invalid currency" do - allow(product).to receive(:currency_for_price).and_return('INVALID_CURRENCY') - expect { - product.write_monetized :price, :price_cents, 10, false, nil, {} - }.to raise_error(MoneyRails::Error) - end - end - - context "raise_error_on_money_parsing disabled" do - it "ignores when given invalid value" do - product.write_monetized :price, :price_cents, '10-50', false, nil, {} - - expect(product.price).to eq(old_price_value) - end + expect(transaction.valid?).to be_truthy + end - it "raises a MoneyRails::Error error when trying to set invalid currency" do - allow(product).to receive(:currency_for_price).and_return('INVALID_CURRENCY') - product.write_monetized :price, :price_cents, 10, false, nil, {} + it "is valid when the monetize field is not set" do + transaction.update(amount: 5_000, currency: :eur) + transaction.reload # reload to simulate the retrieved object - # Can not use public accessor here because currency_for_price is stubbed - expect(product.instance_variable_get('@price')).to eq(old_price_value) + expect(transaction.valid?).to be_truthy + end end end end end - - describe "#currency_for" do - it "detects currency based on instance currency name" do - product = Product.new(sale_price_currency_code: 'CAD') - currency = product.send(:currency_for, :sale_price, :sale_price_currency_code, nil) - - expect(currency).to be_an_instance_of(Money::Currency) - expect(currency.iso_code).to eq('CAD') - end - - it "detects currency based on currency passed as a block" do - product = Product.new - currency = product.send(:currency_for, :lambda_price, nil, ->(_) { 'CAD' }) - - expect(currency).to be_an_instance_of(Money::Currency) - expect(currency.iso_code).to eq('CAD') - end - - it "detects currency based on currency passed explicitly" do - product = Product.new - currency = product.send(:currency_for, :bonus, nil, 'CAD') - - expect(currency).to be_an_instance_of(Money::Currency) - expect(currency.iso_code).to eq('CAD') - end - - it "falls back to a registered currency" do - product = Product.new - currency = product.send(:currency_for, :amount, nil, nil) - - expect(currency).to eq(Product.currency) - end - - it "falls back to a default currency" do - transaction = Transaction.new - currency = transaction.send(:currency_for, :amount, nil, nil) - - expect(currency).to eq(Money.default_currency) - end - end end end diff --git a/spec/active_record/monetizable/monetized_attributes_spec.rb b/spec/active_record/monetizable/monetized_attributes_spec.rb new file mode 100644 index 0000000000..dc45de1675 --- /dev/null +++ b/spec/active_record/monetizable/monetized_attributes_spec.rb @@ -0,0 +1,35 @@ +# encoding: utf-8 + +require 'spec_helper' + +require_relative 'money_helpers' + +if defined? ActiveRecord + describe MoneyRails::ActiveRecord::Monetizable do + include MoneyHelpers + + describe ".monetized_attributes" do + + class InheritedMonetizeProduct < Product + monetize :special_price_cents + end + + it "should be inherited by subclasses" do + assert_monetized_attributes(Sub.monetized_attributes, Product.monetized_attributes) + end + + it "should be inherited by subclasses with new monetized attribute" do + assert_monetized_attributes(InheritedMonetizeProduct.monetized_attributes, Product.monetized_attributes.merge(special_price: "special_price_cents")) + end + + def assert_monetized_attributes(monetized_attributes, expected_attributes) + expect(monetized_attributes).to include expected_attributes + expect(expected_attributes).to include monetized_attributes + expect(monetized_attributes.size).to eql expected_attributes.size + monetized_attributes.keys.each do |key| + expect(key.is_a? String).to be_truthy + end + end + end + end +end diff --git a/spec/active_record/monetizable/money_helpers.rb b/spec/active_record/monetizable/money_helpers.rb new file mode 100644 index 0000000000..d869bed399 --- /dev/null +++ b/spec/active_record/monetizable/money_helpers.rb @@ -0,0 +1,91 @@ +module MoneyHelpers + # ----------------------- + # Instance Checks + # ----------------------- + + def expect_to_be_a_money_instance(value) + expect(value).to be_an_instance_of(Money) + end + + def expect_to_have_money_attributes(model, *attributes) + attributes.each do |attribute| + expect_to_be_a_money_instance(model.send(attribute)) + end + end + + def expect_to_be_a_currency_instance(value) + expect(value).to be_an_instance_of(Money::Currency) + end + + # ----------------------- + # Currency Checks + # ----------------------- + + def expect_currency_is(currency, currency_symbol) + expected_currency = Money::Currency.find(currency_symbol) + + expect_equal_currency(currency, expected_currency) + end + + def expect_equal_currency(currency, expected_currency) + expect_to_be_a_currency_instance(expected_currency) + + expect(currency).to eq(expected_currency) + end + + def expect_currency_iso_code(currency, expected_iso_code) + expect(currency.iso_code).to eq(expected_iso_code) + end + + def expect_money_currency_is(value, currency_symbol) + expect_currency_is(value.currency, currency_symbol) + end + + def expect_equal_money_currency(value, expected_value) + expect(value.currency).to eq(expected_value.currency) + end + + def expect_money_currency_code(value, expected_currency_code) + expect(value.currency.to_s).to eq(expected_currency_code) + end + + # ----------------------- + # Money Value Checks + # ----------------------- + + def expect_equal_money_instance(current_money, amount:, currency:) + expected_money = Money.new(amount, currency) + + expect(current_money).to eq(expected_money) + end + + def expect_equal_money(current_money, expected_money) + expect_to_be_a_money_instance(expected_money) + + expect(current_money).to eq(expected_money) + end + + def expect_money_cents_value(model, expected_cents) + expect(model.cents).to eq(expected_cents) + end + + def expect_equal_money_cents(model, expected_model) + expect_money_cents_value(model, expected_model.cents) + end + + # ----------------------- + # Money Attribute Checks + # ----------------------- + + def expect_money_attribute_cents_value(model, money_attribute, expected_cents) + model_cents = model.send("#{money_attribute}_cents") + + expect(model_cents).to eq(expected_cents) + end + + def expect_money_attribute_currency_value(model, money_attribute, expected_currency) + model_currency = model.send("#{money_attribute}_currency") + + expect(model_currency).to eq(expected_currency) + end +end diff --git a/spec/active_record/monetizable/read_monetized_spec.rb b/spec/active_record/monetizable/read_monetized_spec.rb new file mode 100644 index 0000000000..635602f5ea --- /dev/null +++ b/spec/active_record/monetizable/read_monetized_spec.rb @@ -0,0 +1,101 @@ +# encoding: utf-8 + +require 'spec_helper' + +require_relative 'money_helpers' +require_relative 'shared_contexts' + +if defined? ActiveRecord + describe MoneyRails::ActiveRecord::Monetizable do + include MoneyHelpers + + include_context "monetizable product setup" + + describe "#read_monetized" do + context "when reading monetized attributes" do + let(:reduced_price) { product.read_monetized(:reduced_price, :reduced_price_cents) } + + it "returns a Money object for monetized attribute" do + expect_to_be_a_money_instance(reduced_price) + end + + it "returns monetized attribute with correct amount and currency" do + expect_equal_money_instance(reduced_price, amount: product.reduced_price_cents, currency: product.reduced_price_currency) + end + end + + describe "memoizing monetized attribute values" do + it "memoizes monetized attribute's value" do + product.instance_variable_set '@reduced_price', nil + reduced_price = product.read_monetized(:reduced_price, :reduced_price_cents) + memoized_reduced_price = product.instance_variable_get('@reduced_price') + + expect_equal_money(memoized_reduced_price, reduced_price) + end + + context "when resetting memoized values" do + it "resets the memoized value when the amount changes" do + old_reduced_price = product.read_monetized(:reduced_price, :reduced_price_cents) + product.reduced_price_cents = 100 + new_reduced_price = product.read_monetized(:reduced_price, :reduced_price_cents) + + expect(new_reduced_price).not_to eq(old_reduced_price) + end + + it "resets the memoized value when the currency changes" do + old_reduced_price = product.read_monetized(:reduced_price, :reduced_price_cents) + product.reduced_price_currency = 'CAD' + new_reduced_price = product.read_monetized(:reduced_price, :reduced_price_cents) + + expect(new_reduced_price).not_to eq(old_reduced_price) + end + end + end + + context "with preserve_user_input enabled" do + around(:each) do |example| + MoneyRails::Configuration.preserve_user_input = true + example.run + MoneyRails::Configuration.preserve_user_input = false + end + + it "has no effect if validation passes" do + product.price = '14' + + expect(product.save).to be_truthy + expect(product.read_monetized(:price, :price_cents).to_s).to eq('14.00') + end + + it "preserves user input if validation fails" do + product.price = '14,0' + + expect(product.save).to be_falsy + expect(product.read_monetized(:price, :price_cents).to_s).to eq('14,0') + end + end + + context "with a monetized attribute that is nil" do + let(:service) { Service.create(discount_cents: nil) } + let(:default_currency_lambda) { double("Default Currency Fallback") } + subject { service.read_monetized(:discount, :discount_cents, options) } + + around(:each) do |example| + service # Instantiate instance which relies on Money.default_currency + original_default_currency = Money.default_currency + Money.default_currency = -> { default_currency_lambda.read_currency } + example.run + Money.default_currency = original_default_currency + end + + context "when allow_nil enabled" do + let(:options) { { allow_nil: true } } + + it "does not attempt to read the fallback default currency" do + expect(default_currency_lambda).not_to receive(:read_currency) + subject + end + end + end + end + end +end diff --git a/spec/active_record/monetizable/register_currency_spec.rb b/spec/active_record/monetizable/register_currency_spec.rb new file mode 100644 index 0000000000..a06e4480d9 --- /dev/null +++ b/spec/active_record/monetizable/register_currency_spec.rb @@ -0,0 +1,18 @@ +# encoding: utf-8 + +require 'spec_helper' + +require_relative 'money_helpers' + +if defined? ActiveRecord + describe MoneyRails::ActiveRecord::Monetizable do + include MoneyHelpers + + describe ".register_currency" do + it "attaches currency at model level" do + expect_money_currency_is(Product, :usd) + expect_money_currency_is(DummyProduct, :gbp) + end + end + end +end diff --git a/spec/active_record/monetizable/shared_contexts.rb b/spec/active_record/monetizable/shared_contexts.rb new file mode 100644 index 0000000000..bfaf72b7c4 --- /dev/null +++ b/spec/active_record/monetizable/shared_contexts.rb @@ -0,0 +1,16 @@ +RSpec.shared_context "monetizable product setup" do + let(:product) do + Product.create( + price_cents: 3000, + discount: 150, + bonus_cents: 200, + optional_price: 100, + sale_price_amount: 1200, + delivery_fee_cents: 100, + restock_fee_cents: 2000, + reduced_price_cents: 1500, + reduced_price_currency: :lvl, + lambda_price_cents: 4000 + ) + end +end diff --git a/spec/active_record/monetizable/write_monetized_spec.rb b/spec/active_record/monetizable/write_monetized_spec.rb new file mode 100644 index 0000000000..170d29744d --- /dev/null +++ b/spec/active_record/monetizable/write_monetized_spec.rb @@ -0,0 +1,137 @@ +# encoding: utf-8 + +require 'spec_helper' + +require_relative 'money_helpers' +require_relative 'shared_contexts' + +if defined? ActiveRecord + describe MoneyRails::ActiveRecord::Monetizable do + include MoneyHelpers + + include_context "monetizable product setup" + + describe "#write_monetized" do + let(:value) { Money.new(1_000, 'LVL') } + + it "sets monetized attribute's value to Money object" do + product.write_monetized :price, :price_cents, value, false, nil, {} + + expect_to_be_a_money_instance(product.price) + expect_money_attribute_cents_value(product, :price, value.cents) + # Because :price does not have a column for currency + expect_equal_money_currency(product.price, Product) + end + + it "sets monetized attribute's value from a given Fixnum" do + product.write_monetized :price, :price_cents, 10, false, nil, {} + + expect_to_be_a_money_instance(product.price) + expect_money_attribute_cents_value(product, :price, 1000) + end + + it "sets monetized attribute's value from a given Float" do + product.write_monetized :price, :price_cents, 10.5, false, nil, {} + + expect_to_be_a_money_instance(product.price) + expect_money_attribute_cents_value(product, :price, 1050) + end + + it "resets monetized attribute when given blank input" do + product.write_monetized :price, :price_cents, nil, false, nil, { allow_nil: true } + + expect(product.price).to eq(nil) + end + + it "sets monetized attribute to 0 when given a blank value" do + currency = product.price.currency + product.write_monetized :price, :price_cents, nil, false, nil, {} + + expect(product.price.amount).to eq(0) + expect_equal_currency(product.price.currency, currency) + end + + it "does not memoize monetized attribute's value if currency is read-only" do + product.write_monetized :price, :price_cents, value, false, nil, {} + + price = product.instance_variable_get('@price') + + expect_to_be_a_money_instance(price) + expect(price.amount).not_to eq(value.amount) + end + + describe "instance_currency_name" do + it "updates instance_currency_name attribute" do + product.write_monetized :sale_price, :sale_price_amount, value, false, :sale_price_currency_code, {} + + expect_equal_money(product.sale_price, value) + expect(product.sale_price_currency_code).to eq('LVL') + end + + it "memoizes monetized attribute's value with currency" do + product.write_monetized :sale_price, :sale_price_amount, value, false, :sale_price_currency_code, {} + + expect_equal_money(product.instance_variable_get('@sale_price'), value) + end + + it "ignores empty instance_currency_name" do + product.write_monetized :sale_price, :sale_price_amount, value, false, '', {} + + expect(product.sale_price.amount).to eq(value.amount) + expect_equal_money_currency(product.sale_price, Product) + end + + it "ignores instance_currency_name that model does not respond to" do + product.write_monetized :sale_price, :sale_price_amount, value, false, :non_existing_currency, {} + + expect(product.sale_price.amount).to eq(value.amount) + expect_equal_money_currency(product.sale_price, Product) + end + end + + describe "error handling" do + let!(:old_price_value) { product.price } + + it "ignores values that do not implement to_money method" do + product.write_monetized :price, :price_cents, [10], false, nil, {} + + expect_equal_money(product.price, old_price_value) + end + + context "raise_error_on_money_parsing enabled" do + before { MoneyRails.raise_error_on_money_parsing = true } + after { MoneyRails.raise_error_on_money_parsing = false } + + it "raises a MoneyRails::Error when given an invalid value" do + expect { + product.write_monetized :price, :price_cents, '10-50', false, nil, {} + }.to raise_error(MoneyRails::Error) + end + + it "raises a MoneyRails::Error error when trying to set invalid currency" do + allow(product).to receive(:currency_for_price).and_return('INVALID_CURRENCY') + expect { + product.write_monetized :price, :price_cents, 10, false, nil, {} + }.to raise_error(MoneyRails::Error) + end + end + + context "raise_error_on_money_parsing disabled" do + it "ignores when given invalid value" do + product.write_monetized :price, :price_cents, '10-50', false, nil, {} + + expect_equal_money(product.price, old_price_value) + end + + it "raises a MoneyRails::Error error when trying to set invalid currency" do + allow(product).to receive(:currency_for_price).and_return('INVALID_CURRENCY') + product.write_monetized :price, :price_cents, 10, false, nil, {} + + # Cannot use public accessor here because currency_for_price is stubbed + expect_equal_money(product.instance_variable_get('@price'), old_price_value) + end + end + end + end + end +end diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index 7fb36f0b8c..896d95a25d 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -126,8 +126,15 @@ end describe "rounding mode" do - [BigDecimal::ROUND_UP, BigDecimal::ROUND_DOWN, BigDecimal::ROUND_HALF_UP, BigDecimal::ROUND_HALF_DOWN, - BigDecimal::ROUND_HALF_EVEN, BigDecimal::ROUND_CEILING, BigDecimal::ROUND_FLOOR].each do |mode| + [ + BigDecimal::ROUND_UP, + BigDecimal::ROUND_DOWN, + BigDecimal::ROUND_HALF_UP, + BigDecimal::ROUND_HALF_DOWN, + BigDecimal::ROUND_HALF_EVEN, + BigDecimal::ROUND_CEILING, + BigDecimal::ROUND_FLOOR + ].each do |mode| context "when set to #{mode}" do it "sets Money.rounding mode to #{mode}" do MoneyRails.rounding_mode = mode @@ -142,6 +149,5 @@ end end end - end end diff --git a/spec/dummy/app/models/transaction.rb b/spec/dummy/app/models/transaction.rb index 4b1fe7983a..b2627d7bc1 100644 --- a/spec/dummy/app/models/transaction.rb +++ b/spec/dummy/app/models/transaction.rb @@ -1,5 +1,14 @@ class Transaction < ActiveRecord::Base - monetize :amount_cents, with_model_currency: :currency + monetize :amount_cents, with_model_currency: :currency, + subunit_numericality: { + only_integer: true, + greater_than: 0, + less_than_or_equal_to: 2_000_000, + }, + numericality: { + greater_than: 0, + less_than_or_equal_to: 20_000 + } monetize :tax_cents, with_model_currency: :currency diff --git a/spec/support/shared_examples/currency_shared_examples.rb b/spec/support/shared_examples/currency_shared_examples.rb new file mode 100644 index 0000000000..85db6eaa09 --- /dev/null +++ b/spec/support/shared_examples/currency_shared_examples.rb @@ -0,0 +1,22 @@ +RSpec.shared_examples "currency detection" do |&block| + it "detects currency based on instance currency name" do + product = Product.new(sale_price_currency_code: 'CAD') + currency = product.send(:currency_for, :sale_price, :sale_price_currency_code, nil) + + block.call(product, currency) + end + + it "detects currency based on currency passed as a block" do + product = Product.new + currency = product.send(:currency_for, :lambda_price, nil, ->(_) { 'CAD' }) + + block.call(product, currency) + end + + it "detects currency based on currency passed explicitly" do + product = Product.new + currency = product.send(:currency_for, :bonus, nil, 'CAD') + + block.call(product, currency) + end +end