minting-rails 0.8.1 → 0.8.3

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: dfed7ac7c21d15b7b983eac0bd391ec8f4eb4f1f89b1cb9ae0308701dd15ba76
4
- data.tar.gz: da2eb9d34f56f26a71674a45a792b922adb57d0ff9d6a5098eb96d2e3cf5394a
3
+ metadata.gz: 7f128e6d9ea67e7b1f082a06c27bc7f9a332094bdfefcdf5c63ed101eafbb59b
4
+ data.tar.gz: a8d8a159f47e1a9fa906777072bcb9872db56367a5b2479b786968aaa43804fe
5
5
  SHA512:
6
- metadata.gz: bca70a3dc33e70b65635e1dddf9a435ee718b5d6e06e26262fdc53b8d7a9c7e3b7534b88e4ee064cdc6015c2c2772d7941a9482787e96d84397c8a74ec8cd51e
7
- data.tar.gz: 73ef1dcbedaa6cbbf832d37b450c08af1ebf41c12d47a76eb1a884a571ba869c1fbebb89be24a0422a855e53e245c53cd64811466b7a73ef0fa7de5878aa0791
6
+ metadata.gz: f1076b8630bc62f73a37c3b1f72121e5aee2afce3ebba8b6964dfe84f0cde55961081ea84b48e2514b2230c9ebbacd9a939aca796037401e667e7a4bb8849fde
7
+ data.tar.gz: a6346b842fb7c1337b13ffecf18967f72d581125ecdc9999e82dd358b4021d5ac2824ad4c1fdc8ae43085065343523b75c81796da6f6a5a03a60e536f5620e6e
data/README.md CHANGED
@@ -86,6 +86,51 @@ end
86
86
 
