amounts 0.0.8 → 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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +37 -0
  3. data/CHANGELOG.md +113 -0
  4. data/Gemfile +4 -0
  5. data/README.md +215 -1
  6. data/Rakefile +47 -10
  7. data/lib/amount/active_record/amount_validator.rb +9 -18
  8. data/lib/amount/active_record/attribute_definition.rb +3 -1
  9. data/lib/amount/active_record/locale/en.yml +7 -0
  10. data/lib/amount/active_record/model.rb +9 -0
  11. data/lib/amount/active_record.rb +12 -0
  12. data/lib/amount/compact.rb +137 -0
  13. data/lib/amount/{allocation.rb → concerns/allocatable.rb} +1 -1
  14. data/lib/amount/{arithmetic.rb → concerns/calculable.rb} +1 -1
  15. data/lib/amount/concerns/coercible.rb +65 -0
  16. data/lib/amount/concerns/constructible.rb +92 -0
  17. data/lib/amount/{conversion.rb → concerns/convertible.rb} +1 -1
  18. data/lib/amount/concerns/formattable.rb +112 -0
  19. data/lib/amount/concerns/localizable.rb +39 -0
  20. data/lib/amount/{comparison.rb → concerns/orderable.rb} +2 -2
  21. data/lib/amount/concerns/parsable.rb +58 -0
  22. data/lib/amount/concerns/registrable.rb +80 -0
  23. data/lib/amount/{serialization.rb → concerns/serializable.rb} +3 -3
  24. data/lib/amount/display.rb +81 -46
  25. data/lib/amount/locale.rb +102 -0
  26. data/lib/amount/registry.rb +69 -7
  27. data/lib/amount/ui_parser.rb +109 -0
  28. data/lib/amount/version.rb +1 -1
  29. data/lib/amount.rb +55 -199
  30. data/test/amount/active_record/amount_validator_test.rb +201 -0
  31. data/test/amount/active_record/attribute_definition_test.rb +234 -0
  32. data/test/amount/active_record/migration_methods_test.rb +60 -0
  33. data/test/amount/active_record/model_test.rb +40 -0
  34. data/test/amount/active_record/type_test.rb +80 -0
  35. data/test/amount/active_record_test.rb +12 -0
  36. data/test/amount/compact_test.rb +371 -0
  37. data/test/amount/concerns/allocatable_test.rb +166 -0
  38. data/test/amount/concerns/calculable_test.rb +152 -0
  39. data/test/amount/concerns/coercible_test.rb +49 -0
  40. data/test/amount/concerns/constructible_test.rb +113 -0
  41. data/test/amount/concerns/convertible_test.rb +54 -0
  42. data/test/amount/concerns/formattable_test.rb +336 -0
  43. data/test/amount/concerns/localizable_test.rb +59 -0
  44. data/test/amount/concerns/orderable_test.rb +83 -0
  45. data/test/amount/concerns/parsable_test.rb +98 -0
  46. data/test/amount/concerns/registrable_test.rb +67 -0
  47. data/test/amount/concerns/serializable_test.rb +98 -0
  48. data/test/amount/display_test.rb +181 -0
  49. data/test/amount/locale_test.rb +590 -0
  50. data/test/amount/parser_test.rb +29 -0
  51. data/test/amount/registry/generated_constructors_test.rb +94 -0
  52. data/test/amount/registry_test.rb +149 -0
  53. data/test/amount/ui_parser_test.rb +284 -0
  54. data/test/amount_test.rb +55 -0
  55. data/test/dummy/config/application.rb +3 -0
  56. data/test/generators/amount/active_record/registry_generator_test.rb +116 -0
  57. data/test/integration/postgresql_test.rb +94 -0
  58. data/test/support/active_record_test_support.rb +65 -0
  59. data/test/support/error_helpers.rb +22 -0
  60. data/test/support/locale_test_setup.rb +36 -0
  61. data/test/test_helper.rb +1 -0
  62. metadata +47 -11
  63. data/test/postgresql_integration_test.rb +0 -71
  64. data/test/test_active_record.rb +0 -375
  65. data/test/test_amount.rb +0 -675
  66. data/test/test_registry_generator.rb +0 -104
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c850c2cdba4add1e09b4d6b5bfe0d1d45ffd9002924a24f5ad59e63a9f4ca513
4
- data.tar.gz: bd146211750f51dc54e3967a3f2937eea48334cc88f1c0480b48f199c038e7fa
3
+ metadata.gz: 494f30811e40cd1e304c944fe6033f33d1c64f2a6e962e3636e2d43eb702db4d
4
+ data.tar.gz: 1b1f175209a0b3adb1cb48ad0c8429dcb5f3e0f0fe2558ee0cbbd8616b3a4714
5
5
  SHA512:
