From 1a42eef05197e8b7f47f09655feb33e7dd33dce3 Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Thu, 3 Sep 2026 10:41:59 -0400 Subject: [PATCH 01/20] Add explicit decimal precision to Money Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92 --- README.md | 7 ++ lib/money/errors.rb | 3 + lib/money/money.rb | 74 +++++++++++------- lib/money/rails/job_argument_serializer.rb | 6 +- sig/money.rbs | 10 ++- sig/money/errors.rbs | 3 + spec/money_spec.rb | 90 ++++++++++++++++++++++ spec/rails/job_argument_serializer_spec.rb | 15 ++++ 8 files changed, 176 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index cce46b44..90013dd4 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,13 @@ 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") +# Explicit precision for values smaller than a currency subunit +unit_price = Money.new("0.057", "USD", decimal_precision: 3) +(unit_price * 100).to_s #=> "5.700" + +# Money arithmetic requires matching precision +Money.new(1, "USD", decimal_precision: 3) + Money.new("0.057", "USD", decimal_precision: 3) + m = Money.new(1000, "USD") # Splitting money evenly m.split(2) == [Money.new(500, "USD"), Money.new(500, "USD")] diff --git a/lib/money/errors.rb b/lib/money/errors.rb index 219d8e03..c7f19b0c 100644 --- a/lib/money/errors.rb +++ b/lib/money/errors.rb @@ -6,4 +6,7 @@ class Error < StandardError class IncompatibleCurrencyError < Error end + + class IncompatiblePrecisionError < Error + end end diff --git a/lib/money/money.rb b/lib/money/money.rb index 4d91a523..299d7d46 100644 --- a/lib/money/money.rb +++ b/lib/money/money.rb @@ -8,7 +8,7 @@ class Money NULL_CURRENCY = NullCurrency.new.freeze - attr_reader :value, :currency + attr_reader :value, :currency, :decimal_precision class ReverseOperationProxy include Comparable @@ -65,17 +65,19 @@ def with_currency(currency, &block) with_config(currency: currency, &block) end - def new(value = 0, currency = nil) - return new_from_money(value, currency) if value.is_a?(Money) + def new(value = 0, currency = nil, decimal_precision: nil) + return new_from_money(value, currency, decimal_precision) if value.is_a?(Money) value = Helpers.value_to_decimal(value) currency = Helpers.value_to_currency(currency) + decimal_precision = currency.minor_units if decimal_precision.nil? if value.zero? @@zero_money ||= {} - @@zero_money[currency.iso_code] ||= super(Helpers::DECIMAL_ZERO, currency) + cache_key = [currency.iso_code, decimal_precision] + @@zero_money[cache_key] ||= super(Helpers::DECIMAL_ZERO, currency, decimal_precision) else - super(value, currency) + super(value, currency, decimal_precision) end end alias_method :from_amount, :new @@ -86,12 +88,12 @@ def from_subunits(subunits, currency_iso, format: nil) def from_json(string) hash = JSON.parse(string, symbolize_names: true) - Money.new(hash.fetch(:value), hash.fetch(:currency)) + Money.new(hash.fetch(:value), hash.fetch(:currency), decimal_precision: hash[:decimal_precision]) end def from_hash(hash) hash = hash.transform_keys(&:to_sym) - Money.new(hash.fetch(:value), hash.fetch(:currency)) + Money.new(hash.fetch(:value), hash.fetch(:currency), decimal_precision: hash[:decimal_precision]) end def rational(money1, money2) @@ -103,15 +105,17 @@ def rational(money1, money2) private - def new_from_money(amount, currency) + def new_from_money(amount, currency, decimal_precision) currency = Helpers.value_to_currency(currency) if amount.no_currency? - return Money.new(amount.value, currency) + return Money.new(amount.value, currency, decimal_precision: decimal_precision || amount.decimal_precision) end if amount.currency.compatible?(currency) - return amount + return amount if decimal_precision.nil? || decimal_precision == amount.decimal_precision + + return Money.new(amount.value, currency, decimal_precision: decimal_precision) end msg = "Money.new(Money.new(amount, #{amount.currency}), #{currency}) " \ @@ -121,12 +125,14 @@ def new_from_money(amount, currency) end end - def initialize(value, currency) + def initialize(value, currency, decimal_precision) raise ArgumentError if value.nan? raise ArgumentError if value.infinite? + raise ArgumentError, "decimal_precision must be a non-negative Integer" unless decimal_precision.is_a?(Integer) && decimal_precision >= 0 @currency = currency - @value = BigDecimal(value.round(@currency.minor_units)) + @decimal_precision = decimal_precision + @value = BigDecimal(value.round(decimal_precision)) freeze end @@ -134,12 +140,14 @@ def init_with(coder) initialize( Helpers.value_to_decimal(coder['value']), Helpers.value_to_currency(coder['currency']), + coder['decimal_precision'] || Helpers.value_to_currency(coder['currency']).minor_units, ) end def encode_with(coder) coder['value'] = @value.to_s('F') coder['currency'] = @currency.iso_code + coder['decimal_precision'] = @decimal_precision if @decimal_precision != @currency.minor_units end def subunits(format: nil) @@ -151,7 +159,7 @@ def no_currency? end def -@ - Money.new(-value, currency) + Money.new(-value, currency, decimal_precision: decimal_precision) end def <=>(other) @@ -168,15 +176,17 @@ def <=>(other) def +(other) arithmetic(other) do |money| + result_decimal_precision = calculated_decimal_precision(money) return self if money.value.zero? && !no_currency? - Money.new(value + money.value, calculated_currency(money.currency)) + Money.new(value + money.value, calculated_currency(money.currency), decimal_precision: result_decimal_precision) end end def -(other) arithmetic(other) do |money| + result_decimal_precision = calculated_decimal_precision(money) return self if money.value.zero? && !no_currency? - Money.new(value - money.value, calculated_currency(money.currency)) + Money.new(value - money.value, calculated_currency(money.currency), decimal_precision: result_decimal_precision) end end @@ -184,7 +194,7 @@ def *(other) raise ArgumentError, "Money objects can only be multiplied by a Numeric" unless other.is_a?(Numeric) return self if other == 1 - Money.new(value.to_r * other, currency) + Money.new(value.to_r * other, currency, decimal_precision: decimal_precision) end def /(other) @@ -220,7 +230,7 @@ def coerce(other) end def convert_currency(exchange_rate, new_currency) - Money.new(value * exchange_rate, new_currency) + Money.new(value * exchange_rate, new_currency, decimal_precision: decimal_precision) end def to_money(new_currency = nil) @@ -229,7 +239,7 @@ def to_money(new_currency = nil) end if no_currency? - return Money.new(value, new_currency) + return Money.new(value, new_currency, decimal_precision: decimal_precision) end ensure_compatible_currency( @@ -249,7 +259,7 @@ def to_fs(style = nil) when :legacy_dollars 2 when :amount, nil - currency.minor_units + decimal_precision else raise ArgumentError, "Unexpected format: #{style}" end @@ -281,7 +291,9 @@ def as_json(options = nil) if (options.is_a?(Hash) && options[:legacy_format]) || Money::Config.current.legacy_json_format to_s else - { value: to_s(:amount), currency: currency.to_s } + hash = { value: to_s(:amount), currency: currency.to_s } + hash[:decimal_precision] = decimal_precision if decimal_precision != currency.minor_units + hash end end alias_method :to_h, :as_json @@ -289,26 +301,26 @@ def as_json(options = nil) def abs abs = value.abs return self if value == abs - Money.new(abs, currency) + Money.new(abs, currency, decimal_precision: decimal_precision) end def floor floor = value.floor return self if floor == value - Money.new(floor, currency) + Money.new(floor, currency, decimal_precision: decimal_precision) end def round(ndigits = 0) round = value.round(ndigits) return self if round == value - Money.new(round, currency) + Money.new(round, currency, decimal_precision: decimal_precision) end def fraction(rate) raise ArgumentError, "rate should be positive" if rate < 0 result = value / (1 + rate) - Money.new(result, currency) + Money.new(result, currency, decimal_precision: decimal_precision) end # @see Money::Allocator#allocate @@ -365,7 +377,7 @@ def clamp(min, max) if clamped_value.nil? self else - Money.new(clamped_value, currency) + Money.new(clamped_value, currency, decimal_precision: decimal_precision) end end @@ -381,7 +393,7 @@ def arithmetic(other) yield(other) when Numeric, String - yield(Money.new(other, currency)) + yield(Money.new(other, currency, decimal_precision: decimal_precision)) else raise TypeError, "#{other.class.name} can't be coerced into a Money object" @@ -394,6 +406,16 @@ def ensure_compatible_currency(other_currency, msg) raise Money::IncompatibleCurrencyError, msg end + def calculated_decimal_precision(other) + return other.decimal_precision if no_currency? && decimal_precision == currency.minor_units + return decimal_precision if other.no_currency? && other.decimal_precision == other.currency.minor_units + return decimal_precision if decimal_precision == other.decimal_precision + + raise Money::IncompatiblePrecisionError, + "mathematical operation not permitted for Money objects with different decimal precisions " \ + "#{decimal_precision} and #{other.decimal_precision}." + end + def calculated_currency(other) no_currency? ? other : currency end diff --git a/lib/money/rails/job_argument_serializer.rb b/lib/money/rails/job_argument_serializer.rb index 82af3f3e..cd77dcd0 100644 --- a/lib/money/rails/job_argument_serializer.rb +++ b/lib/money/rails/job_argument_serializer.rb @@ -4,11 +4,13 @@ class Money module Rails class JobArgumentSerializer < ::ActiveJob::Serializers::ObjectSerializer def serialize(money) - super("value" => money.value.to_s("F"), "currency" => money.currency.iso_code) + attributes = { "value" => money.value.to_s("F"), "currency" => money.currency.iso_code } + attributes["decimal_precision"] = money.decimal_precision if money.decimal_precision != money.currency.minor_units + super(attributes) end def deserialize(hash) - Money.new(hash["value"], hash["currency"]) + Money.new(hash["value"], hash["currency"], decimal_precision: hash["decimal_precision"]) end def klass diff --git a/sig/money.rbs b/sig/money.rbs index a2e339a9..2188e835 100644 --- a/sig/money.rbs +++ b/sig/money.rbs @@ -8,6 +8,7 @@ class Money attr_reader value: BigDecimal attr_reader currency: (Currency | NullCurrency) + attr_reader decimal_precision: Integer # Class methods ACTIVE_SUPPORT_DEFINED: untyped @@ -27,15 +28,15 @@ class Money # def_delegators needs this - Forwardable extended in singleton class def self.def_delegators: (untyped accessor, *Symbol methods) -> void - def self.new: (?Numeric | String | Money value, ?String | Currency | NullCurrency | nil currency) -> Money - def self.from_amount: (Numeric | String value, ?String | Currency | NullCurrency | nil currency) -> Money + def self.new: (?Numeric | String | Money value, ?String | Currency | NullCurrency | nil currency, ?decimal_precision: Integer?) -> Money + def self.from_amount: (Numeric | String value, ?String | Currency | NullCurrency | nil currency, ?decimal_precision: Integer?) -> Money def self.from_subunits: (Integer | Numeric subunits, String | Currency | NullCurrency currency_iso, ?format: Symbol?) -> Money def self.from_json: (String string) -> Money def self.from_hash: (Hash[Symbol | String, untyped] hash) -> Money def self.rational: (Money money1, Money money2) -> Rational # Instance methods - def initialize: (BigDecimal value, Currency | NullCurrency currency) -> void + def initialize: (BigDecimal value, Currency | NullCurrency currency, Integer decimal_precision) -> void def init_with: (untyped coder) -> void def encode_with: (untyped coder) -> void @@ -88,8 +89,9 @@ class Money def arithmetic: [T] (Money | Numeric | String other) { (Money) -> T } -> T def ensure_compatible_currency: (Currency | NullCurrency other_currency, String msg) -> void + def calculated_decimal_precision: (Money other) -> Integer def calculated_currency: (Currency | NullCurrency other) -> (Currency | NullCurrency) - def self.new_from_money: (Money amount, String | Currency | NullCurrency | nil currency) -> Money + def self.new_from_money: (Money amount, String | Currency | NullCurrency | nil currency, Integer? decimal_precision) -> Money class ReverseOperationProxy include Comparable diff --git a/sig/money/errors.rbs b/sig/money/errors.rbs index 219d8e03..c7f19b0c 100644 --- a/sig/money/errors.rbs +++ b/sig/money/errors.rbs @@ -6,4 +6,7 @@ class Money class IncompatibleCurrencyError < Error end + + class IncompatiblePrecisionError < Error + end end diff --git a/spec/money_spec.rb b/spec/money_spec.rb index 1b4fc151..d8540b4a 100644 --- a/spec/money_spec.rb +++ b/spec/money_spec.rb @@ -84,11 +84,46 @@ expect(Money.new(1.00)).to eq(Money.new(1)) end + it "uses the currency minor units as the default decimal precision" do + expect(Money.new("1.2345", "USD").decimal_precision).to eq(2) + expect(Money.new("1.2345", "BHD").decimal_precision).to eq(3) + end + + it "uses an explicit decimal precision when constructing a value" do + money = Money.new("1.2345", "USD", decimal_precision: 3) + + expect(money.value).to eq(BigDecimal("1.235")) + expect(money.decimal_precision).to eq(3) + end + + it "requires decimal precision to be a non-negative integer" do + expect { Money.new(1, "USD", decimal_precision: -1) }.to raise_error(ArgumentError, "decimal_precision must be a non-negative Integer") + expect { Money.new(1, "USD", decimal_precision: 1.5) }.to raise_error(ArgumentError, "decimal_precision must be a non-negative Integer") + end + + it "caches zero values separately by decimal precision" do + currency_precision_money = Money.new(0, "USD") + precise_money = Money.new(0, "USD", decimal_precision: 4) + + expect(currency_precision_money.decimal_precision).to eq(2) + expect(precise_money.decimal_precision).to eq(4) + expect(currency_precision_money).not_to equal(precise_money) + end + it "can be constructed with a money object" do expect(Money.new(Money.new(1))).to eq(Money.new(1)) expect(Money.new(Money.new(1, "USD"), "USD")).to eq(Money.new(1, "USD")) end + it "can explicitly change the decimal precision of a money object" do + precise_money = Money.new("1.2345", "USD", decimal_precision: 4) + + money = Money.new(precise_money, "USD", decimal_precision: 2) + + expect(money.value).to eq(BigDecimal("1.23")) + expect(money.decimal_precision).to eq(2) + end + it "can be constructed with a money object with a null currency" do money = Money.new(Money.new(1, Money::NULL_CURRENCY), 'USD') expect(money.value).to eq(1) @@ -112,6 +147,11 @@ expect(non_fractional_money.to_s).to eq("1") end + it "to_s displays the explicit decimal precision" do + expect(Money.new("0.057", "USD", decimal_precision: 3).to_s).to eq("0.057") + expect(Money.new("1", "USD", decimal_precision: 4).to_s).to eq("1.0000") + end + it "to_fs with a legacy_dollars style" do expect(amount_money.to_fs(:legacy_dollars)).to eq("1.23") expect(non_fractional_money.to_fs(:legacy_dollars)).to eq("1.00") @@ -179,6 +219,14 @@ expect(money.as_json).to eq(value: "1.00", currency: "CAD") end + it "serializes non-default decimal precision" do + money = Money.new("0.057", "USD", decimal_precision: 3) + + expect(money.as_json).to eq(value: "0.057", currency: "USD", decimal_precision: 3) + expect(Money.from_json(money.to_json)).to eq(money) + expect(Money.from_json(money.to_json).decimal_precision).to eq(3) + end + it "is constructable with a BigDecimal" do expect(Money.new(BigDecimal("1.23"))).to eq(Money.new(1.23)) end @@ -203,6 +251,35 @@ expect((Money.new(1.51) + Money.new(3.49))).to eq(Money.new(5.00)) end + it "preserves explicit decimal precision across arithmetic" do + unit_price = Money.new("0.057", "USD", decimal_precision: 3) + + expect((unit_price + Money.new("0.001", "USD", decimal_precision: 3)).to_s).to eq("0.058") + expect((unit_price - Money.new("0.007", "USD", decimal_precision: 3)).to_s).to eq("0.050") + expect((unit_price * 100).to_s).to eq("5.700") + end + + it "rejects arithmetic between different decimal precisions" do + precise_money = Money.new("0.057", "USD", decimal_precision: 3) + currency_precision_money = Money.new("1.00", "USD") + + expect { precise_money + currency_precision_money }.to raise_error( + Money::IncompatiblePrecisionError, + "mathematical operation not permitted for Money objects with different decimal precisions 3 and 2.", + ) + expect { currency_precision_money - precise_money }.to raise_error(Money::IncompatiblePrecisionError) + end + + it "uses the currency-bearing value's precision when adding a default null-currency value" do + precise_money = Money.new("0.057", "USD", decimal_precision: 3) + null_currency_money = Money.new(1, Money::NULL_CURRENCY) + + result = null_currency_money + precise_money + + expect(result.to_s).to eq("1.057") + expect(result.decimal_precision).to eq(3) + end + it "keeps currency across calculations" do expect(Money.new(1, 'USD') - Money.new(1, 'USD') + Money.new(1.23, Money::NULL_CURRENCY)).to eq(Money.new(1.23, 'USD')) end @@ -1005,6 +1082,12 @@ money = Money.new(100, 'JPY').to_yaml expect(money).to eq("--- !ruby/object:Money\nvalue: '100.0'\ncurrency: JPY\n") end + + it "includes non-default decimal precision" do + money = Money.new("0.057", "USD", decimal_precision: 3).to_yaml + + expect(money).to eq("--- !ruby/object:Money\nvalue: '0.057'\ncurrency: USD\ndecimal_precision: 3\n") + end end describe "YAML deserialization" do @@ -1018,6 +1101,13 @@ expect(money).to eq(Money.new(750)) end + it "restores non-default decimal precision" do + money = yaml_load("--- !ruby/object:Money\nvalue: '0.057'\ncurrency: USD\ndecimal_precision: 3\n") + + expect(money.value).to eq(BigDecimal("0.057")) + expect(money.decimal_precision).to eq(3) + end + it "accepts serialized NullCurrency objects" do money = yaml_load(<<~EOS) --- diff --git a/spec/rails/job_argument_serializer_spec.rb b/spec/rails/job_argument_serializer_spec.rb index 8477d3b1..2b88f173 100644 --- a/spec/rails/job_argument_serializer_spec.rb +++ b/spec/rails/job_argument_serializer_spec.rb @@ -17,4 +17,19 @@ expect(job2.arguments.first[:value]).to eq(Money.new(10.21, "BRL")) end + + it "roundtrips non-default decimal precision" do + money = Money.new("0.057", "USD", decimal_precision: 3) + serialized_job = MoneyTestJob.new(value: money).serialize + + serialized_value = serialized_job["arguments"][0]["value"] + expect(serialized_value["decimal_precision"]).to eq(3) + + deserialized_job = MoneyTestJob.deserialize(serialized_job) + deserialized_job.send(:deserialize_arguments_if_needed) + deserialized_money = deserialized_job.arguments.first[:value] + + expect(deserialized_money).to eq(money) + expect(deserialized_money.decimal_precision).to eq(3) + end end From 48107d45b33a64ae23bd6a5cca032c2b5eb871d8 Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Thu, 3 Sep 2026 15:25:59 -0400 Subject: [PATCH 02/20] Add decimal precision compatibility coverage Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92 --- spec/money_spec.rb | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/spec/money_spec.rb b/spec/money_spec.rb index d8540b4a..5cc990fa 100644 --- a/spec/money_spec.rb +++ b/spec/money_spec.rb @@ -41,6 +41,13 @@ expect(Money.new(10, "USD").convert_currency(150, "JPY")).to eq(Money.new(1500, "JPY")) end + it "uses the target currency precision when converting a value with implicit precision" do + money = Money.new(100, "JPY").convert_currency(BigDecimal("0.0075"), "USD") + + expect(money.value).to eq(BigDecimal("0.75")) + expect(money.decimal_precision).to eq(2) + end + it "returns itself with to_money" do expect(money.to_money).to eq(money) expect(amount_money.to_money).to eq(amount_money) @@ -50,6 +57,13 @@ expect(Money.new(1).to_money('CAD')).to eq(Money.new(1, 'CAD')) end + it "#to_money uses the target currency precision for a value with implicit precision" do + money = Money.new("1.23", Money::NULL_CURRENCY).to_money("JPY") + + expect(money.value).to eq(BigDecimal("1")) + expect(money.decimal_precision).to eq(0) + end + it "#to_money works with money objects of the same currency" do expect(Money.new(1, 'CAD').to_money('CAD')).to eq(Money.new(1, 'CAD')) end @@ -134,6 +148,20 @@ expect(money.currency.to_s).to eq('USD') end + it "uses the target currency precision when adding a currency to a value with implicit precision" do + money = Money.new(Money.new("1.23", Money::NULL_CURRENCY), "JPY") + + expect(money.value).to eq(BigDecimal("1")) + expect(money.decimal_precision).to eq(0) + end + + it "preserves an existing currency when explicitly changing precision" do + money = Money.new(Money.new(1, "USD"), Money::NULL_CURRENCY, decimal_precision: 3) + + expect(money.currency).to eq(Money::Currency.find!("USD")) + expect(money.decimal_precision).to eq(3) + end + it "constructor raises when changing currency" do expect { Money.new(Money.new(1, 'USD'), 'CAD') }.to raise_error(Money::IncompatibleCurrencyError) end @@ -1154,6 +1182,20 @@ end end + describe "Marshal deserialization" do + it "uses the currency precision for objects serialized before decimal precision was added" do + legacy_money = Money.allocate + legacy_money.instance_variable_set(:@value, BigDecimal("1.23")) + legacy_money.instance_variable_set(:@currency, Money::Currency.find!("USD")) + legacy_money.freeze + + money = Marshal.load(Marshal.dump(legacy_money)) + + expect(money.decimal_precision).to eq(2) + expect(money.to_s).to eq("1.23") + end + end + describe('.deprecate') do it "uses ruby warn if active support is not defined" do stub_const("ACTIVE_SUPPORT_DEFINED", false) From 44356bf05385adc3c49c4877af83c0bc932d3d72 Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Thu, 3 Sep 2026 15:28:53 -0400 Subject: [PATCH 03/20] Preserve implicit Money precision semantics Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92 --- lib/money/money.rb | 66 ++++++++++++++-------- lib/money/rails/job_argument_serializer.rb | 2 +- sig/money.rbs | 8 ++- spec/money_spec.rb | 7 +++ 4 files changed, 56 insertions(+), 27 deletions(-) diff --git a/lib/money/money.rb b/lib/money/money.rb index 299d7d46..e0d6fe7d 100644 --- a/lib/money/money.rb +++ b/lib/money/money.rb @@ -8,7 +8,7 @@ class Money NULL_CURRENCY = NullCurrency.new.freeze - attr_reader :value, :currency, :decimal_precision + attr_reader :value, :currency class ReverseOperationProxy include Comparable @@ -70,8 +70,6 @@ def new(value = 0, currency = nil, decimal_precision: nil) value = Helpers.value_to_decimal(value) currency = Helpers.value_to_currency(currency) - decimal_precision = currency.minor_units if decimal_precision.nil? - if value.zero? @@zero_money ||= {} cache_key = [currency.iso_code, decimal_precision] @@ -109,12 +107,16 @@ def new_from_money(amount, currency, decimal_precision) currency = Helpers.value_to_currency(currency) if amount.no_currency? - return Money.new(amount.value, currency, decimal_precision: decimal_precision || amount.decimal_precision) + precision = decimal_precision + precision ||= amount.decimal_precision if amount.explicit_decimal_precision? + return Money.new(amount.value, currency, decimal_precision: precision) end if amount.currency.compatible?(currency) - return amount if decimal_precision.nil? || decimal_precision == amount.decimal_precision + return amount if decimal_precision.nil? + return amount if amount.explicit_decimal_precision? && decimal_precision == amount.decimal_precision + currency = amount.currency if currency.is_a?(NullCurrency) return Money.new(amount.value, currency, decimal_precision: decimal_precision) end @@ -128,11 +130,13 @@ def new_from_money(amount, currency, decimal_precision) def initialize(value, currency, decimal_precision) raise ArgumentError if value.nan? raise ArgumentError if value.infinite? - raise ArgumentError, "decimal_precision must be a non-negative Integer" unless decimal_precision.is_a?(Integer) && decimal_precision >= 0 + unless decimal_precision.nil? || (decimal_precision.is_a?(Integer) && decimal_precision >= 0) + raise ArgumentError, "decimal_precision must be a non-negative Integer" + end @currency = currency @decimal_precision = decimal_precision - @value = BigDecimal(value.round(decimal_precision)) + @value = BigDecimal(value.round(self.decimal_precision)) freeze end @@ -140,14 +144,14 @@ def init_with(coder) initialize( Helpers.value_to_decimal(coder['value']), Helpers.value_to_currency(coder['currency']), - coder['decimal_precision'] || Helpers.value_to_currency(coder['currency']).minor_units, + coder['decimal_precision'], ) end def encode_with(coder) coder['value'] = @value.to_s('F') coder['currency'] = @currency.iso_code - coder['decimal_precision'] = @decimal_precision if @decimal_precision != @currency.minor_units + coder['decimal_precision'] = decimal_precision if explicit_decimal_precision? end def subunits(format: nil) @@ -158,8 +162,16 @@ def no_currency? currency.is_a?(NullCurrency) end + def decimal_precision + @decimal_precision || currency.minor_units + end + + def explicit_decimal_precision? + !@decimal_precision.nil? + end + def -@ - Money.new(-value, currency, decimal_precision: decimal_precision) + Money.new(-value, currency, decimal_precision: precision_argument) end def <=>(other) @@ -194,7 +206,7 @@ def *(other) raise ArgumentError, "Money objects can only be multiplied by a Numeric" unless other.is_a?(Numeric) return self if other == 1 - Money.new(value.to_r * other, currency, decimal_precision: decimal_precision) + Money.new(value.to_r * other, currency, decimal_precision: precision_argument) end def /(other) @@ -230,7 +242,7 @@ def coerce(other) end def convert_currency(exchange_rate, new_currency) - Money.new(value * exchange_rate, new_currency, decimal_precision: decimal_precision) + Money.new(value * exchange_rate, new_currency, decimal_precision: precision_argument) end def to_money(new_currency = nil) @@ -239,7 +251,7 @@ def to_money(new_currency = nil) end if no_currency? - return Money.new(value, new_currency, decimal_precision: decimal_precision) + return Money.new(value, new_currency, decimal_precision: precision_argument) end ensure_compatible_currency( @@ -292,7 +304,7 @@ def as_json(options = nil) to_s else hash = { value: to_s(:amount), currency: currency.to_s } - hash[:decimal_precision] = decimal_precision if decimal_precision != currency.minor_units + hash[:decimal_precision] = decimal_precision if explicit_decimal_precision? hash end end @@ -301,26 +313,26 @@ def as_json(options = nil) def abs abs = value.abs return self if value == abs - Money.new(abs, currency, decimal_precision: decimal_precision) + Money.new(abs, currency, decimal_precision: precision_argument) end def floor floor = value.floor return self if floor == value - Money.new(floor, currency, decimal_precision: decimal_precision) + Money.new(floor, currency, decimal_precision: precision_argument) end def round(ndigits = 0) round = value.round(ndigits) return self if round == value - Money.new(round, currency, decimal_precision: decimal_precision) + Money.new(round, currency, decimal_precision: precision_argument) end def fraction(rate) raise ArgumentError, "rate should be positive" if rate < 0 result = value / (1 + rate) - Money.new(result, currency, decimal_precision: decimal_precision) + Money.new(result, currency, decimal_precision: precision_argument) end # @see Money::Allocator#allocate @@ -377,7 +389,7 @@ def clamp(min, max) if clamped_value.nil? self else - Money.new(clamped_value, currency, decimal_precision: decimal_precision) + Money.new(clamped_value, currency, decimal_precision: precision_argument) end end @@ -393,7 +405,7 @@ def arithmetic(other) yield(other) when Numeric, String - yield(Money.new(other, currency, decimal_precision: decimal_precision)) + yield(Money.new(other, currency, decimal_precision: precision_argument)) else raise TypeError, "#{other.class.name} can't be coerced into a Money object" @@ -407,15 +419,23 @@ def ensure_compatible_currency(other_currency, msg) end def calculated_decimal_precision(other) - return other.decimal_precision if no_currency? && decimal_precision == currency.minor_units - return decimal_precision if other.no_currency? && other.decimal_precision == other.currency.minor_units - return decimal_precision if decimal_precision == other.decimal_precision + return other.decimal_precision if no_currency? && !explicit_decimal_precision? && other.explicit_decimal_precision? + return if no_currency? && !explicit_decimal_precision? + return precision_argument if other.no_currency? && !other.explicit_decimal_precision? + if decimal_precision == other.decimal_precision + return precision_argument || other.decimal_precision if other.explicit_decimal_precision? + return precision_argument + end raise Money::IncompatiblePrecisionError, "mathematical operation not permitted for Money objects with different decimal precisions " \ "#{decimal_precision} and #{other.decimal_precision}." end + def precision_argument + decimal_precision if explicit_decimal_precision? + end + def calculated_currency(other) no_currency? ? other : currency end diff --git a/lib/money/rails/job_argument_serializer.rb b/lib/money/rails/job_argument_serializer.rb index cd77dcd0..4a93c357 100644 --- a/lib/money/rails/job_argument_serializer.rb +++ b/lib/money/rails/job_argument_serializer.rb @@ -5,7 +5,7 @@ module Rails class JobArgumentSerializer < ::ActiveJob::Serializers::ObjectSerializer def serialize(money) attributes = { "value" => money.value.to_s("F"), "currency" => money.currency.iso_code } - attributes["decimal_precision"] = money.decimal_precision if money.decimal_precision != money.currency.minor_units + attributes["decimal_precision"] = money.decimal_precision if money.explicit_decimal_precision? super(attributes) end diff --git a/sig/money.rbs b/sig/money.rbs index 2188e835..b9c24e8d 100644 --- a/sig/money.rbs +++ b/sig/money.rbs @@ -8,7 +8,8 @@ class Money attr_reader value: BigDecimal attr_reader currency: (Currency | NullCurrency) - attr_reader decimal_precision: Integer + def decimal_precision: () -> Integer + def explicit_decimal_precision?: () -> bool # Class methods ACTIVE_SUPPORT_DEFINED: untyped @@ -36,7 +37,7 @@ class Money def self.rational: (Money money1, Money money2) -> Rational # Instance methods - def initialize: (BigDecimal value, Currency | NullCurrency currency, Integer decimal_precision) -> void + def initialize: (BigDecimal value, Currency | NullCurrency currency, Integer? decimal_precision) -> void def init_with: (untyped coder) -> void def encode_with: (untyped coder) -> void @@ -89,7 +90,8 @@ class Money def arithmetic: [T] (Money | Numeric | String other) { (Money) -> T } -> T def ensure_compatible_currency: (Currency | NullCurrency other_currency, String msg) -> void - def calculated_decimal_precision: (Money other) -> Integer + def calculated_decimal_precision: (Money other) -> Integer? + def precision_argument: () -> Integer? def calculated_currency: (Currency | NullCurrency other) -> (Currency | NullCurrency) def self.new_from_money: (Money amount, String | Currency | NullCurrency | nil currency, Integer? decimal_precision) -> Money diff --git a/spec/money_spec.rb b/spec/money_spec.rb index 5cc990fa..4247de21 100644 --- a/spec/money_spec.rb +++ b/spec/money_spec.rb @@ -138,6 +138,13 @@ expect(money.decimal_precision).to eq(2) end + it "records explicitly selecting the currency's default decimal precision" do + money = Money.new(Money.new("1.23", "USD"), "USD", decimal_precision: 2) + + expect(money).to be_explicit_decimal_precision + expect(money.as_json).to include(decimal_precision: 2) + end + it "can be constructed with a money object with a null currency" do money = Money.new(Money.new(1, Money::NULL_CURRENCY), 'USD') expect(money.value).to eq(1) From f481b2c099fdc25cfa6c66ed6b0fcecbaf881ee1 Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Thu, 3 Sep 2026 19:06:25 -0400 Subject: [PATCH 04/20] Add explicit precision allocation coverage Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92 --- spec/allocator_spec.rb | 25 +++++++++++++++++++++++++ spec/splitter_spec.rb | 8 ++++++++ 2 files changed, 33 insertions(+) diff --git a/spec/allocator_spec.rb b/spec/allocator_spec.rb index d7ff4dd0..3fa970b6 100644 --- a/spec/allocator_spec.rb +++ b/spec/allocator_spec.rb @@ -25,6 +25,21 @@ expect(monies[1]).to eq(Money.new(0.003, 'JOD')) end + specify "#allocate preserves explicit decimal precision" do + money = Money.new("0.057", "USD", decimal_precision: 3) + + expect(money.allocate([1])).to contain_exactly(money) + end + + specify "#allocate distributes explicit precision subunits" do + money = Money.new("0.057", "USD", decimal_precision: 3) + allocations = money.allocate([0.5, 0.5], :roundrobin) + + expect(allocations.map(&:value)).to eq([BigDecimal("0.029"), BigDecimal("0.028")]) + expect(allocations.map(&:decimal_precision)).to eq([3, 3]) + expect(allocations).to all(be_explicit_decimal_precision) + end + specify "#allocate does not lose pennies even when given a lossy split" do monies = new_allocator(1).allocate([0.333,0.333, 0.333]) expect(monies[0].subunits).to eq(34) @@ -329,6 +344,16 @@ new_allocator(24.2).allocate_max_amounts([Money.new(46), Money.new(46), Money.new(50), Money.new(50),Money.new(50)]), ).to eq([Money.new(4.6), Money.new(4.6), Money.new(5), Money.new(5), Money.new(5)]) end + + specify "#allocate_max_amounts supports matching explicit decimal precision" do + money = Money.new("0.057", "USD", decimal_precision: 3) + maximums = [ + Money.new("0.029", "USD", decimal_precision: 3), + Money.new("0.028", "USD", decimal_precision: 3), + ] + + expect(money.allocate_max_amounts(maximums)).to eq(maximums) + end end def new_allocator(amount, currency = nil) diff --git a/spec/splitter_spec.rb b/spec/splitter_spec.rb index 42396dab..0fed390b 100644 --- a/spec/splitter_spec.rb +++ b/spec/splitter_spec.rb @@ -34,6 +34,14 @@ expect(Money.new(5, 'JPY').split(2).to_a).to eq([Money.new(3, 'JPY'), Money.new(2, 'JPY')]) end + specify "#split distributes explicit precision subunits" do + splits = Money.new("0.057", "USD", decimal_precision: 3).split(2).to_a + + expect(splits.map(&:value)).to eq([BigDecimal("0.029"), BigDecimal("0.028")]) + expect(splits.map(&:decimal_precision)).to eq([3, 3]) + expect(splits).to all(be_explicit_decimal_precision) + end + specify "#split a dollar" do moneys = Money.new(1).split(3) expect(moneys[0].subunits).to eq(34) From 3d0a48a13470def97f90afb4a2081f3926a25f78 Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Thu, 3 Sep 2026 19:09:04 -0400 Subject: [PATCH 05/20] Preserve explicit precision in allocations Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92 --- lib/money.rb | 1 + lib/money/allocation_units.rb | 28 ++++++++++++++++++++++++ lib/money/allocator.rb | 40 ++++++++++++++++++++++++++++------- lib/money/splitter.rb | 10 +++++---- spec/allocator_spec.rb | 6 +++++- 5 files changed, 72 insertions(+), 13 deletions(-) create mode 100644 lib/money/allocation_units.rb diff --git a/lib/money.rb b/lib/money.rb index df033616..c837f77e 100644 --- a/lib/money.rb +++ b/lib/money.rb @@ -3,6 +3,7 @@ require_relative 'money/version' require_relative 'money/currency' require_relative 'money/null_currency' +require_relative 'money/allocation_units' require_relative 'money/allocator' require_relative 'money/splitter' require_relative 'money/config' diff --git a/lib/money/allocation_units.rb b/lib/money/allocation_units.rb new file mode 100644 index 00000000..24c17a3d --- /dev/null +++ b/lib/money/allocation_units.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class Money + module AllocationUnits + extend self + + def to_units(money) + return money.subunits unless money.explicit_decimal_precision? + + (money.value * scale(money.decimal_precision)).to_i + end + + def from_units(units, currency, decimal_precision: nil) + return Money.from_subunits(units, currency) if decimal_precision.nil? + + value = Helpers.value_to_decimal(units) / scale(decimal_precision) + Money.new(value, currency, decimal_precision: decimal_precision) + end + + private + + def scale(decimal_precision) + 10**decimal_precision + end + end + + private_constant :AllocationUnits +end diff --git a/lib/money/allocator.rb b/lib/money/allocator.rb index 61cd00ab..168d3809 100644 --- a/lib/money/allocator.rb +++ b/lib/money/allocator.rb @@ -90,7 +90,13 @@ def allocate(splits, strategy = nil) amounts[order[i]][:whole_subunits] += 1 end - amounts.map { |amount| Money.from_subunits(amount[:whole_subunits], currency) } + amounts.map do |amount| + AllocationUnits.from_units( + amount[:whole_subunits], + currency, + decimal_precision: allocation_decimal_precision, + ) + end end # Allocates money between different parties up to the maximum amounts specified. @@ -115,14 +121,18 @@ def allocate(splits, strategy = nil) def allocate_max_amounts(maximums) allocation_currency = extract_currency(maximums + [__getobj__]) maximums = maximums.map { |max| max.to_money(allocation_currency) } - maximums_total = maximums.reduce(Money.new(0, allocation_currency), :+) + maximums_total = maximums.reduce( + Money.new(0, allocation_currency, decimal_precision: allocation_decimal_precision), + :+, + ) + maximums_total_units = AllocationUnits.to_units(maximums_total) splits = maximums.map do |max_amount| - next(Rational(0)) if maximums_total.zero? - Money.rational(max_amount, maximums_total) + next(Rational(0)) if maximums_total_units.zero? + Rational(AllocationUnits.to_units(max_amount), maximums_total_units) end - total_allocatable = [maximums_total.subunits, subunits].min + total_allocatable = [maximums_total_units, allocation_units].min subunits_amounts, left_over = amounts_from_splits(1, splits, total_allocatable) subunits_amounts.map! { |amount| amount[:whole_subunits] } @@ -130,14 +140,20 @@ def allocate_max_amounts(maximums) subunits_amounts.each_with_index do |amount, index| break if left_over <= 0 - max_amount = maximums[index].value * allocation_currency.subunit_to_unit + max_amount = AllocationUnits.to_units(maximums[index]) next if amount >= max_amount left_over -= 1 subunits_amounts[index] += 1 end - subunits_amounts.map { |cents| Money.from_subunits(cents, allocation_currency) } + subunits_amounts.map do |amount| + AllocationUnits.from_units( + amount, + allocation_currency, + decimal_precision: allocation_decimal_precision, + ) + end end private @@ -153,7 +169,7 @@ def extract_currency(money_array) currencies.first || NULL_CURRENCY end - def amounts_from_splits(allocations, splits, subunits_to_split = subunits) + def amounts_from_splits(allocations, splits, subunits_to_split = allocation_units) raise ArgumentError, "All splits values must be of type Rational." unless all_rational?(splits) left_over = subunits_to_split @@ -175,6 +191,14 @@ def all_rational?(splits) splits.all? { |split| split.is_a?(Rational) } end + def allocation_decimal_precision + decimal_precision if explicit_decimal_precision? + end + + def allocation_units + AllocationUnits.to_units(__getobj__) + end + # Given a list of decimal numbers, return a list ordered by which is nearest to the next whole number. # For instance, given inputs [1.1, 1.5, 1.9] the correct ranking is 2, 1, 0. This is because 1.9 is nearly 2. # Note that we are not ranking by absolute size, we only care about the distance between our input number and diff --git a/lib/money/splitter.rb b/lib/money/splitter.rb index 8a34d65b..a761ed68 100644 --- a/lib/money/splitter.rb +++ b/lib/money/splitter.rb @@ -15,11 +15,13 @@ def initialize(money, num) def split @split ||= begin - subunits = @money.subunits - low = Money.from_subunits(subunits / @num, @money.currency) - high = Money.from_subunits(low.subunits + 1, @money.currency) + units = AllocationUnits.to_units(@money) + low_units = units / @num + decimal_precision = @money.decimal_precision if @money.explicit_decimal_precision? + low = AllocationUnits.from_units(low_units, @money.currency, decimal_precision: decimal_precision) + high = AllocationUnits.from_units(low_units + 1, @money.currency, decimal_precision: decimal_precision) - num_high = subunits % @num + num_high = units % @num split = {} split[high] = num_high if num_high > 0 diff --git a/spec/allocator_spec.rb b/spec/allocator_spec.rb index 3fa970b6..bd28d86e 100644 --- a/spec/allocator_spec.rb +++ b/spec/allocator_spec.rb @@ -352,7 +352,11 @@ Money.new("0.028", "USD", decimal_precision: 3), ] - expect(money.allocate_max_amounts(maximums)).to eq(maximums) + allocations = money.allocate_max_amounts(maximums) + + expect(allocations).to eq(maximums) + expect(allocations.map(&:decimal_precision)).to eq([3, 3]) + expect(allocations).to all(be_explicit_decimal_precision) end end From be381e21f54bf1e9bbc0fdb2c003067afd8baefa Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Thu, 3 Sep 2026 19:52:35 -0400 Subject: [PATCH 06/20] Add Binks precision edge case coverage Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92 --- spec/allocator_spec.rb | 12 ++++++++++++ spec/money_spec.rb | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/spec/allocator_spec.rb b/spec/allocator_spec.rb index bd28d86e..79559454 100644 --- a/spec/allocator_spec.rb +++ b/spec/allocator_spec.rb @@ -358,6 +358,18 @@ expect(allocations.map(&:decimal_precision)).to eq([3, 3]) expect(allocations).to all(be_explicit_decimal_precision) end + + specify "#allocate_max_amounts applies explicit decimal precision to numeric and string maxima" do + money = Money.new("0.057", "USD", decimal_precision: 3) + + numeric_allocations = money.allocate_max_amounts([0.029, 0.028]) + string_allocations = money.allocate_max_amounts(["0.029", "0.028"]) + + expect(numeric_allocations.map(&:value)).to eq([BigDecimal("0.029"), BigDecimal("0.028")]) + expect(numeric_allocations).to all(be_explicit_decimal_precision) + expect(string_allocations.map(&:value)).to eq([BigDecimal("0.029"), BigDecimal("0.028")]) + expect(string_allocations).to all(be_explicit_decimal_precision) + end end def new_allocator(amount, currency = nil) diff --git a/spec/money_spec.rb b/spec/money_spec.rb index 4247de21..c25a8358 100644 --- a/spec/money_spec.rb +++ b/spec/money_spec.rb @@ -591,6 +591,20 @@ expect(Money.rational(Money.new(10.0, 'USD'), Money.new(15.0, 'USD'))).to eq(Rational(2,3)) end + it "generates a true rational below the currency subunit with explicit decimal precision" do + half_yen = Money.new("0.5", "JPY", decimal_precision: 1) + one_yen = Money.new("1.0", "JPY", decimal_precision: 1) + + expect(Money.rational(half_yen, one_yen)).to eq(Rational(1, 2)) + end + + it "raises when attempting to make a rational from different decimal precisions" do + one_decimal = Money.new("0.5", "JPY", decimal_precision: 1) + two_decimals = Money.new("1.00", "JPY", decimal_precision: 2) + + expect { Money.rational(one_decimal, two_decimals) }.to raise_error(Money::IncompatiblePrecisionError) + end + it "raises when attempting to make a rational from different currencies" do expect { Money.rational(Money.new(10.0, 'USD'), Money.new(15.0, 'JPY')) }.to raise_error(Money::IncompatibleCurrencyError) end From 8f7bec495ab14c9f9c3e18ab653436aeeef3d15f Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Thu, 3 Sep 2026 19:53:18 -0400 Subject: [PATCH 07/20] Handle Binks precision edge cases Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92 --- lib/money/allocator.rb | 9 ++++++++- lib/money/money.rb | 6 +++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/money/allocator.rb b/lib/money/allocator.rb index 168d3809..d8921874 100644 --- a/lib/money/allocator.rb +++ b/lib/money/allocator.rb @@ -120,7 +120,7 @@ def allocate(splits, strategy = nil) # #=> [Money.new(5), Money.new(2)] def allocate_max_amounts(maximums) allocation_currency = extract_currency(maximums + [__getobj__]) - maximums = maximums.map { |max| max.to_money(allocation_currency) } + maximums = maximums.map { |max| coerce_maximum(max, allocation_currency) } maximums_total = maximums.reduce( Money.new(0, allocation_currency, decimal_precision: allocation_decimal_precision), :+, @@ -169,6 +169,13 @@ def extract_currency(money_array) currencies.first || NULL_CURRENCY end + def coerce_maximum(maximum, allocation_currency) + money = maximum.to_money(allocation_currency) + return money if maximum.is_a?(Money) || allocation_decimal_precision.nil? + + Money.new(money, allocation_currency, decimal_precision: allocation_decimal_precision) + end + def amounts_from_splits(allocations, splits, subunits_to_split = allocation_units) raise ArgumentError, "All splits values must be of type Rational." unless all_rational?(splits) diff --git a/lib/money/money.rb b/lib/money/money.rb index e0d6fe7d..34a98f1a 100644 --- a/lib/money/money.rb +++ b/lib/money/money.rb @@ -95,9 +95,9 @@ def from_hash(hash) end def rational(money1, money2) - money1.send(:arithmetic, money2) do - factor = money1.currency.subunit_to_unit * money2.currency.subunit_to_unit - Rational((money1.value * factor).to_i, (money2.value * factor).to_i) + money1.send(:arithmetic, money2) do |money| + money1.send(:calculated_decimal_precision, money) + money1.value.to_r / money.value.to_r end end From 7edd80a7bba7b2e4a7fad447bbb967637ea0a65a Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Thu, 3 Sep 2026 20:13:23 -0400 Subject: [PATCH 08/20] Add final Binks regression coverage Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92 --- spec/allocator_spec.rb | 10 ++++++++++ spec/money_spec.rb | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/spec/allocator_spec.rb b/spec/allocator_spec.rb index 79559454..fc9e1599 100644 --- a/spec/allocator_spec.rb +++ b/spec/allocator_spec.rb @@ -370,6 +370,16 @@ expect(string_allocations.map(&:value)).to eq([BigDecimal("0.029"), BigDecimal("0.028")]) expect(string_allocations).to all(be_explicit_decimal_precision) end + + specify "#allocate_max_amounts does not round numeric or string maxima before applying explicit precision" do + money = Money.new("0.057", "USD", decimal_precision: 3) + + [[0.029, 0.001], ["0.029", "0.001"]].each do |maximums| + allocations = money.allocate_max_amounts(maximums) + + expect(allocations.map(&:value)).to eq([BigDecimal("0.029"), BigDecimal("0.001")]) + end + end end def new_allocator(amount, currency = nil) diff --git a/spec/money_spec.rb b/spec/money_spec.rb index c25a8358..8e7a9842 100644 --- a/spec/money_spec.rb +++ b/spec/money_spec.rb @@ -294,6 +294,16 @@ expect((unit_price * 100).to_s).to eq("5.700") end + it "applies explicit decimal precision from zero across arithmetic" do + implicit_money = Money.new("1.00", "USD") + explicit_zero = Money.new("0.00", "USD", decimal_precision: 2) + + results = [implicit_money + explicit_zero, implicit_money - explicit_zero] + + expect(results).to all(be_explicit_decimal_precision) + expect(results.map(&:as_json)).to all(eq(value: "1.00", currency: "USD", decimal_precision: 2)) + end + it "rejects arithmetic between different decimal precisions" do precise_money = Money.new("0.057", "USD", decimal_precision: 3) currency_precision_money = Money.new("1.00", "USD") From f2aa3374ef59eb95575fad070166f156d02f988e Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Thu, 3 Sep 2026 20:14:06 -0400 Subject: [PATCH 09/20] Preserve precision for caps and zero arithmetic Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92 --- lib/money/allocator.rb | 7 ++++--- lib/money/money.rb | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/money/allocator.rb b/lib/money/allocator.rb index d8921874..3a997708 100644 --- a/lib/money/allocator.rb +++ b/lib/money/allocator.rb @@ -170,10 +170,11 @@ def extract_currency(money_array) end def coerce_maximum(maximum, allocation_currency) - money = maximum.to_money(allocation_currency) - return money if maximum.is_a?(Money) || allocation_decimal_precision.nil? + if allocation_decimal_precision && !maximum.is_a?(Money) + return Money.new(maximum, allocation_currency, decimal_precision: allocation_decimal_precision) + end - Money.new(money, allocation_currency, decimal_precision: allocation_decimal_precision) + maximum.to_money(allocation_currency) end def amounts_from_splits(allocations, splits, subunits_to_split = allocation_units) diff --git a/lib/money/money.rb b/lib/money/money.rb index 34a98f1a..66eb8961 100644 --- a/lib/money/money.rb +++ b/lib/money/money.rb @@ -189,7 +189,7 @@ def <=>(other) def +(other) arithmetic(other) do |money| result_decimal_precision = calculated_decimal_precision(money) - return self if money.value.zero? && !no_currency? + return self if money.value.zero? && !no_currency? && result_decimal_precision == precision_argument Money.new(value + money.value, calculated_currency(money.currency), decimal_precision: result_decimal_precision) end end @@ -197,7 +197,7 @@ def +(other) def -(other) arithmetic(other) do |money| result_decimal_precision = calculated_decimal_precision(money) - return self if money.value.zero? && !no_currency? + return self if money.value.zero? && !no_currency? && result_decimal_precision == precision_argument Money.new(value - money.value, calculated_currency(money.currency), decimal_precision: result_decimal_precision) end end From 0f0a877a1fc6d2b81ef11d5eda2de2e9c1bef7cf Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Thu, 3 Sep 2026 20:44:03 -0400 Subject: [PATCH 10/20] Add mixed allocation unit coverage Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92 --- spec/allocator_spec.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/spec/allocator_spec.rb b/spec/allocator_spec.rb index fc9e1599..ed507a2f 100644 --- a/spec/allocator_spec.rb +++ b/spec/allocator_spec.rb @@ -380,6 +380,17 @@ expect(allocations.map(&:value)).to eq([BigDecimal("0.029"), BigDecimal("0.001")]) end end + + specify "#allocate_max_amounts normalizes maximums to the receiver allocation units" do + Money.with_config(default_subunit_format: :stripe) do + money = Money.new(1, "ISK", decimal_precision: 0) + + allocations = money.allocate_max_amounts([Money.new(1, "ISK")]) + + expect(allocations.map(&:value)).to eq([BigDecimal(1)]) + expect(allocations).to all(be_explicit_decimal_precision) + end + end end def new_allocator(amount, currency = nil) From 1360351bc5dde21af559d3ae3728e6c64eeb2267 Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Thu, 3 Sep 2026 20:44:42 -0400 Subject: [PATCH 11/20] Normalize maximum allocation units Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92 --- lib/money/allocation_units.rb | 6 +++--- lib/money/allocator.rb | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/money/allocation_units.rb b/lib/money/allocation_units.rb index 24c17a3d..00e2739a 100644 --- a/lib/money/allocation_units.rb +++ b/lib/money/allocation_units.rb @@ -4,10 +4,10 @@ class Money module AllocationUnits extend self - def to_units(money) - return money.subunits unless money.explicit_decimal_precision? + def to_units(money, decimal_precision: money.explicit_decimal_precision? ? money.decimal_precision : nil) + return money.subunits if decimal_precision.nil? - (money.value * scale(money.decimal_precision)).to_i + (money.value * scale(decimal_precision)).to_i end def from_units(units, currency, decimal_precision: nil) diff --git a/lib/money/allocator.rb b/lib/money/allocator.rb index 3a997708..03743ea7 100644 --- a/lib/money/allocator.rb +++ b/lib/money/allocator.rb @@ -125,11 +125,11 @@ def allocate_max_amounts(maximums) Money.new(0, allocation_currency, decimal_precision: allocation_decimal_precision), :+, ) - maximums_total_units = AllocationUnits.to_units(maximums_total) + maximums_total_units = allocation_units(maximums_total) splits = maximums.map do |max_amount| next(Rational(0)) if maximums_total_units.zero? - Rational(AllocationUnits.to_units(max_amount), maximums_total_units) + Rational(allocation_units(max_amount), maximums_total_units) end total_allocatable = [maximums_total_units, allocation_units].min @@ -140,7 +140,7 @@ def allocate_max_amounts(maximums) subunits_amounts.each_with_index do |amount, index| break if left_over <= 0 - max_amount = AllocationUnits.to_units(maximums[index]) + max_amount = allocation_units(maximums[index]) next if amount >= max_amount left_over -= 1 @@ -203,8 +203,8 @@ def allocation_decimal_precision decimal_precision if explicit_decimal_precision? end - def allocation_units - AllocationUnits.to_units(__getobj__) + def allocation_units(money = __getobj__) + AllocationUnits.to_units(money, decimal_precision: allocation_decimal_precision) end # Given a list of decimal numbers, return a list ordered by which is nearest to the next whole number. From 5313bee529b0e0f009ae8f44f8dcefaa0c4b6161 Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Tue, 8 Sep 2026 20:55:24 -0400 Subject: [PATCH 12/20] Move allocation conversions into Money::Helpers Assisted-By: devx/ec9e0de4-fb50-4089-9e14-c59b7c4dacce --- lib/money.rb | 1 - lib/money/allocation_units.rb | 28 ---------------------------- lib/money/allocator.rb | 6 +++--- lib/money/helpers.rb | 13 +++++++++++++ lib/money/splitter.rb | 6 +++--- sig/money/helpers.rbs | 4 ++++ 6 files changed, 23 insertions(+), 35 deletions(-) delete mode 100644 lib/money/allocation_units.rb diff --git a/lib/money.rb b/lib/money.rb index c837f77e..df033616 100644 --- a/lib/money.rb +++ b/lib/money.rb @@ -3,7 +3,6 @@ require_relative 'money/version' require_relative 'money/currency' require_relative 'money/null_currency' -require_relative 'money/allocation_units' require_relative 'money/allocator' require_relative 'money/splitter' require_relative 'money/config' diff --git a/lib/money/allocation_units.rb b/lib/money/allocation_units.rb deleted file mode 100644 index 00e2739a..00000000 --- a/lib/money/allocation_units.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -class Money - module AllocationUnits - extend self - - def to_units(money, decimal_precision: money.explicit_decimal_precision? ? money.decimal_precision : nil) - return money.subunits if decimal_precision.nil? - - (money.value * scale(decimal_precision)).to_i - end - - def from_units(units, currency, decimal_precision: nil) - return Money.from_subunits(units, currency) if decimal_precision.nil? - - value = Helpers.value_to_decimal(units) / scale(decimal_precision) - Money.new(value, currency, decimal_precision: decimal_precision) - end - - private - - def scale(decimal_precision) - 10**decimal_precision - end - end - - private_constant :AllocationUnits -end diff --git a/lib/money/allocator.rb b/lib/money/allocator.rb index 03743ea7..93e43efd 100644 --- a/lib/money/allocator.rb +++ b/lib/money/allocator.rb @@ -91,7 +91,7 @@ def allocate(splits, strategy = nil) end amounts.map do |amount| - AllocationUnits.from_units( + Helpers.money_from_units( amount[:whole_subunits], currency, decimal_precision: allocation_decimal_precision, @@ -148,7 +148,7 @@ def allocate_max_amounts(maximums) end subunits_amounts.map do |amount| - AllocationUnits.from_units( + Helpers.money_from_units( amount, allocation_currency, decimal_precision: allocation_decimal_precision, @@ -204,7 +204,7 @@ def allocation_decimal_precision end def allocation_units(money = __getobj__) - AllocationUnits.to_units(money, decimal_precision: allocation_decimal_precision) + Helpers.money_to_units(money, decimal_precision: allocation_decimal_precision) end # Given a list of decimal numbers, return a list ordered by which is nearest to the next whole number. diff --git a/lib/money/helpers.rb b/lib/money/helpers.rb index 4b9c1a47..e95380ec 100644 --- a/lib/money/helpers.rb +++ b/lib/money/helpers.rb @@ -33,6 +33,19 @@ def value_to_decimal(num) value end + def money_to_units(money, decimal_precision: money.explicit_decimal_precision? ? money.decimal_precision : nil) + return money.subunits if decimal_precision.nil? + + (money.value * 10**decimal_precision).to_i + end + + def money_from_units(units, currency, decimal_precision: nil) + return Money.from_subunits(units, currency) if decimal_precision.nil? + + value = value_to_decimal(units) / 10**decimal_precision + Money.new(value, currency, decimal_precision: decimal_precision) + end + def value_to_currency(currency) case currency when Money::Currency, Money::NullCurrency diff --git a/lib/money/splitter.rb b/lib/money/splitter.rb index a761ed68..b96d5b1d 100644 --- a/lib/money/splitter.rb +++ b/lib/money/splitter.rb @@ -15,11 +15,11 @@ def initialize(money, num) def split @split ||= begin - units = AllocationUnits.to_units(@money) + units = Helpers.money_to_units(@money) low_units = units / @num decimal_precision = @money.decimal_precision if @money.explicit_decimal_precision? - low = AllocationUnits.from_units(low_units, @money.currency, decimal_precision: decimal_precision) - high = AllocationUnits.from_units(low_units + 1, @money.currency, decimal_precision: decimal_precision) + low = Helpers.money_from_units(low_units, @money.currency, decimal_precision: decimal_precision) + high = Helpers.money_from_units(low_units + 1, @money.currency, decimal_precision: decimal_precision) num_high = units % @num diff --git a/sig/money/helpers.rbs b/sig/money/helpers.rbs index c377a1b9..b1ad25f0 100644 --- a/sig/money/helpers.rbs +++ b/sig/money/helpers.rbs @@ -7,9 +7,13 @@ class Money def self.value_to_decimal: (Numeric | String | Money | nil num) -> BigDecimal def self.value_to_currency: (String | Currency | NullCurrency | nil currency) -> (Currency | NullCurrency) + def self.money_to_units: (Money money, ?decimal_precision: Integer?) -> Integer + def self.money_from_units: (Numeric units, String | Currency | NullCurrency currency, ?decimal_precision: Integer?) -> Money # Instance methods (via extend self) def value_to_decimal: (Numeric | String | Money | nil num) -> BigDecimal def value_to_currency: (String | Currency | NullCurrency | nil currency) -> (Currency | NullCurrency) + def money_to_units: (Money money, ?decimal_precision: Integer?) -> Integer + def money_from_units: (Numeric units, String | Currency | NullCurrency currency, ?decimal_precision: Integer?) -> Money end end From aa129ba19e28627c1dee9c26df9de1a71e0ce90c Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Wed, 9 Sep 2026 13:07:53 -0400 Subject: [PATCH 13/20] Use fetch for optional decimal precision when deserializing Assisted-By: devx/ec9e0de4-fb50-4089-9e14-c59b7c4dacce --- lib/money/money.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/money/money.rb b/lib/money/money.rb index 66eb8961..637af500 100644 --- a/lib/money/money.rb +++ b/lib/money/money.rb @@ -86,12 +86,12 @@ def from_subunits(subunits, currency_iso, format: nil) def from_json(string) hash = JSON.parse(string, symbolize_names: true) - Money.new(hash.fetch(:value), hash.fetch(:currency), decimal_precision: hash[:decimal_precision]) + Money.new(hash.fetch(:value), hash.fetch(:currency), decimal_precision: hash.fetch(:decimal_precision, nil)) end def from_hash(hash) hash = hash.transform_keys(&:to_sym) - Money.new(hash.fetch(:value), hash.fetch(:currency), decimal_precision: hash[:decimal_precision]) + Money.new(hash.fetch(:value), hash.fetch(:currency), decimal_precision: hash.fetch(:decimal_precision, nil)) end def rational(money1, money2) From a94edb62e87d44d924346c6552b3c9b328c261e2 Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Wed, 9 Sep 2026 14:03:22 -0400 Subject: [PATCH 14/20] Revert "Use fetch for optional decimal precision when deserializing" This reverts commit aa129ba19e28627c1dee9c26df9de1a71e0ce90c. Assisted-By: devx/ec9e0de4-fb50-4089-9e14-c59b7c4dacce --- lib/money/money.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/money/money.rb b/lib/money/money.rb index 637af500..66eb8961 100644 --- a/lib/money/money.rb +++ b/lib/money/money.rb @@ -86,12 +86,12 @@ def from_subunits(subunits, currency_iso, format: nil) def from_json(string) hash = JSON.parse(string, symbolize_names: true) - Money.new(hash.fetch(:value), hash.fetch(:currency), decimal_precision: hash.fetch(:decimal_precision, nil)) + Money.new(hash.fetch(:value), hash.fetch(:currency), decimal_precision: hash[:decimal_precision]) end def from_hash(hash) hash = hash.transform_keys(&:to_sym) - Money.new(hash.fetch(:value), hash.fetch(:currency), decimal_precision: hash.fetch(:decimal_precision, nil)) + Money.new(hash.fetch(:value), hash.fetch(:currency), decimal_precision: hash[:decimal_precision]) end def rational(money1, money2) From 130b8d8dc9c3f77352b85793bdf9ba96b192fc35 Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Sun, 13 Sep 2026 23:47:22 -0400 Subject: [PATCH 15/20] Add decimal precision edge case coverage Assisted-By: devx/ec9e0de4-fb50-4089-9e14-c59b7c4dacce --- spec/allocator_spec.rb | 9 ++++++ spec/money_spec.rb | 73 ++++++++++++++++++++++++++++++++++++++++++ spec/splitter_spec.rb | 8 +++++ 3 files changed, 90 insertions(+) diff --git a/spec/allocator_spec.rb b/spec/allocator_spec.rb index ed507a2f..6ec1f4c7 100644 --- a/spec/allocator_spec.rb +++ b/spec/allocator_spec.rb @@ -40,6 +40,15 @@ expect(allocations).to all(be_explicit_decimal_precision) end + specify "#allocate applies reverse round-robin at explicit precision" do + money = Money.new("0.057", "USD", decimal_precision: 3) + + allocations = money.allocate([0.5, 0.5], :roundrobin_reverse) + + expect(allocations.map(&:value)).to eq([BigDecimal("0.028"), BigDecimal("0.029")]) + expect(allocations).to all(be_explicit_decimal_precision) + end + specify "#allocate does not lose pennies even when given a lossy split" do monies = new_allocator(1).allocate([0.333,0.333, 0.333]) expect(monies[0].subunits).to eq(34) diff --git a/spec/money_spec.rb b/spec/money_spec.rb index 8e7a9842..cf5ad365 100644 --- a/spec/money_spec.rb +++ b/spec/money_spec.rb @@ -110,6 +110,14 @@ expect(money.decimal_precision).to eq(3) end + it "supports an explicit decimal precision of zero" do + money = Money.new("1.6", "USD", decimal_precision: 0) + + expect(money.value).to eq(BigDecimal("2")) + expect(money.to_s).to eq("2") + expect(money).to be_explicit_decimal_precision + end + it "requires decimal precision to be a non-negative integer" do expect { Money.new(1, "USD", decimal_precision: -1) }.to raise_error(ArgumentError, "decimal_precision must be a non-negative Integer") expect { Money.new(1, "USD", decimal_precision: 1.5) }.to raise_error(ArgumentError, "decimal_precision must be a non-negative Integer") @@ -325,6 +333,59 @@ expect(result.decimal_precision).to eq(3) end + it "preserves explicit precision through numeric, string, and reverse arithmetic" do + money = Money.new("0.057", "USD", decimal_precision: 3) + + results = [money + 0.001, money - "0.007", 1 + money, 1 - money, 2 * money] + + expect(results.map(&:value)).to eq([ + BigDecimal("0.058"), + BigDecimal("0.050"), + BigDecimal("1.057"), + BigDecimal("0.943"), + BigDecimal("0.114"), + ]) + expect(results).to all(be_explicit_decimal_precision) + expect(results.map(&:decimal_precision)).to all(eq(3)) + end + + it "preserves explicit precision in both null-currency operand directions" do + money = Money.new("0.057", "USD", decimal_precision: 3) + implicit_null_money = Money.new(1, Money::NULL_CURRENCY) + explicit_null_money = Money.new("1.000", Money::NULL_CURRENCY, decimal_precision: 3) + + results = [ + money + implicit_null_money, + implicit_null_money + money, + money - implicit_null_money, + implicit_null_money - money, + money + explicit_null_money, + explicit_null_money + money, + ] + + expect(results.map(&:currency)).to all(eq(Money::Currency.find!("USD"))) + expect(results).to all(be_explicit_decimal_precision) + expect(results.map(&:decimal_precision)).to all(eq(3)) + end + + it "preserves explicit precision through value transformations" do + money = Money.new("1.235", "USD", decimal_precision: 3) + negative_money = Money.new("-1.235", "USD", decimal_precision: 3) + + results = [ + -money, + negative_money.abs, + money.floor, + money.round(1), + money.fraction(0.1), + money.clamp(0, 1), + money.convert_currency(2, "CAD"), + ] + + expect(results).to all(be_explicit_decimal_precision) + expect(results.map(&:decimal_precision)).to all(eq(3)) + end + it "keeps currency across calculations" do expect(Money.new(1, 'USD') - Money.new(1, 'USD') + Money.new(1.23, Money::NULL_CURRENCY)).to eq(Money.new(1.23, 'USD')) end @@ -505,6 +566,12 @@ expect(Money.from_hash({ value: 1.01, currency: "CAD" })).to eq(Money.new(1.01, "CAD")) end + it "restores explicit decimal precision" do + money = Money.from_hash({ "value" => "0.057", "currency" => "USD", "decimal_precision" => 3 }) + + expect(money.as_json).to eq(value: "0.057", currency: "USD", decimal_precision: 3) + end + it "raises if Hash does not have the expected keys" do expect { Money.from_hash({ "val": 1.0 }) }.to raise_error(KeyError) end @@ -1121,6 +1188,12 @@ expect { Money.from_amount(1, "CAD") }.to_not raise_error end + it "accepts explicit decimal precision" do + money = Money.from_amount("0.057", "USD", decimal_precision: 3) + + expect(money.as_json).to eq(value: "0.057", currency: "USD", decimal_precision: 3) + end + it "accepts Rational number" do expect(Money.from_amount(Rational("999999999999999999.999")).value).to eql(BigDecimal("1000000000000000000", Money::Helpers::MAX_DECIMAL)) expect(Money.from_amount(Rational("999999999999999999.99")).value).to eql(BigDecimal("999999999999999999.99", Money::Helpers::MAX_DECIMAL)) diff --git a/spec/splitter_spec.rb b/spec/splitter_spec.rb index 0fed390b..3124cffe 100644 --- a/spec/splitter_spec.rb +++ b/spec/splitter_spec.rb @@ -42,6 +42,14 @@ expect(splits).to all(be_explicit_decimal_precision) end + specify "#split supports explicit precision below the currency precision" do + splits = Money.new(5, "USD", decimal_precision: 0).split(2).to_a + + expect(splits.map(&:value)).to eq([BigDecimal("3"), BigDecimal("2")]) + expect(splits.map(&:decimal_precision)).to eq([0, 0]) + expect(splits).to all(be_explicit_decimal_precision) + end + specify "#split a dollar" do moneys = Money.new(1).split(3) expect(moneys[0].subunits).to eq(34) From 47704b1d1bba38d18b092ba3dc988fe6d8b1697f Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Mon, 14 Sep 2026 00:46:10 -0400 Subject: [PATCH 16/20] Define precision contracts for allocation and money columns Assisted-By: devx/ec9e0de4-fb50-4089-9e14-c59b7c4dacce --- README.md | 1 + lib/money/allocator.rb | 18 ++++++++++--- lib/money_column/active_record_hooks.rb | 22 ++++++++++++++-- sig/money_column.rbs | 7 ++++- spec/allocator_spec.rb | 35 +++++++++++++++++++++++++ spec/money_column_spec.rb | 35 +++++++++++++++++++++++++ spec/money_spec.rb | 12 +++++++++ 7 files changed, 124 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 90013dd4..10a92d32 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,7 @@ end | currency | string | hardcoded currency value | | currency_read_only | boolean | when true, `currency_column` won't write the currency back into the db. Must be set to true if `currency_column` is an attr_reader or delegate. Default: false | | coerce_null | boolean | when true, a nil value will be returned as Money.zero. Default: false | +| decimal_precision | integer | fixed decimal precision used when reconstructing values. Explicit-precision assignments must match. Default: the currency's minor units | You can use multiple `money_column` calls to achieve the desired effects with currency on the model or attribute level. diff --git a/lib/money/allocator.rb b/lib/money/allocator.rb index 93e43efd..99360303 100644 --- a/lib/money/allocator.rb +++ b/lib/money/allocator.rb @@ -170,11 +170,23 @@ def extract_currency(money_array) end def coerce_maximum(maximum, allocation_currency) - if allocation_decimal_precision && !maximum.is_a?(Money) - return Money.new(maximum, allocation_currency, decimal_precision: allocation_decimal_precision) + return maximum.to_money(allocation_currency) unless allocation_decimal_precision + return Money.new(maximum, allocation_currency, decimal_precision: allocation_decimal_precision) unless maximum.is_a?(Money) + + if maximum.explicit_decimal_precision? && maximum.decimal_precision != allocation_decimal_precision + raise Money::IncompatiblePrecisionError, + "maximum decimal precision #{maximum.decimal_precision} does not match allocation decimal precision #{allocation_decimal_precision}." end - maximum.to_money(allocation_currency) + normalized_maximum = Money.new( + maximum.value, + allocation_currency, + decimal_precision: allocation_decimal_precision, + ) + return normalized_maximum if normalized_maximum.value == maximum.value + + raise Money::IncompatiblePrecisionError, + "maximum #{maximum} cannot be represented exactly with decimal precision #{allocation_decimal_precision}." end def amounts_from_splits(allocations, splits, subunits_to_split = allocation_units) diff --git a/lib/money_column/active_record_hooks.rb b/lib/money_column/active_record_hooks.rb index fd4ca820..212c883c 100644 --- a/lib/money_column/active_record_hooks.rb +++ b/lib/money_column/active_record_hooks.rb @@ -4,6 +4,7 @@ module MoneyColumn class Error < StandardError; end class CurrencyReadOnlyError < Error; end class CurrencyMismatchError < Error; end + class PrecisionMismatchError < Error; end module ActiveRecordHooks def self.included(base) @@ -41,7 +42,11 @@ def read_money_attribute(column) return if value.nil? && !options[:coerce_null] - @money_column_cache[column] = Money.new(value, options[:currency] || send(options[:currency_column])) + @money_column_cache[column] = Money.new( + value, + options[:currency] || send(options[:currency_column]), + decimal_precision: options[:decimal_precision], + ) end def write_money_attribute(column, money) @@ -55,6 +60,7 @@ def write_money_attribute(column, money) end if money.is_a?(Money) + validate_decimal_precision_compatibility!(column, money, options[:decimal_precision]) write_currency(column, money, options) end @@ -105,6 +111,14 @@ def validate_currency_compatibility!(column, money, currency_column) raise MoneyColumn::CurrencyReadOnlyError, msg end + def validate_decimal_precision_compatibility!(column, money, decimal_precision) + return unless money.explicit_decimal_precision? + return if money.decimal_precision == decimal_precision + + raise MoneyColumn::PrecisionMismatchError, + "Invalid #{column}: Money decimal precision #{money.decimal_precision} does not match money column decimal precision #{decimal_precision.inspect}." + end + def _assign_attributes(new_attributes) @money_raw_new_attributes = new_attributes.symbolize_keys super @@ -115,7 +129,7 @@ def _assign_attributes(new_attributes) module ClassMethods attr_reader :money_column_options - def money_column(*columns, currency_column: nil, currency: nil, currency_read_only: false, coerce_null: false) + def money_column(*columns, currency_column: nil, currency: nil, currency_read_only: false, coerce_null: false, decimal_precision: nil) @money_column_options ||= {} options = normalize_money_column_options( @@ -123,6 +137,7 @@ def money_column(*columns, currency_column: nil, currency: nil, currency_read_on currency: currency, currency_read_only: currency_read_only, coerce_null: coerce_null, + decimal_precision: decimal_precision, ) if options[:currency_column] @@ -153,6 +168,9 @@ def normalize_money_column_options(options) 'cannot set both :currency_column and :currency options' if options[:currency] && options[:currency_column] raise ArgumentError, 'must set one of :currency_column or :currency options' unless options[:currency] || options[:currency_column] + unless options[:decimal_precision].nil? || (options[:decimal_precision].is_a?(Integer) && options[:decimal_precision] >= 0) + raise ArgumentError, "decimal_precision must be a non-negative Integer" + end if options[:currency] options[:currency] = Money::Currency.find!(options[:currency]).to_s.freeze diff --git a/sig/money_column.rbs b/sig/money_column.rbs index 48e9e4fc..486e0f01 100644 --- a/sig/money_column.rbs +++ b/sig/money_column.rbs @@ -10,6 +10,9 @@ module MoneyColumn class CurrencyMismatchError < Error end + class PrecisionMismatchError < Error + end + class ActiveRecordType < ActiveRecord::Type::Decimal def serialize: (Money? money) -> BigDecimal? end @@ -39,6 +42,7 @@ module MoneyColumn def read_currency_column: (String | Symbol currency_column) -> String? def validate_hardcoded_currency_compatibility!: (String column, Money money, String | Money::Currency expected_currency) -> void def validate_currency_compatibility!: (String column, Money money, String | Symbol currency_column) -> void + def validate_decimal_precision_compatibility!: (String column, Money money, Integer? decimal_precision) -> void def _assign_attributes: (Hash[untyped, untyped] new_attributes) -> void module ClassMethods @@ -55,7 +59,8 @@ module MoneyColumn ?currency_column: (String | Symbol)?, ?currency: (String | Money::Currency)?, ?currency_read_only: bool, - ?coerce_null: bool + ?coerce_null: bool, + ?decimal_precision: Integer? ) -> void private diff --git a/spec/allocator_spec.rb b/spec/allocator_spec.rb index 6ec1f4c7..3aa711a7 100644 --- a/spec/allocator_spec.rb +++ b/spec/allocator_spec.rb @@ -368,6 +368,41 @@ expect(allocations).to all(be_explicit_decimal_precision) end + specify "#allocate_max_amounts normalizes exactly representable implicit Money maxima" do + money = Money.new("0.057", "USD", decimal_precision: 3) + maximums = [Money.new("0.03", "USD"), Money.new("0.03", "USD")] + + allocations = money.allocate_max_amounts(maximums) + + expect(allocations.map(&:value)).to eq([BigDecimal("0.029"), BigDecimal("0.028")]) + expect(allocations).to all(be_explicit_decimal_precision) + end + + specify "#allocate_max_amounts normalizes an implicit zero Money maximum" do + money = Money.new("0.057", "USD", decimal_precision: 3) + + allocations = money.allocate_max_amounts([Money.new(0, "USD")]) + + expect(allocations).to eq([Money.new(0, "USD", decimal_precision: 3)]) + expect(allocations).to all(be_explicit_decimal_precision) + end + + specify "#allocate_max_amounts rejects implicit Money maxima that cannot be represented exactly" do + money = Money.new("0.1", "USD", decimal_precision: 1) + + expect { + money.allocate_max_amounts([Money.new("0.05", "USD")]) + }.to raise_error(Money::IncompatiblePrecisionError, /cannot be represented exactly/) + end + + specify "#allocate_max_amounts rejects explicitly mismatched Money maxima" do + money = Money.new("0.057", "USD", decimal_precision: 3) + + expect { + money.allocate_max_amounts([Money.new("0.03", "USD", decimal_precision: 2)]) + }.to raise_error(Money::IncompatiblePrecisionError, /does not match allocation decimal precision/) + end + specify "#allocate_max_amounts applies explicit decimal precision to numeric and string maxima" do money = Money.new("0.057", "USD", decimal_precision: 3) diff --git a/spec/money_column_spec.rb b/spec/money_column_spec.rb index e9acb75c..ae2fb78c 100644 --- a/spec/money_column_spec.rb +++ b/spec/money_column_spec.rb @@ -28,6 +28,11 @@ class MoneyRecordCoerceNull < ActiveRecord::Base money_column :price_usd, currency: 'USD', coerce_null: true end +class MoneyRecordWithDecimalPrecision < ActiveRecord::Base + self.table_name = 'money_records' + money_column :price, currency_column: 'price_currency', decimal_precision: 3 +end + class MoneyWithDelegatedCurrency < ActiveRecord::Base self.table_name = 'money_records' delegate :price_currency, to: :delegated_record @@ -80,6 +85,36 @@ class MoneyClassInheritance2 < MoneyWithCustomAccessors expect(record.price).to eq(Money.new(1.23, 'EUR')) end + it 'rejects explicit precision without a fixed decimal precision' do + expect { + MoneyRecord.new(price: Money.new("0.057", "USD", decimal_precision: 3)) + }.to raise_error(MoneyColumn::PrecisionMismatchError) + end + + it 'preserves a configured fixed decimal precision after reload' do + money = Money.new("0.057", "USD", decimal_precision: 3) + + record = MoneyRecordWithDecimalPrecision.create!(price: money) + record.reload + + expect(record.price.as_json).to eq(value: "0.057", currency: "USD", decimal_precision: 3) + end + + it 'rejects explicit precision that differs from the fixed decimal precision' do + expect { + MoneyRecordWithDecimalPrecision.new(price: Money.new("1.23", "USD", decimal_precision: 2)) + }.to raise_error(MoneyColumn::PrecisionMismatchError) + end + + it 'validates a configured fixed decimal precision' do + expect { + Class.new(ActiveRecord::Base) do + self.table_name = 'money_records' + money_column :price, currency_column: 'price_currency', decimal_precision: -1 + end + }.to raise_error(ArgumentError, "decimal_precision must be a non-negative Integer") + end + it 'writes the currency to the db' do record.update(price_currency: nil) record.update(price: Money.new(4, 'JPY')) diff --git a/spec/money_spec.rb b/spec/money_spec.rb index cf5ad365..c8c3a9ca 100644 --- a/spec/money_spec.rb +++ b/spec/money_spec.rb @@ -658,6 +658,18 @@ expect(money).not_to eq(nil) end + it "compares equal values independently of decimal precision" do + implicit_money = Money.new("1.00", "USD") + explicit_currency_precision = Money.new("1.00", "USD", decimal_precision: 2) + explicit_additional_precision = Money.new("1.000", "USD", decimal_precision: 3) + + expect(explicit_currency_precision).to eq(implicit_money) + expect(explicit_additional_precision).to eq(implicit_money) + expect(explicit_currency_precision.hash).to eq(implicit_money.hash) + expect(explicit_additional_precision.hash).to eq(implicit_money.hash) + expect(explicit_additional_precision <=> implicit_money).to eq(0) + end + it "supports floor" do expect(Money.new(15.52).floor).to eq(Money.new(15.00)) expect(Money.new(18.99).floor).to eq(Money.new(18.00)) From 37e8ab4301514a8ddfa9e57564d55094d4fddedd Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Thu, 17 Sep 2026 20:15:18 -0400 Subject: [PATCH 17/20] Trigger CI after rebase Assisted-By: devx/ec9e0de4-fb50-4089-9e14-c59b7c4dacce From 1d4edaaa8dceb72bf800bb8c3ca86c067125337e Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Thu, 17 Sep 2026 20:50:11 -0400 Subject: [PATCH 18/20] Defer explicit precision rounding Assisted-By: devx/ec9e0de4-fb50-4089-9e14-c59b7c4dacce --- README.md | 5 +++++ lib/money/allocator.rb | 5 +++-- lib/money/converters/converter.rb | 3 ++- lib/money/helpers.rb | 2 +- lib/money/money.rb | 6 +++++- lib/money_column/active_record_hooks.rb | 1 + spec/allocator_spec.rb | 10 ++++++++++ spec/converters_spec.rb | 5 +++++ spec/money_column_spec.rb | 2 +- spec/money_spec.rb | 18 +++++++++++++++--- spec/rails/job_argument_serializer_spec.rb | 4 +++- spec/splitter_spec.rb | 7 +++++++ 12 files changed, 58 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 10a92d32..12857dbb 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ Money.new(1000, "USD") * 5 == Money.new(5000, "USD") unit_price = Money.new("0.057", "USD", decimal_precision: 3) (unit_price * 100).to_s #=> "5.700" +# Explicit-precision values retain additional digits during calculations and +# round when rendered +fractional_unit_price = Money.new("0.0057", "USD", decimal_precision: 3) +(fractional_unit_price * 100).to_s #=> "0.570" + # Money arithmetic requires matching precision Money.new(1, "USD", decimal_precision: 3) + Money.new("0.057", "USD", decimal_precision: 3) diff --git a/lib/money/allocator.rb b/lib/money/allocator.rb index 99360303..e8635a12 100644 --- a/lib/money/allocator.rb +++ b/lib/money/allocator.rb @@ -171,7 +171,8 @@ def extract_currency(money_array) def coerce_maximum(maximum, allocation_currency) return maximum.to_money(allocation_currency) unless allocation_decimal_precision - return Money.new(maximum, allocation_currency, decimal_precision: allocation_decimal_precision) unless maximum.is_a?(Money) + + maximum = Money.new(maximum, allocation_currency, decimal_precision: allocation_decimal_precision) unless maximum.is_a?(Money) if maximum.explicit_decimal_precision? && maximum.decimal_precision != allocation_decimal_precision raise Money::IncompatiblePrecisionError, @@ -179,7 +180,7 @@ def coerce_maximum(maximum, allocation_currency) end normalized_maximum = Money.new( - maximum.value, + maximum.value.round(allocation_decimal_precision), allocation_currency, decimal_precision: allocation_decimal_precision, ) diff --git a/lib/money/converters/converter.rb b/lib/money/converters/converter.rb index 52c26a19..4b05827a 100644 --- a/lib/money/converters/converter.rb +++ b/lib/money/converters/converter.rb @@ -5,7 +5,8 @@ module Converters class Converter def to_subunits(money) raise ArgumentError, "money cannot be nil" if money.nil? - (money.value * subunit_to_unit(money.currency)).to_i + value = money.value.round(money.decimal_precision) + (value * subunit_to_unit(money.currency)).round.to_i end def from_subunits(subunits, currency) diff --git a/lib/money/helpers.rb b/lib/money/helpers.rb index e95380ec..6ebfe552 100644 --- a/lib/money/helpers.rb +++ b/lib/money/helpers.rb @@ -36,7 +36,7 @@ def value_to_decimal(num) def money_to_units(money, decimal_precision: money.explicit_decimal_precision? ? money.decimal_precision : nil) return money.subunits if decimal_precision.nil? - (money.value * 10**decimal_precision).to_i + (money.value.round(decimal_precision) * 10**decimal_precision).to_i end def money_from_units(units, currency, decimal_precision: nil) diff --git a/lib/money/money.rb b/lib/money/money.rb index 66eb8961..6c359e25 100644 --- a/lib/money/money.rb +++ b/lib/money/money.rb @@ -136,7 +136,11 @@ def initialize(value, currency, decimal_precision) @currency = currency @decimal_precision = decimal_precision - @value = BigDecimal(value.round(self.decimal_precision)) + @value = if explicit_decimal_precision? + BigDecimal(value) + else + BigDecimal(value.round(self.decimal_precision)) + end freeze end diff --git a/lib/money_column/active_record_hooks.rb b/lib/money_column/active_record_hooks.rb index 212c883c..97adecf9 100644 --- a/lib/money_column/active_record_hooks.rb +++ b/lib/money_column/active_record_hooks.rb @@ -62,6 +62,7 @@ def write_money_attribute(column, money) if money.is_a?(Money) validate_decimal_precision_compatibility!(column, money, options[:decimal_precision]) write_currency(column, money, options) + money = money.to_s(:amount) end self[column] = Money::Helpers.value_to_decimal(money) diff --git a/spec/allocator_spec.rb b/spec/allocator_spec.rb index 3aa711a7..7354e872 100644 --- a/spec/allocator_spec.rb +++ b/spec/allocator_spec.rb @@ -395,6 +395,16 @@ }.to raise_error(Money::IncompatiblePrecisionError, /cannot be represented exactly/) end + specify "#allocate_max_amounts rejects numeric and string maxima that cannot be represented exactly" do + money = Money.new("0.1", "USD", decimal_precision: 1) + + [0.05, "0.05"].each do |maximum| + expect { + money.allocate_max_amounts([maximum, maximum]) + }.to raise_error(Money::IncompatiblePrecisionError, /cannot be represented exactly/) + end + end + specify "#allocate_max_amounts rejects explicitly mismatched Money maxima" do money = Money.new("0.057", "USD", decimal_precision: 3) diff --git a/spec/converters_spec.rb b/spec/converters_spec.rb index 1ecde398..00ca6aca 100644 --- a/spec/converters_spec.rb +++ b/spec/converters_spec.rb @@ -54,6 +54,11 @@ class InvalidConverter < Money::Converters::Converter expect(converter.to_subunits(Money.new(1, usd))).to eq(100) expect(converter.from_subunits(100, usd)).to eq(Money.new(1, usd)) end + + it 'rounds retained calculation precision when converting to subunits' do + expect(converter.to_subunits(Money.new("0.0099", usd, decimal_precision: 2))).to eq(1) + expect(converter.to_subunits(Money.new("0.0057", "JOD", decimal_precision: 3))).to eq(6) + end end describe Money::Converters::StripeConverter do diff --git a/spec/money_column_spec.rb b/spec/money_column_spec.rb index ae2fb78c..d56bae12 100644 --- a/spec/money_column_spec.rb +++ b/spec/money_column_spec.rb @@ -92,7 +92,7 @@ class MoneyClassInheritance2 < MoneyWithCustomAccessors end it 'preserves a configured fixed decimal precision after reload' do - money = Money.new("0.057", "USD", decimal_precision: 3) + money = Money.new("0.0574", "USD", decimal_precision: 3) record = MoneyRecordWithDecimalPrecision.create!(price: money) record.reload diff --git a/spec/money_spec.rb b/spec/money_spec.rb index c8c3a9ca..b15fe14b 100644 --- a/spec/money_spec.rb +++ b/spec/money_spec.rb @@ -106,14 +106,15 @@ it "uses an explicit decimal precision when constructing a value" do money = Money.new("1.2345", "USD", decimal_precision: 3) - expect(money.value).to eq(BigDecimal("1.235")) + expect(money.value).to eq(BigDecimal("1.2345")) expect(money.decimal_precision).to eq(3) + expect(money.to_s).to eq("1.235") end it "supports an explicit decimal precision of zero" do money = Money.new("1.6", "USD", decimal_precision: 0) - expect(money.value).to eq(BigDecimal("2")) + expect(money.value).to eq(BigDecimal("1.6")) expect(money.to_s).to eq("2") expect(money).to be_explicit_decimal_precision end @@ -142,8 +143,9 @@ money = Money.new(precise_money, "USD", decimal_precision: 2) - expect(money.value).to eq(BigDecimal("1.23")) + expect(money.value).to eq(BigDecimal("1.2345")) expect(money.decimal_precision).to eq(2) + expect(money.to_s).to eq("1.23") end it "records explicitly selecting the currency's default decimal precision" do @@ -302,6 +304,16 @@ expect((unit_price * 100).to_s).to eq("5.700") end + it "defers explicit precision rounding until rendering" do + unit_price = Money.new("0.0057", "USD", decimal_precision: 3) + + expect(unit_price.value).to eq(BigDecimal("0.0057")) + expect(unit_price.to_s).to eq("0.006") + expect(unit_price.as_json).to eq(value: "0.006", currency: "USD", decimal_precision: 3) + expect((unit_price * 100).value).to eq(BigDecimal("0.57")) + expect((unit_price * 100).to_s).to eq("0.570") + end + it "applies explicit decimal precision from zero across arithmetic" do implicit_money = Money.new("1.00", "USD") explicit_zero = Money.new("0.00", "USD", decimal_precision: 2) diff --git a/spec/rails/job_argument_serializer_spec.rb b/spec/rails/job_argument_serializer_spec.rb index 2b88f173..d3e28104 100644 --- a/spec/rails/job_argument_serializer_spec.rb +++ b/spec/rails/job_argument_serializer_spec.rb @@ -19,10 +19,11 @@ end it "roundtrips non-default decimal precision" do - money = Money.new("0.057", "USD", decimal_precision: 3) + money = Money.new("0.0574", "USD", decimal_precision: 3) serialized_job = MoneyTestJob.new(value: money).serialize serialized_value = serialized_job["arguments"][0]["value"] + expect(serialized_value["value"]).to eq("0.0574") expect(serialized_value["decimal_precision"]).to eq(3) deserialized_job = MoneyTestJob.deserialize(serialized_job) @@ -30,6 +31,7 @@ deserialized_money = deserialized_job.arguments.first[:value] expect(deserialized_money).to eq(money) + expect(deserialized_money.value).to eq(BigDecimal("0.0574")) expect(deserialized_money.decimal_precision).to eq(3) end end diff --git a/spec/splitter_spec.rb b/spec/splitter_spec.rb index 3124cffe..2c423278 100644 --- a/spec/splitter_spec.rb +++ b/spec/splitter_spec.rb @@ -42,6 +42,13 @@ expect(splits).to all(be_explicit_decimal_precision) end + specify "#split rounds retained calculation precision to explicit precision units" do + splits = Money.new("0.0057", "USD", decimal_precision: 3).split(2).to_a + + expect(splits.map(&:value)).to eq([BigDecimal("0.003"), BigDecimal("0.003")]) + expect(splits.sum(&:value)).to eq(BigDecimal("0.006")) + end + specify "#split supports explicit precision below the currency precision" do splits = Money.new(5, "USD", decimal_precision: 0).split(2).to_a From a0354c5b9565442e7a1856ffb0e636a9861ccc23 Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Thu, 24 Sep 2026 12:34:30 -0400 Subject: [PATCH 19/20] Separate computation precision from currency presentment --- README.md | 15 +++-- lib/money/allocator.rb | 52 +++++++---------- lib/money/converters/converter.rb | 3 +- lib/money/errors.rb | 3 - lib/money/money.rb | 20 ++----- lib/money_column/active_record_hooks.rb | 11 ---- sig/money/errors.rbs | 3 - sig/money_column.rbs | 4 -- spec/allocator_spec.rb | 50 ++++++++++++---- spec/converters_spec.rb | 7 +++ spec/money_column_spec.rb | 20 ++++--- spec/money_spec.rb | 78 ++++++++++++++++++------- spec/splitter_spec.rb | 6 +- 13 files changed, 154 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index 12857dbb..297a58cd 100644 --- a/README.md +++ b/README.md @@ -41,15 +41,18 @@ Money.new(1000, "USD") * 5 == Money.new(5000, "USD") # Explicit precision for values smaller than a currency subunit unit_price = Money.new("0.057", "USD", decimal_precision: 3) -(unit_price * 100).to_s #=> "5.700" +(unit_price * 100).to_s #=> "5.70" # Explicit-precision values retain additional digits during calculations and -# round when rendered +# round to currency precision when rendered fractional_unit_price = Money.new("0.0057", "USD", decimal_precision: 3) -(fractional_unit_price * 100).to_s #=> "0.570" +(fractional_unit_price * 100).to_s #=> "0.57" -# Money arithmetic requires matching precision -Money.new(1, "USD", decimal_precision: 3) + Money.new("0.057", "USD", decimal_precision: 3) +# Money arithmetic uses the highest operand and currency precision +total = Money.new(1, "USD") + Money.new("0.057", "USD", decimal_precision: 3) +total.value.to_s("F") #=> "1.057" +total.to_s #=> "1.06" +total.decimal_precision #=> 3 m = Money.new(1000, "USD") # Splitting money evenly @@ -281,7 +284,7 @@ end | currency | string | hardcoded currency value | | currency_read_only | boolean | when true, `currency_column` won't write the currency back into the db. Must be set to true if `currency_column` is an attr_reader or delegate. Default: false | | coerce_null | boolean | when true, a nil value will be returned as Money.zero. Default: false | -| decimal_precision | integer | fixed decimal precision used when reconstructing values. Explicit-precision assignments must match. Default: the currency's minor units | +| decimal_precision | integer | model-level computation precision used when reconstructing raw stored values, with currency precision as a minimum. No precision database column is needed. Default: the currency's minor units | You can use multiple `money_column` calls to achieve the desired effects with currency on the model or attribute level. diff --git a/lib/money/allocator.rb b/lib/money/allocator.rb index e8635a12..c03f1439 100644 --- a/lib/money/allocator.rb +++ b/lib/money/allocator.rb @@ -120,19 +120,26 @@ def allocate(splits, strategy = nil) # #=> [Money.new(5), Money.new(2)] def allocate_max_amounts(maximums) allocation_currency = extract_currency(maximums + [__getobj__]) - maximums = maximums.map { |max| coerce_maximum(max, allocation_currency) } - maximums_total = maximums.reduce( - Money.new(0, allocation_currency, decimal_precision: allocation_decimal_precision), - :+, - ) - maximums_total_units = allocation_units(maximums_total) - - splits = maximums.map do |max_amount| + money_values = maximums.grep(Money) + [__getobj__] + precision = if money_values.any?(&:explicit_decimal_precision?) + (money_values.map(&:decimal_precision) + [allocation_currency.minor_units]).max + end + maximums = maximums.map { |max| coerce_maximum(max, allocation_currency, precision) } + maximums_units = maximums.map do |maximum| + if precision + (maximum.value * 10**precision).floor + else + maximum.subunits + end + end + maximums_total_units = maximums_units.sum + + splits = maximums_units.map do |max_units| next(Rational(0)) if maximums_total_units.zero? - Rational(allocation_units(max_amount), maximums_total_units) + Rational(max_units, maximums_total_units) end - total_allocatable = [maximums_total_units, allocation_units].min + total_allocatable = [maximums_total_units, Helpers.money_to_units(__getobj__, decimal_precision: precision)].min subunits_amounts, left_over = amounts_from_splits(1, splits, total_allocatable) subunits_amounts.map! { |amount| amount[:whole_subunits] } @@ -140,7 +147,7 @@ def allocate_max_amounts(maximums) subunits_amounts.each_with_index do |amount, index| break if left_over <= 0 - max_amount = allocation_units(maximums[index]) + max_amount = maximums_units[index] next if amount >= max_amount left_over -= 1 @@ -151,7 +158,7 @@ def allocate_max_amounts(maximums) Helpers.money_from_units( amount, allocation_currency, - decimal_precision: allocation_decimal_precision, + decimal_precision: precision, ) end end @@ -169,25 +176,10 @@ def extract_currency(money_array) currencies.first || NULL_CURRENCY end - def coerce_maximum(maximum, allocation_currency) - return maximum.to_money(allocation_currency) unless allocation_decimal_precision - - maximum = Money.new(maximum, allocation_currency, decimal_precision: allocation_decimal_precision) unless maximum.is_a?(Money) - - if maximum.explicit_decimal_precision? && maximum.decimal_precision != allocation_decimal_precision - raise Money::IncompatiblePrecisionError, - "maximum decimal precision #{maximum.decimal_precision} does not match allocation decimal precision #{allocation_decimal_precision}." - end - - normalized_maximum = Money.new( - maximum.value.round(allocation_decimal_precision), - allocation_currency, - decimal_precision: allocation_decimal_precision, - ) - return normalized_maximum if normalized_maximum.value == maximum.value + def coerce_maximum(maximum, allocation_currency, precision) + return maximum.to_money(allocation_currency) if maximum.is_a?(Money) - raise Money::IncompatiblePrecisionError, - "maximum #{maximum} cannot be represented exactly with decimal precision #{allocation_decimal_precision}." + Money.new(maximum, allocation_currency, decimal_precision: precision) end def amounts_from_splits(allocations, splits, subunits_to_split = allocation_units) diff --git a/lib/money/converters/converter.rb b/lib/money/converters/converter.rb index 4b05827a..58c84e2b 100644 --- a/lib/money/converters/converter.rb +++ b/lib/money/converters/converter.rb @@ -5,8 +5,7 @@ module Converters class Converter def to_subunits(money) raise ArgumentError, "money cannot be nil" if money.nil? - value = money.value.round(money.decimal_precision) - (value * subunit_to_unit(money.currency)).round.to_i + (money.value * subunit_to_unit(money.currency)).round.to_i end def from_subunits(subunits, currency) diff --git a/lib/money/errors.rb b/lib/money/errors.rb index c7f19b0c..219d8e03 100644 --- a/lib/money/errors.rb +++ b/lib/money/errors.rb @@ -6,7 +6,4 @@ class Error < StandardError class IncompatibleCurrencyError < Error end - - class IncompatiblePrecisionError < Error - end end diff --git a/lib/money/money.rb b/lib/money/money.rb index 6c359e25..7522f274 100644 --- a/lib/money/money.rb +++ b/lib/money/money.rb @@ -96,7 +96,6 @@ def from_hash(hash) def rational(money1, money2) money1.send(:arithmetic, money2) do |money| - money1.send(:calculated_decimal_precision, money) money1.value.to_r / money.value.to_r end end @@ -167,7 +166,7 @@ def no_currency? end def decimal_precision - @decimal_precision || currency.minor_units + [@decimal_precision || currency.minor_units, currency.minor_units].max end def explicit_decimal_precision? @@ -275,7 +274,7 @@ def to_fs(style = nil) when :legacy_dollars 2 when :amount, nil - decimal_precision + currency.minor_units else raise ArgumentError, "Unexpected format: #{style}" end @@ -307,7 +306,8 @@ def as_json(options = nil) if (options.is_a?(Hash) && options[:legacy_format]) || Money::Config.current.legacy_json_format to_s else - hash = { value: to_s(:amount), currency: currency.to_s } + serialized_value = explicit_decimal_precision? ? value.to_s("F") : to_s(:amount) + hash = { value: serialized_value, currency: currency.to_s } hash[:decimal_precision] = decimal_precision if explicit_decimal_precision? hash end @@ -423,17 +423,9 @@ def ensure_compatible_currency(other_currency, msg) end def calculated_decimal_precision(other) - return other.decimal_precision if no_currency? && !explicit_decimal_precision? && other.explicit_decimal_precision? - return if no_currency? && !explicit_decimal_precision? - return precision_argument if other.no_currency? && !other.explicit_decimal_precision? - if decimal_precision == other.decimal_precision - return precision_argument || other.decimal_precision if other.explicit_decimal_precision? - return precision_argument - end + return unless explicit_decimal_precision? || other.explicit_decimal_precision? - raise Money::IncompatiblePrecisionError, - "mathematical operation not permitted for Money objects with different decimal precisions " \ - "#{decimal_precision} and #{other.decimal_precision}." + [decimal_precision, other.decimal_precision].max end def precision_argument diff --git a/lib/money_column/active_record_hooks.rb b/lib/money_column/active_record_hooks.rb index 97adecf9..17b56555 100644 --- a/lib/money_column/active_record_hooks.rb +++ b/lib/money_column/active_record_hooks.rb @@ -4,7 +4,6 @@ module MoneyColumn class Error < StandardError; end class CurrencyReadOnlyError < Error; end class CurrencyMismatchError < Error; end - class PrecisionMismatchError < Error; end module ActiveRecordHooks def self.included(base) @@ -60,9 +59,7 @@ def write_money_attribute(column, money) end if money.is_a?(Money) - validate_decimal_precision_compatibility!(column, money, options[:decimal_precision]) write_currency(column, money, options) - money = money.to_s(:amount) end self[column] = Money::Helpers.value_to_decimal(money) @@ -112,14 +109,6 @@ def validate_currency_compatibility!(column, money, currency_column) raise MoneyColumn::CurrencyReadOnlyError, msg end - def validate_decimal_precision_compatibility!(column, money, decimal_precision) - return unless money.explicit_decimal_precision? - return if money.decimal_precision == decimal_precision - - raise MoneyColumn::PrecisionMismatchError, - "Invalid #{column}: Money decimal precision #{money.decimal_precision} does not match money column decimal precision #{decimal_precision.inspect}." - end - def _assign_attributes(new_attributes) @money_raw_new_attributes = new_attributes.symbolize_keys super diff --git a/sig/money/errors.rbs b/sig/money/errors.rbs index c7f19b0c..219d8e03 100644 --- a/sig/money/errors.rbs +++ b/sig/money/errors.rbs @@ -6,7 +6,4 @@ class Money class IncompatibleCurrencyError < Error end - - class IncompatiblePrecisionError < Error - end end diff --git a/sig/money_column.rbs b/sig/money_column.rbs index 486e0f01..4c0d2582 100644 --- a/sig/money_column.rbs +++ b/sig/money_column.rbs @@ -10,9 +10,6 @@ module MoneyColumn class CurrencyMismatchError < Error end - class PrecisionMismatchError < Error - end - class ActiveRecordType < ActiveRecord::Type::Decimal def serialize: (Money? money) -> BigDecimal? end @@ -42,7 +39,6 @@ module MoneyColumn def read_currency_column: (String | Symbol currency_column) -> String? def validate_hardcoded_currency_compatibility!: (String column, Money money, String | Money::Currency expected_currency) -> void def validate_currency_compatibility!: (String column, Money money, String | Symbol currency_column) -> void - def validate_decimal_precision_compatibility!: (String column, Money money, Integer? decimal_precision) -> void def _assign_attributes: (Hash[untyped, untyped] new_attributes) -> void module ClassMethods diff --git a/spec/allocator_spec.rb b/spec/allocator_spec.rb index 7354e872..1387f5ba 100644 --- a/spec/allocator_spec.rb +++ b/spec/allocator_spec.rb @@ -387,30 +387,56 @@ expect(allocations).to all(be_explicit_decimal_precision) end - specify "#allocate_max_amounts rejects implicit Money maxima that cannot be represented exactly" do + specify "#allocate_max_amounts uses currency precision for implicit maxima" do money = Money.new("0.1", "USD", decimal_precision: 1) - expect { - money.allocate_max_amounts([Money.new("0.05", "USD")]) - }.to raise_error(Money::IncompatiblePrecisionError, /cannot be represented exactly/) + allocations = money.allocate_max_amounts([Money.new("0.05", "USD")]) + expect(allocations.map(&:value)).to eq([BigDecimal("0.05")]) + expect(allocations.map(&:decimal_precision)).to eq([2]) end - specify "#allocate_max_amounts rejects numeric and string maxima that cannot be represented exactly" do + specify "#allocate_max_amounts uses currency precision for numeric and string maxima" do money = Money.new("0.1", "USD", decimal_precision: 1) [0.05, "0.05"].each do |maximum| - expect { - money.allocate_max_amounts([maximum, maximum]) - }.to raise_error(Money::IncompatiblePrecisionError, /cannot be represented exactly/) + allocations = money.allocate_max_amounts([maximum, maximum]) + expect(allocations.map(&:value)).to eq([BigDecimal("0.05"), BigDecimal("0.05")]) end end - specify "#allocate_max_amounts rejects explicitly mismatched Money maxima" do + specify "#allocate_max_amounts accepts explicitly mismatched Money maxima" do money = Money.new("0.057", "USD", decimal_precision: 3) - expect { - money.allocate_max_amounts([Money.new("0.03", "USD", decimal_precision: 2)]) - }.to raise_error(Money::IncompatiblePrecisionError, /does not match allocation decimal precision/) + allocations = money.allocate_max_amounts([Money.new("0.03", "USD", decimal_precision: 2)]) + expect(allocations.map(&:value)).to eq([BigDecimal("0.03")]) + expect(allocations.map(&:decimal_precision)).to eq([3]) + end + + specify "#allocate_max_amounts promotes an implicit receiver to the maxima precision" do + money = Money.new("0.06", "USD") + maxima = [Money.new("0.029", "USD", decimal_precision: 3), Money.new("0.028", "USD", decimal_precision: 4)] + allocations = money.allocate_max_amounts(maxima) + + expect(allocations.map(&:value)).to eq(maxima.map(&:value)) + expect(allocations.map(&:decimal_precision)).to eq([4, 4]) + end + + specify "#allocate_max_amounts promotes precision before coercing numeric and string maxima" do + money = Money.new("0.06", "USD") + [0.029, "0.029"].each do |maximum| + allocations = money.allocate_max_amounts([maximum, Money.new("0.028", "USD", decimal_precision: 3)]) + expect(allocations.map(&:value)).to eq([BigDecimal("0.029"), BigDecimal("0.028")]) + expect(allocations.map(&:decimal_precision)).to eq([3, 3]) + end + end + + specify "#allocate_max_amounts does not round caps up or overallocate retained digits" do + money = Money.new("0.1", "USD", decimal_precision: 2) + allocations = money.allocate_max_amounts(["0.055", Money.new("0.045", "USD", decimal_precision: 2)]) + + expect(allocations.map(&:value)).to eq([BigDecimal("0.05"), BigDecimal("0.04")]) + expect(allocations.sum(&:value)).to be <= money.value + expect(money.allocate_max_amounts(["0.001"]).map(&:value)).to eq([BigDecimal(0)]) end specify "#allocate_max_amounts applies explicit decimal precision to numeric and string maxima" do diff --git a/spec/converters_spec.rb b/spec/converters_spec.rb index 00ca6aca..2820af09 100644 --- a/spec/converters_spec.rb +++ b/spec/converters_spec.rb @@ -59,6 +59,13 @@ class InvalidConverter < Money::Converters::Converter expect(converter.to_subunits(Money.new("0.0099", usd, decimal_precision: 2))).to eq(1) expect(converter.to_subunits(Money.new("0.0057", "JOD", decimal_precision: 3))).to eq(6) end + + it 'uses integer currency subunits independently of computation precision' do + expect(converter.to_subunits(Money.new("0.0149", usd, decimal_precision: 3))).to eq(1) + expect(converter.to_subunits(Money.new("-0.0149", usd, decimal_precision: 3))).to eq(-1) + expect(converter.to_subunits(Money.new("1.6", "JPY", decimal_precision: 4))).to eq(2) + expect(Money.new("0.057", "USD", decimal_precision: 4).subunits).to eq(6) + end end describe Money::Converters::StripeConverter do diff --git a/spec/money_column_spec.rb b/spec/money_column_spec.rb index d56bae12..382ba8fa 100644 --- a/spec/money_column_spec.rb +++ b/spec/money_column_spec.rb @@ -85,10 +85,10 @@ class MoneyClassInheritance2 < MoneyWithCustomAccessors expect(record.price).to eq(Money.new(1.23, 'EUR')) end - it 'rejects explicit precision without a fixed decimal precision' do - expect { - MoneyRecord.new(price: Money.new("0.057", "USD", decimal_precision: 3)) - }.to raise_error(MoneyColumn::PrecisionMismatchError) + it 'writes raw values without requiring a decimal precision database column' do + record = MoneyRecord.new(price: Money.new("0.057", "USD", decimal_precision: 3)) + expect(record[:price]).to eq(BigDecimal("0.057")) + expect(record.attributes.keys).not_to include("decimal_precision") end it 'preserves a configured fixed decimal precision after reload' do @@ -97,13 +97,15 @@ class MoneyClassInheritance2 < MoneyWithCustomAccessors record = MoneyRecordWithDecimalPrecision.create!(price: money) record.reload - expect(record.price.as_json).to eq(value: "0.057", currency: "USD", decimal_precision: 3) + expect(record.price.as_json).to eq(value: "0.0574", currency: "USD", decimal_precision: 3) + expect(record.price.to_s).to eq("0.06") end - it 'rejects explicit precision that differs from the fixed decimal precision' do - expect { - MoneyRecordWithDecimalPrecision.new(price: Money.new("1.23", "USD", decimal_precision: 2)) - }.to raise_error(MoneyColumn::PrecisionMismatchError) + it 'accepts differing precision and reconstructs using model configuration' do + record = MoneyRecordWithDecimalPrecision.create!(price: Money.new("1.2345", "USD", decimal_precision: 4)) + record.reload + expect(record.price.value).to eq(BigDecimal("1.2345")) + expect(record.price.decimal_precision).to eq(3) end it 'validates a configured fixed decimal precision' do diff --git a/spec/money_spec.rb b/spec/money_spec.rb index b15fe14b..7adfe812 100644 --- a/spec/money_spec.rb +++ b/spec/money_spec.rb @@ -108,14 +108,15 @@ expect(money.value).to eq(BigDecimal("1.2345")) expect(money.decimal_precision).to eq(3) - expect(money.to_s).to eq("1.235") + expect(money.to_s).to eq("1.23") end it "supports an explicit decimal precision of zero" do money = Money.new("1.6", "USD", decimal_precision: 0) expect(money.value).to eq(BigDecimal("1.6")) - expect(money.to_s).to eq("2") + expect(money.to_s).to eq("1.60") + expect(money.decimal_precision).to eq(2) expect(money).to be_explicit_decimal_precision end @@ -192,9 +193,11 @@ expect(non_fractional_money.to_s).to eq("1") end - it "to_s displays the explicit decimal precision" do - expect(Money.new("0.057", "USD", decimal_precision: 3).to_s).to eq("0.057") - expect(Money.new("1", "USD", decimal_precision: 4).to_s).to eq("1.0000") + it "to_s uses currency precision for presentment" do + expect(Money.new("0.057", "USD", decimal_precision: 3).to_s).to eq("0.06") + expect(Money.new("1", "USD", decimal_precision: 4).to_s).to eq("1.00") + expect(Money.new("1.57", "JPY", decimal_precision: 3).to_s).to eq("2") + expect(Money.new("1.2345", "BHD", decimal_precision: 4).to_s).to eq("1.235") end it "to_fs with a legacy_dollars style" do @@ -299,19 +302,19 @@ it "preserves explicit decimal precision across arithmetic" do unit_price = Money.new("0.057", "USD", decimal_precision: 3) - expect((unit_price + Money.new("0.001", "USD", decimal_precision: 3)).to_s).to eq("0.058") - expect((unit_price - Money.new("0.007", "USD", decimal_precision: 3)).to_s).to eq("0.050") - expect((unit_price * 100).to_s).to eq("5.700") + expect((unit_price + Money.new("0.001", "USD", decimal_precision: 3)).value).to eq(BigDecimal("0.058")) + expect((unit_price - Money.new("0.007", "USD", decimal_precision: 3)).value).to eq(BigDecimal("0.050")) + expect((unit_price * 100).to_s).to eq("5.70") end it "defers explicit precision rounding until rendering" do unit_price = Money.new("0.0057", "USD", decimal_precision: 3) expect(unit_price.value).to eq(BigDecimal("0.0057")) - expect(unit_price.to_s).to eq("0.006") - expect(unit_price.as_json).to eq(value: "0.006", currency: "USD", decimal_precision: 3) + expect(unit_price.to_s).to eq("0.01") + expect(unit_price.as_json).to eq(value: "0.0057", currency: "USD", decimal_precision: 3) expect((unit_price * 100).value).to eq(BigDecimal("0.57")) - expect((unit_price * 100).to_s).to eq("0.570") + expect((unit_price * 100).to_s).to eq("0.57") end it "applies explicit decimal precision from zero across arithmetic" do @@ -321,18 +324,51 @@ results = [implicit_money + explicit_zero, implicit_money - explicit_zero] expect(results).to all(be_explicit_decimal_precision) - expect(results.map(&:as_json)).to all(eq(value: "1.00", currency: "USD", decimal_precision: 2)) + expect(results.map(&:as_json)).to all(eq(value: "1.0", currency: "USD", decimal_precision: 2)) end - it "rejects arithmetic between different decimal precisions" do + it "uses the highest precision for arithmetic in either operand order" do precise_money = Money.new("0.057", "USD", decimal_precision: 3) currency_precision_money = Money.new("1.00", "USD") - expect { precise_money + currency_precision_money }.to raise_error( - Money::IncompatiblePrecisionError, - "mathematical operation not permitted for Money objects with different decimal precisions 3 and 2.", - ) - expect { currency_precision_money - precise_money }.to raise_error(Money::IncompatiblePrecisionError) + sums = [precise_money + currency_precision_money, currency_precision_money + precise_money] + expect(sums.map(&:value)).to all(eq(BigDecimal("1.057"))) + expect(sums.map(&:decimal_precision)).to eq([3, 3]) + expect((currency_precision_money - precise_money).value).to eq(BigDecimal("0.943")) + expect((precise_money - currency_precision_money).value).to eq(BigDecimal("-0.943")) + end + + it "promotes mixed explicit precisions without rounding raw operands" do + low = Money.new("0.0057", "USD", decimal_precision: 2) + high = Money.new("0.00003", "USD", decimal_precision: 4) + + results = [low + high, high + low, low - high, high - low] + expect(results.map(&:decimal_precision)).to all(eq(4)) + expect(results.map(&:value)).to eq(%w[0.00573 0.00573 0.00567 -0.00567].map { |value| BigDecimal(value) }) + expect((low + Money.new(0, "USD", decimal_precision: 5)).decimal_precision).to eq(5) + end + + it "uses currency precision as the minimum across arithmetic and currency conversion" do + money = Money.new("1.2345", "USD", decimal_precision: 0) + results = [money + "0.0001", money - "0.0001", money * 2, -money, money.fraction(1)] + + expect(results.map(&:decimal_precision)).to all(eq(2)) + expect(results.map(&:value)).to eq(%w[1.2346 1.2344 2.469 -1.2345 0.61725].map { |value| BigDecimal(value) }) + converted = money.convert_currency(1, "BHD") + expect(converted.decimal_precision).to eq(3) + expect(converted.value).to eq(money.value) + expect(converted.to_s).to eq("1.235") + end + + it "preserves raw value and precision through JSON, hashes, and YAML" do + money = Money.new("0.0057", "USD", decimal_precision: 3) + restored = [Money.from_json(money.to_json), Money.from_hash(money.to_h), yaml_load(money.to_yaml)] + + expect(restored.map(&:value)).to all(eq(BigDecimal("0.0057"))) + expect(restored.map(&:decimal_precision)).to all(eq(3)) + expect(restored.map { |value| (value * 100).to_s }).to all(eq("0.57")) + expect(money.as_json(legacy_format: true)).to eq("0.01") + expect(money.to_json(legacy_format: true)).to eq("0.01") end it "uses the currency-bearing value's precision when adding a default null-currency value" do @@ -341,7 +377,7 @@ result = null_currency_money + precise_money - expect(result.to_s).to eq("1.057") + expect(result.value).to eq(BigDecimal("1.057")) expect(result.decimal_precision).to eq(3) end @@ -699,11 +735,11 @@ expect(Money.rational(half_yen, one_yen)).to eq(Rational(1, 2)) end - it "raises when attempting to make a rational from different decimal precisions" do + it "makes a rational from different decimal precisions" do one_decimal = Money.new("0.5", "JPY", decimal_precision: 1) two_decimals = Money.new("1.00", "JPY", decimal_precision: 2) - expect { Money.rational(one_decimal, two_decimals) }.to raise_error(Money::IncompatiblePrecisionError) + expect(Money.rational(one_decimal, two_decimals)).to eq(Rational(1, 2)) end it "raises when attempting to make a rational from different currencies" do diff --git a/spec/splitter_spec.rb b/spec/splitter_spec.rb index 2c423278..db3f762a 100644 --- a/spec/splitter_spec.rb +++ b/spec/splitter_spec.rb @@ -49,11 +49,11 @@ expect(splits.sum(&:value)).to eq(BigDecimal("0.006")) end - specify "#split supports explicit precision below the currency precision" do + specify "#split uses at least the currency precision" do splits = Money.new(5, "USD", decimal_precision: 0).split(2).to_a - expect(splits.map(&:value)).to eq([BigDecimal("3"), BigDecimal("2")]) - expect(splits.map(&:decimal_precision)).to eq([0, 0]) + expect(splits.map(&:value)).to eq([BigDecimal("2.5"), BigDecimal("2.5")]) + expect(splits.map(&:decimal_precision)).to eq([2, 2]) expect(splits).to all(be_explicit_decimal_precision) end From da0b0c359e0d64d03eece0bfb04820a61ba9e79c Mon Sep 17 00:00:00 2001 From: Derik Thiessen Date: Thu, 24 Sep 2026 13:08:34 -0400 Subject: [PATCH 20/20] Keep implicit null currency neutral for precision promotion --- lib/money/allocator.rb | 1 + lib/money/money.rb | 2 ++ spec/allocator_spec.rb | 19 +++++++++++++++++++ spec/money_spec.rb | 18 ++++++++++++++++++ 4 files changed, 40 insertions(+) diff --git a/lib/money/allocator.rb b/lib/money/allocator.rb index c03f1439..18eb6594 100644 --- a/lib/money/allocator.rb +++ b/lib/money/allocator.rb @@ -121,6 +121,7 @@ def allocate(splits, strategy = nil) def allocate_max_amounts(maximums) allocation_currency = extract_currency(maximums + [__getobj__]) money_values = maximums.grep(Money) + [__getobj__] + money_values = money_values.reject { |money| money.no_currency? && !money.explicit_decimal_precision? } precision = if money_values.any?(&:explicit_decimal_precision?) (money_values.map(&:decimal_precision) + [allocation_currency.minor_units]).max end diff --git a/lib/money/money.rb b/lib/money/money.rb index 7522f274..764113ab 100644 --- a/lib/money/money.rb +++ b/lib/money/money.rb @@ -424,6 +424,8 @@ def ensure_compatible_currency(other_currency, msg) def calculated_decimal_precision(other) return unless explicit_decimal_precision? || other.explicit_decimal_precision? + return other.decimal_precision if no_currency? && !explicit_decimal_precision? + return decimal_precision if other.no_currency? && !other.explicit_decimal_precision? [decimal_precision, other.decimal_precision].max end diff --git a/spec/allocator_spec.rb b/spec/allocator_spec.rb index 1387f5ba..36cbf60c 100644 --- a/spec/allocator_spec.rb +++ b/spec/allocator_spec.rb @@ -421,6 +421,25 @@ expect(allocations.map(&:decimal_precision)).to eq([4, 4]) end + specify "#allocate_max_amounts ignores implicit null-currency precision" do + cap = Money.new("0.5", "JPY", decimal_precision: 0) + [Money.new(1, "JPY"), Money.new(1, Money::NULL_CURRENCY)].each do |money| + allocations = money.allocate_max_amounts([cap, Money.new(0, Money::NULL_CURRENCY)]) + + expect(allocations.map(&:decimal_precision)).to eq([0, 0]) + expect(allocations.map(&:value)).to eq([0, 0]) + expect(allocations.map { |amount| amount.currency.iso_code }).to eq(["JPY", "JPY"]) + end + end + + specify "#allocate_max_amounts includes explicit null-currency precision" do + cap = Money.new("0.005", Money::NULL_CURRENCY, decimal_precision: 3) + allocations = Money.new(1, "JPY").allocate_max_amounts([cap]) + + expect(allocations.map(&:decimal_precision)).to eq([3]) + expect(allocations.map(&:value)).to eq([BigDecimal("0.005")]) + end + specify "#allocate_max_amounts promotes precision before coercing numeric and string maxima" do money = Money.new("0.06", "USD") [0.029, "0.029"].each do |maximum| diff --git a/spec/money_spec.rb b/spec/money_spec.rb index 7adfe812..2a4e8e9c 100644 --- a/spec/money_spec.rb +++ b/spec/money_spec.rb @@ -381,6 +381,24 @@ expect(result.decimal_precision).to eq(3) end + it "ignores implicit null-currency precision in either arithmetic operand" do + yen = Money.new(1, "JPY", decimal_precision: 0) + placeholder = Money.new(0, Money::NULL_CURRENCY) + results = [yen + placeholder, placeholder + yen, yen - placeholder, placeholder - yen] + + expect(results.map(&:decimal_precision)).to all(eq(0)) + expect(results.map(&:value)).to eq([1, 1, 1, -1]) + expect((yen + placeholder).split(2).map(&:value)).to eq([1, 0]) + end + + it "includes explicitly declared null-currency precision in arithmetic" do + yen = Money.new(1, "JPY", decimal_precision: 0) + placeholder = Money.new(0, Money::NULL_CURRENCY, decimal_precision: 3) + + expect((yen + placeholder).decimal_precision).to eq(3) + expect((placeholder + yen).decimal_precision).to eq(3) + end + it "preserves explicit precision through numeric, string, and reverse arithmetic" do money = Money.new("0.057", "USD", decimal_precision: 3)