Skip to content

Commit 956ece2

Browse files
authored
Merge pull request #1155 from sunny/eql-returns-false-if-currency-differs
Warn when using `#eql?` on zero amounts with different currencies
2 parents f58f801 + 02f8786 commit 956ece2

File tree

6 files changed

+132
-36
lines changed

6 files changed

+132
-36
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@
2727
- Add Zimbabwe Gold (ZWG) currency
2828
- Update thousands_separator for CHF
2929
- Add Caribbean Guilder (XCG) as replacement for Netherlands Antillean Gulden (ANG)
30+
- Add `Money.strict_eql_compare = true` so that comparing zero amounts with different currencies using `Money#eql?` returns `false`
31+
```rb
32+
Money.new(0, "USD").eql?(Money.new(0, "EUR")) #=> true
33+
#> [DEPRECATION] Comparing 0 USD with 0 EUR using `#eql?` will return false…
34+
35+
Money.strict_eql_compare = true
36+
Money.new(0, "USD").eql?(Money.new(0, "EUR")) #=> false
37+
```
3038
- Add `Money#to_nearest_cash_value` to return a rounded Money instance to the smallest denomination
3139
- Deprecate `Money#round_to_nearest_cash_value` in favor of calling `to_nearest_cash_value.fractional`
3240
- Add `Money::Currency#cents_based?` to check if currency is cents-based

lib/money/money.rb

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,27 @@ class << self
146146
# Used to specify precision for converting Rational to BigDecimal
147147
#
148148
# @return [Integer]
149-
attr_accessor :default_formatting_rules, :default_infinite_precision, :conversion_precision
149+
#
150+
# @!attribute [rw] strict_eql_compare
151+
# Use this to specify how +Money#eql?+ behaves. Opt-in to the new
152+
# behavior by setting this to +true+ and disable warnings when comparing
153+
# zero amounts with different currencies.
154+
#
155+
# @example
156+
# Money.strict_eql_compare = false # (default)
157+
# Money.new(0, "USD").eql?(Money.new(0, "EUR")) # => true
158+
# # => [DEPRECATION] warning
159+
#
160+
# Money.strict_eql_compare = true
161+
# Money.new(0, "USD").eql?(Money.new(0, "EUR")) # => false
162+
#
163+
# @return [Boolean]
164+
#
165+
# @see Money#eql
166+
attr_accessor :default_formatting_rules,
167+
:default_infinite_precision,
168+
:conversion_precision,
169+
:strict_eql_compare
150170
attr_reader :use_i18n, :locale_backend
151171
attr_writer :default_bank
152172
end
@@ -238,6 +258,10 @@ def self.setup_defaults
238258

239259
# Default the conversion of Rationals precision to 16
240260
self.conversion_precision = 16
261+
262+
# Defaults to the deprecated behavior where
263+
# `Money.new(0, "USD").eql?(Money.new(0, "EUR"))` is true.
264+
self.strict_eql_compare = false
241265
end
242266

243267
def self.inherited(base)

lib/money/money/arithmetic.rb

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,32 @@ def -@
2222
end
2323

2424
# Checks whether two Money objects have the same currency and the same
25-
# amount. If Money objects have a different currency it will only be true
26-
# if the amounts are both zero. Checks against objects that are not Money or
27-
# a subclass will always return false.
25+
# amount. Checks against objects that are not Money or a subclass will
26+
# always return false.
2827
#
2928
# @param [Money] other_money Value to compare with.
3029
#
3130
# @return [Boolean]
3231
#
3332
# @example
34-
# Money.new(100).eql?(Money.new(101)) #=> false
35-
# Money.new(100).eql?(Money.new(100)) #=> true
36-
# Money.new(100, "USD").eql?(Money.new(100, "GBP")) #=> false
37-
# Money.new(0, "USD").eql?(Money.new(0, "EUR")) #=> true
38-
# Money.new(100).eql?("1.00") #=> false
33+
# Money.new(1_00).eql?(Money.new(1_00)) #=> true
34+
# Money.new(1_00).eql?(Money.new(1_01)) #=> false
35+
# Money.new(1_00, "USD").eql?(Money.new(1_00, "GBP")) #=> false
36+
# Money.new(0, "USD").eql?(Money.new(0, "EUR")) #=> false
37+
# Money.new(1_00).eql?("1.00") #=> false
38+
#
39+
# @see Money.strict_eql_compare
3940
def eql?(other_money)
4041
if other_money.is_a?(Money)
41-
(fractional == other_money.fractional && currency == other_money.currency) ||
42-
(fractional == 0 && other_money.fractional == 0)
42+
if !Money.strict_eql_compare && fractional == 0 && other_money.fractional == 0
43+
warn "[DEPRECATION] Comparing 0 #{currency} with 0 " \
44+
"#{other_money.currency} using `#eql?` will return false in " \
45+
"future versions of Money. Opt-in to the new behavior by " \
46+
"setting `Money.strict_eql_compare = true`."
47+
return true
48+
end
49+
50+
fractional == other_money.fractional && currency == other_money.currency
4351
else
4452
false
4553
end

