amounts 0.0.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 +7 -0
- data/.rubocop.yml +89 -0
- data/CHANGELOG.md +9 -0
- data/Gemfile +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +402 -0
- data/Rakefile +18 -0
- data/bin/console +8 -0
- data/bin/setup +4 -0
- data/lib/amount/active_record/amount_validator.rb +115 -0
- data/lib/amount/active_record/attribute_definition.rb +192 -0
- data/lib/amount/active_record/migration_methods.rb +74 -0
- data/lib/amount/active_record/model.rb +140 -0
- data/lib/amount/active_record/rspec.rb +8 -0
- data/lib/amount/active_record/type.rb +106 -0
- data/lib/amount/active_record.rb +44 -0
- data/lib/amount/display.rb +82 -0
- data/lib/amount/parser.rb +50 -0
- data/lib/amount/registry/generated_constructors.rb +62 -0
- data/lib/amount/registry.rb +236 -0
- data/lib/amount/rspec.rb +27 -0
- data/lib/amount/rspec_matchers.rb +105 -0
- data/lib/amount/rspec_support.rb +47 -0
- data/lib/amount/serializer.rb +35 -0
- data/lib/amount/version.rb +5 -0
- data/lib/amount.rb +494 -0
- data/test/dummy/app/models/holding.rb +7 -0
- data/test/dummy/bin/rails +8 -0
- data/test/dummy/config/application.rb +15 -0
- data/test/dummy/config/database.yml +11 -0
- data/test/dummy/config/environment.rb +6 -0
- data/test/dummy/db/schema.rb +9 -0
- data/test/dummy/log/development.log +0 -0
- data/test/dummy/log/test.log +0 -0
- data/test/postgresql_integration_test.rb +71 -0
- data/test/support/amount_test_support.rb +38 -0
- data/test/test_active_record.rb +312 -0
- data/test/test_amount.rb +472 -0
- data/test/test_helper.rb +4 -0
- metadata +105 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 92c0c2ebae750b68a79648157559ca2fe6c0c67d5892a83a31beb36e15936648
|
|
4
|
+
data.tar.gz: c5b8661d2b980a48a655407fdababfe8428bdd978e1fd7a6d52a6d8a554be287
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2bc8b173facf22c287824621e15f02102cff7cb4e096789e35945a77418815b6027cf9637e0e86bcfc3c427946b1b0aa3f544b01cc338550503aa50b2fa34c7e
|
|
7
|
+
data.tar.gz: e4cf1297e4c987f8121d6a9f35f76bc9e668a5f22b130531022428ec0443d019f9ad7d637f098c34be26edaa78d1f105479d935765d3353ef9f42cf8199e3936
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
NewCops: enable
|
|
3
|
+
SuggestExtensions: false
|
|
4
|
+
TargetRubyVersion: 3.1
|
|
5
|
+
UseCache: false
|
|
6
|
+
Exclude:
|
|
7
|
+
- "vendor/**/*"
|
|
8
|
+
- "site/**/*"
|
|
9
|
+
|
|
10
|
+
Layout/LineLength:
|
|
11
|
+
Enabled: false
|
|
12
|
+
|
|
13
|
+
Metrics/BlockLength:
|
|
14
|
+
Enabled: false
|
|
15
|
+
Exclude:
|
|
16
|
+
- "amounts.gemspec"
|
|
17
|
+
- "test/**/*"
|
|
18
|
+
|
|
19
|
+
Metrics/MethodLength:
|
|
20
|
+
Enabled: false
|
|
21
|
+
|
|
22
|
+
Metrics/AbcSize:
|
|
23
|
+
Enabled: false
|
|
24
|
+
|
|
25
|
+
Metrics/CyclomaticComplexity:
|
|
26
|
+
Enabled: false
|
|
27
|
+
|
|
28
|
+
Metrics/PerceivedComplexity:
|
|
29
|
+
Enabled: false
|
|
30
|
+
|
|
31
|
+
Metrics/ClassLength:
|
|
32
|
+
Enabled: false
|
|
33
|
+
|
|
34
|
+
Style/Documentation:
|
|
35
|
+
Enabled: false
|
|
36
|
+
|
|
37
|
+
Style/StringLiterals:
|
|
38
|
+
Enabled: false
|
|
39
|
+
|
|
40
|
+
Style/WordArray:
|
|
41
|
+
Enabled: false
|
|
42
|
+
|
|
43
|
+
Layout/HashAlignment:
|
|
44
|
+
Enabled: false
|
|
45
|
+
|
|
46
|
+
Lint/AmbiguousBlockAssociation:
|
|
47
|
+
Enabled: false
|
|
48
|
+
|
|
49
|
+
Lint/RedundantRequireStatement:
|
|
50
|
+
Enabled: false
|
|
51
|
+
|
|
52
|
+
Naming/RescuedExceptionsVariableName:
|
|
53
|
+
Enabled: false
|
|
54
|
+
|
|
55
|
+
Naming/BinaryOperatorParameterName:
|
|
56
|
+
Enabled: false
|
|
57
|
+
|
|
58
|
+
Naming/MethodParameterName:
|
|
59
|
+
Enabled: false
|
|
60
|
+
|
|
61
|
+
Naming/PredicateName:
|
|
62
|
+
Enabled: false
|
|
63
|
+
|
|
64
|
+
Metrics/ParameterLists:
|
|
65
|
+
Enabled: false
|
|
66
|
+
|
|
67
|
+
Style/IfUnlessModifier:
|
|
68
|
+
Enabled: false
|
|
69
|
+
|
|
70
|
+
Style/RedundantParentheses:
|
|
71
|
+
Enabled: false
|
|
72
|
+
|
|
73
|
+
Style/StringLiteralsInInterpolation:
|
|
74
|
+
Enabled: false
|
|
75
|
+
|
|
76
|
+
Style/SoleNestedConditional:
|
|
77
|
+
Enabled: false
|
|
78
|
+
|
|
79
|
+
Style/RedundantConstantBase:
|
|
80
|
+
Enabled: false
|
|
81
|
+
|
|
82
|
+
Layout/SpaceAroundKeyword:
|
|
83
|
+
Enabled: false
|
|
84
|
+
|
|
85
|
+
Gemspec/DevelopmentDependencies:
|
|
86
|
+
Enabled: false
|
|
87
|
+
|
|
88
|
+
Gemspec/RequireMFA:
|
|
89
|
+
Enabled: false
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.0.1 - 2026-04-25
|
|
4
|
+
|
|
5
|
+
- Initial release.
|
|
6
|
+
- Added the `Amount` core value object with atomic integer storage.
|
|
7
|
+
- Added directional default-rate conversion for cross-type arithmetic and comparison.
|
|
8
|
+
- Added explicit `[parts, remainder]` semantics for `split` and `allocate`.
|
|
9
|
+
- Added optional ActiveRecord integration via `require "amount/active_record"`.
|
data/Gemfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gemspec
|
|
6
|
+
|
|
7
|
+
gem "minitest", ">= 5.0", "< 6.0"
|
|
8
|
+
gem "rake", ">= 13.0"
|
|
9
|
+
gem "rspec", ">= 3.13"
|
|
10
|
+
|
|
11
|
+
group :development do
|
|
12
|
+
gem "irb", ">= 1.13"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
group :quality do
|
|
16
|
+
gem "rubocop", "~> 1.63.0"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
group :active_record do
|
|
20
|
+
gem "activerecord", ">= 7.1", "< 8.0"
|
|
21
|
+
gem "pg", ">= 1.5"
|
|
22
|
+
gem "railties", ">= 7.1", "< 8.0"
|
|
23
|
+
gem "sqlite3", ">= 2.0"
|
|
24
|
+
end
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Seb Scholl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
# amounts
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/amounts)
|
|
4
|
+
[](https://github.com/zarpay/amounts/actions/workflows/ci.yml)
|
|
5
|
+
[](https://github.com/zarpay/amounts/releases)
|
|
6
|
+
[](https://github.com/zarpay/amounts/blob/main/LICENSE.txt)
|
|
7
|
+
[](https://rubygems.org/gems/amounts)
|
|
8
|
+
|
|
9
|
+
`amounts` is a Ruby gem for precise quantities of fungible things: money, crypto tokens, commodities, inventory units, points, and similar value-like amounts. It stores every value as an arbitrary-precision atomic `Integer`, keeps type identity in a registry, rejects accidental cross-type math unless an explicit directional rate exists, and offers an optional ActiveRecord adapter without making Rails part of the core runtime.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bundle add amounts
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
or:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
gem install amounts
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The library entrypoint is:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require "amount"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Load the Rails adapter only when needed:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
require "amount/active_record"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
require "amount"
|
|
39
|
+
|
|
40
|
+
Amount.register :USDC,
|
|
41
|
+
decimals: 6,
|
|
42
|
+
display_symbol: "$",
|
|
43
|
+
display_position: :prefix,
|
|
44
|
+
ui_decimals: 2
|
|
45
|
+
|
|
46
|
+
Amount.register :USD,
|
|
47
|
+
decimals: 2,
|
|
48
|
+
display_symbol: "$",
|
|
49
|
+
display_position: :prefix,
|
|
50
|
+
ui_decimals: 2
|
|
51
|
+
|
|
52
|
+
Amount.register_default_rate :USD, :USDC, 1
|
|
53
|
+
|
|
54
|
+
usdc = Amount.usdc("10.00")
|
|
55
|
+
usd = Amount.new("5.00", :USD)
|
|
56
|
+
|
|
57
|
+
(usdc + usd).ui
|
|
58
|
+
# => "$15.00"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Concepts
|
|
62
|
+
|
|
63
|
+
### Atomic vs. UI values
|
|
64
|
+
|
|
65
|
+
`Amount` stores values as atomic integers in the smallest registered unit for that type.
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
Amount.register :USDC, decimals: 6
|
|
69
|
+
|
|
70
|
+
amount = Amount.new("1.5", :USDC)
|
|
71
|
+
amount.atomic
|
|
72
|
+
# => 1500000
|
|
73
|
+
|
|
74
|
+
amount.decimal.to_s("F")
|
|
75
|
+
# => "1.5"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Construction rules:
|
|
79
|
+
|
|
80
|
+
- `Integer` defaults to atomic units
|
|
81
|
+
- `String` defaults to UI decimal values
|
|
82
|
+
- `Float`, `BigDecimal`, and `Rational` are treated as decimal UI values
|
|
83
|
+
- `from: :atomic`, `:ui`, or `:float` overrides inference
|
|
84
|
+
- registering `:USDC` also defines `Amount.usdc(...)` when the symbol is a valid Ruby method name
|
|
85
|
+
|
|
86
|
+
### Registry
|
|
87
|
+
|
|
88
|
+
The registry defines the full behavior of each type:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
Amount.register :GOLD,
|
|
92
|
+
decimals: 8,
|
|
93
|
+
display_symbol: "oz t",
|
|
94
|
+
display_position: :suffix,
|
|
95
|
+
ui_decimals: 4,
|
|
96
|
+
display_units: {
|
|
97
|
+
oz_t: { scale: 1, symbol: "oz t", ui_decimals: 4 },
|
|
98
|
+
gram: { scale: "31.1035", symbol: "g", ui_decimals: 2 }
|
|
99
|
+
},
|
|
100
|
+
default_display: :oz_t
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Boot-time registry configuration can be frozen once setup is complete:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
Amount.registry.lock!
|
|
107
|
+
Amount.registry.locked? # => true
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Display units are not conversions
|
|
111
|
+
|
|
112
|
+
Display units scale presentation only:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
gold = Amount.new("1.5", :GOLD)
|
|
116
|
+
gold.ui(unit: :gram)
|
|
117
|
+
# => "46.65 g"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The value is still `:GOLD`.
|
|
121
|
+
|
|
122
|
+
### Directional conversion rates
|
|
123
|
+
|
|
124
|
+
Cross-type `+`, `-`, and `<=>` only work when the right-hand side can be converted into the left-hand side using a registered default rate.
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
Amount.register_default_rate :USD, :USDC, 1
|
|
128
|
+
|
|
129
|
+
Amount.new("10", :USDC) + Amount.new("5", :USD)
|
|
130
|
+
# => #<Amount USDC|15.0>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Rates are directional. `:USD -> :USDC` does not imply `:USDC -> :USD`.
|
|
134
|
+
|
|
135
|
+
### Split vs. division
|
|
136
|
+
|
|
137
|
+
Scalar division returns one scaled amount:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
Amount.new("10", :USDC) / 2
|
|
141
|
+
# => #<Amount USDC|5.0>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`split` and `allocate` return explicit remainders:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
parts, remainder = Amount.new(10, :LOGS).split(3)
|
|
148
|
+
parts.map(&:atomic) # => [3, 3, 3]
|
|
149
|
+
remainder.atomic # => 1
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Negative values follow the same rules with rounding toward zero:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
parts, remainder = Amount.new(-10, :LOGS).allocate([1, 1, 1])
|
|
156
|
+
parts.map(&:atomic) # => [-3, -3, -3]
|
|
157
|
+
remainder.atomic # => -1
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Core API
|
|
161
|
+
|
|
162
|
+
### Construction
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
Amount.new(1_500_000, :USDC)
|
|
166
|
+
Amount.new("1.50", :USDC)
|
|
167
|
+
Amount.usdc("1.50")
|
|
168
|
+
Amount.parse("USDC|1.50")
|
|
169
|
+
Amount.load(atomic: 1_500_000, symbol: :USDC)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Math
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
a = Amount.new("10", :USDC)
|
|
176
|
+
b = Amount.new("2", :USDC)
|
|
177
|
+
|
|
178
|
+
a + b
|
|
179
|
+
a - b
|
|
180
|
+
a * 2
|
|
181
|
+
a / 2
|
|
182
|
+
a / b
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`Amount * Amount` raises `Amount::TypeMismatch`.
|
|
186
|
+
|
|
187
|
+
### Comparison
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
Amount.new("1", :USDC) < Amount.new("2", :USDC)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Cross-type comparison returns `nil` when no directional rate exists.
|
|
194
|
+
|
|
195
|
+
### Conversion
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
Amount.new("10", :USDC).to(:USD, rate: "1")
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Display
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
amount.formatted
|
|
205
|
+
amount.ui
|
|
206
|
+
amount.ui(direction: :ceil)
|
|
207
|
+
amount.ui(unit: :gram)
|
|
208
|
+
amount.in_unit(:gram)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Serialization
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
payload = amount.to_h
|
|
215
|
+
Amount.load(payload)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Registry API
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
Amount.register(...)
|
|
222
|
+
Amount.register_default_rate(:USD, :USDC, "1")
|
|
223
|
+
Amount.registry.lookup(:USDC)
|
|
224
|
+
Amount.registry.symbols
|
|
225
|
+
Amount.registry.clear!
|
|
226
|
+
Amount.registry.lock!
|
|
227
|
+
Amount.registry.locked?
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## ActiveRecord Integration
|
|
231
|
+
|
|
232
|
+
Load the adapter explicitly:
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
require "amount/active_record"
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Migration DSL
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
create_table :holdings do |t|
|
|
242
|
+
t.amount :amount
|
|
243
|
+
t.amount :default_amount, default: "USDC|1.25"
|
|
244
|
+
t.amount :fee, symbol: :SOL
|
|
245
|
+
t.amount :reserve, precision: 40
|
|
246
|
+
end
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
This generates:
|
|
250
|
+
|
|
251
|
+
- `*_atomic` as `numeric(78, 0)` by default
|
|
252
|
+
- `*_symbol` as `string(10)` for multi-symbol amounts
|
|
253
|
+
- `precision:` override support for the atomic column
|
|
254
|
+
|
|
255
|
+
### Model Macro
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
class Holding < ApplicationRecord
|
|
259
|
+
has_amount :amount
|
|
260
|
+
has_amount :fee, symbol: :SOL
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
holding = Holding.new
|
|
264
|
+
holding.amount = "USDC|1.50"
|
|
265
|
+
holding.fee = 0.25
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Writers accept:
|
|
269
|
+
|
|
270
|
+
- `Amount`
|
|
271
|
+
- `String` like `"USDC|1.50"`
|
|
272
|
+
- `Hash` payloads
|
|
273
|
+
- raw numeric values for fixed-symbol attributes only
|
|
274
|
+
|
|
275
|
+
Scopes:
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
Holding.where_amount("USDC|1.50")
|
|
279
|
+
Holding.where_amount_gt("USDC|1.00")
|
|
280
|
+
Holding.where_amount_gte("USDC|1.00")
|
|
281
|
+
Holding.where_amount_lt("USDC|5.00")
|
|
282
|
+
Holding.where_amount_lte("USDC|5.00")
|
|
283
|
+
Holding.where_amount_between("USDC|1.00", "USDC|5.00")
|
|
284
|
+
Holding.amount_in(:USDC)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Suggested check constraint for multi-symbol amounts:
|
|
288
|
+
|
|
289
|
+
```sql
|
|
290
|
+
ALTER TABLE holdings ADD CONSTRAINT amount_both_or_neither
|
|
291
|
+
CHECK ((amount_atomic IS NULL) = (amount_symbol IS NULL));
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### SQLite Note
|
|
295
|
+
|
|
296
|
+
The adapter supports SQLite for general integration tests and development, but SQLite does not preserve `DECIMAL(78,0)` values above 64-bit range exactly under ActiveRecord's default numeric handling. For exact wei-scale persistence, use PostgreSQL or another database with true arbitrary-precision numeric behavior.
|
|
297
|
+
|
|
298
|
+
### PostgreSQL Dummy App
|
|
299
|
+
|
|
300
|
+
A minimal Rails dummy app lives under `test/dummy` for PostgreSQL-backed integration testing.
|
|
301
|
+
|
|
302
|
+
Run the default suite:
|
|
303
|
+
|
|
304
|
+
```bash
|
|
305
|
+
bundle exec rake
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Run the PostgreSQL integration test explicitly:
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
AMOUNTS_POSTGRES_URL=postgresql://localhost/amounts_test \
|
|
312
|
+
bundle exec ruby -Ilib:test test/postgresql_integration_test.rb
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
If `AMOUNTS_POSTGRES_URL` is not set, the PostgreSQL test file skips cleanly.
|
|
316
|
+
|
|
317
|
+
Open a console against the dummy app:
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
AMOUNTS_POSTGRES_URL=postgresql://localhost/amounts_test \
|
|
321
|
+
test/dummy/bin/rails console
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
## Testing
|
|
325
|
+
|
|
326
|
+
The gem ships opt-in RSpec matchers for app-level specs:
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
# spec/spec_helper.rb
|
|
330
|
+
require "amount/rspec"
|
|
331
|
+
require "amount/active_record/rspec"
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Core matcher examples:
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
expect(holding.amount).to eq_amount("USDC|1.50")
|
|
338
|
+
expect(holding.amount).to be_amount_of(:USDC)
|
|
339
|
+
expect(holding.amount).to be_positive_amount
|
|
340
|
+
expect(converted).to be_approximately_amount(:GOLD, "0.0042", within: "0.0001")
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
ActiveRecord matcher examples:
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
expect(holding).to have_amount_column(:amount, "USDC|1.50")
|
|
347
|
+
expect(Holding.group(:amount_symbol).sum(:amount_atomic))
|
|
348
|
+
.to match_amounts(USDC: "10500.00", SOL: "12.5")
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
The gem's own test suite runs both Minitest and RSpec:
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
bundle exec rake
|
|
355
|
+
bundle exec rspec
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
## Releases
|
|
359
|
+
|
|
360
|
+
RubyGems publishing is intended to run from GitHub Releases using RubyGems trusted publishing.
|
|
361
|
+
|
|
362
|
+
Workflow:
|
|
363
|
+
|
|
364
|
+
- create and push a version tag such as `v0.0.1`
|
|
365
|
+
- publish a GitHub Release for that tag
|
|
366
|
+
- GitHub Actions runs `.github/workflows/release.yml`
|
|
367
|
+
- the workflow verifies the test suite and publishes the gem to RubyGems.org
|
|
368
|
+
|
|
369
|
+
RubyGems setup:
|
|
370
|
+
|
|
371
|
+
- on RubyGems.org, configure a trusted publisher for the `amounts` gem
|
|
372
|
+
- repository owner: `zarpay`
|
|
373
|
+
- repository name: `amounts`
|
|
374
|
+
- workflow filename: `release.yml`
|
|
375
|
+
- GitHub Actions environment: `release`
|
|
376
|
+
|
|
377
|
+
This uses OIDC trusted publishing, so no RubyGems API token needs to be stored in GitHub Actions. See the official RubyGems trusted publishing guide:
|
|
378
|
+
|
|
379
|
+
- https://guides.rubygems.org/trusted-publishing/
|
|
380
|
+
|
|
381
|
+
## Compared to `money`
|
|
382
|
+
|
|
383
|
+
`money` is excellent for fiat currency workflows and has a mature ecosystem. `amounts` takes a different tradeoff:
|
|
384
|
+
|
|
385
|
+
| Concern | `money` | `amounts` |
|
|
386
|
+
| --- | --- | --- |
|
|
387
|
+
| Internal storage | integer subunits | arbitrary-precision atomic integer |
|
|
388
|
+
| Non-fiat tokens | awkward at high decimals | first-class |
|
|
389
|
+
| Cross-type math | money-oriented exchange features | explicit directional rates only |
|
|
390
|
+
| Display units | currency formatting | arbitrary per-type display scaling |
|
|
391
|
+
| Rails dependency | common usage path | optional adapter only |
|
|
392
|
+
|
|
393
|
+
## Contributing
|
|
394
|
+
|
|
395
|
+
1. Install dependencies with `bin/setup`
|
|
396
|
+
2. Run `bundle exec rake`
|
|
397
|
+
3. Keep the core gem Rails-agnostic
|
|
398
|
+
4. Add tests for behavioral changes
|
|
399
|
+
|
|
400
|
+
## License
|
|
401
|
+
|
|
402
|
+
Released under the MIT License. See [LICENSE.txt](LICENSE.txt).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
require "rspec/core/rake_task"
|
|
6
|
+
require "rubocop/rake_task"
|
|
7
|
+
Rake::TestTask.new(:test) do |task|
|
|
8
|
+
task.libs << "lib"
|
|
9
|
+
task.libs << "test"
|
|
10
|
+
task.pattern = "test/**/*_test.rb"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
RuboCop::RakeTask.new(:rubocop)
|
|
14
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
15
|
+
|
|
16
|
+
task lint: %i[rubocop]
|
|
17
|
+
|
|
18
|
+
task default: %i[test spec rubocop]
|
data/bin/console
ADDED
data/bin/setup
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# Validates model-level business rules for `has_amount` attributes.
|
|
6
|
+
#
|
|
7
|
+
# Structural integrity is still handled by `has_amount` itself. This
|
|
8
|
+
# validator adds declarative Rails validations such as symbol checks and
|
|
9
|
+
# comparison constraints.
|
|
10
|
+
#
|
|
11
|
+
# @example Requiring a specific symbol
|
|
12
|
+
# class Holding < ApplicationRecord
|
|
13
|
+
# has_amount :amount
|
|
14
|
+
# validates :amount, amount: { symbol: :USDC }
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# @example Applying numeric thresholds to a fixed-symbol amount
|
|
18
|
+
# class FeeSchedule < ApplicationRecord
|
|
19
|
+
# has_amount :fee, symbol: :SOL
|
|
20
|
+
# validates :fee, amount: { greater_than_or_equal_to: 0 }
|
|
21
|
+
# end
|
|
22
|
+
class AmountValidator < ::ActiveModel::EachValidator
|
|
23
|
+
COMPARATORS = {
|
|
24
|
+
greater_than: :>,
|
|
25
|
+
greater_than_or_equal_to: :>=,
|
|
26
|
+
less_than: :<,
|
|
27
|
+
less_than_or_equal_to: :<=
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
def validate_each(record, attribute, value)
|
|
31
|
+
return if pending_assignment_error?(record, attribute)
|
|
32
|
+
return if value.nil?
|
|
33
|
+
|
|
34
|
+
validate_amount_instance(record, attribute, value)
|
|
35
|
+
validate_symbol(record, attribute, value)
|
|
36
|
+
validate_comparators(record, attribute, value)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def validate_amount_instance(record, attribute, value)
|
|
42
|
+
return if value.is_a?(::Amount)
|
|
43
|
+
|
|
44
|
+
record.errors.add(attribute, "must be an Amount")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate_symbol(record, attribute, value)
|
|
48
|
+
expected_symbol = options[:symbol]&.to_sym
|
|
49
|
+
return unless expected_symbol
|
|
50
|
+
return if value.symbol == expected_symbol
|
|
51
|
+
|
|
52
|
+
record.errors.add(attribute, "must have symbol #{expected_symbol}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_comparators(record, attribute, value)
|
|
56
|
+
COMPARATORS.each do |option_name, operator|
|
|
57
|
+
next unless options.key?(option_name)
|
|
58
|
+
|
|
59
|
+
other = coerce_comparison_amount(record, attribute, options.fetch(option_name))
|
|
60
|
+
next unless other
|
|
61
|
+
|
|
62
|
+
compare_amounts(record, attribute, value, operator, other, option_name)
|
|
63
|
+
rescue ::Amount::Error, ArgumentError => error
|
|
64
|
+
record.errors.add(attribute, "has invalid #{option_name} constraint: #{error.message}")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def coerce_comparison_amount(record, attribute, candidate)
|
|
69
|
+
definition = fetch_definition(record, attribute)
|
|
70
|
+
return candidate if candidate.is_a?(::Amount)
|
|
71
|
+
|
|
72
|
+
definition.type.cast(candidate)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def compare_amounts(record, attribute, value, operator, other, option_name)
|
|
76
|
+
comparison = value <=> other
|
|
77
|
+
if comparison.nil?
|
|
78
|
+
record.errors.add(attribute, "cannot compare #{value.symbol} to #{other.symbol} for #{option_name}")
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
return if comparison.public_send(operator, 0)
|
|
83
|
+
|
|
84
|
+
record.errors.add(attribute, failure_message(option_name, other))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def failure_message(option_name, other)
|
|
88
|
+
case option_name
|
|
89
|
+
when :greater_than
|
|
90
|
+
"must be greater than #{other}"
|
|
91
|
+
when :greater_than_or_equal_to
|
|
92
|
+
"must be greater than or equal to #{other}"
|
|
93
|
+
when :less_than
|
|
94
|
+
"must be less than #{other}"
|
|
95
|
+
when :less_than_or_equal_to
|
|
96
|
+
"must be less than or equal to #{other}"
|
|
97
|
+
else
|
|
98
|
+
"is invalid"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def fetch_definition(record, attribute)
|
|
103
|
+
record.class.amount_attribute_definitions.fetch(attribute.to_sym)
|
|
104
|
+
rescue KeyError
|
|
105
|
+
raise ArgumentError, "#{record.class.name}##{attribute} is not declared with has_amount"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def pending_assignment_error?(record, attribute)
|
|
109
|
+
record.send(:pending_amount_assignment_errors).key?(attribute.to_sym)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
::AmountValidator = Amount::ActiveRecord::AmountValidator unless defined?(::AmountValidator)
|