87
87
  See the [Minting gem](https://github.com/gferraz/minting) for full configuration options (custom currencies, formatting, rounding).
88
88
 
89
+ ### I18n / Locale-aware formatting
90
+
91
+ Minting-rails integrates with Rails I18n to automatically format money amounts according to the current locale.
92
+
93
+ With `I18n.locale` set to `:en`:
94
+ ```ruby
95
+ Mint.money(1234.56, 'USD').to_s # => "$1,234.56"
96
+ ```
97
+
98
+ Switch to `:'pt-BR'` and the separators change automatically (requires [`rails-i18n`](https://github.com/svenfuchs/rails-i18n) or your own locale file):
99
+ ```ruby
100
+ I18n.locale = :'pt-BR'
101
+ Mint.money(1234.56, 'USD').to_s # => "$1.234,56"
102
+ ```
103
+
104
+ The locale backend reads `number.currency.format` from your I18n translations and maps Rails format syntax (`%n` for amount, `%u` for unit) to `Mint::Money#to_s`. If the translation key is missing (no locale file for that language), it falls back to hardcoded defaults (`.` decimal, `,` thousand, `%<symbol>s%<amount>f` format).
105
+
106
+ You can configure per-sign formatting by adding `positive`, `negative`, and `zero` keys to your locale:
107
+
108
+ ```yaml
109
+ # config/locales/minting-rails.en.yml
110
+ en:
111
+ number:
112
+ currency:
113
+ format:
114
+ format: "%u%n" # fallback when no per-sign key matches
115
+ positive: "%u%n" # "$1,234.56"
116
+ negative: "(%u%n)" # "($1,234.56)"
117
+ zero: "--" # "--"
118
+ separator: "."
119
+ delimiter: ","
120
+ ```
121
+
122
+ When any of `positive`, `negative`, or `zero` is present, a Hash format is built. Missing keys fall back to `format`:
123
+
124
+ ```ruby
125
+ Mint.money(1234.56, 'USD').to_s # => "$1,234.56"
126
+ Mint.money(-1234.56, 'USD').to_s # => "($1,234.56)"
127
+ Mint.money(0, 'USD').to_s # => "--"
128
+ ```
129
+
130
+ If none of those keys are set, `format` is used as a plain string (simple formatting).
131
+
132
+ > Formatting respects the currency's own `subunit` for decimal precision — `I18n` locale settings for `precision` are ignored since that is a currency property, not a locale one.
133
+
89
134
  ## Usage — Two modes
90
135
 
91
136
  ### Decision table
@@ -224,6 +269,46 @@ end
224
269
 
225
270
  The mapping keys are `:amount` and `:currency`; values are your database column names.
226
271
 
272
+ ## Column resolution
273
+
274
+ When you declare `money_attribute :name`, the gem resolves which database columns to use by checking the table schema in this order:
275
+
276
+ | Step | Condition | Columns used | Mode |
277
+ |---|---|---|---|
278
+ | 1 | `mapping:` provided | As specified | Explicit composite |
279
+ | 2 | `name_currency` column exists | `name` + `name_currency` | Composite (multi-currency) |
280
+ | 3 | `name == 'amount'` AND `currency` column exists | `amount` + `currency` | Composite (multi-currency) |
281
+ | 4 | `name_amount` + `name_currency` columns exist | `name_amount` + `name_currency` | Composite (multi-currency) |
282
+ | 5 | `name` column exists (no currency partner) | `name` alone | Single-column (fixed-currency) |
283
+
284
+ **Example**
285
+
286
+ ```ruby
287
+ create_table :financial_transactions do |t|
288
+ t.integer :amount
289
+ t.string :currency, limit: 3
290
+ t.integer :discount
291
+ t.string :discount_currency, limit: 3
292
+ t.decimal :price_amount
293
+ t.string :price_currency, limit: 3
294
+ t.bigint :surplus
295
+ t.bigint :tax
296
+ t.decimal :total_amount
297
+ t.string :currency_code, limit: 3
298
+ end
299
+ ```
300
+
301
+ ```ruby
302
+ class FinancialTransaction < ApplicationRecord
303
+ money_attribute :amount # step 3: amount(int) + currency
304
+ money_attribute :discount # step 2: discount(int) + discount_currency
305
+ money_attribute :price # step 4: price_amount(dec) + price_currency
306
+ money_attribute :surplus, currency: 'EUR' # step 5: surplus(int) (single-column, will use EUR)
307
+ money_attribute :tax # step 5: tax(int) (single-column, will use default currency)
308
+ money_attribute :total, mapping: { amount: :total_amount, currency: :currency_code } # step 1: explicit
309
+ end
310
+ ```
311
+
227
312
  ## Querying
228
313
 
229
314
  Fixed-currency attributes support Rails-native querying through the custom type:
@@ -411,7 +496,7 @@ Minting-rails is intentionally minimal — it focuses on storing and reading mon
411
496
  | **Mongoid support** | Yes | ActiveRecord only |
412
497
  | **Migration helpers** | `add_monetize :products, :price` | None |
413
498
  | **View helpers** | `humanized_money`, `money_without_cents`, etc. | None |
414
- | **I18n / locale files** | Built-in locale-aware formatting | None |
499
+ | **I18n / locale files** | Locale-aware formatting via I18n `number.currency.format` — reads your existing translations, no extra setup | Built-in locale-aware formatting with bundled translations |
415
500
  | **Test matcher** | `monetize(:price_cents)` RSpec matcher | None |
416
501
  | **Currency exchange** | `default_bank`, `add_rate`, EuCentralBank | None |
417
502
  | **Custom currencies** | `register_currency` for non-ISO codes | Via `minting` gem config |
@@ -429,7 +514,6 @@ If you need any of these features today, money-rails may be a better fit. mintin
429
514
  1. **Allow nil** — `money_attribute :price, currency: 'USD', allow_nil: true`
430
515
  1. **Method-level currency** — lambda-based currency resolution for multi-tenant and instance-level scenarios
431
516
  1. **Migration helper**
432
- 1. **Internationalization**
433
517
 
434
518
  Contributions and suggestions are welcome — open an issue or PR at [gferraz/minting-rails](https://github.com/gferraz/minting-rails).
435
519
 
@@ -10,8 +10,8 @@ Mint.configure do |config|
10
10
  # {currency: 'NGN', subunit: 3, symbol: '₦'}
11
11
  # ]
12
12
  config.added_currencies = [
13
- { currency: 'CRC', subunit: 2, symbol: '₡' },
14
- { currency: 'NGN', subunit: 3, symbol: '₦' }
13
+ { currency: 'XCRC', subunit: 2, symbol: '₡' },
14
+ { currency: 'XNGN', subunit: 3, symbol: '₦' }
15
15
  ]
16
16
 
17
17
  # To set the default currency
@@ -1,65 +1,91 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mint
4
- # MoneyAttribute
5
4
  module MoneyAttribute
6
5
  extend ActiveSupport::Concern
7
6
 
8
7
  class_methods do
9
- # Money attribute
10
8
  def money_attribute(name, currency: Mint.default_currency, mapping: nil)
9
+ columns = attribute_names
11
10
  currency = Currency.resolve!(currency)
11
+ name = name.to_s
12
12
  parser = Parser.new(currency)
13
- if attribute_names.include? name.to_s
14
- attribute(name, :mint_money, currency:)
15
- normalizes(name, with: parser)
13
+ resolved_mapping = mapping || resolve_mapping(name, columns)
14
+
15
+ if columns.include?(name) && resolved_mapping.nil?
16
+ define_single_column_money_attribute(name, currency, parser)
16
17
  else
17
- aggregated = find_money_attributes(name, mapping:)
18
- options = {
19
- allow_nil: true, class_name: 'Mint::Money',
20
- constructor: parser, converter: parser,
21
- mapping: {
22
- aggregated[:amount] => amount_extractor_for(aggregated[:amount]),
23
- aggregated[:currency] => :currency_code
24
- }
25
- }
26
- composed_of(name, options)
18
+ define_composite_money_attribute(name, resolved_mapping, parser)
27
19
  end
28
20
  end
29
21
 
30
- def amount_extractor_for(column_name)
31
- col = columns.find { |c| c.name == column_name.to_s }
22
+ private
32
23
 
33
- case col&.type
34
- when :bigint, :integer
35
- :fractional
36
- else
37
- :to_d # :decimal, :numeric, unknown
24
+ # --- Preparation (no side effects) ---
25
+
26
+ def resolve_mapping(name, columns)
27
+ return nil unless columns.include?(name)
28
+
29
+ if columns.include?("#{name}_currency")
30
+ { amount: name, currency: :"#{name}_currency" }
31
+ elsif columns.include?('currency') && name == 'amount'
32
+ { amount: name, currency: :currency }
38
33
  end
39
34
  end
40
35
 
41
- def find_money_attributes(name, mapping:)
42
- if mapping.present?
43
- missing_keys = (%i[amount currency] - mapping.keys).join(', ')
44
- if missing_keys.present?
45
- raise ArgumentError,
46
- "Mapping for :#{name} money attribute is missing required keys: #{missing_keys}"
47
- end
48
- composite = { amount: mapping[:amount].to_s, currency: mapping[:currency].to_s }
49
- else
50
- composite = { amount: "#{name}_amount", currency: "#{name}_currency" }
51
- end
36
+ def resolve_composite_for(name, mapping:)
37
+ composite = { amount: "#{name}_amount", currency: "#{name}_currency" }
52
38
 
53
- missing = composite.values - attribute_names
54
- if missing.any?
55
- raise ArgumentError,
56
- "Could not find columns for :#{name} money attribute. " \
57
- "Expected: #{composite.values.join(', ')}, " \
58
- "Found: #{attribute_names.join(', ')}"
59
- end
39
+ composite[:amount] = mapping[:amount].to_s if mapping&.key?(:amount)
40
+ composite[:currency] = mapping[:currency].to_s if mapping&.key?(:currency)
60
41
 
42
+ assert_columns_exist!(name, composite)
61
43
  composite
62
44
  end
45
+
46
+ def assert_columns_exist!(name, composite)
47
+ missing = composite.values - attribute_names
48
+ return if missing.empty?
49
+
50
+ raise ArgumentError,
51
+ "Could not find columns for :#{name} money attribute. " \
52
+ "Expected: #{composite.values.join(', ')}, " \
53
+ "Found: #{attribute_names.join(', ')}"
54
+ end
55
+
56
+ def amount_extractor_for(column_name)
57
+ integer_column?(column_name) ? :fractional : :to_d
58
+ end
59
+
60
+ def money_constructor_for(amount_column) = integer_column?(amount_column) ? :from_fractional : :from
61
+
62
+ def integer_column?(column_name)
63
+ col = columns.find { |c| c.name == column_name }
64
+ %i[integer bigint].include?(col&.type)
65
+ end
66
+
67
+ # --- Configuration (registers types, normalizers, composed_of) ---
68
+
69
+ def define_single_column_money_attribute(name, currency, parser)
70
+ column_type = integer_column?(name) ? ActiveRecord::Type::Integer.new : ActiveRecord::Type::Decimal.new
71
+ attribute(name.to_sym, :mint_money, currency:, column_type: column_type)
72
+ normalizes(name.to_sym, with: parser)
73
+ end
74
+
75
+ def define_composite_money_attribute(name, mapping, parser)
76
+ aggregated = resolve_composite_for(name, mapping:)
77
+
78
+ composed_of(name.to_sym, {
79
+ allow_nil: true,
80
+ class_name: 'Mint::Money',
81
+ constructor: money_constructor_for(aggregated[:amount]),
82
+ converter: parser,
83
+ mapping: {
84
+ aggregated[:amount] => amount_extractor_for(aggregated[:amount]),
85
+ aggregated[:currency] => :currency_code
86
+ }
87
+ })
88
+ end
63
89
  end
64
90
  end
65
91
  end
@@ -26,9 +26,9 @@ module Mint
26
26
  return nil unless value
27
27
 
28
28
  if @column_type.is_a?(ActiveRecord::Type::Integer)
29
- Mint.money(value * @currency.fractional_multiplier, @currency)
29
+ Mint::Money.from_fractional(value, @currency)
30
30
  else
31
- Mint.money(value, @currency)
31
+ Mint::Money.from(value, @currency)
32
32
  end
33
33
  end
34
34
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Mint
4
4
  module MoneyAttribute
5
- VERSION = '0.8.1'
5
+ VERSION = '0.8.3'
6
6
  end
7
7
  end
@@ -7,15 +7,41 @@ module Mint
7
7
  end
8
8
 
9
9
  config.after_initialize do
10
+ setup_locale_backend!
11
+ register_custom_currencies!
12
+ end
13
+
14
+ def self.setup_locale_backend!
15
+ Mint.locale_backend = lambda {
16
+ fmt = I18n.t('number.currency.format', default: {})
17
+ translator = ->(s) { s&.gsub('%n', '%<amount>f')&.gsub('%u', '%<symbol>s') }
18
+
19
+ format = if fmt.key?(:positive) || fmt.key?(:negative) || fmt.key?(:zero)
20
+ {
21
+ positive: translator.call(fmt[:positive] || fmt[:format]),
22
+ negative: translator.call(fmt[:negative] || fmt[:format]),
23
+ zero: translator.call(fmt[:zero] || fmt[:format])
24
+ }
25
+ else
26
+ translator.call(fmt[:format])
27
+ end
28
+
29
+ { decimal: fmt[:separator], thousand: fmt[:delimiter], format: format }
30
+ }
31
+ end
32
+
33
+ def self.register_custom_currencies!
10
34
  Array(Mint.config.added_currencies).each do |currency_data|
11
35
  if currency_data.respond_to?(:values_at)
12
- code = currency_data[:currency] || currency_data['currency']
13
- subunit = currency_data[:subunit] || currency_data['subunit']
14
- symbol = currency_data[:symbol] || currency_data['symbol']
36
+ code = currency_data[:currency]
37
+ subunit = currency_data[:subunit]
38
+ symbol = currency_data[:symbol]
15
39
  else
16
40
  code, subunit, symbol = *currency_data
17
41
  end
18
42
  Currency.register(code:, subunit:, symbol:)
43
+ rescue KeyError
44
+ nil
19
45
  end
20
46
  end
21
47
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: minting-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.8.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gilson Ferraz