sig/lib/money/money.rbs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,24 @@ class Money
193193
# @return [Integer]
194194
attr_accessor self.conversion_precision: untyped
195195

196+
# @!attribute [rw] strict_eql_compare
197+
# Use this to specify how +Money#eql?+ behaves. Opt-in to the new
198+
# behavior by setting this to +true+ and disable warnings when comparing
199+
# zero amounts with different currencies.
200+
#
201+
# @example
202+
# Money.strict_eql_compare = false # (default)
203+
# Money.new(0, "USD").eql?(Money.new(0, "EUR")) # => true
204+
# # => [DEPRECATION] warning
205+
#
206+
# Money.strict_eql_compare = true
207+
# Money.new(0, "USD").eql?(Money.new(0, "EUR")) # => false
208+
#
209+
# @return [Boolean]
210+
#
211+
# @see Money#eql
212+
attr_accessor self.strict_eql_compare: untyped
213+
196214
attr_reader self.use_i18n: untyped
197215

198216
attr_reader self.locale_backend: untyped

sig/lib/money/money/arithmetic.rbs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,21 @@ class Money
1313
def -@: () -> Money
1414

1515
# Checks whether two Money objects have the same currency and the same
16-
# amount. If Money objects have a different currency it will only be true
17-
# if the amounts are both zero. Checks against objects that are not Money or
18-
# a subclass will always return false.
16+
# amount. Checks against objects that are not Money or a subclass will
17+
# always return false.
1918
#
2019
# @param [Money] other_money Value to compare with.
2120
#
2221
# @return [Boolean]
2322
#
2423
# @example
25-
# Money.new(100).eql?(Money.new(101)) #=> false
26-
# Money.new(100).eql?(Money.new(100)) #=> true
27-
# Money.new(100, "USD").eql?(Money.new(100, "GBP")) #=> false
28-
# Money.new(0, "USD").eql?(Money.new(0, "EUR")) #=> true
29-
# Money.new(100).eql?("1.00") #=> false
24+
# Money.new(1_00).eql?(Money.new(1_00)) #=> true
25+
# Money.new(1_00).eql?(Money.new(1_01)) #=> false
26+
# Money.new(1_00, "USD").eql?(Money.new(1_00, "GBP")) #=> false
27+
# Money.new(0, "USD").eql?(Money.new(0, "EUR")) #=> false
28+
# Money.new(1_00).eql?("1.00") #=> false
29+
#
30+
# @see Money.strict_eql_compare
3031
def eql?: (Money other_money) -> bool
3132

3233
# Compares two Money objects. If money objects have a different currency it
@@ -194,4 +195,4 @@ class Money
194195
# 2 * Money.new(10) #=> #<Money @fractional=20>
195196
def coerce: (untyped other) -> ::Array[self | untyped]
196197
end
197-
end
198+
end

spec/money/arithmetic_spec.rb

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,17 @@
4949
end
5050

5151
it 'allows comparison with zero' do
52-
expect(Money.new(0, :usd)).to eq 0
53-
expect(Money.new(0, :usd)).to eq 0.0
54-
expect(Money.new(0, :usd)).to eq BigDecimal(0)
55-
expect(Money.new(1, :usd)).to_not eq 0
52+
expect(Money.new(0, "USD")).to eq 0
53+
expect(Money.new(0, "EUR")).to eq 0
54+
expect(Money.new(0, "USD")).to eq 0.0
55+
expect(Money.new(0, "USD")).to eq BigDecimal(0)
56+
expect(Money.new(1, "USD")).to_not eq 0
5657
end
5758

