amounts 0.0.7 → 0.1.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/.rubocop.yml +37 -0
- data/CHANGELOG.md +142 -1
- data/Gemfile +4 -0
- data/README.md +218 -4
- data/Rakefile +47 -10
- data/lib/amount/active_record/amount_validator.rb +9 -18
- data/lib/amount/active_record/attribute_definition.rb +3 -1
- data/lib/amount/active_record/locale/en.yml +7 -0
- data/lib/amount/active_record/model.rb +9 -0
- data/lib/amount/active_record/railtie.rb +1 -1
- data/lib/amount/active_record.rb +12 -0
- data/lib/amount/compact.rb +137 -0
- data/lib/amount/{allocation.rb → concerns/allocatable.rb} +1 -1
- data/lib/amount/{arithmetic.rb → concerns/calculable.rb} +9 -9
- data/lib/amount/concerns/coercible.rb +65 -0
- data/lib/amount/concerns/constructible.rb +92 -0
- data/lib/amount/{conversion.rb → concerns/convertible.rb} +3 -3
- data/lib/amount/concerns/formattable.rb +112 -0
- data/lib/amount/concerns/localizable.rb +39 -0
- data/lib/amount/{comparison.rb → concerns/orderable.rb} +10 -10
- data/lib/amount/concerns/parsable.rb +58 -0
- data/lib/amount/concerns/registrable.rb +80 -0
- data/lib/amount/{serialization.rb → concerns/serializable.rb} +4 -4
- data/lib/amount/display.rb +86 -51
- data/lib/amount/locale.rb +102 -0
- data/lib/amount/registry/generated_constructors.rb +10 -5
- data/lib/amount/registry.rb +72 -9
- data/lib/amount/rspec.rb +4 -4
- data/lib/amount/ui_parser.rb +109 -0
- data/lib/amount/version.rb +1 -1
- data/lib/amount.rb +61 -205
- data/test/amount/active_record/amount_validator_test.rb +201 -0
- data/test/amount/active_record/attribute_definition_test.rb +234 -0
- data/test/amount/active_record/migration_methods_test.rb +60 -0
- data/test/amount/active_record/model_test.rb +40 -0
- data/test/amount/active_record/type_test.rb +80 -0
- data/test/amount/active_record_test.rb +12 -0
- data/test/amount/compact_test.rb +371 -0
- data/test/amount/concerns/allocatable_test.rb +166 -0
- data/test/amount/concerns/calculable_test.rb +152 -0
- data/test/amount/concerns/coercible_test.rb +49 -0
- data/test/amount/concerns/constructible_test.rb +113 -0
- data/test/amount/concerns/convertible_test.rb +54 -0
- data/test/amount/concerns/formattable_test.rb +336 -0
- data/test/amount/concerns/localizable_test.rb +59 -0
- data/test/amount/concerns/orderable_test.rb +83 -0
- data/test/amount/concerns/parsable_test.rb +98 -0
- data/test/amount/concerns/registrable_test.rb +67 -0
- data/test/amount/concerns/serializable_test.rb +98 -0
- data/test/amount/display_test.rb +181 -0
- data/test/amount/locale_test.rb +590 -0
- data/test/amount/parser_test.rb +29 -0
- data/test/amount/registry/generated_constructors_test.rb +94 -0
- data/test/amount/registry_test.rb +149 -0
- data/test/amount/ui_parser_test.rb +284 -0
- data/test/amount_test.rb +55 -0
- data/test/dummy/config/application.rb +3 -0
- data/test/generators/amount/active_record/registry_generator_test.rb +116 -0
- data/test/integration/postgresql_test.rb +94 -0
- data/test/support/active_record_test_support.rb +65 -0
- data/test/support/error_helpers.rb +22 -0
- data/test/support/locale_test_setup.rb +36 -0
- data/test/test_helper.rb +1 -0
- metadata +47 -11
- data/test/postgresql_integration_test.rb +0 -71
- data/test/test_active_record.rb +0 -375
- data/test/test_amount.rb +0 -663
- data/test/test_registry_generator.rb +0 -104
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 494f30811e40cd1e304c944fe6033f33d1c64f2a6e962e3636e2d43eb702db4d
|
|
4
|
+
data.tar.gz: 1b1f175209a0b3adb1cb48ad0c8429dcb5f3e0f0fe2558ee0cbbd8616b3a4714
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c70c23049b1f4932b122628c71a8fa66d079e387f8a2b9f6ff967dc3cbfb2921628eb7b633afaf54bd9f47a785f90b9f2ccc78e50d46a129d8b418c5cd3aacf7
|
|
7
|
+
data.tar.gz: 700869d84355a4bcc8bd126fe93ea05c9e9574dad67d88124f091b28161eef45056c10e4507fbc62467868a9515822b0522b1c0074437e6fba9ee838573f6ee9
|
data/.rubocop.yml
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
require:
|
|
2
|
+
- ./rubocop/cop/amounts/one_assertion_per_test
|
|
3
|
+
- ./rubocop/cop/amounts/one_test_class_per_file
|
|
4
|
+
- ./rubocop/cop/amounts/mirrored_test_path
|
|
5
|
+
- ./rubocop/cop/amounts/concern_naming
|
|
6
|
+
|
|
1
7
|
AllCops:
|
|
2
8
|
NewCops: enable
|
|
3
9
|
SuggestExtensions: false
|
|
@@ -87,3 +93,34 @@ Gemspec/DevelopmentDependencies:
|
|
|
87
93
|
|
|
88
94
|
Gemspec/RequireMFA:
|
|
89
95
|
Enabled: false
|
|
96
|
+
|
|
97
|
+
# Project cops (rubocop/cop/amounts/). See .claude/rules/tests.md.
|
|
98
|
+
Amounts/OneAssertionPerTest:
|
|
99
|
+
Enabled: true
|
|
100
|
+
Include:
|
|
101
|
+
- "test/**/*_test.rb"
|
|
102
|
+
- "spec/**/*_spec.rb"
|
|
103
|
+
|
|
104
|
+
Amounts/OneTestClassPerFile:
|
|
105
|
+
Enabled: true
|
|
106
|
+
Include:
|
|
107
|
+
- "test/**/*_test.rb"
|
|
108
|
+
- "spec/**/*_spec.rb"
|
|
109
|
+
|
|
110
|
+
Amounts/MirroredTestPath:
|
|
111
|
+
Enabled: true
|
|
112
|
+
Include:
|
|
113
|
+
- "test/**/*_test.rb"
|
|
114
|
+
- "spec/**/*_spec.rb"
|
|
115
|
+
# These directories don't mirror lib/ (see .claude/rules/tests.md).
|
|
116
|
+
Exclude:
|
|
117
|
+
- "test/support/**/*"
|
|
118
|
+
- "test/dummy/**/*"
|
|
119
|
+
- "test/integration/**/*"
|
|
120
|
+
- "spec/support/**/*"
|
|
121
|
+
|
|
122
|
+
# See .claude/rules/concerns.md.
|
|
123
|
+
Amounts/ConcernNaming:
|
|
124
|
+
Enabled: true
|
|
125
|
+
Include:
|
|
126
|
+
- "lib/amount/concerns/**/*.rb"
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,146 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0 - 2026-11-02
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Compact notation for large numbers: `ui(compact: true)` renders `50M TOKENS`
|
|
8
|
+
or `$1.5M`. It keeps 3 significant digits (`compact: { precision: 2 }` to
|
|
9
|
+
change it), rounds in `direction:` (so `1_999_999` floors to `1.99M`), trims
|
|
10
|
+
trailing zeros and moves up a scale when rounding reaches it. Numbers below
|
|
11
|
+
one thousand are formatted as usual. Scale words run from `K`/`M`/`B`/`T`
|
|
12
|
+
through `Qa`/`Qi`/`Sx`/`Sp`/`Oc`/`No` to `Dc` (decillion, 10^33), joined
|
|
13
|
+
as `"%n%u"`; when localizing they come from
|
|
14
|
+
`number.human.decimal_units.units` (pluralized with the compacted number)
|
|
15
|
+
joined by `number.human.decimal_units.format` (default `"%n %u"`), falling
|
|
16
|
+
back to the short form for a missing word. Works with `unit:` (the scaled
|
|
17
|
+
number is compacted), labels and templates (which wrap the compacted
|
|
18
|
+
number; plural labels agree with the full value) and `decorated: false`.
|
|
19
|
+
Output without `compact:` is unchanged.
|
|
20
|
+
- `Amount.parse_ui(input, symbol, locale: nil)` parses what a person types in a
|
|
21
|
+
locale's number format: `parse_ui("1.500,50", :USD, locale: :de)` and
|
|
22
|
+
`parse_ui("1,500.50", :USD, locale: :en)` both give `USD|1500.50`. The
|
|
23
|
+
effective locale is `locale:`, then `I18n.locale` when `Amount.localize` is
|
|
24
|
+
true, else plain parsing (`.` decimal, no delimiters). Accepts surrounding
|
|
25
|
+
whitespace (including NBSP), a leading `-` or `+`, delimiters in correct
|
|
26
|
+
groups of three, and the type's label or display symbol at either end in any
|
|
27
|
+
plural form (`"$1,500.50"`, `"1.500,50 €"`). Raises `InvalidInput` for more
|
|
28
|
+
decimals than the type has (never rounds), misplaced delimiters, a repeated
|
|
29
|
+
separator, another type's label, or empty input. Returns the type's
|
|
30
|
+
registered class; an unregistered symbol raises as `Amount.new` does.
|
|
31
|
+
`Amount.parse` and the constructors are unchanged. Wiring it into the
|
|
32
|
+
ActiveRecord type is not part of this change.
|
|
33
|
+
- Templates for where the number and unit go. `register` takes
|
|
34
|
+
`display_format:` and display unit specs take `format:`, where `%n` is the
|
|
35
|
+
formatted number (sign included) and `%u` the label: `"%u%n"`,
|
|
36
|
+
`"%n\u00A0%u"`. Templates are used exactly as given, so non-breaking spaces
|
|
37
|
+
survive. When localizing, `amounts.formats.<symbol downcased>` and
|
|
38
|
+
`amounts.display_unit_formats.<symbol downcased>.<unit>` override them; a
|
|
39
|
+
locale template without `%n` is ignored. `display_position` stays as
|
|
40
|
+
shorthand for `"%u%n"` / `"%n %u"`, so existing output is unchanged.
|
|
41
|
+
Passing both `display_format:` and `display_position:` raises
|
|
42
|
+
`ArgumentError`; a display unit with both `:format` and `:position` raises
|
|
43
|
+
`InvalidDisplayUnit`. `Registry::Entry#display_format` always holds the
|
|
44
|
+
type's template, and `#display_position` is `nil` for a type registered
|
|
45
|
+
with `display_format:`.
|
|
46
|
+
- `Amount#display_format(unit: nil, locale: nil)` returns the template `ui`
|
|
47
|
+
uses for the type or a display unit, with the same locale precedence.
|
|
48
|
+
Template behavior lives in the new `Amount::Formattable` concern, included
|
|
49
|
+
in `Amount`.
|
|
50
|
+
- `Registry::Entry#display_unit(unit)` returns a display unit's spec, raising
|
|
51
|
+
`InvalidDisplayUnit` for an unknown unit.
|
|
52
|
+
- Plural unit labels. `display_symbol:` (and a display unit's `symbol:`) accept
|
|
53
|
+
a hash such as `{ one: "search", other: "searches" }`, giving `1 search` /
|
|
54
|
+
`2 searches`. `register` raises (`Registry::InvalidDisplaySymbol`, or
|
|
55
|
+
`InvalidDisplayUnit` for a unit) unless the hash has `:other` and maps
|
|
56
|
+
Symbols to Strings. The count is the displayed number (after scaling and
|
|
57
|
+
rounding). Without localizing, `:one` is used when it equals 1, else
|
|
58
|
+
`:other`; when localizing, I18n translations and hashes are pluralized with
|
|
59
|
+
`count:` through the locale's rules (`few`, `many`, ...). A missing form falls
|
|
60
|
+
back to `:other`. `unit_label` takes `count:`; without it the `:other` form is
|
|
61
|
+
used. String labels are unchanged.
|
|
62
|
+
- Unit labels can come from I18n. `register` takes `label_key:` (String or
|
|
63
|
+
Symbol); without it the key is `amounts.units.<symbol downcased>`. Display
|
|
64
|
+
units take `label_key:` in their spec, defaulting to
|
|
65
|
+
`amounts.display_units.<symbol downcased>.<unit>`. Lookups only happen when
|
|
66
|
+
localizing (`locale:` or `Amount.localize = true`). Fallbacks: a type falls
|
|
67
|
+
back to `display_symbol`; a display unit to its `:symbol`, then the type's
|
|
68
|
+
label. A missing key never renders "translation missing". Output is
|
|
69
|
+
unchanged when not localizing.
|
|
70
|
+
- `Amount#unit_label(unit: nil, locale: nil)` returns the label alone, with
|
|
71
|
+
the same rules as `ui`, for column headers and chips.
|
|
72
|
+
- `ui` can format numbers for a locale: `ui(locale: :de)` uses the locale's
|
|
73
|
+
`number.format` delimiter and separator (`"$122.500,00"`), falling back to
|
|
74
|
+
`,` and `.` for missing keys. Works with `unit:`, `decorated: false`,
|
|
75
|
+
`trim_zeros` and `direction`.
|
|
76
|
+
- `Amount.localize = true` makes `ui` use `I18n.locale` when no `locale:` is
|
|
77
|
+
given. It defaults to `false`, so output is unchanged unless you opt in.
|
|
78
|
+
Passing `locale:` or setting `Amount.localize = true` without the `i18n` gem
|
|
79
|
+
loaded raises `ArgumentError`. `to_s`, `formatted`, `as_json`, serialization
|
|
80
|
+
and `Amount.parse` never change with the locale.
|
|
81
|
+
|
|
82
|
+
### Changed
|
|
83
|
+
|
|
84
|
+
- `Amount`'s internal concern modules are renamed as -able adjectives and live
|
|
85
|
+
in `lib/amount/concerns/`: `Arithmetic` → `Calculable`, `Allocation` →
|
|
86
|
+
`Allocatable`, `Comparison` → `Orderable`, `Conversion` → `Convertible`,
|
|
87
|
+
`Serialization` → `Serializable` (`Formattable` is unchanged). Five new
|
|
88
|
+
concerns hold logic that used to sit in `Amount` itself: `Registrable`,
|
|
89
|
+
`Localizable`, `Parsable`, `Constructible` and `Coercible`. None of these
|
|
90
|
+
modules are documented API, and every `Amount` method is unchanged. Code
|
|
91
|
+
that referenced a module by name, or required a file under `amount/`
|
|
92
|
+
other than the documented entry points, should `require "amount"` and call
|
|
93
|
+
`Amount`'s methods.
|
|
94
|
+
- `AmountValidator` now adds errors by I18n key instead of hard-coded English
|
|
95
|
+
strings, so apps can translate or reword them: `:amount_type`,
|
|
96
|
+
`:amount_symbol`, `:amount_invalid_constraint`, `:amount_incomparable`, plus
|
|
97
|
+
Rails' own `:greater_than`, `:greater_than_or_equal_to`, `:less_than`,
|
|
98
|
+
`:less_than_or_equal_to` and `:invalid`. English defaults ship in
|
|
99
|
+
`lib/amount/active_record/locale/en.yml`, loaded into `I18n.load_path` when
|
|
100
|
+
`amount/active_record` is required (once, with or without Rails). With no app
|
|
101
|
+
translations, message text is unchanged. `errors.details` now reports the
|
|
102
|
+
keys and interpolation values.
|
|
103
|
+
|
|
104
|
+
### Fixed
|
|
105
|
+
|
|
106
|
+
- Anonymous ActiveRecord classes using `has_amount` get a fallback
|
|
107
|
+
`model_name`, so validation messages can be generated for them.
|
|
108
|
+
- On Rails 8.1, assigning an amount whose atomic value has more than 16 digits
|
|
109
|
+
(`Amount.new(10**30, :SOL, from: :atomic)`) silently truncated it before
|
|
110
|
+
saving (to `10**15`). Rails 8.1 cuts a string cast to an integer column to
|
|
111
|
+
16 bytes, and `has_amount` wrote the atomic value as a string. It now writes
|
|
112
|
+
the Integer, which every Rails version stores exactly. Reads were never
|
|
113
|
+
affected; rows already written under Rails 8.1 with more than 16 digits
|
|
114
|
+
hold the truncated value.
|
|
115
|
+
|
|
116
|
+
## 0.0.8 - 2026-05-05
|
|
117
|
+
|
|
118
|
+
### Changed
|
|
119
|
+
|
|
120
|
+
- **Breaking:** Generated constructors now have an `of_` prefix:
|
|
121
|
+
`Amount.usdc("1.50")` becomes `Amount.of_usdc("1.50")`, `Amount.gold("1.0")`
|
|
122
|
+
becomes `Amount.of_gold("1.0")`, and so on. Every generated constructor on
|
|
123
|
+
`Amount` is renamed; only the registered symbol stays the same, so
|
|
124
|
+
`Amount.new("1.50", :USDC)` still works.
|
|
125
|
+
- The original naming was a latent footgun: any short ISO/symbol code that
|
|
126
|
+
happened to match a method already defined on `Object` (`try`, `then`,
|
|
127
|
+
`tap`, `send`, `display`, `format`, ...) would raise
|
|
128
|
+
`Amount::Registry::AlreadyRegistered` at boot. The first reported
|
|
129
|
+
manifestation was `:TRY` (Turkish Lira) under Rails, where ActiveSupport's
|
|
130
|
+
`Object#try` made the gem unusable for any application that needed to
|
|
131
|
+
denominate amounts in Turkish Lira.
|
|
132
|
+
- The collision check in `GeneratedConstructors#raise_collision!` is kept as
|
|
133
|
+
a defensive backstop, but with the `of_` prefix it should never fire in
|
|
134
|
+
practice.
|
|
135
|
+
|
|
136
|
+
### Migration
|
|
137
|
+
|
|
138
|
+
Search-and-replace `Amount.<symbol>(` with `Amount.of_<symbol>(` across your
|
|
139
|
+
codebase. Any place that builds an `Amount` from a registered symbol uses one
|
|
140
|
+
of the generated constructors; everywhere else (`Amount.new`,
|
|
141
|
+
`Amount.parse`, `Amount.register`, `Amount.registry`, `Amount.with_registry`)
|
|
142
|
+
is unchanged.
|
|
143
|
+
|
|
3
144
|
## 0.0.7 - 2026-05-03
|
|
4
145
|
|
|
5
146
|
### Fixed
|
|
@@ -38,7 +179,7 @@
|
|
|
38
179
|
numeric string without the `display_symbol` prefix or suffix. Useful when
|
|
39
180
|
the caller renders the currency label separately (e.g. in a column header
|
|
40
181
|
or chip). Composes with `unit:` and `direction:` — for example,
|
|
41
|
-
`Amount.
|
|
182
|
+
`Amount.of_gold("1").ui(unit: :gram, decorated: false)` returns `"31.10"`.
|
|
42
183
|
Default remains `decorated: true`, so existing callers see no change.
|
|
43
184
|
|
|
44
185
|
## 0.0.4 - 2026-04-26
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -60,7 +60,7 @@ Amount.register :USD,
|
|
|
60
60
|
|
|
61
61
|
Amount.register_default_rate :USD, :USDC, 1
|
|
62
62
|
|
|
63
|
-
usdc = Amount.
|
|
63
|
+
usdc = Amount.of_usdc("10.00")
|
|
64
64
|
usd = Amount.new("5.00", :USD)
|
|
65
65
|
|
|
66
66
|
(usdc + usd).ui
|
|
@@ -90,7 +90,7 @@ Construction rules:
|
|
|
90
90
|
- `String` defaults to UI decimal values
|
|
91
91
|
- `Float`, `BigDecimal`, and `Rational` are treated as decimal UI values
|
|
92
92
|
- `from: :atomic`, `:ui`, or `:float` overrides inference
|
|
93
|
-
- registering `:USDC` also defines `Amount.
|
|
93
|
+
- registering `:USDC` also defines `Amount.of_usdc(...)` when the symbol is a valid Ruby method name
|
|
94
94
|
|
|
95
95
|
### Registry
|
|
96
96
|
|
|
@@ -173,7 +173,7 @@ remainder.atomic # => -1
|
|
|
173
173
|
```ruby
|
|
174
174
|
Amount.new(1_500_000, :USDC)
|
|
175
175
|
Amount.new("1.50", :USDC)
|
|
176
|
-
Amount.
|
|
176
|
+
Amount.of_usdc("1.50")
|
|
177
177
|
Amount.parse("USDC|1.50")
|
|
178
178
|
Amount.load(atomic: 1_500_000, symbol: :USDC)
|
|
179
179
|
```
|
|
@@ -215,8 +215,193 @@ amount.ui
|
|
|
215
215
|
amount.ui(direction: :ceil)
|
|
216
216
|
amount.ui(unit: :gram)
|
|
217
217
|
amount.in_unit(:gram)
|
|
218
|
+
amount.ui(locale: :de) # => "$122.500,00" (needs the i18n gem)
|
|
219
|
+
amount.ui(compact: true) # => "$1.5M" for 1,500,000
|
|
220
|
+
|
|
221
|
+
Amount.localize = true # ui follows I18n.locale
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`ui` formats numbers for a locale (thousands delimiter and decimal separator
|
|
225
|
+
from `number.format`) only when you pass `locale:` or set `Amount.localize = true`.
|
|
226
|
+
Both require I18n to be loaded and raise `ArgumentError` otherwise. `to_s`,
|
|
227
|
+
`formatted`, serialization and `Amount.parse` never depend on the locale.
|
|
228
|
+
|
|
229
|
+
Unit labels can be translated too. When localizing, the label comes from the
|
|
230
|
+
type's `label_key:` (default `amounts.units.<symbol downcased>`), then falls back
|
|
231
|
+
to `display_symbol`. Display units use their spec's `label_key:` (default
|
|
232
|
+
`amounts.display_units.<symbol downcased>.<unit>`), then the unit's `:symbol`,
|
|
233
|
+
then the type's label. Missing keys never yield "translation missing".
|
|
234
|
+
|
|
235
|
+
```yaml
|
|
236
|
+
en:
|
|
237
|
+
amounts:
|
|
238
|
+
display_units:
|
|
239
|
+
gold: { oz_t: "oz t", gram: "g", kg: "kg" }
|
|
240
|
+
de:
|
|
241
|
+
amounts:
|
|
242
|
+
display_units:
|
|
243
|
+
gold: { oz_t: "Feinunze", gram: "Gramm", kg: "Kilogramm" }
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
gold = Amount.of_gold("12.5") # :GOLD with oz_t / gram / kg display units
|
|
248
|
+
|
|
249
|
+
gold.ui(unit: :gram, locale: :en) # => "388.79 g"
|
|
250
|
+
gold.ui(unit: :gram, locale: :de) # => "388,79 Gramm"
|
|
251
|
+
gold.ui(unit: :kg, locale: :de) # => "0,38879 Kilogramm"
|
|
252
|
+
gold.unit_label(unit: :kg, locale: :de) # => "Kilogramm"
|
|
253
|
+
gold.unit_label(unit: :gram) # => "g" (not localizing)
|
|
254
|
+
|
|
255
|
+
Amount.register :SEARCHES, decimals: 0, label_key: "amounts.units.searches"
|
|
256
|
+
Amount.of_searches(3).unit_label(locale: :de) # => "Suchen" (with that key in de)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
`unit_label(unit: nil, locale: nil, count: nil)` returns the label alone, following
|
|
260
|
+
the same rules as `ui`.
|
|
261
|
+
|
|
262
|
+
### Parsing typed input
|
|
263
|
+
|
|
264
|
+
`Amount.parse_ui` reads what a person types, in a locale's number format. The
|
|
265
|
+
locale is `locale:`, then `I18n.locale` when `Amount.localize` is true, else
|
|
266
|
+
plain parsing (`.` decimal, no delimiters). `Amount.parse` stays the compact
|
|
267
|
+
wire format.
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
Amount.parse_ui("1.500,50", :USD, locale: :de) # => USD|1500.50
|
|
271
|
+
Amount.parse_ui("1,500.50", :USD, locale: :en) # => USD|1500.50
|
|
272
|
+
Amount.parse_ui("1500.50", :USD) # => USD|1500.50
|
|
273
|
+
Amount.parse_ui("1.500,50 €", :EUR, locale: :de) # label at either end, any plural form
|
|
218
274
|
```
|
|
219
275
|
|
|
276
|
+
It accepts surrounding whitespace (including NBSP), a leading `-` or `+`, and
|
|
277
|
+
delimiters in groups of three. It raises `Amount::InvalidInput` for more
|
|
278
|
+
decimal places than the type has (it never rounds), misplaced delimiters
|
|
279
|
+
(`"1,50,0"`), a repeated separator, another type's label, or empty input.
|
|
280
|
+
`ui(locale:)` output round-trips through `parse_ui(..., locale:)`.
|
|
281
|
+
|
|
282
|
+
### Plural labels
|
|
283
|
+
|
|
284
|
+
Labels can agree with the number: `1 search`, `2 searches`. `count` is the
|
|
285
|
+
number as displayed, after unit scaling and rounding (`direction:`) and before
|
|
286
|
+
formatting, so `1.00` is one and `1.50` is other.
|
|
287
|
+
|
|
288
|
+
In code, without I18n, give `display_symbol:` (or a display unit's `symbol:`) a
|
|
289
|
+
hash. It must have `:other`; `register` raises otherwise.
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
Amount.register :SEARCHES, decimals: 0, display_symbol: { one: "search", other: "searches" }
|
|
293
|
+
|
|
294
|
+
Amount.of_searches(1).ui # => "1 search"
|
|
295
|
+
Amount.of_searches(0).ui # => "0 searches"
|
|
296
|
+
Amount.of_searches(2).ui # => "2 searches"
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
The hash uses `:one` when the displayed number equals 1, else `:other`. When
|
|
300
|
+
localizing with I18n loaded, the locale's pluralization rules decide instead,
|
|
301
|
+
so a hash can carry `few`, `many`, ... too. I18n translations can be plural
|
|
302
|
+
hashes as well:
|
|
303
|
+
|
|
304
|
+
```yaml
|
|
305
|
+
en:
|
|
306
|
+
amounts:
|
|
307
|
+
units:
|
|
308
|
+
searches:
|
|
309
|
+
one: "search"
|
|
310
|
+
other: "searches"
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
`unit_label(count: nil)` takes the same behavior; without a number it uses
|
|
314
|
+
`:other`: `Amount.of_searches(1).unit_label(count: 1) # => "search"`.
|
|
315
|
+
String labels behave exactly as before.
|
|
316
|
+
|
|
317
|
+
Where the number and label go is a template, like Rails'
|
|
318
|
+
`number.currency.format.format`: `%n` is the formatted number (sign included)
|
|
319
|
+
and `%u` the label. Set it with `display_format:` on `register` or `format:` on
|
|
320
|
+
a display unit. `display_position` is shorthand for one: `:prefix` is `"%u%n"`
|
|
321
|
+
and `:suffix` (the default) is `"%n %u"`. A type takes one or the other;
|
|
322
|
+
passing both raises `ArgumentError`.
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
Amount.register :EUR, decimals: 2, display_symbol: "€", display_format: "%u%n"
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
When localizing, `amounts.formats.<symbol downcased>` (and
|
|
329
|
+
`amounts.display_unit_formats.<symbol downcased>.<unit>` for display units)
|
|
330
|
+
overrides the configured template:
|
|
331
|
+
|
|
332
|
+
```yaml
|
|
333
|
+
de:
|
|
334
|
+
amounts:
|
|
335
|
+
formats:
|
|
336
|
+
eur: "%n\u00A0%u" # non-breaking space: "12,50 €" never wraps apart
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
```ruby
|
|
340
|
+
Amount.of_eur("12.5").ui(locale: :en) # => "€12.50"
|
|
341
|
+
Amount.of_eur("12.5").ui(locale: :de) # => "12,50\u00A0€"
|
|
342
|
+
Amount.of_eur("12.5").ui(locale: :de, decorated: false) # => "12,50"
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Templates are used exactly as given; nothing inserts non-breaking spaces for
|
|
346
|
+
you, so put them in your locale files. Negative numbers keep their sign inside
|
|
347
|
+
`%n` (`"$-5.00"`).
|
|
348
|
+
|
|
349
|
+
`display_format(unit: nil, locale: nil)` returns the template `ui` would use,
|
|
350
|
+
for rendering the number and label yourself:
|
|
351
|
+
|
|
352
|
+
```ruby
|
|
353
|
+
Amount.of_eur("12.5").display_format(locale: :de) # => "%n\u00A0%u"
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### Compact notation
|
|
357
|
+
|
|
358
|
+
`ui(compact: true)` shortens numbers of one thousand or more: `50M`, `$1.5M`.
|
|
359
|
+
It keeps 3 significant digits (`compact: { precision: 2 }` changes that),
|
|
360
|
+
rounds in `direction:` (floor by default, so `1_999_999` is `1.99M`, never
|
|
361
|
+
`2M`) and trims trailing zeros. Below one thousand the number is formatted as
|
|
362
|
+
usual. Not using `compact:` leaves output unchanged.
|
|
363
|
+
|
|
364
|
+
```ruby
|
|
365
|
+
Amount.register :TOKENS, decimals: 0
|
|
366
|
+
|
|
367
|
+
Amount.of_tokens(50_000_000).ui(compact: true) # => "50M TOKENS"
|
|
368
|
+
Amount.of_tokens(1_999_999).ui(compact: true) # => "1.99M TOKENS"
|
|
369
|
+
Amount.of_tokens(1_999_999).ui(compact: true, direction: :ceil) # => "2M TOKENS"
|
|
370
|
+
Amount.of_tokens(1_299_999).ui(compact: { precision: 2 }) # => "1.2M TOKENS"
|
|
371
|
+
Amount.of_tokens(50_000_000).ui(compact: true, decorated: false) # => "50M"
|
|
372
|
+
Amount.of_usd("1500000").ui(compact: true) # => "$1.5M"
|
|
373
|
+
Amount.of_gold("1000").ui(unit: :gram, compact: true) # => "31.1K g"
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Not localizing, the scale words are `K`, `M`, `B`, `T`, `Qa` (quadrillion),
|
|
377
|
+
`Qi` (quintillion), `Sx`, `Sp`, `Oc`, `No` and `Dc` (decillion, 10^33), joined
|
|
378
|
+
as `"%n%u"`. When localizing, they come from `number.human.decimal_units.units`
|
|
379
|
+
(`thousand`, `million`, `billion`, `trillion`, `quadrillion`, `quintillion`,
|
|
380
|
+
`sextillion`, `septillion`, `octillion`, `nonillion`, `decillion`, optionally
|
|
381
|
+
plural hashes counted with the compacted number), joined by
|
|
382
|
+
`number.human.decimal_units.format` (default `"%n %u"`), the same keys Rails'
|
|
383
|
+
`number_to_human` reads (Rails itself stops at `quadrillion`). A scale with no
|
|
384
|
+
translation falls back to its short form. Numbers past decillions stay in
|
|
385
|
+
decillions (`1500Dc`).
|
|
386
|
+
|
|
387
|
+
```yaml
|
|
388
|
+
de:
|
|
389
|
+
number:
|
|
390
|
+
human:
|
|
391
|
+
decimal_units:
|
|
392
|
+
format: "%n %u"
|
|
393
|
+
units: { thousand: "Tsd.", million: "Mio.", billion: "Mrd." }
|
|
394
|
+
amounts:
|
|
395
|
+
units: { tokens: "Token" }
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
```ruby
|
|
399
|
+
Amount.of_tokens(1_500_000).ui(compact: true, locale: :de) # => "1,5 Mio. Token"
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
The type's label and template wrap the compacted number, and a plural label
|
|
403
|
+
agrees with the full value (`1K searches`), not the compacted digits.
|
|
404
|
+
|
|
220
405
|
### Serialization
|
|
221
406
|
|
|
222
407
|
```ruby
|
|
@@ -316,6 +501,35 @@ ALTER TABLE holdings ADD CONSTRAINT amount_both_or_neither
|
|
|
316
501
|
CHECK ((amount_atomic IS NULL) = (amount_symbol IS NULL));
|
|
317
502
|
```
|
|
318
503
|
|
|
504
|
+
### Validation messages
|
|
505
|
+
|
|
506
|
+
`validates :amount, amount: { symbol: :USDC, greater_than: 0 }` adds errors by
|
|
507
|
+
I18n key, so apps can translate or reword them. The English defaults ship in
|
|
508
|
+
`lib/amount/active_record/locale/en.yml`, which the gem adds to `I18n.load_path`
|
|
509
|
+
when `amount/active_record` loads (Rails or plain ActiveRecord).
|
|
510
|
+
|
|
511
|
+
| Case | Key | Interpolations | English default |
|
|
512
|
+
|---|---|---|---|
|
|
513
|
+
| Not an Amount | `:amount_type` | | `must be an Amount` |
|
|
514
|
+
| Wrong symbol | `:amount_symbol` | `expected` | `must have symbol %{expected}` |
|
|
515
|
+
| Invalid constraint | `:amount_invalid_constraint` | `option`, `message` | `has invalid %{option} constraint: %{message}` |
|
|
516
|
+
| Incomparable | `:amount_incomparable` | `symbol`, `other_symbol`, `option` | `cannot compare %{symbol} to %{other_symbol} for %{option}` |
|
|
517
|
+
| Comparisons | Rails' `:greater_than`, `:greater_than_or_equal_to`, `:less_than`, `:less_than_or_equal_to` | `count` (the other amount's `to_s`) | Rails' own, e.g. `must be greater than %{count}` |
|
|
518
|
+
| Fallback | Rails' `:invalid` | | `is invalid` |
|
|
519
|
+
|
|
520
|
+
Override any of them in your own locale files:
|
|
521
|
+
|
|
522
|
+
```yaml
|
|
523
|
+
de:
|
|
524
|
+
errors:
|
|
525
|
+
messages:
|
|
526
|
+
amount_symbol: "muss das Symbol %{expected} haben"
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
`record.errors.details` reports the keys and interpolation values.
|
|
530
|
+
Apps that already translate Rails' `greater_than` etc. get those translations
|
|
531
|
+
for free.
|
|
532
|
+
|
|
319
533
|
### SQLite Note
|
|
320
534
|
|
|
321
535
|
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.
|
|
@@ -334,7 +548,7 @@ Run the PostgreSQL integration test explicitly:
|
|
|
334
548
|
|
|
335
549
|
```bash
|
|
336
550
|
AMOUNTS_POSTGRES_URL=postgresql://localhost/amounts_test \
|
|
337
|
-
bundle exec
|
|
551
|
+
bundle exec rake test:integration
|
|
338
552
|
```
|
|
339
553
|
|
|
340
554
|
If `AMOUNTS_POSTGRES_URL` is not set, the PostgreSQL test file skips cleanly.
|
data/Rakefile
CHANGED
|
@@ -2,17 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
4
|
require "rake/testtask"
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
|
|
6
|
+
# Each group runs in its own process: ActiveSupport patches core classes, and
|
|
7
|
+
# test/dummy (integration) defines models that collide with the SQLite ones.
|
|
8
|
+
TEST_GROUPS = {
|
|
9
|
+
core: FileList["test/amount_test.rb", "test/amount/**/*_test.rb"].exclude(
|
|
10
|
+
"test/amount/active_record_test.rb", "test/amount/active_record/**/*"
|
|
11
|
+
),
|
|
12
|
+
active_record: FileList[
|
|
13
|
+
"test/amount/active_record_test.rb", "test/amount/active_record/**/*_test.rb", "test/generators/**/*_test.rb"
|
|
14
|
+
],
|
|
15
|
+
integration: FileList["test/integration/**/*_test.rb"]
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
namespace :test do
|
|
19
|
+
TEST_GROUPS.each do |group, files|
|
|
20
|
+
Rake::TestTask.new(group) do |task|
|
|
21
|
+
task.libs << "lib" << "test"
|
|
22
|
+
task.test_files = files
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
desc "Run every Minitest group"
|
|
28
|
+
task test: TEST_GROUPS.keys.map { |group| "test:#{group}" }
|
|
29
|
+
|
|
30
|
+
# RuboCop and RSpec are optional bundle groups in CI; define their tasks only
|
|
31
|
+
# when installed.
|
|
32
|
+
begin
|
|
33
|
+
require "rspec/core/rake_task"
|
|
34
|
+
|
|
35
|
+
namespace :spec do
|
|
36
|
+
RSpec::Core::RakeTask.new(:core) { |task| task.pattern = "spec/amount/rspec/**/*_spec.rb" }
|
|
37
|
+
RSpec::Core::RakeTask.new(:active_record) { |task| task.pattern = "spec/amount/active_record/**/*_spec.rb" }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
desc "Run every RSpec group"
|
|
41
|
+
task spec: %w[spec:core spec:active_record]
|
|
42
|
+
rescue LoadError
|
|
43
|
+
nil
|
|
11
44
|
end
|
|
12
45
|
|
|
13
|
-
|
|
14
|
-
|
|
46
|
+
begin
|
|
47
|
+
require "rubocop/rake_task"
|
|
15
48
|
|
|
16
|
-
|
|
49
|
+
RuboCop::RakeTask.new(:rubocop)
|
|
50
|
+
task lint: %i[rubocop]
|
|
51
|
+
rescue LoadError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
17
54
|
|
|
18
|
-
task default: %i[test spec
|
|
55
|
+
task default: %i[test spec lint]
|
|
@@ -41,7 +41,7 @@ class Amount
|
|
|
41
41
|
def validate_amount_instance(record, attribute, value)
|
|
42
42
|
return if value.is_a?(::Amount)
|
|
43
43
|
|
|
44
|
-
record.errors.add(attribute,
|
|
44
|
+
record.errors.add(attribute, :amount_type)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def validate_symbol(record, attribute, value)
|
|
@@ -49,7 +49,7 @@ class Amount
|
|
|
49
49
|
return unless expected_symbol
|
|
50
50
|
return if value.symbol == expected_symbol
|
|
51
51
|
|
|
52
|
-
record.errors.add(attribute,
|
|
52
|
+
record.errors.add(attribute, :amount_symbol, expected: expected_symbol)
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def validate_comparators(record, attribute, value)
|
|
@@ -61,7 +61,7 @@ class Amount
|
|
|
61
61
|
|
|
62
62
|
compare_amounts(record, attribute, value, operator, other, option_name)
|
|
63
63
|
rescue ::Amount::Error, ArgumentError => error
|
|
64
|
-
record.errors.add(attribute,
|
|
64
|
+
record.errors.add(attribute, :amount_invalid_constraint, option: option_name, message: error.message)
|
|
65
65
|
end
|
|
66
66
|
end
|
|
67
67
|
|
|
@@ -78,28 +78,19 @@ class Amount
|
|
|
78
78
|
def compare_amounts(record, attribute, value, operator, other, option_name)
|
|
79
79
|
comparison = value <=> other
|
|
80
80
|
if comparison.nil?
|
|
81
|
-
record.errors.add(attribute,
|
|
81
|
+
record.errors.add(attribute, :amount_incomparable,
|
|
82
|
+
symbol: value.symbol, other_symbol: other.symbol, option: option_name)
|
|
82
83
|
return
|
|
83
84
|
end
|
|
84
85
|
|
|
85
86
|
return if comparison.public_send(operator, 0)
|
|
86
87
|
|
|
87
|
-
record.errors.add(attribute,
|
|
88
|
+
record.errors.add(attribute, failure_key(option_name), count: other.to_s)
|
|
88
89
|
end
|
|
89
90
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
"must be greater than #{other}"
|
|
94
|
-
when :greater_than_or_equal_to
|
|
95
|
-
"must be greater than or equal to #{other}"
|
|
96
|
-
when :less_than
|
|
97
|
-
"must be less than #{other}"
|
|
98
|
-
when :less_than_or_equal_to
|
|
99
|
-
"must be less than or equal to #{other}"
|
|
100
|
-
else
|
|
101
|
-
"is invalid"
|
|
102
|
-
end
|
|
91
|
+
# Rails' own keys, so apps that translate them get these for free.
|
|
92
|
+
def failure_key(option_name)
|
|
93
|
+
COMPARATORS.key?(option_name) ? option_name : :invalid
|
|
103
94
|
end
|
|
104
95
|
|
|
105
96
|
def fetch_definition(record, attribute)
|
|
@@ -41,7 +41,9 @@ class Amount
|
|
|
41
41
|
return nil
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
# The Integer itself, not its string: Rails 8.1 cuts a String cast to an
|
|
45
|
+
# integer column to 16 bytes, which silently truncates large values.
|
|
46
|
+
record.write_attribute(atomic_column, value.atomic)
|
|
45
47
|
record.write_attribute(symbol_column, value.symbol.to_s) unless fixed_symbol?
|
|
46
48
|
value
|
|
47
49
|
end
|
|
@@ -15,6 +15,15 @@ class Amount
|
|
|
15
15
|
define_amount_validation(definition)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
# Anonymous classes have no `model_name`, which Rails needs to build
|
|
19
|
+
# messages from error keys. Fall back to a stable placeholder so
|
|
20
|
+
# validations on `Class.new(ApplicationRecord)` models still report.
|
|
21
|
+
def model_name
|
|
22
|
+
return super if name
|
|
23
|
+
|
|
24
|
+
@model_name ||= ::ActiveModel::Name.new(self, nil, "AnonymousModel")
|
|
25
|
+
end
|
|
26
|
+
|
|
18
27
|
def amount_attribute_definitions
|
|
19
28
|
@amount_attribute_definitions ||= {}
|
|
20
29
|
end
|