money_attribute 0.10.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8058c06f7d8fd8f12abbe12c979efc9802cd56dcd788bf4f83e100838275d0fa
4
+ data.tar.gz: '09ade456fbca1582b9f600858177bf20f049d24d35f8539967fce3989d445534'
5
+ SHA512:
6
+ metadata.gz: 1623898044fb45adb289c256683e7843365b078ecb840d5bc7e178995f7a9580aa6df92baa65a5ead5d45cd00ae8aa496062f9579ece5e9f960b9b64a3814fda
7
+ data.tar.gz: 48a8712ec075f95c446265c14813067f4c3ff7dd8f76bad2dba5887685235481ac95a57c0c24542ec913a0b3b4153eb9ed7187ca1cfcad74d73ec2cfbbf190ea
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Gilson Ferraz
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,381 @@
1
+ # MoneyAttribute
2
+
3
+ [![CI](https://github.com/gferraz/money-attribute/actions/workflows/ci.yml/badge.svg)](https://github.com/gferraz/money-attribute/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/money_attribute.svg)](https://badge.fury.io/rb/money_attribute)
5
+
6
+ Store and read Active Record attributes as `Mint::Money` objects with a single `money_attribute` declaration. No manual serialization, no boilerplate.
7
+
8
+ ```ruby
9
+ class Product < ApplicationRecord
10
+ money_attribute :price, currency: 'USD' # fixed currency, single column
11
+ money_attribute :total # multi-currency, two columns
12
+ end
13
+
14
+ Product.new(price: 12).price # => [USD 12.00]
15
+ ```
16
+
17
+ ## Quick start
18
+
19
+ ```sh
20
+ bundle add money_attribute
21
+ bin/rails g money_attribute:initializer
22
+ ```
23
+
24
+ ```ruby
25
+ # db/migrate/20260620000000_create_products.rb
26
+ class CreateProducts < ActiveRecord::Migration[8.1]
27
+ def change
28
+ create_table :products do |t|
29
+ t.string :name
30
+ t.money :price
31
+ t.timestamps
32
+ end
33
+ end
34
+ end
35
+ ```
36
+
37
+ ```ruby
38
+ # app/models/product.rb
39
+ class Product < ApplicationRecord
40
+ money_attribute :price, currency: 'USD'
41
+ end
42
+ ```
43
+
44
+ That's it. `Product.new(price: 12).price` is a `Mint::Money`.
45
+
46
+ ## Why MoneyAttribute?
47
+
48
+ - **No serialization boilerplate** — declare once, read/write `Mint::Money` everywhere.
49
+ - **Two storage modes** — single column for fixed-currency apps (simpler), amount+currency columns for multi-currency records (more flexible).
50
+ - **Integer or decimal columns** — auto-detects the column type and adjusts serialization (e.g. integer stores cents, decimal stores unit value).
51
+ - **Normalizes everything** — pass a number, string, or `Mint::Money`; always get a `Mint::Money` back.
52
+ - **Currency enforcement** — fixed-currency attributes reject wrong currencies at assignment time.
53
+ - **Built on Rails primitives** — uses `ActiveRecord::Type`, `composed_of`, and `normalizes` under the hood. No monkey-patching of core classes.
54
+
55
+ ### At a glance — vs money-rails
56
+
57
+ | Feature | MoneyAttribute | money-rails |
58
+ |---|---|---|
59
+ | **Declaration** | `money_attribute :price` | `monetize :price_cents` |
60
+ | **Column types** | `integer`, `decimal`, `bigint` — auto-detected | `integer` cents only |
61
+ | **Storage modes** | Single column, composite (amount+currency)| Single cents column, composite (cents+currency) |
62
+ | **Decimal columns** | Native — `t.decimal :price` | Not supported — must convert to cents manually |
63
+ | **Multi-currency** | `money_attribute :price` (convention: `<name>_amount` + `<name>_currency`) | `monetize :price_cents, with_currency: :price_currency` |
64
+ | **Rails integration** | `ActiveRecord::Type` + `composed_of` — no monkey-patches | `monetize` overrides reader/writer methods |
65
+ | **Query (fixed)** | `Model.where(price: money)` — `=`, `IN`, `BETWEEN`, `ORDER`, `SUM` | Through cents column (`price_cents`) |
66
+ | **Query (multi)** | `Model.where(price: money)` | `Model.where(price_cents:, price_currency:)` |
67
+ | **Internal amount** | `Rational` | `BigDecimal` |
68
+ | **Performance** | See [BENCHMARKS.md](BENCHMARKS.md) — wins 9/11 cells | |
69
+
70
+ For a detailed side-by-side comparison, see [COMPARISON.md](COMPARISON.md).
71
+
72
+ ## Requirements
73
+
74
+ - Ruby 3.3+
75
+ - Rails 7.1.3.2+
76
+ - [Minting](https://github.com/gferraz/minting) 1.8.0+
77
+
78
+ ## Installation
79
+
80
+ ```ruby
81
+ # Gemfile
82
+ gem 'money_attribute'
83
+ ```
84
+
85
+ ```sh
86
+ bundle install
87
+ bin/rails g money_attribute:initializer
88
+ ```
89
+
90
+ The generator creates `config/initializers/money_attribute.rb`.
91
+
92
+ ## Migration helpers
93
+
94
+ MoneyAttribute adds `add_money` / `remove_money` for existing tables and `t.money` / `t.remove_money` for `create_table` / `change_table` blocks:
95
+
96
+ ```ruby
97
+ class CreateProducts < ActiveRecord::Migration[8.1]
98
+ def change
99
+ create_table :products do |t|
100
+ t.string :name
101
+ t.money :price # price (decimal) + price_currency (string)
102
+ t.money :price_amount # price_amount + price_currency (strips _amount suffix)
103
+ t.money :fee, currency: false # single column, no currency
104
+ t.money :tax, type: :bigint # bigint amount + currency
105
+ t.timestamps
106
+ end
107
+ end
108
+ end
109
+
110
+ class AddPriceToProducts < ActiveRecord::Migration[8.1]
111
+ def change
112
+ add_money :products, :price # add price + price_currency
113
+ add_money :products, :discount, type: :integer
114
+ remove_money :products, :obsolete_fee # reversible in change
115
+ end
116
+ end
117
+ ```
118
+
119
+ ### Naming
120
+
121
+ | Migration call | Columns created | Model declaration |
122
+ |---|---|---|
123
+ | `t.money :price` | `price` decimal + `price_currency` string | `money_attribute :price` |
124
+ | `t.money :price_amount` | `price_amount` decimal + `price_currency` string | `money_attribute :price` |
125
+ | `t.money :price, currency: false` | `price` decimal | `money_attribute :price` |
126
+ | `t.money :price, type: :integer` | `price` integer + `price_currency` string | `money_attribute :price` |
127
+ | `t.money :price, amount: :a, currency: :c` | `a` + `c` | `money_attribute :price, mapping: { amount: :a, currency: :c }` |
128
+
129
+ ## Configuration
130
+
131
+ ```ruby
132
+ # config/initializers/money_attribute.rb
133
+ MoneyAttribute.configure do |config|
134
+ config.default_currency = 'USD'
135
+ end
136
+ ```
137
+
138
+ See the [Minting gem](https://github.com/gferraz/minting) for full configuration options (custom currencies, formatting, rounding).
139
+
140
+ ### I18n / Locale-aware formatting
141
+
142
+ MoneyAttribute integrates with Rails I18n to automatically format money amounts according to the current locale.
143
+
144
+ With `I18n.locale` set to `:en`:
145
+ ```ruby
146
+ Mint.money(1234.56, 'USD').to_s # => "$1,234.56"
147
+ ```
148
+
149
+ Switch to `:'pt-BR'` and the separators change automatically (requires [`rails-i18n`](https://github.com/svenfuchs/rails-i18n) or your own locale file):
150
+ ```ruby
151
+ I18n.locale = :'pt-BR'
152
+ Mint.money(1234.56, 'USD').to_s # => "$1.234,56"
153
+ ```
154
+
155
+ 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).
156
+
157
+ You can configure per-sign formatting by adding `positive`, `negative`, and `zero` keys to your locale:
158
+
159
+ ```yaml
160
+ # config/locales/money_attribute.en.yml
161
+ en:
162
+ number:
163
+ currency:
164
+ format:
165
+ format: "%u%n" # fallback when no per-sign key matches
166
+ positive: "%u%n" # "$1,234.56"
167
+ negative: "(%u%n)" # "($1,234.56)"
168
+ zero: "--" # "--"
169
+ separator: "."
170
+ delimiter: ","
171
+ ```
172
+
173
+ When any of `positive`, `negative`, or `zero` is present, a Hash format is built. Missing keys fall back to `format`:
174
+
175
+ ```ruby
176
+ Mint.money(1234.56, 'USD').to_s # => "$1,234.56"
177
+ Mint.money(-1234.56, 'USD').to_s # => "($1,234.56)"
178
+ Mint.money(0, 'USD').to_s # => "--"
179
+ ```
180
+
181
+ If none of those keys are set, `format` is used as a plain string (simple formatting).
182
+
183
+ > 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.
184
+
185
+ ## Usage — Two modes
186
+
187
+ ### Decision table
188
+
189
+ | | Fixed currency (single column) | Multi-currency (amount + currency) |
190
+ |---|---|---|
191
+ | **Migration** | `t.money :price` (or `t.decimal :price`) | `t.money :price` (or `t.decimal :price_amount` + `t.string :price_currency`) |
192
+ | **Model** | `money_attribute :price, currency: 'USD'` | `money_attribute :price` |
193
+ | **When to use** | Column always holds the same currency | Each row can hold a different currency |
194
+ | **Column type** | `decimal`, `integer`, or `bigint` | `decimal`, `integer`, or `bigint` for amount; `string` for currency |
195
+ | **Query** | `Product.where(price: 10.to_money('USD'))` — full type support | `Offer.where(price: 10.to_money('EUR'))` — equality only |
196
+
197
+ ### Fixed currency
198
+
199
+ ```ruby
200
+ class Product < ApplicationRecord
201
+ money_attribute :price, currency: 'USD'
202
+ end
203
+
204
+ product = Product.new(price: 12)
205
+ product.price # => [USD 12.00]
206
+
207
+ Product.new(price: 12.to_money('EUR'))
208
+ # => ArgumentError: ... has different currency. Only USD allowed.
209
+ ```
210
+
211
+ ### Multi-currency
212
+
213
+ ```ruby
214
+ class Offer < ApplicationRecord
215
+ money_attribute :price
216
+ end
217
+
218
+ offer = Offer.new(price: 15.to_money('EUR'))
219
+ offer.price # => [EUR 15.00]
220
+ offer.price_amount # => 15.0
221
+ offer.price_currency # => "EUR"
222
+
223
+ offer = Offer.new(price: '12')
224
+ offer.price.currency.code # => "USD"
225
+ ```
226
+
227
+ ## Column type detection
228
+
229
+ Declare the column as `decimal`, `integer`, or `bigint` — the gem adapts:
230
+
231
+ ```ruby
232
+ # Migration
233
+ create_table :orders do |t|
234
+ t.bigint :total_amount # stored as cents (subunits)
235
+ t.string :total_currency
236
+ end
237
+
238
+ # Model
239
+ class Order < ApplicationRecord
240
+ money_attribute :total
241
+ end
242
+
243
+ Order.new(total: 19.99.to_money('USD')).total_amount # => 1999
244
+ ```
245
+
246
+ Same for fixed-currency attributes:
247
+
248
+ ```ruby
249
+ # Migration
250
+ t.bigint :price
251
+
252
+ # Model (no change needed)
253
+ money_attribute :price, currency: 'USD'
254
+ ```
255
+
256
+ > Use `integer`/`bigint` for large tables (faster, smaller). Use `decimal` when SQL-level readability matters.
257
+
258
+ ## Custom column names
259
+
260
+ If your columns don't follow the `<name>_amount` / `<name>_currency` convention:
261
+
262
+ ```ruby
263
+ class Invoice < ApplicationRecord
264
+ money_attribute :total, mapping: {
265
+ amount: :total_amount,
266
+ currency: :currency_code
267
+ }
268
+ end
269
+ ```
270
+
271
+ The mapping keys are `:amount` and `:currency`; values are your database column names.
272
+
273
+ ## Column resolution
274
+
275
+ When you declare `money_attribute :name`, the gem resolves which database columns to use by checking the table schema in this order:
276
+
277
+ | Step | Condition | Columns used | Mode |
278
+ |---|---|---|---|
279
+ | 1 | `mapping:` provided | As specified | Explicit composite |
280
+ | 2 | `name_currency` column exists | `name` + `name_currency` | Composite (multi-currency) |
281
+ | 3 | `name == 'amount'` AND `currency` column exists | `amount` + `currency` | Composite (multi-currency) |
282
+ | 4 | `name_amount` + `name_currency` columns exist | `name_amount` + `name_currency` | Composite (multi-currency) |
283
+ | 5 | `name` column exists (no currency partner) | `name` alone | Single-column (fixed-currency) |
284
+
285
+ **Example**
286
+
287
+ ```ruby
288
+ create_table :financial_transactions do |t|
289
+ t.integer :amount
290
+ t.string :currency, limit: 3
291
+ t.integer :discount
292
+ t.string :discount_currency, limit: 3
293
+ t.decimal :price_amount
294
+ t.string :price_currency, limit: 3
295
+ t.bigint :surplus
296
+ t.bigint :tax
297
+ t.decimal :total_amount
298
+ t.string :currency_code, limit: 3
299
+ end
300
+ ```
301
+
302
+ ```ruby
303
+ class FinancialTransaction < ApplicationRecord
304
+ money_attribute :amount # step 3: amount(int) + currency
305
+ money_attribute :discount # step 2: discount(int) + discount_currency
306
+ money_attribute :price # step 4: price_amount(dec) + price_currency
307
+ money_attribute :surplus, currency: 'EUR' # step 5: surplus(int) (single-column, will use EUR)
308
+ money_attribute :tax # step 5: tax(int) (single-column, will use default currency)
309
+ money_attribute :total, mapping: { amount: :total_amount, currency: :currency_code } # step 1: explicit
310
+ end
311
+ ```
312
+
313
+ ## Querying
314
+
315
+ Fixed-currency attributes support Rails-native querying through the custom type:
316
+
317
+ ```ruby
318
+ # Equality
319
+ Product.where(price: 10.to_money('USD'))
320
+
321
+ # IN clause
322
+ Product.where(price: [10.to_money('USD'), 20.to_money('USD')])
323
+
324
+ # BETWEEN
325
+ Product.where(price: 10.to_money('USD')..20.to_money('USD'))
326
+
327
+ # Ordering
328
+ Product.order(price: :desc)
329
+
330
+ # Aggregation
331
+ Product.where(price: 10.to_money('USD')).sum(:price)
332
+ ```
333
+
334
+ Multi-currency attributes support equality queries via `composed_of`:
335
+
336
+ ```ruby
337
+ Offer.where(price: 10.to_money('EUR'))
338
+ ```
339
+
340
+ For comparisons on multi-currency attributes, use the backing columns directly:
341
+
342
+ ```ruby
343
+ Offer.where(price_amount: 10..20, price_currency: 'EUR')
344
+ Offer.where('price_amount > ? AND price_currency = ?', 10, 'EUR')
345
+ ```
346
+
347
+ ## Convenience methods
348
+
349
+ MoneyAttribute adds small helpers on `Numeric` and `String`:
350
+
351
+ ```ruby
352
+ 12.to_money('USD') # => [USD 12.00]
353
+ 12.dollars # => [USD 12.00]
354
+ 12.euros # => [EUR 12.00]
355
+ ```
356
+
357
+ > If you prefer not to extend core classes, use `Mint.money(12, 'USD')` instead.
358
+
359
+ ## Roadmap
360
+
361
+ 1. **Method-level currency** — lambda-based currency resolution for multi-tenant and instance-level scenarios
362
+ 2. Prepare to official 1.0 launh
363
+
364
+ Contributions and suggestions are welcome — open an issue or PR at [gferraz/money-attribute](https://github.com/gferraz/money-attribute).
365
+
366
+ ## Development
367
+
368
+ ```sh
369
+ bundle install
370
+ bundle exec rake test
371
+ ```
372
+
373
+ The dummy Rails app under `test/dummy` exercises the engine in a full Rails environment.
374
+
375
+ ## Contributing
376
+
377
+ Bug reports and pull requests welcome at [gferraz/money-attribute](https://github.com/gferraz/money-attribute).
378
+
379
+ ## License
380
+
381
+ [MIT](MIT-LICENSE)
data/Rakefile ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'bundler/gem_tasks'
5
+ require 'rake/testtask'
6
+
7
+ task default: :test_run
8
+
9
+ desc 'Run tests'
10
+ Rake::TestTask.new(:test_run) do |t|
11
+ t.libs << 'test'
12
+ t.libs << 'lib'
13
+ t.test_files = FileList['test/**/*_test.rb']
14
+ t.ruby_opts << '-rtest_helper.rb'
15
+ end
16
+
17
+ desc 'Migrate test database'
18
+ task :test_db_migrate do
19
+ Dir.chdir('test/dummy') do
20
+ sh({ 'RAILS_ENV' => 'test' }, 'bin/rails', 'db:migrate')
21
+ end
22
+ end
23
+
24
+ desc 'Run tests (migrates test DB first)'
25
+ task test: %i[test_db_migrate test_run]
26
+
27
+ desc 'Run money_attribute vs money-rails benchmark'
28
+ task :bench do
29
+ sh({ 'RAILS_ENV' => 'test' }, 'bundle', 'exec', 'ruby', 'benchmark/comparison.rb')
30
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
4
+ module Generators
5
+ class InitializerGenerator < ::Rails::Generators::Base
6
+ source_root File.expand_path('../templates', __dir__)
7
+
8
+ desc 'Creates MoneyAttribute initializer.'
9
+
10
+ def copy_initializer
11
+ copy_file 'money_attribute.rb', 'config/initializers/money_attribute.rb'
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,22 @@
1
+ # encoding : utf-8
2
+ # frozen_string_literal: true
3
+
4
+ MoneyAttribute.configure do |config|
5
+ # Register a custom currency
6
+ #
7
+ # Example:
8
+ # config.added_currencies = [
9
+ # {currency: 'CRC', subunit: 2, symbol: '₡'},
10
+ # {currency: 'NGN', subunit: 3, symbol: '₦'}
11
+ # ]
12
+ config.added_currencies = [
13
+ { currency: 'XCRC', subunit: 2, symbol: '₡' },
14
+ { currency: 'XNGN', subunit: 3, symbol: '₦' }
15
+ ]
16
+
17
+ # To set the default currency
18
+ #
19
+ # It must be a registered currency
20
+ #
21
+ config.default_currency = 'BRL'
22
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
4
+ class Configuration
5
+ attr_accessor :added_currencies, :default_currency
6
+
7
+ def initialize
8
+ @added_currencies = []
9
+ @default_currency = 'USD'
10
+ end
11
+ end
12
+
13
+ def self.config
14
+ @config ||= Configuration.new
15
+ end
16
+
17
+ def self.configure
18
+ yield config if block_given?
19
+ @default_currency = nil
20
+ config
21
+ end
22
+
23
+ def self.default_currency
24
+ @default_currency ||= ::Mint::Currency.resolve!(config.default_currency)
25
+ end
26
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :nodoc
4
+ class Numeric
5
+ def to_money(currency = MoneyAttribute.default_currency)
6
+ ::Mint.money(self, currency)
7
+ end
8
+
9
+ def dollars
10
+ ::Mint.money(self, 'USD')
11
+ end
12
+
13
+ def euros
14
+ ::Mint.money(self, 'EUR')
15
+ end
16
+
17
+ alias dollar dollars
18
+ alias euro euros
19
+ end
20
+
21
+ # :nodoc
22
+ class String
23
+ def to_money(currency = MoneyAttribute.default_currency)
24
+ ::Mint.money(to_r, currency)
25
+ end
26
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
4
+ module Macro
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ def money_attribute(name, currency: MoneyAttribute.default_currency, mapping: nil)
9
+ columns = attribute_names
10
+ currency = ::Mint::Currency.resolve!(currency)
11
+ name = name.to_s
12
+ parser = Parser.new(currency)
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)
17
+ else
18
+ define_composite_money_attribute(name, resolved_mapping, parser)
19
+ end
20
+ end
21
+
22
+ private
23
+
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 }
33
+ end
34
+ end
35
+
36
+ def resolve_composite_for(name, mapping:)
37
+ composite = { amount: "#{name}_amount", currency: "#{name}_currency" }
38
+
39
+ composite[:amount] = mapping[:amount].to_s if mapping&.key?(:amount)
40
+ composite[:currency] = mapping[:currency].to_s if mapping&.key?(:currency)
41
+
42
+ assert_columns_exist!(name, composite)
43
+ composite
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, :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
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
4
+ module MigrationExtensions
5
+ module Helper
6
+ private
7
+
8
+ def parse_money_args(accessor, options = {})
9
+ name = accessor.to_s
10
+
11
+ amount_col = options.key?(:amount) ? options[:amount].to_s : name
12
+
13
+ if options.key?(:currency)
14
+ currency_col = if options[:currency] == false
15
+ nil
16
+ else
17
+ options[:currency].to_s
18
+ end
19
+ else
20
+ stripped = name.end_with?('_amount') ? name.sub(/_amount$/, '') : name
21
+ currency_col = "#{stripped}_currency"
22
+ end
23
+
24
+ col_type = options[:type] || :decimal
25
+
26
+ amount_opts = {}
27
+ amount_opts[:type] = col_type
28
+
29
+ if options.key?(:amount) && options[:amount].is_a?(Hash)
30
+ amount_opts[:null] = options[:amount][:null] if options[:amount].key?(:null)
31
+ amount_opts[:default] = options[:amount][:default] if options[:amount].key?(:default)
32
+ end
33
+
34
+ currency_opts = {}
35
+ currency_opts[:limit] = options[:currency_limit] if options[:currency_limit]
36
+
37
+ if options[:currency].is_a?(Hash)
38
+ currency_opts[:null] = options[:currency][:null] if options[:currency].key?(:null)
39
+ currency_opts[:default] = options[:currency][:default] if options[:currency].key?(:default)
40
+ end
41
+
42
+ [amount_col, currency_col, amount_opts, currency_opts]
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+
5
+ module MoneyAttribute
6
+ module MigrationExtensions
7
+ module SchemaStatements
8
+ include Helper
9
+
10
+ def add_money(table_name, accessor, options = {})
11
+ amount_col, currency_col, amount_opts, currency_opts = parse_money_args(accessor, options)
12
+
13
+ add_column(table_name, amount_col, amount_opts[:type], **amount_opts.except(:type))
14
+ add_column(table_name, currency_col, :string, **currency_opts) if currency_col
15
+ end
16
+
17
+ def remove_money(table_name, accessor, options = {})
18
+ amount_col, currency_col, = parse_money_args(accessor, options)
19
+
20
+ remove_column(table_name, amount_col)
21
+ remove_column(table_name, currency_col) if currency_col
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+
5
+ module MoneyAttribute
6
+ module MigrationExtensions
7
+ module TableDefinition
8
+ include Helper
9
+
10
+ def money(accessor, options = {})
11
+ amount_col, currency_col, amount_opts, currency_opts = parse_money_args(accessor, options)
12
+
13
+ column(amount_col, amount_opts[:type], **amount_opts.except(:type))
14
+ column(currency_col, :string, **currency_opts) if currency_col
15
+ end
16
+
17
+ def remove_money(accessor, options = {})
18
+ amount_col, currency_col, = parse_money_args(accessor, options)
19
+
20
+ remove_column(amount_col)
21
+ remove_column(currency_col) if currency_col
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
4
+ class Parser
5
+ def initialize(currency = MoneyAttribute.default_currency)
6
+ @default_currency = currency
7
+ end
8
+
9
+ def parse(amount, currency = @default_currency)
10
+ currency = ::Mint::Currency.resolve!(currency)
11
+ case amount
12
+ when NilClass then nil
13
+ when Numeric then ::Mint::Money.from(amount, currency)
14
+ when String then ::Mint::Money.from(amount.to_r, currency)
15
+ when ::Mint::Money
16
+ return amount if amount.currency == currency
17
+
18
+ raise TypeError, "Cannot automatically convert #{amount} to #{currency.code}"
19
+ else
20
+ ::Mint.parse(amount, currency)
21
+ end
22
+ end
23
+ alias call parse
24
+ end
25
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
4
+ class Railtie < ::Rails::Railtie
5
+ generators do
6
+ require 'generators/money_attribute/initializer_generator'
7
+ end
8
+
9
+ config.after_initialize do
10
+ require 'money_attribute/migration_extensions/schema_statements'
11
+ require 'money_attribute/migration_extensions/table_definition'
12
+
13
+ ActiveRecord::Migration.include(MoneyAttribute::MigrationExtensions::SchemaStatements)
14
+ ActiveRecord::ConnectionAdapters::TableDefinition.include(MoneyAttribute::MigrationExtensions::TableDefinition)
15
+ ActiveRecord::ConnectionAdapters::Table.include(MoneyAttribute::MigrationExtensions::TableDefinition)
16
+
17
+ setup_locale_backend!
18
+ register_custom_currencies!
19
+ end
20
+
21
+ def self.setup_locale_backend!
22
+ ::Mint.locale_backend = lambda {
23
+ fmt = I18n.t('number.currency.format', default: {})
24
+ translator = ->(s) { s&.gsub('%n', '%<amount>f')&.gsub('%u', '%<symbol>s') }
25
+
26
+ format = if fmt.key?(:positive) || fmt.key?(:negative) || fmt.key?(:zero)
27
+ {
28
+ positive: translator.call(fmt[:positive] || fmt[:format]),
29
+ negative: translator.call(fmt[:negative] || fmt[:format]),
30
+ zero: translator.call(fmt[:zero] || fmt[:format])
31
+ }
32
+ else
33
+ translator.call(fmt[:format])
34
+ end
35
+
36
+ { decimal: fmt[:separator], thousand: fmt[:delimiter], format: format }
37
+ }
38
+ end
39
+
40
+ def self.register_custom_currencies!
41
+ Array(MoneyAttribute.config.added_currencies).each do |currency_data|
42
+ if currency_data.respond_to?(:values_at)
43
+ code = currency_data[:currency]
44
+ subunit = currency_data[:subunit]
45
+ symbol = currency_data[:symbol]
46
+ else
47
+ code, subunit, symbol = *currency_data
48
+ end
49
+ ::Mint::Currency.register(code:, subunit:, symbol:)
50
+ rescue KeyError
51
+ nil
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
4
+ # Type
5
+ class Type < ActiveRecord::Type::Value
6
+ def initialize(currency:, column_type: ActiveRecord::Type::Decimal.new)
7
+ @currency = currency
8
+ @column_type = column_type
9
+ super()
10
+ end
11
+
12
+ def assert_valid_value(value)
13
+ case value
14
+ when NilClass, Numeric, String then return
15
+ when ::Mint::Money
16
+ return if value.currency == @currency
17
+
18
+ message = "'#{value.inspect}' has different currency. Only #{@currency.code} allowed."
19
+ else
20
+ message = "'#{value.inspect}' is not a valid type for the attribute."
21
+ end
22
+ raise ArgumentError, message
23
+ end
24
+
25
+ def deserialize(value)
26
+ return nil unless value
27
+
28
+ if @column_type.is_a?(ActiveRecord::Type::Integer)
29
+ ::Mint::Money.from_fractional(value, @currency)
30
+ else
31
+ ::Mint::Money.from(value, @currency)
32
+ end
33
+ end
34
+
35
+ def serialize(value)
36
+ return nil unless value
37
+
38
+ if @column_type.is_a?(ActiveRecord::Type::Integer)
39
+ value.fractional
40
+ else
41
+ value.to_d
42
+ end
43
+ end
44
+
45
+ def self.type
46
+ :money
47
+ end
48
+ end
49
+ end
50
+
51
+ ActiveSupport.on_load(:active_record) do
52
+ include MoneyAttribute::Macro
53
+
54
+ ActiveRecord::Type.register(:money, MoneyAttribute::Type)
55
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
4
+ VERSION = '0.10.0'
5
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minting'
4
+ require 'money_attribute/core_ext'
5
+ require 'money_attribute/configuration'
6
+ require 'money_attribute/macro'
7
+ require 'money_attribute/parser'
8
+ require 'money_attribute/type'
9
+ require 'money_attribute/railtie'
10
+ require 'money_attribute/version'
@@ -0,0 +1 @@
1
+ # frozen_string_literal: true
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: money_attribute
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.10.0
5
+ platform: ruby
6
+ authors:
7
+ - Gilson Ferraz
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minting
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 1.8.1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 1.8.1
26
+ - !ruby/object:Gem::Dependency
27
+ name: rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 7.1.3.2
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 7.1.3.2
40
+ description: ''
41
+ email:
42
+ - gilson@cesar.etc.br
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - MIT-LICENSE
48
+ - README.md
49
+ - Rakefile
50
+ - lib/generators/money_attribute/initializer_generator.rb
51
+ - lib/generators/templates/money_attribute.rb
52
+ - lib/money_attribute.rb
53
+ - lib/money_attribute/configuration.rb
54
+ - lib/money_attribute/core_ext.rb
55
+ - lib/money_attribute/macro.rb
56
+ - lib/money_attribute/migration_extensions/helper.rb
57
+ - lib/money_attribute/migration_extensions/schema_statements.rb
58
+ - lib/money_attribute/migration_extensions/table_definition.rb
59
+ - lib/money_attribute/parser.rb
60
+ - lib/money_attribute/railtie.rb
61
+ - lib/money_attribute/type.rb
62
+ - lib/money_attribute/version.rb
63
+ - lib/tasks/money_attribute.rake
64
+ homepage: https://github.com/gferraz/money-attribute
65
+ licenses:
66
+ - MIT
67
+ metadata:
68
+ allowed_push_host: https://rubygems.org
69
+ homepage_uri: https://github.com/gferraz/money-attribute
70
+ source_code_uri: https://github.com/gferraz/money-attribute
71
+ changelog_uri: https://github.com/gferraz/money-attribute/releases
72
+ bug_tracker_uri: https://github.com/gferraz/money-attribute/issues
73
+ rubygems_mfa_required: 'true'
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '3.3'
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 4.0.10
89
+ specification_version: 4
90
+ summary: Money attributes for ActiveRecord
91
+ test_files: []