5859
it 'raises error for non-zero numerics' do
59-
expect { Money.new(1_00, :usd) == 1 }.to raise_error ArgumentError
60-
expect { Money.new(1_00, :usd) == -2.0 }.to raise_error ArgumentError
61-
expect { Money.new(1_00, :usd) == Float::INFINITY }.to raise_error ArgumentError
60+
expect { Money.new(1_00, "USD") == 1 }.to raise_error ArgumentError
61+
expect { Money.new(1_00, "USD") == -2.0 }.to raise_error ArgumentError
62+
expect { Money.new(1_00, "USD") == Float::INFINITY }.to raise_error ArgumentError
6263
end
6364
end
6465

@@ -70,18 +71,54 @@
7071
expect(Money.new(1_00, "USD").eql?(Money.new(99_00, "EUR"))).to be false
7172
end
7273

73-
it "returns true when their amounts are zero and currencies differ" do
74-
expect(Money.new(0, "USD").eql?(Money.new(0, "EUR"))).to be true
75-
expect(Money.new(0, "USD").eql?(Money.new(0, "USD"))).to be true
76-
expect(Money.new(0, "AUD").eql?(Money.new(0, "EUR"))).to be true
74+
context "with strict_eql_compare set to false" do
75+
before { Money.strict_eql_compare = false }
76+
after { Money.setup_defaults }
77+
78+
it "returns true when comparing zero" do
79+
expect(Money.new(0, "USD").eql?(Money.new(0, "USD"))).to be true
80+
expect(Money.new(0, "USD").eql?(Money.new(0, "EUR"))).to be true
81+
expect(Money.new(0, "EUR").eql?(Money.new(0, "USD"))).to be true
82+
end
83+
84+
it "warns" do
85+
amount = Money.new(0, "USD")
86+
allow(amount).to receive(:warn)
87+
88+
expect(amount.eql?(Money.new(0, "EUR"))).to be true
89+
expect(amount).to have_received(:warn)
90+
.with(/\A\[DEPRECATION\] Comparing 0 USD with 0 EUR using `#eql\?`/)
91+
end
92+
end
93+
94+
context "with strict_eql_compare set to true" do
95+
before { Money.strict_eql_compare = true }
96+
after { Money.setup_defaults }
97+
98+
it "does not make an exception for zero" do
99+
expect(Money.new(0, "USD").eql?(Money.new(0, "USD"))).to be true
100+
expect(Money.new(0, "USD").eql?(Money.new(0, "EUR"))).to be false
101+
expect(Money.new(0, "EUR").eql?(Money.new(0, "USD"))).to be false
102+
end
103+
104+
it "does not warn" do
105+
amount = Money.new(0, "USD")
106+
allow(amount).to receive(:warn)
107+
108+
expect(amount.eql?(Money.new(0, "EUR"))).to be false
109+
expect(amount).not_to have_received(:warn)
110+
end
77111
end
78112

79113
it "returns false if used to compare with an object that doesn't inherit from Money" do
80-
expect(Money.new(1_00, "USD").eql?(Object.new)).to be false
81-
expect(Money.new(1_00, "USD").eql?(Class)).to be false
82-
expect(Money.new(1_00, "USD").eql?(Kernel)).to be false
83-
expect(Money.new(1_00, "USD").eql?(/foo/)).to be false
84-
expect(Money.new(1_00, "USD").eql?(nil)).to be false
114+
expect(Money.new(1_00, "USD").eql?(Object.new)).to be false
115+
expect(Money.new(1_00, "USD").eql?(Class)).to be false
116+
expect(Money.new(1_00, "USD").eql?(Kernel)).to be false
117+
expect(Money.new(1_00, "USD").eql?(/foo/)).to be false
118+
expect(Money.new(1_00, "USD").eql?(nil)).to be false
119+
expect(Money.new(1_00, "USD").eql?(1.0)).to be false
120+
expect(Money.new(1_00, "USD").eql?(1)).to be false
121+
expect(Money.new(1_00, "USD").eql?(1_00)).to be false
85122
end
86123

87124
it "can be used to compare with an object that inherits from Money" do

0 commit comments

Comments
 (0)