6
- metadata.gz: ff1b3637f8a8df2b98a8c64975f1f0eef01f876bf8239b36a097624a3cc7218477da32d7dc95d26d22f11a83068dabf2306195480efc1d9f65007e01e3308ef1
7
- data.tar.gz: 947b9a81380612ca2b0f4ccd234936fca76f479bed1be42096a45715a1cd59fb80ead76089b4cb0c8120f8bf1d8fbe3b26c88c9a9860c39265d0f2bcbc1719da
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,118 @@
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
+
3
116
  ## 0.0.8 - 2026-05-05
4
117
 
5
118
  ### Changed
data/Gemfile CHANGED
@@ -12,6 +12,10 @@ group :development do
12
12
  gem "irb", ">= 1.13"
13
13
  end
14
14
 
15
+ group :test do
16
+ gem "i18n", ">= 1.14"
17
+ end
18
+
15
19
  group :quality do
16
20
  gem "rubocop", "~> 1.63.0"
17
21
  end
data/README.md CHANGED
@@ -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 ruby -Ilib:test test/postgresql_integration_test.rb
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
- 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"
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
- RuboCop::RakeTask.new(:rubocop)
14
- RSpec::Core::RakeTask.new(:spec)
46
+ begin
47
+ require "rubocop/rake_task"
15
48
 
16
- task lint: %i[rubocop]
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 rubocop]
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, "must be an Amount")
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, "must have symbol #{expected_symbol}")
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, "has invalid #{option_name} constraint: #{error.message}")
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, "cannot compare #{value.symbol} to #{other.symbol} for #{option_name}")
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, failure_message(option_name, other))
88
+ record.errors.add(attribute, failure_key(option_name), count: other.to_s)
88
89
  end
89
90
 
90
- def failure_message(option_name, other)
91
- case option_name
92
- when :greater_than
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
- record.write_attribute(atomic_column, value.atomic.to_s)
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
@@ -0,0 +1,7 @@
1
+ en:
2
+ errors:
3
+ messages:
4
+ amount_type: "must be an Amount"
5
+ amount_symbol: "must have symbol %{expected}"
6
+ amount_invalid_constraint: "has invalid %{option} constraint: %{message}"
7
+ amount_incomparable: "cannot compare %{symbol} to %{other_symbol} for %{option}"
@@ -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
@@ -24,6 +24,17 @@ class Amount
24
24
  # has_amount :fee, symbol: :SOL
25
25
  # end
26
26
  module ActiveRecord
27
+ # English defaults for the validator's error keys. Apps override them in
28
+ # their own locale files.
29
+ LOCALE_PATH = File.expand_path("active_record/locale/en.yml", __dir__).freeze
30
+
31
+ # Registers the gem's locale file with I18n, once.
32
+ #
33
+ # @return [void]
34
+ def self.install_locale!
35
+ ::I18n.load_path << LOCALE_PATH unless ::I18n.load_path.include?(LOCALE_PATH)
36
+ end
37
+
27
38
  # Installs the migration DSL and model macros into ActiveRecord.
28
39
  #
29
40
  # This is called automatically when the file is required.
@@ -41,4 +52,5 @@ class Amount
41
52
  end
42
53
  end
43
54
 
55
+ Amount::ActiveRecord.install_locale!
44
56
  Amount::ActiveRecord.install!