shopify-money 3.1.2 → 3.2.1

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: 03fea29a7733cd7ac14493fff381a197b42a16de9369c8a15b0bba9905f7a8a0
4
- data.tar.gz: '025213910adf2f5090dfb7afaa85f715f05515094e15f14c25b5805eaef569eb'
3
+ metadata.gz: d16632a569c75054c496e3d341e83ffb2ce08e25177b0c12a344c9b1c5fa23c2
4
+ data.tar.gz: 43d4b4e5f1c8b79c2d366dbad9077056cdd00744a86035ff58931e192b8fbff7
5
5
  SHA512:
6
- metadata.gz: dad64b959edcde085fc92904dd442babdc33122c7b2c30b91e204e597fd38e547ccf62c3e6881d78d1a2b2005581d27dcad1177b1d64ae066d2bfbafabe89cb5
7
- data.tar.gz: 2047c647afe7e4a9b373997184bde9c1a84e47e410571531266fc60075a99da1a65ec4de685c6e79fd852e7de61af427a40440e317ad8a13014a8cc4ccd4f372
6
+ metadata.gz: a15a1d6761fd8bed35cedf19ebec19b03aa7a8f10eb03c52004ce6fb166158feab2bba29a41f7fb005cf49339d39361f572c3636e716dd80dbd0a2a702db1476
7
+ data.tar.gz: e2b3760497b1d8c4947ae5adea3592a01e625e41d4d916a8155c0d18809358a040e63b897edd5942030ec8652e7e39487adb5b965023b991c8e100fa4f637c73
@@ -1,5 +1,9 @@
1
1
  version: 2
2
2
  updates:
3
+ - package-ecosystem: github-actions
4
+ directory: "/"
5
+ schedule:
6
+ interval: weekly
3
7
  - package-ecosystem: bundler
4
8
  directory: "/"
5
9
  schedule:
@@ -13,12 +13,12 @@ jobs:
13
13
 
14
14
  name: Ruby ${{ matrix.ruby }}
15
15
  steps:
16
- - uses: actions/checkout@v4
16
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
17
17
  - name: Set up Ruby ${{ matrix.ruby }}
18
- uses: ruby/setup-ruby@v1
18
+ uses: ruby/setup-ruby@e34163cd15f4bb403dcd72d98e295997e6a55798 # v1.238.0
19
19
  with:
20
20
  ruby-version: ${{ matrix.ruby }}
21
- - uses: actions/cache@v4
21
+ - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
22
22
  with:
23
23
  path: vendor/bundle
24
24
  key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
data/Gemfile.lock CHANGED
@@ -1,7 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- shopify-money (3.1.2)
4
+ shopify-money (3.2.1)
5
+ bigdecimal (>= 3.0)
5
6
 
6
7
  GEM
7
8
  remote: https://rubygems.org/
@@ -107,8 +108,8 @@ GEM
107
108
  pp (>= 0.6.0)
108
109
  rdoc (>= 4.0.0)
109
110
  reline (>= 0.4.2)
110
- json (2.10.2)
111
- language_server-protocol (3.17.0.4)
111
+ json (2.12.2)
112
+ language_server-protocol (3.17.0.5)
112
113
  lint_roller (1.1.0)
113
114
  logger (1.7.0)
114
115
  loofah (2.24.0)
@@ -148,8 +149,8 @@ GEM
148
149
  nokogiri (1.18.7-x86_64-linux-gnu)
149
150
  racc (~> 1.4)
150
151
  ostruct (0.6.1)
151
- parallel (1.26.3)
152
- parser (3.3.7.4)
152
+ parallel (1.27.0)
153
+ parser (3.3.8.0)
153
154
  ast (~> 2.4.1)
154
155
  racc
155
156
  pp (0.6.2)
@@ -223,7 +224,7 @@ GEM
223
224
  diff-lcs (>= 1.2.0, < 2.0)
224
225
  rspec-support (~> 3.13.0)
225
226
  rspec-support (3.13.1)
226
- rubocop (1.75.2)
227
+ rubocop (1.75.7)
227
228
  json (~> 2.3)
228
229
  language_server-protocol (~> 3.17.0.2)
229
230
  lint_roller (~> 1.1.0)
@@ -234,14 +235,14 @@ GEM
234
235
  rubocop-ast (>= 1.44.0, < 2.0)
235
236
  ruby-progressbar (~> 1.7)
236
237
  unicode-display_width (>= 2.4.0, < 4.0)
237
- rubocop-ast (1.44.0)
238
+ rubocop-ast (1.44.1)
238
239
  parser (>= 3.3.7.2)
239
240
  prism (~> 1.4)
240
241
  rubocop-performance (1.25.0)
241
242
  lint_roller (~> 1.1)
242
243
  rubocop (>= 1.75.0, < 2.0)
243
244
  rubocop-ast (>= 1.38.0, < 2.0)
244
- rubocop-shopify (2.16.0)
245
+ rubocop-shopify (2.17.0)
245
246
  rubocop (~> 1.62)
246
247
  ruby-progressbar (1.13.0)
247
248
  securerandom (0.4.1)
data/README.md CHANGED
@@ -165,6 +165,45 @@ Money.configure do |config|
165
165
  end
