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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4ba344a6f24cfb6476f553ea2cc6bf339538363a2cd28dfe57a305ed93883e5
4
- data.tar.gz: 8ac696ebcf2ceeb7126f8d18a12dbbb9f13aab91bddc73d495272b0dddb69176
3
+ metadata.gz: 22d6b7f34e2df9d08722a1b5a3c4297bb95d68557b7e82c448476478336cf339
4
+ data.tar.gz: c267cac812901641a799ddc08afd446051f808c632d75ef62c8c80ad4237bf72
5
5
  SHA512:
6
- metadata.gz: 2f883c89dc5f50f21639b275d1976549c86a8d459b143f90635c1f7f79d94fbee09d4385c744a63cc2d565b557b279ea9f4dd982848f17e354a64798dc7b3844
7
- data.tar.gz: 7902aa98cd6355c7ab298bf7c07406d3e504da9ebc9c61f28c892b4f3fdff6ae93b10b86a14950c81f2a72552a5a425ba5eb41540caeda5d9e6b08a3e511dc35
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.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.1.9)
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,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Money
4
- VERSION = "3.2.2"
4
+ VERSION = "3.2.4"
5
5
  end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MoneyColumn
4
- class CurrencyReadOnlyError < StandardError; end
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
- unless money.is_a?(Money)
56
- return self[column] = Money::Helpers.value_to_decimal(money)
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
- unless compatible_currency?(money, options)
61
- msg = "Cannot update #{column}: Attempting to write a money with currency #{money.currency} to a record with currency #{currency}. If you do want to change the currency, either remove `currency_read_only` or update the record's currency manually"
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
- self[column] = money.value
77
+ if currency_column && !money.no_currency?
78
+ self[currency_column] = money.currency.to_s
79
+ end
73
80
  end
74
81
 
75
- def compatible_currency?(money, options)
76
- currency_column = options[:currency_column]
77
- currency = options[:currency] ||
78
- @money_raw_new_attributes[currency_column.to_sym] ||
79
- try(currency_column)
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
- currency.nil? || money.currency.compatible?(Money::Helpers.value_to_currency(currency))
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)
@@ -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: 'currency'
10
- money_column :prix, currency_column: :devise
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, :currency, presence: true
17
- money_column :price, currency_column: 'currency'
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: 'currency', currency_read_only: true
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: 'currency', coerce_null: true
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 :currency, to: :delegated_record
34
- money_column :price, currency_column: 'currency', currency_read_only: true
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.currency
37
+ delegated_record.price_currency
38
38
  end
39
39
 
40
40
  private
41
41
 
42
42
  def delegated_record
43
- MoneyRecord.new(currency: 'USD')
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: 'currency'
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: 'currency'
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.devise = 'CAD'
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(currency: nil)
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(currency: 'invalid')
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.currency.to_s).to eq('EUR')
170
+ expect(record.price_currency.to_s).to eq('EUR')
146
171
  record.update(price: Money.new(0, Money::NULL_CURRENCY))
147
- expect(record.currency.to_s).to eq('EUR')
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.currency.to_s).to eq('EUR')
176
+ expect(record.price_currency.to_s).to eq('EUR')
152
177
  record.update(price: Money.new(4, 'JPY'))
153
- expect(record.currency.to_s).to eq('JPY')
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[:currency]).to include("can't be blank")
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 'does not write the currency to the db' do
250
+ it 'raises CurrencyReadOnlyError when updating price with different currency' do
226
251
  record = MoneyWithReadOnlyCurrency.create
227
- record.update_columns(currency: 'USD')
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(currency: 'USD')
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(currency: 'USD', price: 1)
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(currency: 'invalid', price: 1)
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(currency: 'CAD', price: 1)
264
- record.currency = 'USD'
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('currency')
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(currency: nil)
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(currency: nil, price: 3)
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(currency: nil)
409
- record.update(price: 7, currency: 'GBP')
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!(currency: "CAD") }
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
- currency: 'USD'
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.currency).to eq('USD')
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 "currency", limit: 3
5
+ t.string "price_currency", limit: 3
6
6
  t.decimal "prix", precision: 20, scale: 3, default: '0.000'
7
- t.string "devise", limit: 3
7
+ t.string "prix_currency", limit: 3
8
8
  t.decimal "price_usd"
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shopify-money
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.2.2
4
+ version: 3.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify Inc