shopify-money 3.2.2 → 3.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +2 -2
- data/lib/money/version.rb +1 -1
- data/lib/money_column/active_record_hooks.rb +52 -20
- data/spec/money_column_spec.rb +607 -35
- data/spec/schema.rb +2 -2
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 22d6b7f34e2df9d08722a1b5a3c4297bb95d68557b7e82c448476478336cf339
|
4
|
+
data.tar.gz: c267cac812901641a799ddc08afd446051f808c632d75ef62c8c80ad4237bf72
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4da3c3318564210997ae4e3a56c99e46d4702de1c19d258f61948f7aee0b978c7ccaea8f9be13d270216af9ad23d52d25e48a44e514e154014bd179ca217403f
|
7
|
+
data.tar.gz: e1378b9689d6e7a22cf4d9892a4802e09ecdca0e67b4bb3bc5a5c7c924fa06c3fdf0bea6ab5b4f65db206cd06c4becd5f23b0f46381ad425350aa4925d4b9e8b
|
data/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
shopify-money (3.2.
|
4
|
+
shopify-money (3.2.4)
|
5
5
|
bigdecimal (>= 3.0)
|
6
6
|
|
7
7
|
GEM
|
@@ -81,7 +81,7 @@ GEM
|
|
81
81
|
ast (2.4.3)
|
82
82
|
base64 (0.2.0)
|
83
83
|
benchmark (0.4.0)
|
84
|
-
bigdecimal (3.
|
84
|
+
bigdecimal (3.2.2)
|
85
85
|
builder (3.3.0)
|
86
86
|
byebug (12.0.0)
|
87
87
|
coderay (1.1.3)
|
data/lib/money/version.rb
CHANGED
@@ -1,7 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module MoneyColumn
|
4
|
-
class
|
4
|
+
class Error < StandardError; end
|
5
|
+
class CurrencyReadOnlyError < Error; end
|
6
|
+
class CurrencyMismatchError < Error; end
|
5
7
|
|
6
8
|
module ActiveRecordHooks
|
7
9
|
def self.included(base)
|
@@ -52,33 +54,63 @@ module MoneyColumn
|
|
52
54
|
return self[column] = nil
|
53
55
|
end
|
54
56
|
|
55
|
-
|
56
|
-
|
57
|
+
if money.is_a?(Money)
|
58
|
+
write_currency(column, money, options)
|
59
|
+
end
|
60
|
+
|
61
|
+
self[column] = Money::Helpers.value_to_decimal(money)
|
62
|
+
end
|
63
|
+
|
64
|
+
def write_currency(column, money, options)
|
65
|
+
currency_column = options[:currency_column]
|
66
|
+
|
67
|
+
if options[:currency]
|
68
|
+
validate_hardcoded_currency_compatibility!(column, money, options[:currency])
|
69
|
+
return
|
57
70
|
end
|
58
71
|
|
59
72
|
if options[:currency_read_only]
|
60
|
-
|
61
|
-
|
62
|
-
if Money::Config.current.legacy_deprecations
|
63
|
-
Money.deprecate(msg)
|
64
|
-
else
|
65
|
-
raise MoneyColumn::CurrencyReadOnlyError, msg
|
66
|
-
end
|
67
|
-
end
|
68
|
-
else
|
69
|
-
self[options[:currency_column]] = money.currency.to_s unless money.no_currency?
|
73
|
+
validate_currency_compatibility!(column, money, currency_column)
|
74
|
+
return
|
70
75
|
end
|
71
76
|
|
72
|
-
|
77
|
+
if currency_column && !money.no_currency?
|
78
|
+
self[currency_column] = money.currency.to_s
|
79
|
+
end
|
73
80
|
end
|
74
81
|
|
75
|
-
def
|
76
|
-
|
77
|
-
|
78
|
-
@money_raw_new_attributes[currency_column.to_sym]
|
79
|
-
|
82
|
+
def read_currency_column(currency_column)
|
83
|
+
if @money_raw_new_attributes&.key?(currency_column.to_sym)
|
84
|
+
# currency column in the process of being updated
|
85
|
+
return @money_raw_new_attributes[currency_column.to_sym]
|
86
|
+
end
|
80
87
|
|
81
|
-
|
88
|
+
try(currency_column)
|
89
|
+
end
|
90
|
+
|
91
|
+
def validate_hardcoded_currency_compatibility!(column, money, expected_currency)
|
92
|
+
return if money.currency.compatible?(Money::Helpers.value_to_currency(expected_currency))
|
93
|
+
|
94
|
+
msg = "Invalid #{column}: attempting to write a money object with currency '#{money.currency}' to a record with hard-coded currency '#{expected_currency}'."
|
95
|
+
if Money::Config.current.legacy_deprecations
|
96
|
+
Money.deprecate(msg)
|
97
|
+
else
|
98
|
+
raise MoneyColumn::CurrencyMismatchError, msg
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def validate_currency_compatibility!(column, money, currency_column)
|
103
|
+
current_currency = read_currency_column(currency_column)
|
104
|
+
return if current_currency.nil? || money.currency.compatible?(Money::Helpers.value_to_currency(current_currency))
|
105
|
+
|
106
|
+
msg = "Invalid #{column}: attempting to write a money object with currency '#{money.currency}' to a record with currency '#{current_currency}'. " \
|
107
|
+
"If you do want to change the record's currency, either remove `currency_read_only` or update the record's currency manually"
|
108
|
+
|
109
|
+
if Money::Config.current.legacy_deprecations
|
110
|
+
Money.deprecate(msg)
|
111
|
+
else
|
112
|
+
raise MoneyColumn::CurrencyReadOnlyError, msg
|
113
|
+
end
|
82
114
|
end
|
83
115
|
|
84
116
|
def _assign_attributes(new_attributes)
|
data/spec/money_column_spec.rb
CHANGED
@@ -6,47 +6,47 @@ class MoneyRecord < ActiveRecord::Base
|
|
6
6
|
before_validation do
|
7
7
|
self.price_usd = Money.new(self["price"] * RATE, 'USD') if self["price"]
|
8
8
|
end
|
9
|
-
money_column :price, currency_column: '
|
10
|
-
money_column :prix, currency_column: :
|
9
|
+
money_column :price, currency_column: 'price_currency'
|
10
|
+
money_column :prix, currency_column: :prix_currency
|
11
11
|
money_column :price_usd, currency: 'USD'
|
12
12
|
end
|
13
13
|
|
14
14
|
class MoneyWithValidation < ActiveRecord::Base
|
15
15
|
self.table_name = 'money_records'
|
16
|
-
validates :price, :
|
17
|
-
money_column :price, currency_column: '
|
16
|
+
validates :price, :price_currency, presence: true
|
17
|
+
money_column :price, currency_column: 'price_currency'
|
18
18
|
end
|
19
19
|
|
20
20
|
class MoneyWithReadOnlyCurrency < ActiveRecord::Base
|
21
21
|
self.table_name = 'money_records'
|
22
|
-
money_column :price, currency_column: '
|
22
|
+
money_column :price, currency_column: 'price_currency', currency_read_only: true
|
23
23
|
end
|
24
24
|
|
25
25
|
class MoneyRecordCoerceNull < ActiveRecord::Base
|
26
26
|
self.table_name = 'money_records'
|
27
|
-
money_column :price, currency_column: '
|
27
|
+
money_column :price, currency_column: 'price_currency', coerce_null: true
|
28
28
|
money_column :price_usd, currency: 'USD', coerce_null: true
|
29
29
|
end
|
30
30
|
|
31
31
|
class MoneyWithDelegatedCurrency < ActiveRecord::Base
|
32
32
|
self.table_name = 'money_records'
|
33
|
-
delegate :
|
34
|
-
money_column :price, currency_column: '
|
33
|
+
delegate :price_currency, to: :delegated_record
|
34
|
+
money_column :price, currency_column: 'price_currency', currency_read_only: true
|
35
35
|
money_column :prix, currency_column: 'currency2', currency_read_only: true
|
36
36
|
def currency2
|
37
|
-
delegated_record.
|
37
|
+
delegated_record.price_currency
|
38
38
|
end
|
39
39
|
|
40
40
|
private
|
41
41
|
|
42
42
|
def delegated_record
|
43
|
-
MoneyRecord.new(
|
43
|
+
MoneyRecord.new(price_currency: 'USD')
|
44
44
|
end
|
45
45
|
end
|
46
46
|
|
47
47
|
class MoneyWithCustomAccessors < ActiveRecord::Base
|
48
48
|
self.table_name = 'money_records'
|
49
|
-
money_column :price, currency_column: '
|
49
|
+
money_column :price, currency_column: 'price_currency'
|
50
50
|
def price
|
51
51
|
read_money_attribute(:price)
|
52
52
|
end
|
@@ -56,7 +56,7 @@ class MoneyWithCustomAccessors < ActiveRecord::Base
|
|
56
56
|
end
|
57
57
|
|
58
58
|
class MoneyClassInheritance < MoneyWithCustomAccessors
|
59
|
-
money_column :prix, currency_column: '
|
59
|
+
money_column :prix, currency_column: 'price_currency'
|
60
60
|
end
|
61
61
|
|
62
62
|
class MoneyClassInheritance2 < MoneyWithCustomAccessors
|
@@ -71,7 +71,7 @@ RSpec.describe 'MoneyColumn' do
|
|
71
71
|
let(:toonie) { Money.new(2.00, 'CAD') }
|
72
72
|
let(:subject) { MoneyRecord.new(price: money, prix: toonie) }
|
73
73
|
let(:record) do
|
74
|
-
subject.
|
74
|
+
subject.prix_currency = 'CAD'
|
75
75
|
subject.save
|
76
76
|
subject.reload
|
77
77
|
end
|
@@ -81,7 +81,7 @@ RSpec.describe 'MoneyColumn' do
|
|
81
81
|
end
|
82
82
|
|
83
83
|
it 'writes the currency to the db' do
|
84
|
-
record.update(
|
84
|
+
record.update(price_currency: nil)
|
85
85
|
record.update(price: Money.new(4, 'JPY'))
|
86
86
|
record.reload
|
87
87
|
expect(record.price.value).to eq(4)
|
@@ -101,10 +101,35 @@ RSpec.describe 'MoneyColumn' do
|
|
101
101
|
expect(record.price_usd).to eq(Money.new(1.44, 'USD'))
|
102
102
|
end
|
103
103
|
|
104
|
+
describe 'hard-coded currency (currency: "USD")' do
|
105
|
+
let(:record) { MoneyRecord.new }
|
106
|
+
|
107
|
+
it 'raises CurrencyMismatchError when assigning Money with wrong currency' do
|
108
|
+
expect {
|
109
|
+
record.price_usd = Money.new(5, 'EUR')
|
110
|
+
}.to raise_error(MoneyColumn::CurrencyMismatchError)
|
111
|
+
end
|
112
|
+
|
113
|
+
it 'allows assigning Money with the correct currency' do
|
114
|
+
record.price_usd = Money.new(8, 'USD')
|
115
|
+
expect(record.price_usd.value).to eq(8)
|
116
|
+
expect(record.price_usd.currency.to_s).to eq('USD')
|
117
|
+
end
|
118
|
+
|
119
|
+
it 'deprecates (but does not raise) under legacy_deprecations' do
|
120
|
+
configure(legacy_deprecations: true) do
|
121
|
+
expect(Money).to receive(:deprecate).once
|
122
|
+
record.price_usd = Money.new(9, 'EUR')
|
123
|
+
expect(record.price_usd.value).to eq(9)
|
124
|
+
expect(record.price_usd.currency.to_s).to eq('USD')
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
104
129
|
it 'returns money with null currency when the currency in the DB is invalid' do
|
105
130
|
configure(legacy_deprecations: true) do
|
106
131
|
expect(Money).to receive(:deprecate).once
|
107
|
-
record.update_columns(
|
132
|
+
record.update_columns(price_currency: 'invalid')
|
108
133
|
record.reload
|
109
134
|
expect(record.price.currency).to be_a(Money::NullCurrency)
|
110
135
|
expect(record.price.value).to eq(1.23)
|
@@ -142,15 +167,15 @@ RSpec.describe 'MoneyColumn' do
|
|
142
167
|
end
|
143
168
|
|
144
169
|
it 'does not overwrite a currency column with a default currency when saving zero' do
|
145
|
-
expect(record.
|
170
|
+
expect(record.price_currency.to_s).to eq('EUR')
|
146
171
|
record.update(price: Money.new(0, Money::NULL_CURRENCY))
|
147
|
-
expect(record.
|
172
|
+
expect(record.price_currency.to_s).to eq('EUR')
|
148
173
|
end
|
149
174
|
|
150
175
|
it 'does overwrite a currency' do
|
151
|
-
expect(record.
|
176
|
+
expect(record.price_currency.to_s).to eq('EUR')
|
152
177
|
record.update(price: Money.new(4, 'JPY'))
|
153
|
-
expect(record.
|
178
|
+
expect(record.price_currency.to_s).to eq('JPY')
|
154
179
|
end
|
155
180
|
|
156
181
|
describe 'non-fractional-currencies' do
|
@@ -217,21 +242,39 @@ RSpec.describe 'MoneyColumn' do
|
|
217
242
|
|
218
243
|
it 'is not allowed to be saved because `to_s` returns a blank string' do
|
219
244
|
subject.valid?
|
220
|
-
expect(subject.errors[:
|
245
|
+
expect(subject.errors[:price_currency]).to include("can't be blank")
|
221
246
|
end
|
222
247
|
end
|
223
248
|
|
224
249
|
describe 'read_only_currency true' do
|
225
|
-
it '
|
250
|
+
it 'raises CurrencyReadOnlyError when updating price with different currency' do
|
226
251
|
record = MoneyWithReadOnlyCurrency.create
|
227
|
-
record.update_columns(
|
252
|
+
record.update_columns(price_currency: 'USD')
|
228
253
|
expect { record.update(price: Money.new(4, 'CAD')) }.to raise_error(MoneyColumn::CurrencyReadOnlyError)
|
229
254
|
end
|
230
255
|
|
256
|
+
it 'raises CurrencyReadOnlyError when assigning money with different currency' do
|
257
|
+
record = MoneyWithReadOnlyCurrency.create(price_currency: 'USD', price: 1)
|
258
|
+
expect { record.price = Money.new(2, 'CAD') }.to raise_error(MoneyColumn::CurrencyReadOnlyError)
|
259
|
+
end
|
260
|
+
|
261
|
+
it 'allows updating price when currency matches existing currency' do
|
262
|
+
record = MoneyWithReadOnlyCurrency.create
|
263
|
+
record.update_columns(price_currency: 'USD')
|
264
|
+
record.update(price: Money.new(4, 'USD'))
|
265
|
+
expect(record.price.value).to eq(4)
|
266
|
+
end
|
267
|
+
|
268
|
+
it 'allows assigning price when currency matches existing currency' do
|
269
|
+
record = MoneyWithReadOnlyCurrency.create(price_currency: 'CAD', price: 1)
|
270
|
+
record.price = Money.new(2, 'CAD')
|
271
|
+
expect(record.price.value).to eq(2)
|
272
|
+
end
|
273
|
+
|
231
274
|
it 'legacy_deprecations does not write the currency to the db' do
|
232
275
|
configure(legacy_deprecations: true) do
|
233
276
|
record = MoneyWithReadOnlyCurrency.create
|
234
|
-
record.update_columns(
|
277
|
+
record.update_columns(price_currency: 'USD')
|
235
278
|
|
236
279
|
expect(Money).to receive(:deprecate).once
|
237
280
|
record.update(price: Money.new(4, 'CAD'))
|
@@ -242,7 +285,7 @@ RSpec.describe 'MoneyColumn' do
|
|
242
285
|
|
243
286
|
it 'reads the currency that is already in the db' do
|
244
287
|
record = MoneyWithReadOnlyCurrency.create
|
245
|
-
record.update_columns(
|
288
|
+
record.update_columns(price_currency: 'USD', price: 1)
|
246
289
|
record.reload
|
247
290
|
expect(record.price.value).to eq(1)
|
248
291
|
expect(record.price.currency.to_s).to eq('USD')
|
@@ -252,7 +295,7 @@ RSpec.describe 'MoneyColumn' do
|
|
252
295
|
configure(legacy_deprecations: true) do
|
253
296
|
expect(Money).to receive(:deprecate).once
|
254
297
|
record = MoneyWithReadOnlyCurrency.create
|
255
|
-
record.update_columns(
|
298
|
+
record.update_columns(price_currency: 'invalid', price: 1)
|
256
299
|
record.reload
|
257
300
|
expect(record.price.value).to eq(1)
|
258
301
|
expect(record.price.currency.to_s).to eq('')
|
@@ -260,8 +303,8 @@ RSpec.describe 'MoneyColumn' do
|
|
260
303
|
end
|
261
304
|
|
262
305
|
it 'sets the currency correctly when the currency is changed' do
|
263
|
-
record = MoneyWithReadOnlyCurrency.create(
|
264
|
-
record.
|
306
|
+
record = MoneyWithReadOnlyCurrency.create(price_currency: 'CAD', price: 1)
|
307
|
+
record.price_currency = 'USD'
|
265
308
|
expect(record.price.currency.to_s).to eq('USD')
|
266
309
|
end
|
267
310
|
|
@@ -368,7 +411,7 @@ RSpec.describe 'MoneyColumn' do
|
|
368
411
|
|
369
412
|
describe 'class inheritance' do
|
370
413
|
it 'shares money columns declared on the parent class' do
|
371
|
-
expect(MoneyClassInheritance.instance_variable_get(:@money_column_options).dig('price', :currency_column)).to eq('
|
414
|
+
expect(MoneyClassInheritance.instance_variable_get(:@money_column_options).dig('price', :currency_column)).to eq('price_currency')
|
372
415
|
expect(MoneyClassInheritance.instance_variable_get(:@money_column_options).dig('price', :currency)).to eq(nil)
|
373
416
|
expect(MoneyClassInheritance.new(price: Money.new(1, 'USD')).price).to eq(Money.new(2, 'USD'))
|
374
417
|
end
|
@@ -392,7 +435,7 @@ RSpec.describe 'MoneyColumn' do
|
|
392
435
|
end
|
393
436
|
|
394
437
|
it 'writes currency from input value to the db' do
|
395
|
-
record.update(
|
438
|
+
record.update(price_currency: nil)
|
396
439
|
record.update(price: Money.new(7, 'GBP'))
|
397
440
|
record.reload
|
398
441
|
expect(record.price.value).to eq(7)
|
@@ -400,13 +443,13 @@ RSpec.describe 'MoneyColumn' do
|
|
400
443
|
end
|
401
444
|
|
402
445
|
it 'raises missing currency error reading a value that was saved using legacy non-money object' do
|
403
|
-
record.update(
|
446
|
+
record.update(price_currency: nil, price: 3)
|
404
447
|
expect { record.price }.to raise_error(ArgumentError, 'missing currency')
|
405
448
|
end
|
406
449
|
|
407
450
|
it 'handles legacy support for saving price and currency separately' do
|
408
|
-
record.update(
|
409
|
-
record.update(price: 7,
|
451
|
+
record.update(price_currency: nil)
|
452
|
+
record.update(price: 7, price_currency: 'GBP')
|
410
453
|
record.reload
|
411
454
|
expect(record.price.value).to eq(7)
|
412
455
|
expect(record.price.currency.to_s).to eq('GBP')
|
@@ -414,17 +457,546 @@ RSpec.describe 'MoneyColumn' do
|
|
414
457
|
end
|
415
458
|
|
416
459
|
describe 'updating amount and currency simultaneously' do
|
417
|
-
let(:record) { MoneyWithReadOnlyCurrency.create!(
|
460
|
+
let(:record) { MoneyWithReadOnlyCurrency.create!(price_currency: "CAD") }
|
418
461
|
|
419
462
|
it 'allows updating both amount and currency at the same time' do
|
420
463
|
record.update!(
|
421
464
|
price: Money.new(10, 'USD'),
|
422
|
-
|
465
|
+
price_currency: 'USD'
|
423
466
|
)
|
424
467
|
record.reload
|
425
468
|
expect(record.price.value).to eq(10)
|
426
469
|
expect(record.price.currency.to_s).to eq('USD')
|
427
|
-
expect(record.
|
470
|
+
expect(record.price_currency).to eq('USD')
|
471
|
+
end
|
472
|
+
end
|
473
|
+
|
474
|
+
describe 'multiple money columns' do
|
475
|
+
it 'handles multiple money columns with different currencies' do
|
476
|
+
record = MoneyRecord.create!(
|
477
|
+
price: Money.new(100, 'USD'),
|
478
|
+
prix: Money.new(200, 'EUR'),
|
479
|
+
prix_currency: 'EUR'
|
480
|
+
)
|
481
|
+
record.reload
|
482
|
+
expect(record.price.value).to eq(100)
|
483
|
+
expect(record.price.currency.to_s).to eq('USD')
|
484
|
+
expect(record.prix.value).to eq(200)
|
485
|
+
expect(record.prix.currency.to_s).to eq('EUR')
|
486
|
+
# price_usd is calculated from price * RATE (1.17) in before_validation
|
487
|
+
expect(record.price_usd.value).to eq(117)
|
488
|
+
expect(record.price_usd.currency.to_s).to eq('USD')
|
489
|
+
end
|
490
|
+
|
491
|
+
it 'maintains separate caches for each money column' do
|
492
|
+
record = MoneyRecord.new
|
493
|
+
record.price = Money.new(100, 'USD')
|
494
|
+
record.prix = Money.new(200, 'EUR')
|
495
|
+
|
496
|
+
expect(record.price).to eq(Money.new(100, 'USD'))
|
497
|
+
expect(record.prix).to eq(Money.new(200, 'EUR'))
|
498
|
+
|
499
|
+
# Verify they're independent by changing one
|
500
|
+
record.price = Money.new(300, 'CAD')
|
501
|
+
expect(record.price).to eq(Money.new(300, 'CAD'))
|
502
|
+
expect(record.prix).to eq(Money.new(200, 'EUR'))
|
503
|
+
end
|
504
|
+
end
|
505
|
+
|
506
|
+
describe 'blank money handling' do
|
507
|
+
it 'handles empty string as nil' do
|
508
|
+
record = MoneyRecord.new(price: '')
|
509
|
+
expect(record.price).to be_nil
|
510
|
+
end
|
511
|
+
|
512
|
+
it 'handles whitespace string as nil' do
|
513
|
+
record = MoneyRecord.new(price: ' ')
|
514
|
+
expect(record.price).to be_nil
|
515
|
+
end
|
516
|
+
|
517
|
+
it 'clears cache when setting to blank' do
|
518
|
+
record = MoneyRecord.new(price: Money.new(100, 'USD'))
|
519
|
+
expect(record.price).to eq(Money.new(100, 'USD'))
|
520
|
+
|
521
|
+
record.price = ''
|
522
|
+
expect(record.price).to be_nil
|
523
|
+
|
524
|
+
# Verify the cache was cleared by setting a new value
|
525
|
+
record.price = Money.new(200, 'EUR')
|
526
|
+
expect(record.price).to eq(Money.new(200, 'EUR'))
|
527
|
+
end
|
528
|
+
end
|
529
|
+
|
530
|
+
describe 'currency column cache clearing' do
|
531
|
+
it 'clears all money column caches when currency changes' do
|
532
|
+
record = MoneyRecord.new(
|
533
|
+
price: Money.new(100, 'USD'),
|
534
|
+
price_currency: 'USD'
|
535
|
+
)
|
536
|
+
|
537
|
+
expect(record.price).to eq(Money.new(100, 'USD'))
|
538
|
+
|
539
|
+
# Change currency should invalidate the cache
|
540
|
+
record.price_currency = 'EUR'
|
541
|
+
expect(record.price.currency.to_s).to eq('EUR')
|
542
|
+
end
|
543
|
+
|
544
|
+
it 'only defines currency setter once for shared currency columns' do
|
545
|
+
class MoneyWithSharedCurrency < ActiveRecord::Base
|
546
|
+
self.table_name = 'money_records'
|
547
|
+
money_column :price, currency_column: 'price_currency'
|
548
|
+
money_column :prix, currency_column: 'price_currency'
|
549
|
+
end
|
550
|
+
|
551
|
+
record = MoneyWithSharedCurrency.new
|
552
|
+
methods_count = record.methods.count { |m| m.to_s == 'price_currency=' }
|
553
|
+
expect(methods_count).to eq(1)
|
554
|
+
end
|
555
|
+
end
|
556
|
+
|
557
|
+
describe 'no_currency handling' do
|
558
|
+
it 'does not write currency when money has no_currency' do
|
559
|
+
record = MoneyRecord.create!(price_currency: 'USD')
|
560
|
+
record.price = Money.new(100, Money::NULL_CURRENCY)
|
561
|
+
record.save!
|
562
|
+
record.reload
|
563
|
+
expect(record.price_currency).to eq('USD')
|
564
|
+
end
|
565
|
+
end
|
566
|
+
|
567
|
+
describe 'edge cases' do
|
568
|
+
it 'handles BigDecimal values' do
|
569
|
+
record = MoneyRecord.new(price: BigDecimal('123.45'))
|
570
|
+
expect(record.price.value).to eq(123.45)
|
571
|
+
end
|
572
|
+
|
573
|
+
it 'handles negative values' do
|
574
|
+
record = MoneyRecord.new(price: Money.new(-100, 'USD'))
|
575
|
+
record.save!
|
576
|
+
record.reload
|
577
|
+
expect(record.price.value).to eq(-100)
|
578
|
+
expect(record.price.currency.to_s).to eq('USD')
|
579
|
+
end
|
580
|
+
|
581
|
+
it 'handles very large values' do
|
582
|
+
large_value = BigDecimal('999999999999999.999')
|
583
|
+
record = MoneyRecord.new(price: Money.new(large_value, 'USD'))
|
584
|
+
record.save!
|
585
|
+
record.reload
|
586
|
+
# Database might round very large values
|
587
|
+
expect(record.price.value).to be_within(0.001).of(large_value)
|
588
|
+
end
|
589
|
+
|
590
|
+
it 'handles zero values' do
|
591
|
+
record = MoneyRecord.new(price: Money.new(0, 'USD'))
|
592
|
+
record.save!
|
593
|
+
record.reload
|
594
|
+
expect(record.price.value).to eq(0)
|
595
|
+
expect(record.price.currency.to_s).to eq('USD')
|
596
|
+
end
|
597
|
+
end
|
598
|
+
|
599
|
+
describe 'ActiveRecord callbacks integration' do
|
600
|
+
class MoneyWithCallbacks < ActiveRecord::Base
|
601
|
+
self.table_name = 'money_records'
|
602
|
+
money_column :price, currency_column: 'price_currency'
|
603
|
+
|
604
|
+
before_save :double_price
|
605
|
+
|
606
|
+
private
|
607
|
+
|
608
|
+
def double_price
|
609
|
+
self.price = price * 2 if price
|
610
|
+
end
|
611
|
+
end
|
612
|
+
|
613
|
+
it 'works with before_save callbacks' do
|
614
|
+
record = MoneyWithCallbacks.new(price: Money.new(50, 'USD'))
|
615
|
+
record.save!
|
616
|
+
expect(record.price.value).to eq(100)
|
617
|
+
end
|
618
|
+
end
|
619
|
+
|
620
|
+
describe 'validation integration' do
|
621
|
+
class MoneyWithCustomValidation < ActiveRecord::Base
|
622
|
+
self.table_name = 'money_records'
|
623
|
+
money_column :price, currency_column: 'price_currency'
|
624
|
+
|
625
|
+
validate :price_must_be_positive
|
626
|
+
|
627
|
+
private
|
628
|
+
|
629
|
+
def price_must_be_positive
|
630
|
+
errors.add(:price, 'must be positive') if price && price.value < 0
|
631
|
+
end
|
632
|
+
end
|
633
|
+
|
634
|
+
it 'works with custom validations' do
|
635
|
+
record = MoneyWithCustomValidation.new(price: Money.new(-10, 'USD'))
|
636
|
+
expect(record).not_to be_valid
|
637
|
+
expect(record.errors[:price]).to include('must be positive')
|
638
|
+
end
|
639
|
+
|
640
|
+
it 'allows valid values' do
|
641
|
+
record = MoneyWithCustomValidation.new(price: Money.new(10, 'USD'))
|
642
|
+
expect(record).to be_valid
|
643
|
+
end
|
644
|
+
end
|
645
|
+
|
646
|
+
describe 'ActiveRecord query interface' do
|
647
|
+
before do
|
648
|
+
MoneyRecord.delete_all
|
649
|
+
MoneyRecord.create!(price: Money.new(100, 'USD'), price_currency: 'USD')
|
650
|
+
MoneyRecord.create!(price: Money.new(200, 'USD'), price_currency: 'USD')
|
651
|
+
MoneyRecord.create!(price: Money.new(150, 'EUR'), price_currency: 'EUR')
|
652
|
+
end
|
653
|
+
|
654
|
+
it 'supports where queries with money values' do
|
655
|
+
records = MoneyRecord.where(price: 100)
|
656
|
+
expect(records.count).to eq(1)
|
657
|
+
expect(records.first.price.value).to eq(100)
|
658
|
+
end
|
659
|
+
|
660
|
+
it 'supports range queries' do
|
661
|
+
records = MoneyRecord.where(price: 100..200)
|
662
|
+
expect(records.count).to eq(3)
|
663
|
+
end
|
664
|
+
|
665
|
+
it 'supports ordering by money columns' do
|
666
|
+
records = MoneyRecord.order(:price)
|
667
|
+
expect(records.map { |r| r.price.value }).to eq([100, 150, 200])
|
668
|
+
end
|
669
|
+
|
670
|
+
it 'supports pluck with money columns' do
|
671
|
+
values = MoneyRecord.pluck(:price)
|
672
|
+
expect(values).to contain_exactly(100, 200, 150)
|
673
|
+
end
|
674
|
+
end
|
675
|
+
|
676
|
+
describe 'thread safety' do
|
677
|
+
it 'maintains separate caches per instance' do
|
678
|
+
record1 = MoneyRecord.new
|
679
|
+
record2 = MoneyRecord.new
|
680
|
+
|
681
|
+
record1.price = Money.new(100, 'USD')
|
682
|
+
record2.price = Money.new(200, 'EUR')
|
683
|
+
|
684
|
+
expect(record1.price).to eq(Money.new(100, 'USD'))
|
685
|
+
expect(record2.price).to eq(Money.new(200, 'EUR'))
|
686
|
+
end
|
687
|
+
end
|
688
|
+
|
689
|
+
describe 'attribute assignment' do
|
690
|
+
it 'handles hash assignment with string keys' do
|
691
|
+
record = MoneyRecord.new('price' => 100, 'price_currency' => 'USD')
|
692
|
+
expect(record.price.value).to eq(100)
|
693
|
+
expect(record.price.currency.to_s).to eq('USD')
|
694
|
+
end
|
695
|
+
|
696
|
+
it 'handles hash assignment with symbol keys' do
|
697
|
+
record = MoneyRecord.new(price: 100, price_currency: 'USD')
|
698
|
+
expect(record.price.value).to eq(100)
|
699
|
+
expect(record.price.currency.to_s).to eq('USD')
|
700
|
+
end
|
701
|
+
|
702
|
+
it 'handles update_attributes' do
|
703
|
+
record = MoneyRecord.create!(price: Money.new(100, 'USD'))
|
704
|
+
record.update!(price: Money.new(200, 'EUR'))
|
705
|
+
expect(record.price.value).to eq(200)
|
706
|
+
expect(record.price.currency.to_s).to eq('EUR')
|
707
|
+
end
|
708
|
+
end
|
709
|
+
|
710
|
+
describe 'error handling' do
|
711
|
+
it 'provides helpful error message for invalid currency in money object' do
|
712
|
+
expect {
|
713
|
+
MoneyRecord.new(price: Money.new(100, 'INVALID'))
|
714
|
+
}.to raise_error(Money::Currency::UnknownCurrency)
|
715
|
+
end
|
716
|
+
|
717
|
+
it 'handles non-numeric string values' do
|
718
|
+
expect {
|
719
|
+
MoneyRecord.new(price: 'not a number')
|
720
|
+
}.to raise_error(ArgumentError)
|
721
|
+
end
|
722
|
+
end
|
723
|
+
|
724
|
+
describe 'coerce_null with different scenarios' do
|
725
|
+
it 'coerces nil to zero money with proper currency from column' do
|
726
|
+
record = MoneyRecordCoerceNull.new(price_currency: 'EUR')
|
727
|
+
expect(record.price.value).to eq(0)
|
728
|
+
expect(record.price.currency.to_s).to eq('EUR')
|
729
|
+
end
|
730
|
+
|
731
|
+
it 'coerces nil to zero money with hardcoded currency' do
|
732
|
+
record = MoneyRecordCoerceNull.new
|
733
|
+
expect(record.price_usd.value).to eq(0)
|
734
|
+
expect(record.price_usd.currency.to_s).to eq('USD')
|
735
|
+
end
|
736
|
+
|
737
|
+
it 'does not coerce non-nil values' do
|
738
|
+
record = MoneyRecordCoerceNull.new(price: Money.new(100, 'USD'))
|
739
|
+
expect(record.price.value).to eq(100)
|
740
|
+
end
|
741
|
+
end
|
742
|
+
|
743
|
+
describe 'currency_read_only with edge cases' do
|
744
|
+
it 'allows setting money when currency column is nil' do
|
745
|
+
record = MoneyWithReadOnlyCurrency.new
|
746
|
+
record.price = Money.new(100, 'USD')
|
747
|
+
expect(record.price.value).to eq(100)
|
748
|
+
# Currency is not written for read_only columns when not saved
|
749
|
+
expect(record.price_currency).to be_nil
|
750
|
+
end
|
751
|
+
|
752
|
+
it 'allows setting money with compatible currency using string' do
|
753
|
+
record = MoneyWithReadOnlyCurrency.create!(price_currency: 'USD')
|
754
|
+
record.price = Money.new(100, 'USD')
|
755
|
+
expect(record.price.value).to eq(100)
|
756
|
+
end
|
757
|
+
end
|
758
|
+
|
759
|
+
describe 'initialize_dup behavior' do
|
760
|
+
it 'creates independent cache for duplicated record' do
|
761
|
+
original = MoneyRecord.new(price: Money.new(100, 'USD'))
|
762
|
+
duplicate = original.dup
|
763
|
+
|
764
|
+
duplicate.price = Money.new(200, 'EUR')
|
765
|
+
|
766
|
+
expect(original.price).to eq(Money.new(100, 'USD'))
|
767
|
+
expect(duplicate.price).to eq(Money.new(200, 'EUR'))
|
768
|
+
end
|
769
|
+
|
770
|
+
it 'preserves money values when duplicating' do
|
771
|
+
original = MoneyRecord.create!(
|
772
|
+
price: Money.new(100, 'USD'),
|
773
|
+
prix: Money.new(200, 'EUR')
|
774
|
+
)
|
775
|
+
|
776
|
+
duplicate = original.dup
|
777
|
+
expect(duplicate.price).to eq(Money.new(100, 'USD'))
|
778
|
+
expect(duplicate.prix).to eq(Money.new(200, 'EUR'))
|
779
|
+
expect(duplicate).to be_new_record
|
780
|
+
end
|
781
|
+
end
|
782
|
+
|
783
|
+
describe 'ActiveRecord dirty tracking' do
|
784
|
+
it 'tracks changes to money columns' do
|
785
|
+
record = MoneyRecord.create!(price: Money.new(100, 'USD'))
|
786
|
+
record.price = Money.new(200, 'USD')
|
787
|
+
|
788
|
+
expect(record.price_changed?).to be true
|
789
|
+
expect(record.price_was).to eq(100)
|
790
|
+
expect(record.price_change).to eq([100, 200])
|
791
|
+
end
|
792
|
+
|
793
|
+
it 'tracks currency changes' do
|
794
|
+
record = MoneyRecord.create!(price_currency: 'USD', price: 100)
|
795
|
+
record.price_currency = 'EUR'
|
796
|
+
|
797
|
+
expect(record.price_currency_changed?).to be true
|
798
|
+
expect(record.price_currency_was).to eq('USD')
|
799
|
+
end
|
800
|
+
end
|
801
|
+
|
802
|
+
describe 'mass assignment with currency updates' do
|
803
|
+
it 'handles simultaneous updates of money and currency in mass assignment' do
|
804
|
+
record = MoneyWithReadOnlyCurrency.create!(price_currency: 'USD', price: 100)
|
805
|
+
|
806
|
+
record.assign_attributes(
|
807
|
+
price_currency: 'EUR',
|
808
|
+
price: Money.new(200, 'EUR')
|
809
|
+
)
|
810
|
+
|
811
|
+
expect { record.save! }.not_to raise_error
|
812
|
+
expect(record.price.value).to eq(200)
|
813
|
+
expect(record.price_currency).to eq('EUR')
|
814
|
+
end
|
815
|
+
end
|
816
|
+
|
817
|
+
describe 'decimal precision handling' do
|
818
|
+
it 'preserves precision up to currency minor units' do
|
819
|
+
# USD has 2 minor units, so 123.456 will be rounded to 123.46
|
820
|
+
record = MoneyRecord.create!(price: Money.new(123.456, 'USD'))
|
821
|
+
record.reload
|
822
|
+
expect(record.price.value.to_f).to eq(123.46)
|
823
|
+
end
|
824
|
+
|
825
|
+
it 'preserves full precision for currencies with 3 decimal places' do
|
826
|
+
# JOD has 3 minor units, so it preserves 3 decimal places
|
827
|
+
record = MoneyRecord.create!(price: Money.new(123.456, 'JOD'), price_currency: 'JOD')
|
828
|
+
record.reload
|
829
|
+
expect(record.price.value).to eq(123.456)
|
830
|
+
end
|
831
|
+
|
832
|
+
it 'rounds database values beyond 3 decimal places' do
|
833
|
+
record = MoneyRecord.new
|
834
|
+
record['price'] = 123.4567
|
835
|
+
record.price_currency = 'USD'
|
836
|
+
record.save!
|
837
|
+
record.reload
|
838
|
+
expect(record['price'].to_f.round(3)).to eq(123.457)
|
839
|
+
end
|
840
|
+
end
|
841
|
+
|
842
|
+
describe 'ActiveRecord Type integration' do
|
843
|
+
it 'uses MoneyColumn::ActiveRecordType for money columns' do
|
844
|
+
type = MoneyRecord.attribute_types['price']
|
845
|
+
expect(type).to be_a(MoneyColumn::ActiveRecordType)
|
846
|
+
end
|
847
|
+
end
|
848
|
+
|
849
|
+
describe 'money column options inheritance' do
|
850
|
+
it 'does not share options between different models' do
|
851
|
+
class MoneyModel1 < ActiveRecord::Base
|
852
|
+
self.table_name = 'money_records'
|
853
|
+
money_column :price, currency_column: 'currency'
|
854
|
+
end
|
855
|
+
|
856
|
+
class MoneyModel2 < ActiveRecord::Base
|
857
|
+
self.table_name = 'money_records'
|
858
|
+
money_column :price, currency: 'EUR'
|
859
|
+
end
|
860
|
+
|
861
|
+
expect(MoneyModel1.money_column_options['price'][:currency_column]).to eq('currency')
|
862
|
+
expect(MoneyModel1.money_column_options['price'][:currency]).to be_nil
|
863
|
+
|
864
|
+
expect(MoneyModel2.money_column_options['price'][:currency]).to eq('EUR')
|
865
|
+
expect(MoneyModel2.money_column_options['price'][:currency_column]).to be_nil
|
866
|
+
end
|
867
|
+
end
|
868
|
+
|
869
|
+
describe 'raw attributes access' do
|
870
|
+
it 'allows direct access to raw decimal value' do
|
871
|
+
record = MoneyRecord.create!(price: Money.new(123.45, 'USD'))
|
872
|
+
expect(record['price']).to eq(123.45)
|
873
|
+
expect(record.read_attribute(:price)).to eq(123.45)
|
874
|
+
end
|
875
|
+
|
876
|
+
it 'allows direct writing of raw decimal value' do
|
877
|
+
record = MoneyRecord.new
|
878
|
+
record['price'] = 99.99
|
879
|
+
record.price_currency = 'EUR'
|
880
|
+
expect(record.price.value).to eq(99.99)
|
881
|
+
expect(record.price.currency.to_s).to eq('EUR')
|
882
|
+
end
|
883
|
+
end
|
884
|
+
|
885
|
+
describe 'nil handling' do
|
886
|
+
it 'returns Money with default currency for zero values' do
|
887
|
+
record = MoneyRecord.new
|
888
|
+
# The default value in the schema is 0.000, not nil
|
889
|
+
expect(record['price']).to eq(0)
|
890
|
+
# With default currency CAD, it returns Money with 0 value
|
891
|
+
expect(record.price).to eq(Money.new(0, 'CAD'))
|
892
|
+
end
|
893
|
+
|
894
|
+
it 'returns nil when value is explicitly nil' do
|
895
|
+
record = MoneyRecord.new
|
896
|
+
record['price'] = nil
|
897
|
+
expect(record.price).to be_nil
|
898
|
+
end
|
899
|
+
|
900
|
+
it 'handles nil assignment' do
|
901
|
+
record = MoneyRecord.create!(price: Money.new(100, 'USD'))
|
902
|
+
record.price = nil
|
903
|
+
record.save!
|
904
|
+
record.reload
|
905
|
+
expect(record.price).to be_nil
|
906
|
+
end
|
907
|
+
end
|
908
|
+
|
909
|
+
describe 'currency normalization' do
|
910
|
+
it 'normalizes currency strings to uppercase' do
|
911
|
+
record = MoneyRecord.new(price: Money.new(100, 'usd'))
|
912
|
+
expect(record.price.currency.to_s).to eq('USD')
|
913
|
+
end
|
914
|
+
|
915
|
+
it 'freezes currency strings for performance' do
|
916
|
+
class MoneyWithFrozenCurrency < ActiveRecord::Base
|
917
|
+
self.table_name = 'money_records'
|
918
|
+
money_column :price, currency: 'USD'
|
919
|
+
end
|
920
|
+
|
921
|
+
expect(MoneyWithFrozenCurrency.money_column_options['price'][:currency]).to be_frozen
|
922
|
+
end
|
923
|
+
end
|
924
|
+
|
925
|
+
describe 'error messages' do
|
926
|
+
it 'provides clear error for missing currency when default_currency is nil' do
|
927
|
+
configure(default_currency: nil) do
|
928
|
+
record = MoneyRecord.create!(price: 100, price_currency: nil)
|
929
|
+
expect { record.reload.price }.to raise_error(ArgumentError, 'missing currency')
|
930
|
+
end
|
931
|
+
end
|
932
|
+
end
|
933
|
+
|
934
|
+
describe 'money column with different column names' do
|
935
|
+
class MoneyWithCustomColumns < ActiveRecord::Base
|
936
|
+
self.table_name = 'money_records'
|
937
|
+
money_column :price, currency_column: :prix_currency
|
938
|
+
money_column :prix, currency_column: 'price_currency'
|
939
|
+
end
|
940
|
+
|
941
|
+
it 'supports both string and symbol currency column names' do
|
942
|
+
record = MoneyWithCustomColumns.new(
|
943
|
+
price: Money.new(100, 'EUR'),
|
944
|
+
prix_currency: 'EUR',
|
945
|
+
prix: Money.new(200, 'USD'),
|
946
|
+
price_currency: 'USD'
|
947
|
+
)
|
948
|
+
|
949
|
+
expect(record.price.currency.to_s).to eq('EUR')
|
950
|
+
expect(record.prix.currency.to_s).to eq('USD')
|
951
|
+
end
|
952
|
+
end
|
953
|
+
|
954
|
+
describe 'money column array syntax' do
|
955
|
+
class MoneyWithArrayColumns < ActiveRecord::Base
|
956
|
+
self.table_name = 'money_records'
|
957
|
+
money_column [:price, :prix], currency_column: 'price_currency'
|
958
|
+
end
|
959
|
+
|
960
|
+
it 'supports defining multiple columns at once' do
|
961
|
+
record = MoneyWithArrayColumns.new(
|
962
|
+
price: Money.new(100, 'USD'),
|
963
|
+
prix: Money.new(200, 'USD'),
|
964
|
+
price_currency: 'USD'
|
965
|
+
)
|
966
|
+
|
967
|
+
expect(record.price).to eq(Money.new(100, 'USD'))
|
968
|
+
expect(record.prix).to eq(Money.new(200, 'USD'))
|
969
|
+
end
|
970
|
+
end
|
971
|
+
|
972
|
+
describe 'ActiveRecord scopes' do
|
973
|
+
it 'works with ActiveRecord scopes' do
|
974
|
+
MoneyRecord.delete_all
|
975
|
+
cheap = MoneyRecord.create!(price: Money.new(10, 'USD'))
|
976
|
+
expensive = MoneyRecord.create!(price: Money.new(100, 'USD'))
|
977
|
+
|
978
|
+
scope = MoneyRecord.where('price < ?', 50)
|
979
|
+
expect(scope.to_a).to eq([cheap])
|
980
|
+
end
|
981
|
+
end
|
982
|
+
|
983
|
+
describe 'JSON serialization' do
|
984
|
+
it 'includes money values in as_json' do
|
985
|
+
record = MoneyRecord.new(price: Money.new(100, 'USD'))
|
986
|
+
json = record.as_json
|
987
|
+
# Money columns are serialized as a hash with symbol keys
|
988
|
+
expect(json['price']).to eq({ currency: 'USD', value: '100.00' })
|
989
|
+
expect(json['price_currency']).to eq('USD')
|
990
|
+
end
|
991
|
+
end
|
992
|
+
|
993
|
+
describe 'update_columns behavior' do
|
994
|
+
it 'bypasses money column methods when using update_columns' do
|
995
|
+
record = MoneyRecord.create!(price: Money.new(100, 'USD'))
|
996
|
+
record.update_columns(price: 200)
|
997
|
+
record.reload
|
998
|
+
expect(record.price.value).to eq(200)
|
999
|
+
expect(record.price.currency.to_s).to eq('USD')
|
428
1000
|
end
|
429
1001
|
end
|
430
1002
|
end
|
data/spec/schema.rb
CHANGED
@@ -2,9 +2,9 @@
|
|
2
2
|
ActiveRecord::Schema.define do
|
3
3
|
create_table "money_records", :force => true do |t|
|
4
4
|
t.decimal "price", precision: 20, scale: 3, default: '0.000'
|
5
|
-
t.string "
|
5
|
+
t.string "price_currency", limit: 3
|
6
6
|
t.decimal "prix", precision: 20, scale: 3, default: '0.000'
|
7
|
-
t.string "
|
7
|
+
t.string "prix_currency", limit: 3
|
8
8
|
t.decimal "price_usd"
|
9
9
|
end
|
10
10
|
end
|