166
166
  ```
167
167
 
168
+ ### Converters
169
+ The Money gem provides a flexible converter system for handling different subunit formats. This is particularly useful when working with payment providers or APIs that have their own conventions for handling currency subunits.
170
+
171
+ #### Built-in Converters
172
+ - `:iso4217` (default) - Uses the standard ISO 4217 subunit definitions
173
+ - `:stripe` - Uses Stripe's special cases for certain currencies
174
+ - `:legacy_dollar` - Always uses 100 as the subunit_to_unit value
175
+
176
+ #### Using Converters
177
+ ```ruby
178
+ # Convert to subunits using ISO4217 format (default)
179
+ Money.new(1.00, 'USD').subunits # => 100
180
+ Money.new(1.00, 'ISK').subunits # => 1
181
+
182
+ # Convert to subunits using Stripe format
183
+ Money.new(1.00, 'ISK').subunits(format: :stripe) # => 100
184
+
185
+ # Convert from subunits
186
+ Money.from_subunits(100, 'ISK', format: :stripe) # => Money.new(1.00, 'ISK')
187
+ ```
188
+
189
+ #### Custom Converters
190
+ You can create your own converter by subclassing `Money::Converters::Converter`:
191
+
192
+ ```ruby
193
+ class MyCustomConverter < Money::Converters::Converter
194
+ def subunit_to_unit(currency)
195
+ # Your custom logic here
196
+ 1000
197
+ end
198
+ end
199
+
200
+ # Register your converter
201
+ Money::Converters.register(:my_format, MyCustomConverter)
202
+
203
+ # Use your converter
204
+ Money.new(1.00, 'USD').subunits(format: :my_format) # => 1000
205
+ ```
206
+
168
207
  ## Money column
169
208
 
170
209
  Since money internally uses BigDecimal it's logical to use a `decimal` column
data/lib/money/config.rb CHANGED
@@ -2,7 +2,62 @@
2
2
 
3
3
  class Money
4
4
  class Config
5
- attr_accessor :default_currency, :legacy_json_format, :legacy_deprecations, :experimental_crypto_currencies
5
+ CONFIG_THREAD = :shopify_money__configs
6
+
7
+ class << self
8
+ def global
9
+ @config ||= new
10
+ end
11
+
12
+ def current
13
+ thread_local_config[Fiber.current.object_id] ||= global.dup
14
+ end
15
+
16
+ def current=(config)
17
+ thread_local_config[Fiber.current.object_id] = config
18
+ end
19
+
20
+ def configure_current(**configs, &block)
21
+ old_config = current.dup
22
+ current.tap do |config|
23
+ configs.each do |k, v|
24
+ config.public_send("#{k}=", v)
25
+ end
26
+ end
27
+ yield
28
+ ensure
29
+ self.current = old_config
30
+ end
31
+
32
+ def reset_current
33
+ thread_local_config.delete(Fiber.current.object_id)
34
+ Thread.current[CONFIG_THREAD] = nil if thread_local_config.empty?
35
+ end
36
+
37
+ private
38
+
39
+ def thread_local_config
40
+ Thread.current[CONFIG_THREAD] ||= {}
41
+ end
42
+ end
43
+
44
+ attr_accessor :legacy_json_format, :legacy_deprecations, :experimental_crypto_currencies, :default_subunit_format
45
+
46
+ attr_reader :default_currency
47
+ alias_method :currency, :default_currency
48
+
49
+ def default_currency=(value)
50
+ @default_currency =
51
+ case value
52
+ when String
53
+ Currency.find!(value)
54
+ when Money::Currency, Money::NullCurrency, nil
55
+ value
56
+ else
57
+ raise ArgumentError, "Invalid currency"
58
+ end
59
+ end
60
+ alias_method :currency=, :default_currency=
6
61
 
7
62
  def legacy_default_currency!
8
63
  @default_currency ||= Money::NULL_CURRENCY
@@ -25,14 +80,7 @@ class Money
25
80
  @legacy_json_format = false
26
81
  @legacy_deprecations = false
27
82
  @experimental_crypto_currencies = false
28
- end
29
-
30
- def without_legacy_deprecations(&block)
31
- old_legacy_deprecations = @legacy_deprecations
32
- @legacy_deprecations = false
33
- yield
34
- ensure
35
- @legacy_deprecations = old_legacy_deprecations
83
+ @default_subunit_format = :iso4217
36
84
  end
37
85
  end
38
86
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Money
4
+ module Converters
5
+ class Converter
6
+ def to_subunits(money)
7
+ raise ArgumentError, "money cannot be nil" if money.nil?
8
+ (money.value * subunit_to_unit(money.currency)).to_i
9
+ end
10
+
11
+ def from_subunits(subunits, currency)
12
+ currency = Helpers.value_to_currency(currency)
13
+ value = Helpers.value_to_decimal(subunits) / subunit_to_unit(currency)
14
+ Money.new(value, currency)
15
+ end
16
+
17
+ protected
18
+
19
+ def subunit_to_unit(currency)
20
+ raise NotImplementedError, "subunit_to_unit method must be implemented in subclasses"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Money
4
+ module Converters
5
+ class << self
6
+ def subunit_converters
7
+ @subunit_converters ||= {}
8
+ end
9
+
10
+ def register(key, klass)
11
+ subunit_converters[key.to_sym] = klass
12
+ end
13
+
14
+ def for(format)
15
+ format ||= Money::Config.current.default_subunit_format
16
+
17
+ if (klass = subunit_converters[format.to_sym])
18
+ klass.new
19
+ else
20
+ raise(ArgumentError, "unknown format: '#{format}'")
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Money
4
+ module Converters
5
+ class Iso4217Converter < Converter
6
+ def subunit_to_unit(currency)
7
+ currency.subunit_to_unit
8
+ end
9
+ end
10
+ end
11
+ end
12
+ Money::Converters.register(:iso4217, Money::Converters::Iso4217Converter)
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Money
4
+ module Converters
5
+ class LegacyDollarsConverter < Converter
6
+ def subunit_to_unit(currency)
7
+ 100
8
+ end
9
+ end
10
+ end
11
+ end
12
+ Money::Converters.register(:legacy_dollar, Money::Converters::LegacyDollarsConverter)
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Money
4
+ module Converters
5
+ class StripeConverter < Iso4217Converter
6
+ SUBUNIT_TO_UNIT = {
7
+ # https://docs.stripe.com/currencies#special-cases
8
+ 'ISK' => 100,
9
+ 'UGX' => 100,
10
+ 'HUF' => 100,
11
+ 'TWD' => 100,
12
+ # https://docs.stripe.com/currencies#zero-decimal
13
+ 'BIF' => 1,
14
+ 'CLP' => 1,
15
+ 'DJF' => 1,
16
+ 'GNF' => 1,
17
+ 'JPY' => 1,
18
+ 'KMF' => 1,
19
+ 'KRW' => 1,
20
+ 'MGA' => 1,
21
+ 'PYG' => 1,
22
+ 'RWF' => 1,
23
+ 'VND' => 1,
24
+ 'VUV' => 1,
25
+ 'XAF' => 1,
26
+ 'XOF' => 1,
27
+ 'XPF' => 1,
28
+ 'USDC' => 1_000_000,
29
+ }.freeze
30
+
31
+ def subunit_to_unit(currency)
32
+ SUBUNIT_TO_UNIT.fetch(currency.iso_code, super)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ Money::Converters.register(:stripe, Money::Converters::StripeConverter)
@@ -17,7 +17,7 @@ class String
17
17
  def to_money(currency = nil)
18
18
  currency = Money::Helpers.value_to_currency(currency)
19
19
 
20
- unless Money.config.legacy_deprecations
20
+ unless Money::Config.current.legacy_deprecations
21
21
  return Money.new(self, currency)
22
22
  end
23
23
 
@@ -30,6 +30,10 @@ class Money
30
30
  def crypto_currencies
31
31
  @@crypto_currencies ||= Loader.load_crypto_currencies
32
32
  end
33
+
34
+ def reset_loaded_currencies
35
+ @@loaded_currencies = {}
36
+ end
33
37
  end
34
38
 
35
39
  attr_reader :iso_code,
@@ -45,7 +49,10 @@ class Money
45
49
 
46
50
  def initialize(currency_iso)
47
51
  data = self.class.currencies[currency_iso]
48
- data = self.class.crypto_currencies[currency_iso] if data.nil? && Money.config.experimental_crypto_currencies
52
+ if data.nil? && Money::Config.current.experimental_crypto_currencies
53
+ data = self.class.crypto_currencies[currency_iso]
54
+ end
55
+
49
56
  raise UnknownCurrency, "Invalid iso4217 currency '#{currency_iso}'" unless data
50
57
  @symbol = data['symbol']
51
58
  @disambiguate_symbol = data['disambiguate_symbol'] || data['symbol']
data/lib/money/helpers.rb CHANGED
@@ -9,12 +9,6 @@ class Money
9
9
  DECIMAL_ZERO = BigDecimal(0).freeze
10
10
  MAX_DECIMAL = 21
11
11
 
12
- STRIPE_SUBUNIT_OVERRIDE = {
13
- 'ISK' => 100,
14
- 'UGX' => 100,
15
- 'USDC' => 1_000_000,
16
- }.freeze
17
-
18
12
  def value_to_decimal(num)
19
13
  value =
20
14
  case num
@@ -44,7 +38,7 @@ class Money
44
38
  when Money::Currency, Money::NullCurrency
45
39
  currency
46
40
  when nil, ''
47
- default = Money.current_currency || Money.default_currency
41
+ default = Money::Config.current.currency
48
42
  raise(Money::Currency::UnknownCurrency, 'missing currency') if default.nil? || default == ''
49
43
  value_to_currency(default)
50
44
  when 'xxx', 'XXX'
@@ -53,7 +47,7 @@ class Money
53
47
  begin
54
48
  Currency.find!(currency)
55
49
  rescue Money::Currency::UnknownCurrency => error
56
- if Money.config.legacy_deprecations
50
+ if Money::Config.current.legacy_deprecations
57
51
  Money.deprecate(error.message)
58
52
  Money::NULL_CURRENCY
59
53
  else
data/lib/money/money.rb CHANGED
@@ -39,19 +39,37 @@ class Money
39
39
 
40
40
  class << self
41
41
  extend Forwardable
42
- def_delegators :config, :default_currency, :default_currency=, :without_legacy_deprecations
42
+ def_delegators :'Money::Config.global', :default_currency, :default_currency=
43
+
44
+ def without_legacy_deprecations(&block)
45
+ with_config(legacy_deprecations: false, &block)
46
+ end
47
+
48
+ def with_config(**configs, &block)
49
+ Money::Config.configure_current(**configs, &block)
50
+ end
43
51
 
44
52
  def config
45
- Thread.current[:shopify_money__config] ||= @config.dup
53
+ Money::Config.global
54
+ end
55
+
56
+ def configure(&block)
57
+ Money::Config.global.tap(&block)
46
58
  end
47
59
 
48
- def config=(config)
49
- Thread.current[:shopify_money__config] = config
60
+ def current_currency
61
+ Money::Config.current.currency
62
+ end
63
+
64
+ def current_currency=(value)
65
+ Money::Config.current.currency = value
50
66
  end
51
67
 
52
- def configure
53
- @config ||= Config.new
54
- yield(@config) if block_given?
68
+ def with_currency(currency, &block)
69
+ if currency.nil?
70
+ currency = current_currency
71
+ end
72
+ with_config(currency: currency, &block)
55
73
  end
56
74
 
57
75
  def new(value = 0, currency = nil)
@@ -69,19 +87,8 @@ class Money
69
87
  end
70
88
  alias_method :from_amount, :new
71
89
 
72
- def from_subunits(subunits, currency_iso, format: :iso4217)
73
- currency = Helpers.value_to_currency(currency_iso)
74
-
75
- subunit_to_unit_value = if format == :iso4217
76
- currency.subunit_to_unit
77
- elsif format == :stripe
78
- Helpers::STRIPE_SUBUNIT_OVERRIDE.fetch(currency.iso_code, currency.subunit_to_unit)
79
- else
80
- raise ArgumentError, "unknown format #{format}"
81
- end
82
-
83
- value = Helpers.value_to_decimal(subunits) / subunit_to_unit_value
84
- new(value, currency)
90
+ def from_subunits(subunits, currency_iso, format: nil)
91
+ Converters.for(format).from_subunits(subunits, currency_iso)
85
92
  end
86
93
 
87
94
  def from_json(string)
@@ -101,26 +108,6 @@ class Money
101
108
  end
102
109
  end
103
110
 
104
- def current_currency
105
- Thread.current[:money_currency]
106
- end
107
-
108
- def current_currency=(currency)
109
- Thread.current[:money_currency] = currency
110
- end
111
-
112
- # Set Money.default_currency inside the supplied block, resets it to
113
- # the previous value when done to prevent leaking state. Similar to
114
- # I18n.with_locale and ActiveSupport's Time.use_zone. This won't affect
115
- # instances being created with explicitly set currency.
116
- def with_currency(new_currency)
117
- old_currency = Money.current_currency
118
- Money.current_currency = new_currency
119
- yield
120
- ensure
121
- Money.current_currency = old_currency
122
- end
123
-
124
111
  private
125
112
 
126
113
  def new_from_money(amount, currency)
@@ -137,7 +124,7 @@ class Money
137
124
  msg = "Money.new(Money.new(amount, #{amount.currency}), #{currency}) " \
138
125
  "is changing the currency of an existing money object"
139
126
 
140
- if Money.config.legacy_deprecations
127
+ if Money::Config.current.legacy_deprecations
141
128
  Money.deprecate("#{msg}. A Money::IncompatibleCurrencyError will raise in the next major release")
142
129
  Money.new(amount.value, currency)
143
130
  else
@@ -145,7 +132,6 @@ class Money
145
132
  end
146
133
  end
147
134
  end
148
- configure
149
135
 
150
136
  def initialize(value, currency)
151
137
  raise ArgumentError if value.nan?
@@ -163,16 +149,8 @@ class Money
163
149
  coder['currency'] = @currency.iso_code
164
150
  end
165
151
 
166
- def subunits(format: :iso4217)
167
- subunit_to_unit_value = if format == :iso4217
168
- @currency.subunit_to_unit
169
- elsif format == :stripe
170
- Helpers::STRIPE_SUBUNIT_OVERRIDE.fetch(@currency.iso_code, @currency.subunit_to_unit)
171
- else
172
- raise ArgumentError, "unknown format #{format}"
173
- end
174
-
175
- (@value * subunit_to_unit_value).to_i
152
+ def subunits(format: nil)
153
+ Converters.for(format).to_subunits(self)
176
154
  end
177
155
 
178
156
  def no_currency?
@@ -286,7 +264,7 @@ class Money
286
264
  alias_method :to_formatted_s, :to_fs
287
265
 
288
266
  def to_json(options = nil)
289
- if (options.is_a?(Hash) && options[:legacy_format]) || Money.config.legacy_json_format
267
+ if (options.is_a?(Hash) && options[:legacy_format]) || Money::Config.current.legacy_json_format
290
268
  to_s
291
269
  else
292
270
  as_json(options).to_json
@@ -294,7 +272,7 @@ class Money
294
272
  end
295
273
 
296
274
  def as_json(options = nil)
297
- if (options.is_a?(Hash) && options[:legacy_format]) || Money.config.legacy_json_format
275
+ if (options.is_a?(Hash) && options[:legacy_format]) || Money::Config.current.legacy_json_format
298
276
  to_s
299
277
  else
300
278
  { value: to_s(:amount), currency: currency.to_s }
@@ -407,7 +385,7 @@ class Money
407
385
  def ensure_compatible_currency(other_currency, msg)
408
386
  return if currency.compatible?(other_currency)
409
387
 
410
- if Money.config.legacy_deprecations
388
+ if Money::Config.current.legacy_deprecations
411
389
  Money.deprecate("#{msg}. A Money::IncompatibleCurrencyError will raise in the next major release")
412
390
  else
413
391
  raise Money::IncompatibleCurrencyError, msg
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.1.2"
4
+ VERSION = "3.2.1"
5
5
  end
data/lib/money.rb CHANGED
@@ -1,19 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'money/version'
4
- require_relative 'money/parser/fuzzy'
5
- require_relative 'money/helpers'
6
4
  require_relative 'money/currency'
7
5
  require_relative 'money/null_currency'
8
6
  require_relative 'money/allocator'
9
7
  require_relative 'money/splitter'
10
8
  require_relative 'money/config'
11
9
  require_relative 'money/money'
10
+ require_relative 'money/converters/factory'
11
+ require_relative 'money/converters/converter'
12
+ require_relative 'money/converters/iso4217_converter'
13
+ require_relative 'money/converters/stripe_converter'
14
+ require_relative 'money/converters/legacy_dollars_converter'
12
15
  require_relative 'money/errors'
13
16
  require_relative 'money/deprecations'
17
+ require_relative 'money/parser/fuzzy'
14
18
  require_relative 'money/parser/accounting'
15
- require_relative 'money/parser/locale_aware'
16
19
  require_relative 'money/parser/simple'
20
+ require_relative 'money/parser/locale_aware'
21
+ require_relative 'money/helpers'
17
22
  require_relative 'money/core_extensions'
18
23
  require_relative 'money_column' if defined?(ActiveRecord)
19
24
  require_relative 'money/railtie' if defined?(Rails::Railtie)
@@ -57,10 +57,9 @@ module MoneyColumn
57
57
  end
58
58
 
59
59
  if options[:currency_read_only]
60
- currency = options[:currency] || try(options[:currency_column])
61
- if currency && !money.currency.compatible?(Money::Helpers.value_to_currency(currency))
62
- msg = "[money_column] currency mismatch between #{currency} and #{money.currency} in column #{column}."
63
- if Money.config.legacy_deprecations
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
64
63
  Money.deprecate(msg)
65
64
  else
66
65
  raise MoneyColumn::CurrencyReadOnlyError, msg
@@ -73,6 +72,22 @@ module MoneyColumn
73
72
  self[column] = money.value
74
73
  end
75
74
 
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)
80
+
81
+ currency.nil? || money.currency.compatible?(Money::Helpers.value_to_currency(currency))
82
+ end
83
+
84
+ def _assign_attributes(new_attributes)
85
+ @money_raw_new_attributes = new_attributes.symbolize_keys
86
+ super
87
+ ensure
88
+ @money_raw_new_attributes = nil
89
+ end
90
+
76
91
  module ClassMethods
77
92
  attr_reader :money_column_options
78
93
 
data/money.gemspec CHANGED
@@ -16,6 +16,8 @@ Gem::Specification.new do |s|
16
16
 
17
17
  s.metadata['allowed_push_host'] = "https://rubygems.org"
18
18
 
19
+ s.add_dependency("bigdecimal", ">= 3.0")
20
+
19
21
  s.add_development_dependency("bundler")
20
22
  s.add_development_dependency("database_cleaner", "~> 2.0")
21
23
  s.add_development_dependency("ostruct")
data/spec/config_spec.rb CHANGED
@@ -3,27 +3,52 @@ require 'spec_helper'
3
3
 
4
4
  RSpec.describe "Money::Config" do
5
5
  describe 'thread safety' do
6
- it 'does not share the same config across threads' do
6
+ it 'does not share the same config across fibers' do
7
7
  configure(legacy_deprecations: false, default_currency: 'USD') do
8
- expect(Money.config.legacy_deprecations).to eq(false)
9
- expect(Money.config.default_currency).to eq('USD')
10
- thread = Thread.new do
11
- Money.config.legacy_deprecations!
12
- Money.default_currency = "EUR"
13
- expect(Money.config.legacy_deprecations).to eq(true)
14
- expect(Money.config.default_currency).to eq("EUR")
8
+ expect(Money::Config.current.legacy_deprecations).to eq(false)
9
+ expect(Money::Config.current.default_currency.to_s).to eq('USD')
10
+
11
+ fiber = Fiber.new do
12
+ Money::Config.current.legacy_deprecations!
13
+ Money::Config.current.default_currency = "EUR"
14
+
15
+ expect(Money::Config.current.legacy_deprecations).to eq(true)
16
+ expect(Money::Config.current.default_currency.to_s).to eq("EUR")
17
+
18
+ :fiber_completed
15
19
  end
16
- thread.join
17
- expect(Money.config.legacy_deprecations).to eq(false)
18
- expect(Money.config.default_currency).to eq('USD')
20
+ # run the fiber
21
+ expect(fiber.resume).to eq(:fiber_completed)
22
+
23
+ # Verify main fiber's config was not affected
24
+ expect(Money::Config.current.legacy_deprecations).to eq(false)
25
+ expect(Money::Config.current.default_currency.to_s).to eq('USD')
19
26
  end
20
27
  end
28
+
29
+ it 'isolates configuration between threads' do
30
+ expect(Money::Config.current.legacy_deprecations).to eq(false)
31
+ expect(Money::Config.current.default_currency).to eq(Money::Currency.find!('CAD'))
32
+
33
+ thread = Thread.new do
34
+ Money::Config.current.legacy_deprecations!
35
+ Money::Config.current.default_currency = "EUR"
36
+
37
+ expect(Money::Config.current.legacy_deprecations).to eq(true)
38
+ expect(Money::Config.current.default_currency).to eq(Money::Currency.find!("EUR"))
39
+ end
40
+
41
+ thread.join
42
+
43
+ expect(Money::Config.current.legacy_deprecations).to eq(false)
44
+ expect(Money::Config.current.default_currency).to eq(Money::Currency.find!('CAD'))
45
+ end
21
46
  end
22
47
 
23
48
  describe 'legacy_deprecations' do
24
49
  it "respects the default currency" do
25
50
  configure(default_currency: 'USD', legacy_deprecations: true) do
26
- expect(Money.default_currency).to eq("USD")
51
+ expect(Money::Config.current.default_currency.to_s).to eq("USD")
27
52
  end
28
53
  end
29
54
 
@@ -33,7 +58,7 @@ RSpec.describe "Money::Config" do
33
58
 
34
59
  it 'legacy_deprecations returns true when opting in to v1' do
35
60
  configure(legacy_deprecations: true) do
36
- expect(Money.config.legacy_deprecations).to eq(true)
61
+ expect(Money::Config.current.legacy_deprecations).to eq(true)
37
62
  end
38
63
  end
39
64
 
@@ -45,30 +70,33 @@ RSpec.describe "Money::Config" do
45
70
 
46
71
  it 'legacy_deprecations defaults to NULL_CURRENCY' do
47
72
  configure(legacy_default_currency: true) do
48
- expect(Money.config.default_currency).to eq(Money::NULL_CURRENCY)
73
+ expect(Money::Config.current.default_currency).to eq(Money::NULL_CURRENCY)
49
74
  end
50
75
  end
51
76
  end
52
77
 
53
78
  describe 'default_currency' do
54
79
  it 'defaults to nil' do
55
- configure do
56
- expect(Money.config.default_currency).to eq(nil)
57
- end
80
+ expect(Money::Config.new.default_currency).to eq(nil)
58
81
  end
59
82
 
60
83
  it 'can be set to a new currency' do
61
84
  configure(default_currency: 'USD') do
62
- expect(Money.config.default_currency).to eq('USD')
85
+ expect(Money::Config.current.default_currency.to_s).to eq('USD')
63
86
  end
64
87
  end
88
+
89
+ it 'raises ArgumentError for invalid currency' do
90
+ config = Money::Config.new
91
+ expect { config.default_currency = 123 }.to raise_error(ArgumentError, "Invalid currency")
92
+ end
65
93
  end
66
-
94
+
67
95
  describe 'experimental_crypto_currencies' do
68
96
  it 'defaults to false' do
69
97
  expect(Money::Config.new.experimental_crypto_currencies).to eq(false)
70
98
  end
71
-
99
+
72
100
  it 'can be set to true' do
73
101
  config = Money::Config.new
74
102
  config.experimental_crypto_currencies = true
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+ require 'spec_helper'
3
+
4
+ RSpec.describe Money::Converters do
5
+ let(:usd) { Money::Currency.find!('USD') }
6
+ let(:ugx) { Money::Currency.find!('UGX') }
7
+
8
+ describe '.for' do
9
+ it 'returns Iso4217Converter for :iso4217' do
10
+ expect(Money::Converters.for(:iso4217)).to be_a(Money::Converters::Iso4217Converter)
11
+ end
12
+
13
+ it 'returns StripeConverter for :stripe' do
14
+ expect(Money::Converters.for(:stripe)).to be_a(Money::Converters::StripeConverter)
15
+ end
16
+
17
+ it 'returns LegacyDollarsConverter for :legacy_dollar' do
18
+ expect(Money::Converters.for(:legacy_dollar)).to be_a(Money::Converters::LegacyDollarsConverter)
19
+ end
20
+
21
+ it 'raises ArgumentError for unknown format' do
22
+ expect { Money::Converters.for(:unknown) }.to raise_error(ArgumentError, /unknown format/)
23
+ end
24
+ end
25
+
26
+ describe 'registering a custom converter' do
27
+ class DummyConverter < Money::Converters::Converter
28
+ def subunit_to_unit(currency); 42; end
29
+ end
30
+
31
+ class InvalidConverter < Money::Converters::Converter
32
+ # Intentionally not implementing subunit_to_unit
33
+ end
34
+
35
+ after { Money::Converters.subunit_converters.delete(:dummy) }
36
+
37
+ it 'registers and uses a custom converter' do
38
+ Money::Converters.register(:dummy, DummyConverter)
39
+ converter = Money::Converters.for(:dummy)
40
+ expect(converter).to be_a(DummyConverter)
41
+ expect(converter.to_subunits(Money.new(1, 'USD'))).to eq(42)
42
+ end
43
+
44
+ it 'raises NotImplementedError when subunit_to_unit is not implemented' do
45
+ Money::Converters.register(:invalid, InvalidConverter)
46
+ converter = Money::Converters.for(:invalid)
47
+ expect { converter.to_subunits(Money.new(1, 'USD')) }.to raise_error(NotImplementedError, "subunit_to_unit method must be implemented in subclasses")
48
+ end
49
+ end
50
+
51
+ describe Money::Converters::Iso4217Converter do
52
+ let(:converter) { described_class.new }
53
+ it 'uses currency.subunit_to_unit' do
54
+ expect(converter.to_subunits(Money.new(1, usd))).to eq(100)
55
+ expect(converter.from_subunits(100, usd)).to eq(Money.new(1, usd))
56
+ end
57
+ end
58
+
59
+ describe Money::Converters::StripeConverter do
60
+ let(:converter) { described_class.new }
61
+
62
+ it 'uses Stripe special cases' do
63
+ expect(converter.to_subunits(Money.new(1, ugx))).to eq(100)
64
+ expect(converter.from_subunits(100, ugx)).to eq(Money.new(1, ugx))
65
+ expect(converter.to_subunits(Money.new(1, usd))).to eq(100)
66
+ expect(converter.from_subunits(100, usd)).to eq(Money.new(1, usd))
67
+ end
68
+
69
+ it 'handles USDC if present' do
70
+ configure(experimental_crypto_currencies: true) do
71
+ expect(converter.to_subunits(Money.new(1, "usdc"))).to eq(1_000_000)
72
+ expect(converter.from_subunits(1_000_000, "usdc")).to eq(Money.new(1, "usdc"))
73
+ end
74
+ end
75
+ end
76
+
77
+ describe Money::Converters::LegacyDollarsConverter do
78
+ let(:converter) { described_class.new }
79
+ it 'always uses 100 as subunit_to_unit' do
80
+ expect(converter.to_subunits(Money.new(1, usd))).to eq(100)
81
+ expect(converter.from_subunits(100, usd)).to eq(Money.new(1, usd))
82
+ end
83
+ end
84
+ end
@@ -412,4 +412,19 @@ RSpec.describe 'MoneyColumn' do
412
412
  expect(record.price.currency.to_s).to eq('GBP')
413
413
  end
414
414
  end
415
+
416
+ describe 'updating amount and currency simultaneously' do
417
+ let(:record) { MoneyWithReadOnlyCurrency.create!(currency: "CAD") }
418
+
419
+ it 'allows updating both amount and currency at the same time' do
420
+ record.update!(
421
+ price: Money.new(10, 'USD'),
422
+ currency: 'USD'
423
+ )
424
+ record.reload
425
+ expect(record.price.value).to eq(10)
426
+ expect(record.price.currency.to_s).to eq('USD')
427
+ expect(record.currency).to eq('USD')
428
+ end
429
+ end
415
430
  end
data/spec/money_spec.rb CHANGED
@@ -20,6 +20,15 @@ RSpec.describe "Money" do
20
20
  end
21
21
  end
22
22
 
23
+ it ".configure the config" do
24
+ config = Money::Config.new
25
+ allow(Money::Config).to receive(:global).and_return(config)
26
+
27
+ expect {
28
+ Money.configure { |c| c.default_currency = "USD" }
29
+ }.to change { config.default_currency }.from(nil).to(Money::Currency.find!("USD"))
30
+ end
31
+
23
32
  it ".zero has no currency" do
24
33
  expect(Money.new(0, Money::NULL_CURRENCY).currency).to be_a(Money::NullCurrency)
25
34
  end
@@ -158,6 +167,13 @@ RSpec.describe "Money" do
158
167
  expect(Money.new("999999999999999999.99", "USD").to_s).to eq("999999999999999999.99")
159
168
  end
160
169
 
170
+ it "to_fs formats with correct decimal places" do
171
+ expect(amount_money.to_fs).to eq("1.23")
172
+ expect(non_fractional_money.to_fs).to eq("1")
173
+ expect(Money.new(1.2345, 'USD').to_fs).to eq("1.23")
174
+ expect(Money.new(1.2345, 'BHD').to_fs).to eq("1.235")
175
+ end
176
+
161
177
  it "to_fs raises ArgumentError on unsupported style" do
162
178
  expect{ money.to_fs(:some_weird_style) }.to raise_error(ArgumentError)
163
179
  end
@@ -749,6 +765,12 @@ RSpec.describe "Money" do
749
765
  expect(Money.new(1, 'UGX').subunits(format: :stripe)).to eq(100)
750
766
  end
751
767
 
768
+ it 'overrides the subunit_to_unit amount for USDC' do
769
+ configure(experimental_crypto_currencies: true) do
770
+ expect(Money.from_subunits(500000, "USDC", format: :stripe)).to eq(Money.new(0.50, 'USDC'))
771
+ end
772
+ end
773
+
752
774
  it 'fallbacks to the default subunit_to_unit amount if no override is specified' do
753
775
  expect(Money.new(1, 'USD').subunits(format: :stripe)).to eq(100)
754
776
  end
@@ -1095,7 +1117,7 @@ RSpec.describe "Money" do
1095
1117
  end
1096
1118
  end
1097
1119
 
1098
- describe '#use_currency' do
1120
+ describe '.with_currency' do
1099
1121
  it "allows setting the implicit default currency for a block scope" do
1100
1122
  money = nil
1101
1123
  Money.with_currency('CAD') do
@@ -1114,6 +1136,15 @@ RSpec.describe "Money" do
1114
1136
  expect(money.currency.iso_code).to eq('USD')
1115
1137
  end
1116
1138
 
1139
+ it "accepts nil as currency" do
1140
+ money = nil
1141
+ Money.with_currency(nil) do
1142
+ money = Money.new(1.00)
1143
+ end
1144
+ # uses the default currency
1145
+ expect(money.currency.iso_code).to eq('CAD')
1146
+ end
1147
+
1117
1148
  context "with .default_currency set" do
1118
1149
  around(:each) { |test| configure(default_currency: Money::Currency.new('EUR')) { test.run }}
1119
1150
 
@@ -1159,4 +1190,48 @@ RSpec.describe "Money" do
1159
1190
  expect(money.currency.iso_code).to eq('EUR')
1160
1191
  end
1161
1192
  end
1193
+
1194
+ describe ".current_currency" do
1195
+ it "gets and sets the current currency via Config.current" do
1196
+ Money.current_currency = "USD"
1197
+ expect(Money.default_currency.iso_code).to eq("CAD")
1198
+ expect(Money.current_currency.iso_code).to eq("USD")
1199
+ end
1200
+ end
1201
+
1202
+ describe 'from_subunits' do
1203
+ it 'creates money from subunits using ISO4217 format' do
1204
+ expect(Money.from_subunits(100, 'USD')).to eq(Money.new(1.00, 'USD'))
1205
+ expect(Money.from_subunits(10, 'JPY')).to eq(Money.new(10, 'JPY'))
1206
+ end
1207
+
1208
+ it 'creates money from subunits using custom format' do
1209
+ expect(Money.from_subunits(100, 'USD', format: :iso4217)).to eq(Money.new(1.00, 'USD'))
1210
+ expect(Money.from_subunits(100, 'USD', format: :stripe)).to eq(Money.new(1.00, 'USD'))
1211
+ end
1212
+
1213
+ it 'raises error for unknown format' do
1214
+ expect {
1215
+ Money.from_subunits(100, 'USD', format: :unknown)
1216
+ }.to raise_error(ArgumentError, /unknown format/)
1217
+ end
1218
+ end
1219
+
1220
+ describe 'subunits' do
1221
+ it 'converts money to subunits using ISO4217 format' do
1222
+ expect(Money.new(1.00, 'USD').subunits).to eq(100)
1223
+ expect(Money.new(10, 'JPY').subunits).to eq(10)
1224
+ end
1225
+
1226
+ it 'converts money to subunits using custom format' do
1227
+ expect(Money.new(1.00, 'USD').subunits(format: :iso4217)).to eq(100)
1228
+ expect(Money.new(1.00, 'USD').subunits(format: :stripe)).to eq(100)
1229
+ end
1230
+
1231
+ it 'raises error for unknown format' do
1232
+ expect {
1233
+ Money.new(1.00, 'USD').subunits(format: :unknown)
1234
+ }.to raise_error(ArgumentError, /unknown format/)
1235
+ end
1236
+ end
1162
1237
  end
data/spec/spec_helper.rb CHANGED
@@ -54,6 +54,8 @@ RSpec.configure do |config|
54
54
  DatabaseCleaner.cleaning do
55
55
  example.run
56
56
  end
57
+ ensure
58
+ Money::Config.reset_current
57
59
  end
58
60
  end
59
61
 
@@ -73,20 +75,18 @@ end
73
75
 
74
76
 
75
77
  def configure(default_currency: nil, legacy_json_format: nil, legacy_deprecations: nil, legacy_default_currency: nil, experimental_crypto_currencies: nil)
76
- old_currencies = Money::Currency.class_variable_get(:@@loaded_currencies) rescue {}
77
- Money::Currency.class_variable_set(:@@loaded_currencies, {})
78
- old_config = Money.config
79
- Money.config = Money::Config.new.tap do |config|
78
+ Money::Config.current = Money::Config.new.tap do |config|
80
79
  config.default_currency = default_currency if default_currency
81
80
  config.legacy_json_format! if legacy_json_format
82
81
  config.legacy_deprecations! if legacy_deprecations
83
82
  config.legacy_default_currency! if legacy_default_currency
84
83
  config.experimental_crypto_currencies! if experimental_crypto_currencies
85
84
  end
85
+ Money::Currency.reset_loaded_currencies if experimental_crypto_currencies == false
86
+
86
87
  yield
87
88
  ensure
88
- Money::Currency.class_variable_set(:@@loaded_currencies, old_currencies)
89
- Money.config = old_config
89
+ Money::Config.reset_current
90
90
  end
91
91
 
92
92
  def yaml_load(yaml)
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.1.2
4
+ version: 3.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify Inc
@@ -9,6 +9,20 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bigdecimal
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '3.0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: bundler
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -137,6 +151,11 @@ files:
137
151
  - lib/money.rb
138
152
  - lib/money/allocator.rb
139
153
  - lib/money/config.rb
154
+ - lib/money/converters/converter.rb
155
+ - lib/money/converters/factory.rb
156
+ - lib/money/converters/iso4217_converter.rb
157
+ - lib/money/converters/legacy_dollars_converter.rb
158
+ - lib/money/converters/stripe_converter.rb
140
159
  - lib/money/core_extensions.rb
141
160
  - lib/money/currency.rb
142
161
  - lib/money/currency/loader.rb
@@ -164,6 +183,7 @@ files:
164
183
  - money.gemspec
165
184
  - spec/allocator_spec.rb
166
185
  - spec/config_spec.rb
186
+ - spec/converters_spec.rb
167
187
  - spec/core_extensions_spec.rb
168
188
  - spec/currency/loader_spec.rb
169
189
  - spec/currency_spec.rb
@@ -203,7 +223,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
203
223
  - !ruby/object:Gem::Version
204
224
  version: '0'
205
225
  requirements: []
206
- rubygems_version: 3.6.8
226
+ rubygems_version: 3.6.9
207
227
  specification_version: 4
208
228
  summary: Shopify's money gem
209
229
  test_files: []