money_attribute 0.14.5 → 1.2.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 +4 -4
- data/README.md +257 -105
- data/Rakefile +50 -6
- data/lib/generators/templates/money_attribute.rb +20 -12
- data/lib/money_attribute/attribute_spec.rb +78 -0
- data/lib/money_attribute/attribute_spec_registry.rb +50 -0
- data/lib/money_attribute/configuration.rb +44 -17
- data/lib/money_attribute/converter.rb +18 -6
- data/lib/money_attribute/core_ext/numeric.rb +8 -0
- data/lib/money_attribute/core_ext/string.rb +8 -0
- data/lib/money_attribute/current.rb +13 -0
- data/lib/money_attribute/form_builder_extension.rb +4 -1
- data/lib/money_attribute/macro.rb +50 -73
- data/lib/money_attribute/migration_extensions/helper.rb +52 -45
- data/lib/money_attribute/migration_extensions/schema_statements.rb +16 -3
- data/lib/money_attribute/migration_extensions/table_definition.rb +15 -2
- data/lib/money_attribute/money_amount.rb +33 -0
- data/lib/money_attribute/query/amount_condition.rb +48 -0
- data/lib/money_attribute/query/amount_order.rb +22 -0
- data/lib/money_attribute/query/currency_condition.rb +23 -0
- data/lib/money_attribute/query/helpers.rb +30 -0
- data/lib/money_attribute/query/pick.rb +38 -0
- data/lib/money_attribute/query/pluck.rb +43 -0
- data/lib/money_attribute/query/sum.rb +40 -0
- data/lib/money_attribute/query.rb +120 -0
- data/lib/money_attribute/railtie.rb +32 -16
- data/lib/money_attribute/type.rb +42 -36
- data/lib/money_attribute/version.rb +1 -1
- data/lib/money_attribute.rb +17 -1
- metadata +20 -7
- data/lib/money_attribute/core_ext.rb +0 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cb71d942a1dd57ae30fa3d1d8941efacf402d9346192b99cca4d0eabf69246fb
|
|
4
|
+
data.tar.gz: 70dbb86d5d328e5d42fd09593358445d931261b59212d49f3a8e6a450d275c09
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 93e1d50ccda57e7a535fba457e4551525923358b45ef629b381bb04c900eb1fc7b76c1aa11d18410a0669fb96f610a53df1e76e10dcc58860e1d0cd8e3dd19f6
|
|
7
|
+
data.tar.gz: 55d8b84306bf8988e066d426e525af1d6bfb2b0f202727a9a3a38aa5a52f9461d57edac0d5c003f5996141a5e662439d661109ba6aab975fd420b882ea754a29
|
data/README.md
CHANGED
|
@@ -3,17 +3,38 @@
|
|
|
3
3
|
[](https://github.com/gferraz/money-attribute/actions/workflows/ci.yml)
|
|
4
4
|
[](https://badge.fury.io/rb/money_attribute)
|
|
5
5
|
|
|
6
|
-
Store and read Active Record attributes as `Mint::Money` objects with
|
|
6
|
+
Store and read Active Record attributes as `Mint::Money` objects with no manual serialization.
|
|
7
|
+
|
|
8
|
+
`money_attribute` uses two DB columns (amount + currency) for per-row multi-currency data. A simpler `money_amount` variant is also available for fixed-currency models (see [note](#single-column-mode-money_amount-fixed-currency)).
|
|
7
9
|
|
|
8
10
|
```ruby
|
|
9
11
|
class Product < ApplicationRecord
|
|
10
|
-
money_attribute :price
|
|
11
|
-
money_attribute :total # multi-currency, two columns
|
|
12
|
+
money_attribute :price
|
|
12
13
|
end
|
|
13
14
|
|
|
14
|
-
Product.new(price: 12).price # => [USD 12.00]
|
|
15
|
+
p = Product.new(price: 12.44.dollars).price # => [USD 12.00]
|
|
16
|
+
p.price * 2 # => [USD 24.88]
|
|
15
17
|
```
|
|
16
18
|
|
|
19
|
+
## Table of contents
|
|
20
|
+
|
|
21
|
+
- [Quick start](#quick-start)
|
|
22
|
+
- [Why MoneyAttribute](#why-moneyattribute)
|
|
23
|
+
- [Requirements](#requirements)
|
|
24
|
+
- [Installation](#installation)
|
|
25
|
+
- [Migration helpers](#migration-helpers)
|
|
26
|
+
- [Configuration](#configuration)
|
|
27
|
+
- [Usage](#usage)
|
|
28
|
+
- [Column type detection](#column-type-detection)
|
|
29
|
+
- [Custom column names](#custom-column-names)
|
|
30
|
+
- [Column resolution](#column-resolution)
|
|
31
|
+
- [Querying](#querying)
|
|
32
|
+
- [Convenience methods](#convenience-methods)
|
|
33
|
+
- [Form helpers](#form-helpers)
|
|
34
|
+
- [Roadmap](#roadmap)
|
|
35
|
+
- [Development & Contributing](#development)
|
|
36
|
+
- [License](#license)
|
|
37
|
+
|
|
17
38
|
## Quick start
|
|
18
39
|
|
|
19
40
|
```sh
|
|
@@ -27,7 +48,7 @@ class CreateProducts < ActiveRecord::Migration[8.1]
|
|
|
27
48
|
def change
|
|
28
49
|
create_table :products do |t|
|
|
29
50
|
t.string :name
|
|
30
|
-
t.money_attribute :price
|
|
51
|
+
t.money_attribute :price # price: decimal(20,4), price_currency: string
|
|
31
52
|
t.timestamps
|
|
32
53
|
end
|
|
33
54
|
end
|
|
@@ -37,16 +58,15 @@ end
|
|
|
37
58
|
```ruby
|
|
38
59
|
# app/models/product.rb
|
|
39
60
|
class Product < ApplicationRecord
|
|
40
|
-
money_attribute :price
|
|
61
|
+
money_attribute :price
|
|
41
62
|
end
|
|
42
63
|
```
|
|
43
64
|
|
|
44
|
-
That's it. `Product.new(price: 12).price` is a `Mint::Money`.
|
|
65
|
+
That's it. `Product.new(price: 12.dollars).price` is a `Mint::Money`.
|
|
45
66
|
|
|
46
67
|
## Why MoneyAttribute?
|
|
47
68
|
|
|
48
69
|
- **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
70
|
- **Integer or decimal columns** — auto-detects the column type and adjusts serialization (e.g. integer stores cents, decimal stores unit value).
|
|
51
71
|
- **Normalizes everything** — pass a number, string, or `Mint::Money`; always get a `Mint::Money` back.
|
|
52
72
|
- **Currency enforcement** — fixed-currency attributes reject wrong currencies at assignment time.
|
|
@@ -56,16 +76,16 @@ That's it. `Product.new(price: 12).price` is a `Mint::Money`.
|
|
|
56
76
|
|
|
57
77
|
| Feature | MoneyAttribute | money-rails |
|
|
58
78
|
|---|---|---|
|
|
59
|
-
| **
|
|
79
|
+
| **Declare** | `t.money_attribute :price` / `money_attribute :price` or `t.money_amount :price` / `money_amount :price` | `monetize :price_cents` |
|
|
60
80
|
| **Column types** | `integer`, `decimal`, `bigint` — auto-detected | `integer` cents only |
|
|
61
|
-
| **Storage modes** |
|
|
81
|
+
| **Storage modes** | Composite (amount+currency), single column | Single cents column, composite (cents+currency) |
|
|
62
82
|
| **Decimal columns** | Native — `t.decimal :price` | Not supported — must convert to cents manually |
|
|
63
83
|
| **Multi-currency** | `money_attribute :price` (convention: `<name>_amount` + `<name>_currency`) | `monetize :price_cents, with_currency: :price_currency` |
|
|
64
84
|
| **Rails integration** | `ActiveRecord::Type` + `composed_of` — no monkey-patches | `monetize` overrides reader/writer methods |
|
|
65
85
|
| **Query (fixed)** | `Model.where(price: money)` — `=`, `IN`, `BETWEEN`, `ORDER`, `SUM` | Through cents column (`price_cents`) |
|
|
66
86
|
| **Query (multi)** | `Model.where(price: money)` | `Model.where(price_cents:, price_currency:)` |
|
|
67
|
-
| **Internal amount** | `Rational`
|
|
68
|
-
| **Performance** | See [BENCHMARKS.md](BENCHMARKS.md) — wins 9/11 cells |
|
|
87
|
+
| **Internal amount** | `Rational` | `BigDecimal` |
|
|
88
|
+
| **Performance** | See [BENCHMARKS.md](BENCHMARKS.md) — wins 9/11 cells | — |
|
|
69
89
|
|
|
70
90
|
For a detailed side-by-side comparison, see [COMPARISON.md](COMPARISON.md).
|
|
71
91
|
|
|
@@ -73,7 +93,7 @@ For a detailed side-by-side comparison, see [COMPARISON.md](COMPARISON.md).
|
|
|
73
93
|
|
|
74
94
|
- Ruby 3.3+
|
|
75
95
|
- Rails 7.1.3.2+
|
|
76
|
-
- [Minting](https://github.com/gferraz/minting)
|
|
96
|
+
- [Minting](https://github.com/gferraz/minting) 2.0+
|
|
77
97
|
|
|
78
98
|
## Installation
|
|
79
99
|
|
|
@@ -91,19 +111,32 @@ The generator creates `config/initializers/money_attribute.rb`.
|
|
|
91
111
|
|
|
92
112
|
## Migration helpers
|
|
93
113
|
|
|
94
|
-
|
|
114
|
+
### `money_attribute` (composite — amount + currency)
|
|
115
|
+
|
|
116
|
+
Primary migration helper for multi-currency attributes. Creates two columns — amount and currency.
|
|
117
|
+
|
|
118
|
+
| Method | Action |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `add_money_attribute` / `t.money_attribute` | Amount column + currency column |
|
|
121
|
+
| `remove_money_attribute` / `t.remove_money_attribute` | Drops both columns |
|
|
122
|
+
|
|
123
|
+
Default columns: `decimal(20,4)` for amount + `string(16)` for currency.
|
|
124
|
+
|
|
125
|
+
### Column types
|
|
95
126
|
|
|
96
|
-
|
|
127
|
+
| Amount type | Column type | Precision/Scale | Maximum | Integer digits |
|
|
128
|
+
|---|---|---|---|---|
|
|
129
|
+
| `:crypto_decimal` | `decimal` | `36/18` | ~1 quintillion | 18 |
|
|
130
|
+
| `:fiat_decimal` | `decimal` | `20/4` | ~10 quadrillion | 16 |
|
|
131
|
+
| `:fiat_integer` | `bigint` | — | ~922 trillion | ~15 |
|
|
97
132
|
|
|
98
133
|
```ruby
|
|
99
134
|
class CreateProducts < ActiveRecord::Migration[8.1]
|
|
100
135
|
def change
|
|
101
136
|
create_table :products do |t|
|
|
102
137
|
t.string :name
|
|
103
|
-
t.money_attribute :
|
|
104
|
-
t.money_attribute :
|
|
105
|
-
t.money_attribute :fee, currency: false # single column, no currency
|
|
106
|
-
t.money_attribute :tax, amount: { type: :bigint } # bigint amount + currency
|
|
138
|
+
t.money_attribute :multi # decimal(20,4) + currency
|
|
139
|
+
t.money_attribute :tax, amount: { type: :fiat_integer } # bigint + currency
|
|
107
140
|
t.timestamps
|
|
108
141
|
end
|
|
109
142
|
end
|
|
@@ -111,24 +144,23 @@ end
|
|
|
111
144
|
|
|
112
145
|
class AddPriceToProducts < ActiveRecord::Migration[8.1]
|
|
113
146
|
def change
|
|
114
|
-
add_money_attribute :products, :price
|
|
115
|
-
|
|
116
|
-
remove_money_attribute :products, :obsolete_fee # reversible in change
|
|
147
|
+
add_money_attribute :products, :price # price + price_currency
|
|
148
|
+
remove_money_attribute :products, :obsolete_fee # reversible in change
|
|
117
149
|
end
|
|
118
150
|
end
|
|
119
151
|
```
|
|
120
152
|
|
|
121
153
|
### Naming
|
|
122
154
|
|
|
155
|
+
**`money_attribute` (composite):**
|
|
156
|
+
|
|
123
157
|
| Migration call | Columns created | Model declaration |
|
|
124
158
|
|---|---|---|
|
|
125
|
-
| `t.money_attribute :price` | `price` decimal + `price_currency` string | `money_attribute :price` |
|
|
126
|
-
| `t.money_attribute :price_amount` | `price_amount` decimal + `price_currency` string | `money_attribute :price` |
|
|
127
|
-
| `t.money_attribute :price,
|
|
128
|
-
| `t.money_attribute :price, amount: { type: :integer }` | `price` integer + `price_currency` string | `money_attribute :price` |
|
|
159
|
+
| `t.money_attribute :price` | `price` decimal(20,4) + `price_currency` string(16) | `money_attribute :price` |
|
|
160
|
+
| `t.money_attribute :price_amount` | `price_amount` decimal(20,4) + `price_currency` string(16) | `money_attribute :price` |
|
|
161
|
+
| `t.money_attribute :price, amount: { type: :fiat_integer }` | `price` bigint + `price_currency` string(16) | `money_attribute :price` |
|
|
129
162
|
| `t.money_attribute :price, amount: { column: :a }, currency: { column: :c }` | `a` + `c` | `money_attribute :price, mapping: { amount: :a, currency: :c }` |
|
|
130
|
-
| `t.money_attribute :price, currency: { limit:
|
|
131
|
-
| `t.money_attribute :price, amount: { precision: 14, scale: 2, null: false }, currency: { limit: 3, default: 'USD' }` | `price` decimal(14,2) NOT NULL + `price_currency` string(3) DEFAULT 'USD' | `money_attribute :price` |
|
|
163
|
+
| `t.money_attribute :price, currency: { limit: 5 }` | `price` decimal(20,4) + `price_currency` string(5) | `money_attribute :price` |
|
|
132
164
|
| `t.remove_money_attribute :price` | Removes `price` + `price_currency` | `money_attribute :price` |
|
|
133
165
|
|
|
134
166
|
Inside `change_table`:
|
|
@@ -156,13 +188,13 @@ MoneyAttribute integrates with Rails I18n to automatically format money amounts
|
|
|
156
188
|
|
|
157
189
|
With `I18n.locale` set to `:en`:
|
|
158
190
|
```ruby
|
|
159
|
-
|
|
191
|
+
Money.from(1234.56, 'USD').to_s # => "$1,234.56"
|
|
160
192
|
```
|
|
161
193
|
|
|
162
194
|
Switch to `:'pt-BR'` and the separators change automatically (requires [`rails-i18n`](https://github.com/svenfuchs/rails-i18n) or your own locale file):
|
|
163
195
|
```ruby
|
|
164
196
|
I18n.locale = :'pt-BR'
|
|
165
|
-
|
|
197
|
+
Money.from(1234.56, 'USD').to_s # => "$1.234,56"
|
|
166
198
|
```
|
|
167
199
|
|
|
168
200
|
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).
|
|
@@ -186,42 +218,16 @@ en:
|
|
|
186
218
|
When any of `positive`, `negative`, or `zero` is present, a Hash format is built. Missing keys fall back to `format`:
|
|
187
219
|
|
|
188
220
|
```ruby
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
221
|
+
Money.from(1234.56, 'USD').to_s # => "$1,234.56"
|
|
222
|
+
Money.from(-1234.56, 'USD').to_s # => "($1,234.56)"
|
|
223
|
+
Money.from(0, 'USD').to_s # => "--"
|
|
192
224
|
```
|
|
193
225
|
|
|
194
226
|
If none of those keys are set, `format` is used as a plain string (simple formatting).
|
|
195
227
|
|
|
196
228
|
> 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.
|
|
197
229
|
|
|
198
|
-
## Usage
|
|
199
|
-
|
|
200
|
-
### Decision table
|
|
201
|
-
|
|
202
|
-
| | Fixed currency (single column) | Multi-currency (amount + currency) |
|
|
203
|
-
|---|---|---|
|
|
204
|
-
| **Migration** | `t.money_attribute :price` (or `t.decimal :price`) | `t.money_attribute :price` (or `t.decimal :price_amount` + `t.string :price_currency`) |
|
|
205
|
-
| **Model** | `money_attribute :price, currency: 'USD'` | `money_attribute :price` |
|
|
206
|
-
| **When to use** | Column always holds the same currency | Each row can hold a different currency |
|
|
207
|
-
| **Column type** | `decimal`, `integer`, or `bigint` | `decimal`, `integer`, or `bigint` for amount; `string` for currency |
|
|
208
|
-
| **Query** | `Product.where(price: 10.to_money('USD'))` — full type support | `Offer.where(price: 10.to_money('EUR'))` — equality only |
|
|
209
|
-
|
|
210
|
-
### Fixed currency
|
|
211
|
-
|
|
212
|
-
```ruby
|
|
213
|
-
class Product < ApplicationRecord
|
|
214
|
-
money_attribute :price, currency: 'USD'
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
product = Product.new(price: 12)
|
|
218
|
-
product.price # => [USD 12.00]
|
|
219
|
-
|
|
220
|
-
Product.new(price: 12.to_money('EUR'))
|
|
221
|
-
# => ArgumentError: ... has different currency. Only USD allowed.
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
### Multi-currency
|
|
230
|
+
## Usage
|
|
225
231
|
|
|
226
232
|
```ruby
|
|
227
233
|
class Offer < ApplicationRecord
|
|
@@ -239,15 +245,29 @@ offer.price.currency.code # => "USD"
|
|
|
239
245
|
|
|
240
246
|
Unlike fixed-currency attributes, composite mode does not enforce a specific currency — any registered currency is accepted at assignment.
|
|
241
247
|
|
|
248
|
+
### Invalid currencies in the database
|
|
249
|
+
|
|
250
|
+
If the currency column contains a value that is not a registered currency (e.g. a legacy code that was removed, or data corruption), `money_attribute` does not crash. The currency resolves to **XXX** (ISO 4217 "No Currency") and the monetary amount is preserved:
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
offer = Offer.find(42)
|
|
254
|
+
offer.price # => [XXX 10.00] # amount preserved, currency flagged
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Records with XXX currency are easily queryable for cleanup:
|
|
258
|
+
|
|
259
|
+
```ruby
|
|
260
|
+
Offer.where(price_currency: 'XXX')
|
|
261
|
+
```
|
|
262
|
+
|
|
242
263
|
## Column type detection
|
|
243
264
|
|
|
244
|
-
|
|
265
|
+
Select the amount column type via the `type:` option. The gem adapts serialization accordingly:
|
|
245
266
|
|
|
246
267
|
```ruby
|
|
247
268
|
# Migration
|
|
248
269
|
create_table :orders do |t|
|
|
249
|
-
t.
|
|
250
|
-
t.string :total_currency
|
|
270
|
+
t.money_attribute :total, amount: { type: :fiat_integer } # bigint — stored as subunits
|
|
251
271
|
end
|
|
252
272
|
|
|
253
273
|
# Model
|
|
@@ -258,17 +278,7 @@ end
|
|
|
258
278
|
Order.new(total: 19.99.to_money('USD')).total_amount # => 1999
|
|
259
279
|
```
|
|
260
280
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
```ruby
|
|
264
|
-
# Migration
|
|
265
|
-
t.bigint :price
|
|
266
|
-
|
|
267
|
-
# Model (no change needed)
|
|
268
|
-
money_attribute :price, currency: 'USD'
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
> Use `integer`/`bigint` for large tables (faster, smaller). Use `decimal` when SQL-level readability matters.
|
|
281
|
+
> Use `:fiat_integer` (bigint) for large tables — smaller and sufficient for most fiat use cases (~922 trillion max). Use `:fiat_decimal` (decimal) when SQL-level readability matters. For cryto currencies support, `:crypto_decimal` is mandatory.
|
|
272
282
|
|
|
273
283
|
## Custom column names
|
|
274
284
|
|
|
@@ -294,17 +304,16 @@ end
|
|
|
294
304
|
|
|
295
305
|
## Column resolution
|
|
296
306
|
|
|
297
|
-
|
|
307
|
+
`money_attribute :name` is always composite. It resolves columns in this order:
|
|
298
308
|
|
|
299
|
-
| Step | Condition | Columns used |
|
|
300
|
-
|
|
301
|
-
| 1 | `mapping:` provided | As specified
|
|
302
|
-
| 2 | `name_currency` column exists | `name` + `name_currency` |
|
|
303
|
-
| 3 | `name == 'amount'` AND `currency` column exists | `amount` + `currency` |
|
|
304
|
-
| 4 |
|
|
305
|
-
| 5 | None of the above (name column missing) | `name_amount` + `name_currency` (convention) | Composite (multi-currency) |
|
|
309
|
+
| Step | Condition | Columns used |
|
|
310
|
+
|---|---|---|
|
|
311
|
+
| 1 | `mapping:` provided | As specified (missing keys fall back to `<name>_amount` / `<name>_currency`) |
|
|
312
|
+
| 2 | `name_currency` column exists AND `name` column exists | `name` + `name_currency` |
|
|
313
|
+
| 3 | `name == 'amount'` AND `currency` column exists | `amount` + `currency` |
|
|
314
|
+
| 4 | None of the above | `<name>_amount` + `<name>_currency` (convention) |
|
|
306
315
|
|
|
307
|
-
Step
|
|
316
|
+
Step 4 raises `ArgumentError` if the convention columns don't exist. For single-column fixed-currency attributes, see [`money_amount`](#single-column-mode-money_amount-fixed-currency).
|
|
308
317
|
|
|
309
318
|
**Example**
|
|
310
319
|
|
|
@@ -316,7 +325,6 @@ create_table :financial_transactions do |t|
|
|
|
316
325
|
t.string :discount_currency, limit: 3
|
|
317
326
|
t.decimal :price_amount
|
|
318
327
|
t.string :price_currency, limit: 3
|
|
319
|
-
t.bigint :surplus
|
|
320
328
|
t.bigint :tax
|
|
321
329
|
t.decimal :total_amount
|
|
322
330
|
t.string :currency_code, limit: 3
|
|
@@ -327,47 +335,134 @@ end
|
|
|
327
335
|
class FinancialTransaction < ApplicationRecord
|
|
328
336
|
money_attribute :amount # step 3: amount(int) + currency
|
|
329
337
|
money_attribute :discount # step 2: discount(int) + discount_currency
|
|
330
|
-
money_attribute :price # step 4: price_amount
|
|
331
|
-
money_attribute :surplus, currency: 'EUR' # step 5: surplus(int) (single-column, will use EUR)
|
|
332
|
-
money_attribute :tax # step 5: tax(int) (single-column, will use default currency)
|
|
338
|
+
money_attribute :price # step 4: price_amount + price_currency
|
|
333
339
|
money_attribute :total, mapping: { amount: :total_amount, currency: :currency_code } # step 1: explicit
|
|
340
|
+
money_amount :tax # single-column, fixed-currency (uses default currency)
|
|
334
341
|
end
|
|
335
342
|
```
|
|
336
343
|
|
|
337
344
|
## Querying
|
|
338
345
|
|
|
339
|
-
|
|
346
|
+
### Rails-native queries
|
|
347
|
+
|
|
348
|
+
Multi-currency (`money_attribute`) attributes support equality queries via `composed_of`:
|
|
349
|
+
|
|
350
|
+
```ruby
|
|
351
|
+
Offer.where(price: 10.to_money('EUR'))
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
For comparisons, use the backing columns directly:
|
|
355
|
+
|
|
356
|
+
```ruby
|
|
357
|
+
Offer.where(price_amount: 10..20, price_currency: 'EUR')
|
|
358
|
+
Offer.where('price_amount > ? AND price_currency = ?', 10, 'EUR')
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Fixed-currency (`money_amount`) attributes support full Rails-native querying through the custom type — equality, IN, BETWEEN, ordering, and aggregation all work:
|
|
340
362
|
|
|
341
363
|
```ruby
|
|
342
|
-
#
|
|
343
|
-
Product.where(price: 10.to_money('USD'))
|
|
364
|
+
Product.where(price: 10.to_money('USD')) # equality
|
|
365
|
+
Product.where(price: [10.to_money('USD'), 20.to_money('USD')]) # IN
|
|
366
|
+
Product.where(price: 10.to_money('USD')..20.to_money('USD')) # BETWEEN
|
|
367
|
+
Product.order(price: :desc) # ordering
|
|
368
|
+
Product.where(price: 10.to_money('USD')).sum(:price) # aggregation
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### Money-aware query helpers
|
|
372
|
+
|
|
373
|
+
For multi-currency attributes, manually decomposing columns is tedious. The query helpers handle this automatically — just pass Money objects:
|
|
344
374
|
|
|
345
|
-
|
|
346
|
-
Product.where(price: [10.to_money('USD'), 20.to_money('USD')])
|
|
375
|
+
#### `where_amount`
|
|
347
376
|
|
|
348
|
-
|
|
349
|
-
Product.where(price: 10.to_money('USD')..20.to_money('USD'))
|
|
377
|
+
Filters by amount value. Accepts a scalar, Range, or Array.
|
|
350
378
|
|
|
351
|
-
|
|
352
|
-
|
|
379
|
+
```ruby
|
|
380
|
+
Offer.where_amount(price: 10) # equality (any currency)
|
|
381
|
+
Offer.where_amount(price: [10, 30]) # IN — matches EUR 10, USD 30
|
|
382
|
+
Offer.where_amount(price: 10..100) # BETWEEN (inclusive)
|
|
383
|
+
Offer.where_amount(price: 10...100) # BETWEEN (exclusive upper bound)
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Ranges work across currencies — `10..50` matches EUR 10 and USD 50:
|
|
387
|
+
|
|
388
|
+
```ruby
|
|
389
|
+
Offer.create!(price: 10.euros)
|
|
390
|
+
Offer.create!(price: 50.dollars)
|
|
353
391
|
|
|
354
|
-
#
|
|
355
|
-
Product.where(price: 10.to_money('USD')).sum(:price)
|
|
392
|
+
Offer.where_amount(price: 10..50) # => both records
|
|
356
393
|
```
|
|
357
394
|
|
|
358
|
-
|
|
395
|
+
For integer (subunit) columns, pass `Mint::Money` objects directly — subunit conversion is handled automatically:
|
|
359
396
|
|
|
360
397
|
```ruby
|
|
361
|
-
|
|
398
|
+
FinancialTransaction.where_amount(amount: [10.dollars, 10.yens])
|
|
399
|
+
FinancialTransaction.where_amount(amount: 10.dollars..100.dollars)
|
|
362
400
|
```
|
|
363
401
|
|
|
364
|
-
For
|
|
402
|
+
For decimal columns, raw numbers work:
|
|
365
403
|
|
|
366
404
|
```ruby
|
|
367
|
-
|
|
368
|
-
|
|
405
|
+
SimpleOffer.where_amount(price: 50)
|
|
406
|
+
SimpleOffer.where_amount(price: 10..100)
|
|
369
407
|
```
|
|
370
408
|
|
|
409
|
+
#### `where_currency`
|
|
410
|
+
|
|
411
|
+
Filters by currency code. Composite attributes only — raises `ArgumentError` for single-column attributes.
|
|
412
|
+
|
|
413
|
+
```ruby
|
|
414
|
+
Offer.where_currency(price: 'EUR')
|
|
415
|
+
Offer.where_currency(price: 10.euros.currency) # also accepts Currency object
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
#### `order_by_amount`
|
|
419
|
+
|
|
420
|
+
Orders by amount. Composite attributes sort by currency ASC first, then amount. Single-column attributes sort by amount only.
|
|
421
|
+
|
|
422
|
+
```ruby
|
|
423
|
+
Offer.order_by_amount(price: :asc) # EUR 10, EUR 100, USD 50
|
|
424
|
+
Offer.order_by_amount(price: :desc) # EUR 100, EUR 10, USD 50
|
|
425
|
+
Offer.order_by_amount(price: nil) # defaults to :asc
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
#### `pluck_amount`
|
|
429
|
+
|
|
430
|
+
Returns money-aware amounts. Follows Rails' `pluck` arity — one attribute returns flat values, multiple attributes return row arrays.
|
|
431
|
+
|
|
432
|
+
```ruby
|
|
433
|
+
Offer.pluck_amount(:price) # => [EUR 10.00, USD 20.00]
|
|
434
|
+
Offer.pluck_amount(:amount, :discount) # => [[USD 100.00, EUR 20.00], ...]
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
#### `pick_amount`
|
|
438
|
+
|
|
439
|
+
Returns a single money-aware value. Follows Rails' `pick` arity.
|
|
440
|
+
|
|
441
|
+
```ruby
|
|
442
|
+
Offer.pick_amount(:price) # => EUR 10.00
|
|
443
|
+
Offer.pick_amount(:amount, :discount) # => [USD 100.00, EUR 20.00]
|
|
444
|
+
Offer.none.pick_amount(:price) # => nil
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
#### `sum_amount`
|
|
448
|
+
|
|
449
|
+
Sums amounts grouped by currency for composite attributes. Accepts a single attribute name only.
|
|
450
|
+
|
|
451
|
+
```ruby
|
|
452
|
+
Offer.sum_amount(:price)
|
|
453
|
+
# => [EUR 30.00, USD 70.00] (one Money per currency, sorted by code)
|
|
454
|
+
|
|
455
|
+
SimpleOffer.sum_amount(:price)
|
|
456
|
+
# => [BRL 60.00] (single-column always returns one Money)
|
|
457
|
+
|
|
458
|
+
Offer.none.sum_amount(:price)
|
|
459
|
+
# => [Mint::Money(0, 'BRL')] (empty result returns zero Money)
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
### Notes
|
|
463
|
+
|
|
464
|
+
All query helpers raise `ArgumentError` for non-money attributes. Internally, money attribute metadata is registered per model class. The same attribute name can be used safely in different models, but subclasses do not automatically inherit a parent model's registered money attributes.
|
|
465
|
+
|
|
371
466
|
## Convenience methods
|
|
372
467
|
|
|
373
468
|
MoneyAttribute adds small helpers on `Numeric` and `String`:
|
|
@@ -378,7 +473,7 @@ MoneyAttribute adds small helpers on `Numeric` and `String`:
|
|
|
378
473
|
12.euros # => [EUR 12.00]
|
|
379
474
|
```
|
|
380
475
|
|
|
381
|
-
> If you prefer not to extend core classes, use `
|
|
476
|
+
> If you prefer not to extend core classes, use `Money.from(12, 'USD')` instead.
|
|
382
477
|
|
|
383
478
|
## Form helpers
|
|
384
479
|
|
|
@@ -394,10 +489,67 @@ MoneyAttribute adds `money_field` and `money_amount_field` to Rails form builder
|
|
|
394
489
|
<% end %>
|
|
395
490
|
```
|
|
396
491
|
|
|
492
|
+
### Single-column mode — `money_amount` (fixed-currency)
|
|
493
|
+
|
|
494
|
+
`money_amount` wraps a numeric column as `Mint::Money` using the application's default currency. No per-row currency. A lighter alternative when you don't need multi-currency support.
|
|
495
|
+
|
|
496
|
+
The accessor name must match the column name. `money_amount` does not support custom column mapping.
|
|
497
|
+
|
|
498
|
+
#### Migration helpers
|
|
499
|
+
|
|
500
|
+
| Method | Action |
|
|
501
|
+
|---|---|
|
|
502
|
+
| `add_money_amount` / `t.money_amount` | Amount column only |
|
|
503
|
+
| `remove_money_amount` / `t.remove_money_amount` | Drops the column |
|
|
504
|
+
|
|
505
|
+
Default column: `decimal(20,4)`. The top-level `type:` shortcut selects the column type:
|
|
506
|
+
|
|
507
|
+
```ruby
|
|
508
|
+
t.money_amount :price # decimal(20,4)
|
|
509
|
+
t.money_amount :btc_balance, type: :crypto_decimal # decimal(36,18)
|
|
510
|
+
t.money_amount :qty, type: :fiat_integer # bigint
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
#### Naming
|
|
514
|
+
|
|
515
|
+
| Migration call | Columns created | Model declaration |
|
|
516
|
+
|---|---|---|
|
|
517
|
+
| `t.money_amount :price` | `price` decimal(20,4) | `money_amount :price` |
|
|
518
|
+
| `t.money_amount :btc, type: :crypto_decimal` | `btc` decimal(36,18) | `money_amount :btc` |
|
|
519
|
+
| `t.money_amount :price, type: :fiat_integer` | `price` bigint | `money_amount :price` |
|
|
520
|
+
| `t.money_amount :price, type: :fiat_decimal` | `price` decimal(20,4) | `money_amount :price` |
|
|
521
|
+
|
|
522
|
+
#### Usage
|
|
523
|
+
|
|
524
|
+
```ruby
|
|
525
|
+
class Product < ApplicationRecord
|
|
526
|
+
money_amount :price
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
product = Product.new(price: 12)
|
|
530
|
+
product.price # => [USD 12.00]
|
|
531
|
+
|
|
532
|
+
Product.new(price: 12.to_money('EUR'))
|
|
533
|
+
# => ArgumentError: ... has different currency. Only USD allowed.
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
#### Column type shortcut
|
|
537
|
+
|
|
538
|
+
```ruby
|
|
539
|
+
# Migration
|
|
540
|
+
t.money_amount :price, type: :fiat_integer # bigint column
|
|
541
|
+
|
|
542
|
+
# Model
|
|
543
|
+
money_amount :price
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
#### Querying
|
|
547
|
+
|
|
548
|
+
Fixed-currency attributes support full Rails-native querying — see [Querying](#querying) for examples.
|
|
549
|
+
|
|
397
550
|
## Roadmap
|
|
398
551
|
|
|
399
552
|
1. **Method-level currency** — lambda-based currency resolution for multi-tenant and instance-level scenarios
|
|
400
|
-
2. Prepare to official 1.0 launh
|
|
401
553
|
|
|
402
554
|
Contributions and suggestions are welcome — open an issue or PR at [gferraz/money-attribute](https://github.com/gferraz/money-attribute).
|
|
403
555
|
|
data/Rakefile
CHANGED
|
@@ -15,16 +15,60 @@ Rake::TestTask.new(:test_run) do |t|
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
desc 'Migrate test database'
|
|
18
|
-
task :test_db_migrate do
|
|
19
|
-
|
|
20
|
-
sh({ 'RAILS_ENV' => 'test' }, 'bin/rails', 'db:migrate')
|
|
21
|
-
end
|
|
18
|
+
task :test_db_migrate do # rubocop:disable Rails/RakeEnvironment
|
|
19
|
+
sh({ 'RAILS_ENV' => 'test' }, 'bin/rails', 'db:migrate', chdir: 'test/dummy', %i[out err] => File::NULL)
|
|
22
20
|
end
|
|
23
21
|
|
|
24
22
|
desc 'Run tests (migrates test DB first)'
|
|
25
23
|
task test: %i[test_db_migrate test_run]
|
|
26
24
|
|
|
25
|
+
# --- Adapter-specific tasks ---
|
|
26
|
+
|
|
27
|
+
%w[postgresql mysql2].each do |adapter|
|
|
28
|
+
namespace :test do
|
|
29
|
+
desc "Migrate test database (#{adapter})"
|
|
30
|
+
task "db_migrate:#{adapter}" do # rubocop:disable Rails/RakeEnvironment
|
|
31
|
+
sh({ 'RAILS_ENV' => 'test', 'DATABASE_ADAPTER' => adapter },
|
|
32
|
+
'bin/rails', 'db:migrate', chdir: 'test/dummy', %i[out err] => File::NULL)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
desc "Run tests against #{adapter}"
|
|
36
|
+
task adapter do # rubocop:disable Rails/RakeEnvironment
|
|
37
|
+
Rake::Task[:"test:db_migrate:#{adapter}"].invoke
|
|
38
|
+
sh({ 'DATABASE_ADAPTER' => adapter }, 'bundle', 'exec', 'rake', 'test_run')
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
desc 'Run tests against all adapters (sqlite3, postgresql, mysql2)'
|
|
44
|
+
task 'test:all' do # rubocop:disable Rails/RakeEnvironment
|
|
45
|
+
Rake::Task[:test].invoke
|
|
46
|
+
%w[postgresql mysql2].each do |adapter|
|
|
47
|
+
Rake::Task[:"test:#{adapter}"].invoke
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# --- Benchmark ---
|
|
52
|
+
|
|
27
53
|
desc 'Run money_attribute vs money-rails benchmark'
|
|
28
|
-
task :
|
|
29
|
-
|
|
54
|
+
task bench: :test_db_migrate do
|
|
55
|
+
puts
|
|
56
|
+
puts '=' * 80
|
|
57
|
+
puts 'money_attribute (minting gem)'
|
|
58
|
+
puts '=' * 80
|
|
59
|
+
sh({ 'RAILS_ENV' => 'test', 'BENCH_SIDE' => 'minting' },
|
|
60
|
+
'bundle', 'exec', 'ruby', 'benchmark/comparison.rb')
|
|
61
|
+
|
|
62
|
+
puts
|
|
63
|
+
puts '=' * 80
|
|
64
|
+
puts 'money-rails (money gem)'
|
|
65
|
+
puts '=' * 80
|
|
66
|
+
sh({ 'RAILS_ENV' => 'test', 'BENCH_SIDE' => 'money_rails',
|
|
67
|
+
'BUNDLE_GEMFILE' => 'Gemfile.benchmark' },
|
|
68
|
+
'bundle', 'exec', 'ruby', 'benchmark/comparison.rb')
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
desc 'Generate consolidated benchmark report (markdown)'
|
|
72
|
+
task 'bench:report' do # rubocop:disable Rails/RakeEnvironment
|
|
73
|
+
ruby 'benchmark/report.rb'
|
|
30
74
|
end
|
|
@@ -2,21 +2,29 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
MoneyAttribute.configure do |config|
|
|
5
|
+
# To set the default currency
|
|
6
|
+
#
|
|
7
|
+
# It must be a registered currency
|
|
8
|
+
#
|
|
9
|
+
config.default_currency = 'USD'
|
|
10
|
+
|
|
11
|
+
# Register built-in crypto currencies
|
|
12
|
+
#
|
|
13
|
+
# 1. Register specific crypto currencies:
|
|
14
|
+
# Mint::Currency.register_crypto('BTC', 'ETH')
|
|
15
|
+
#
|
|
16
|
+
# 2. Register all built-in crypto currencies at once:
|
|
17
|
+
# Mint::Currency.register_all_crypto
|
|
18
|
+
#
|
|
19
|
+
# See available crypto currencies:
|
|
20
|
+
# Mint::Currency.crypto_currencies
|
|
21
|
+
|
|
5
22
|
# Register a custom currency
|
|
6
23
|
#
|
|
7
24
|
# Example:
|
|
8
25
|
# config.added_currencies = [
|
|
9
|
-
#
|
|
10
|
-
# {currency: '
|
|
26
|
+
# {currency: 'ZCRC', subunit: 2, symbol: '₡'},
|
|
27
|
+
# {currency: 'ZNGN', subunit: 3, symbol: '₦'}
|
|
11
28
|
# ]
|
|
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'
|
|
29
|
+
config.added_currencies = []
|
|
22
30
|
end
|