amounts 0.0.5 → 0.0.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b83ca37936b92cf689a865d0173dafd36e354f5e058fab07983efa436796b5d6
4
- data.tar.gz: 3071b5b4bb06f46511a8dc45ec8d0f888b0863d61cfdbda363b313d806fb2f8a
3
+ metadata.gz: e8169dc3622ec4944cf900418dac855398a8f1ab1dcf59c07a7d839ace60e205
4
+ data.tar.gz: 6e2d1b8adcaae026bbe777be07f615ec7f7f2c8902e90c6b59e25a0210417b4b
5
5
  SHA512:
6
- metadata.gz: 398502b610a4e35059307a3f2e895f119ba8456bdfeb22cf95a0976d37b21fcbbedc8f0ff201b90efa6faf5b9cb1f18e9dae2209cc838f8f6cc3929ea73d4d81
7
- data.tar.gz: d26be5a7c85fd002ddc3435880d25bebae7b7a29007295b064efb2bcfe0eacb0d3959044c499db1621e7e8e1b238b311c546dc24c591e8ccfc02b61e4865e281
6
+ metadata.gz: 8afe516fe8c082748e669faa6db55f780a59b8b51948780fb5c23615ac417c830227a447393f5c33393249bbe4b10b40012ee8e090109cfeaf6ca366b197c87e
7
+ data.tar.gz: 984d7e60a58d97dd6b3fed6ba40845bcaa66f2e6bc72e3a2bf36e80880886743ae38217f26efd84a048c7b31d2682b11d993620bd97b6c9dd8901f92e5c29c0c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.7 - 2026-05-03
4
+
5
+ ### Fixed
6
+
7
+ - `Display#to_s` now formats the decimal value using `ui_decimals`, matching
8
+ `ui(decorated: false)`. Previously `to_s` used raw `BigDecimal#to_s("F")`
9
+ which dropped trailing zeros (`USDC|1.5` instead of `USDC|1.50`), defeating
10
+ the purpose of the compact format carrying the display-ready amount.
11
+ - `Amount#as_json` returns the compact string (`to_s`), preventing a
12
+ `SystemStackError` (stack level too deep) when calling `as_json` or `to_json`
13
+ on an Amount in Rails. The circular reference between `Display` and `Amount`
14
+ caused ActiveSupport's default recursive serialization to overflow.
15
+
16
+ ## 0.0.6 - 2026-04-28
17
+
18
+ ### Added
19
+
20
+ - `trim_zeros` option for `Amount.register` strips trailing zeros from `ui`
21
+ output. Defaults to `false` so fiat currencies keep fixed decimals (`$1.50`).
22
+ Set `true` for tokens with high precision to get clean output (`1.5 SOL`
23
+ instead of `1.5000 SOL`). Display units can override via
24
+ `display_units: { gram: { ..., trim_zeros: false } }`.
25
+ - `Amount#ui(trim_zeros:)` call-site override. Pass `true` or `false` to
26
+ override the registry and display-unit settings for a single render.
27
+ Precedence: call-site > display unit spec > registry default.
28
+ - Railtie `initializer` block that auto-requires `amount/active_record` in
29
+ Rails apps. `has_amount` and `t.amount` are now available without manual
30
+ `require` statements — Bundler's auto-require of the `amounts` gem handles
31
+ everything.
32
+
3
33
  ## 0.0.5 - 2026-04-26
4
34
 
5
35
  ### Added
data/README.md CHANGED
@@ -1,9 +1,14 @@
1
+ > **You are reading the gem package readme.** The repository also contains
2
+ > [`site/`](../site) (the docs site) and [`demo/`](../demo) (a Rails
3
+ > case-study app that exercises every feature). See the
4
+ > [root README](../README.md) for the layout overview.
5
+
1
6
  # amounts
2
7
 
3
8
  [![Gem Version](https://img.shields.io/gem/v/amounts)](https://rubygems.org/gems/amounts)
4
9
  [![CI](https://github.com/zarpay/amounts/actions/workflows/ci.yml/badge.svg)](https://github.com/zarpay/amounts/actions/workflows/ci.yml)
5
10
  [![Release](https://img.shields.io/github/v/release/zarpay/amounts)](https://github.com/zarpay/amounts/releases)
6
- [![License](https://img.shields.io/github/license/zarpay/amounts)](https://github.com/zarpay/amounts/blob/main/LICENSE.txt)
11
+ [![License](https://img.shields.io/github/license/zarpay/amounts)](https://github.com/zarpay/amounts/blob/main/gem/LICENSE.txt)
7
12
  [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%203.1-red)](https://rubygems.org/gems/amounts)
8
13
 
9
14
  `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.
@@ -32,6 +37,10 @@ Load the Rails adapter only when needed:
32
37
  require "amount/active_record"
33
38
  ```
34
39
 
40
+ If the gem is bundled into a Rails app, the Rails-only generator hooks are
41
+ loaded through the adapter railtie. You do not need a separate top-level
42
+ generator require.
43
+
35
44
  ## Quickstart
36
45
 
37
46
  ```ruby
@@ -235,6 +244,22 @@ Load the adapter explicitly:
235
244
  require "amount/active_record"
236
245
  ```
237
246
 
247
+ ### Preset registry generator
248
+
249
+ In a Rails app, you can generate a starter initializer with curated baskets of
250
+ registered symbols:
251
+
252
+ ```bash
253
+ bin/rails generate amounts:registry fiat
254
+ bin/rails generate amounts:registry metals
255
+ bin/rails generate amounts:registry crypto
256
+ bin/rails generate amounts:registry all
257
+ ```
258
+
259
+ The generator writes `config/initializers/amounts.rb` with registration
260
+ metadata only. It intentionally does not register default conversion rates,
261
+ because those are directional and application-specific.
262
+
238
263
  ### Migration DSL
239
264
 
240
265
  ```ruby
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Amount
4
+ module ActiveRecord
5
+ class Railtie < ::Rails::Railtie
6
+ initializer "amount.active_record" do
7
+ require "amount/active_record"
8
+ end
9
+
10
+ generators do
11
+ require_relative "../../../generators/amount/active_record/registry_generator"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -21,23 +21,29 @@ class Amount
21
21
  # @param decorated [Boolean] when `false`, omit the display symbol and
22
22
  # return just the rounded number. Useful when the caller renders the
23
23
  # currency label separately (e.g. in a column header or a chip).
24
+ # @param trim_zeros [Boolean, nil] strip trailing zeros from the formatted
25
+ # number. When `nil` (the default), falls back to the display unit's
26
+ # setting, then the registry entry's setting. An explicit `true` or
27
+ # `false` overrides both.
24
28
  # @return [String]
25
29
  # @example
26
30
  # Amount.usdc("1.50").ui # => "$1.50"
27
31
  # Amount.usdc("1.50").ui(decorated: false) # => "1.50"
28
32
  # Amount.gold("1").ui(unit: :gram) # => "31.10 g"
29
33
  # Amount.gold("1").ui(unit: :gram, decorated: false) # => "31.10"
30
- def ui(unit: nil, direction: :floor, decorated: true)
34
+ # Amount.sol("2.5").ui(trim_zeros: true) # => "2.5 SOL"
35
+ def ui(unit: nil, direction: :floor, decorated: true, trim_zeros: nil)
31
36
  if unit
32
- render_display_unit(unit, direction, decorated:)
37
+ render_display_unit(unit, direction, decorated:, trim_zeros:)
33
38
  else
34
- render_default(direction, decorated:)
39
+ render_default(direction, decorated:, trim_zeros:)
35
40
  end
36
41
  end
37
42
 
38
43
  # @return [String]
39
44
  def to_s
40
- "#{@entry.symbol}|#{@amount.decimal.to_s("F")}"
45
+ rounded = round(@amount.decimal, @entry.ui_decimals, :floor)
46
+ "#{@entry.symbol}|#{format_number(rounded, @entry.ui_decimals, false)}"
41
47
  end
42
48
 
43
49
  # @param unit [Symbol]
@@ -49,20 +55,20 @@ class Amount
49
55
 
50
56
  private
51
57
 
52
- def render_default(direction, decorated:)
58
+ def render_default(direction, decorated:, trim_zeros:)
53
59
  rounded = round(@amount.decimal, @entry.ui_decimals, direction)
54
- formatted = format("%.#{@entry.ui_decimals}f", rounded)
60
+ formatted = format_number(rounded, @entry.ui_decimals, resolve_trim(trim_zeros))
55
61
  return formatted unless decorated
56
62
 
57
63
  apply_symbol(formatted, @entry.display_symbol, @entry.display_position)
58
64
  end
59
65
 
60
- def render_display_unit(unit, direction, decorated:)
66
+ def render_display_unit(unit, direction, decorated:, trim_zeros:)
61
67
  spec = fetch_display_unit(unit)
62
68
  scaled = @amount.decimal * Amount.coerce_decimal(spec[:scale])
63
69
  decimals = spec[:ui_decimals] || @entry.ui_decimals
64
70
  rounded = round(scaled, decimals, direction)
65
- formatted = format("%.#{decimals}f", rounded)
71
+ formatted = format_number(rounded, decimals, resolve_trim(trim_zeros, spec))
66
72
  return formatted unless decorated
67
73
 
68
74
  apply_symbol(
@@ -90,6 +96,18 @@ class Amount
90
96
  truncated / factor
91
97
  end
92
98
 
99
+ def format_number(value, decimals, trim)
100
+ str = format("%.#{decimals}f", value)
101
+ trim ? str.sub(/(\.\d*?)0+\z/, '\1').chomp('.') : str
102
+ end
103
+
104
+ def resolve_trim(call_site, spec = nil)
105
+ return call_site unless call_site.nil?
106
+ return spec[:trim_zeros] if spec&.key?(:trim_zeros)
107
+
108
+ @entry.trim_zeros
109
+ end
110
+
93
111
  def apply_symbol(str, symbol, position)
94
112
  return str if symbol.nil? || symbol.empty?
95
113
 
@@ -35,6 +35,7 @@ class Amount
35
35
  :display_units,
36
36
  :default_display,
37
37
  :amount_class,
38
+ :trim_zeros,
38
39
  keyword_init: true
39
40
  )
40
41
 
@@ -60,6 +61,7 @@ class Amount
60
61
  # @param ui_decimals [Integer] decimals displayed by default UI formatting
61
62
  # @param display_units [Hash, nil] optional display-only scaling definitions
62
63
  # @param default_display [Symbol, nil] optional default display unit key
64
+ # @param trim_zeros [Boolean] strip trailing zeros from UI output
63
65
  # @param class [Class, nil] optional custom `Amount` subclass
64
66
  # @return [void]
65
67
  # @raise [AlreadyRegistered] if the symbol is already registered or the
@@ -73,7 +75,7 @@ class Amount
73
75
  # ui_decimals: 2
74
76
  def register(symbol, decimals:, display_symbol: symbol.to_s, display_position: :suffix,
75
77
  ui_decimals: decimals, display_units: nil, default_display: nil,
76
- class: nil)
78
+ trim_zeros: false, class: nil)
77
79
  raise ArgumentError, "symbol must not be blank" if symbol.nil? || symbol.to_s.empty?
78
80
 
79
81
  symbol = symbol.to_sym
@@ -92,7 +94,8 @@ class Amount
92
94
  ui_decimals:,
93
95
  display_units:,
94
96
  default_display:,
95
- amount_class: binding.local_variable_get(:class) || Amount
97
+ amount_class: binding.local_variable_get(:class) || Amount,
98
+ trim_zeros:
96
99
  )
97
100
 
98
101
  @entries[symbol] = entry
@@ -50,5 +50,10 @@ class Amount
50
50
  symbol: @symbol.to_s
51
51
  }
52
52
  end
53
+
54
+ # @return [String]
55
+ def as_json(*)
56
+ to_s
57
+ end
53
58
  end
54
59
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Amount
4
- VERSION = "0.0.5"
4
+ VERSION = "0.0.7"
5
5
  end
data/lib/amounts.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Thin gem-name entrypoint for Bundler auto-require.
4
+ # Core users should still require "amount" directly.
5
+ require_relative "amount"
6
+
7
+ begin
8
+ require "rails/railtie"
9
+ require_relative "amount/active_record/railtie"
10
+ rescue LoadError
11
+ nil
12
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ class Amount
6
+ module ActiveRecord
7
+ class RegistryGenerator < ::Rails::Generators::Base
8
+ namespace "amounts:registry"
9
+
10
+ source_root File.expand_path("templates", __dir__)
11
+ VALID_PRESETS = %i[fiat metals crypto all].freeze
12
+
13
+ argument :preset, type: :string, banner: "fiat|metals|crypto|all"
14
+
15
+ def create_initializer
16
+ template "registry.rb.tt", "config/initializers/amounts.rb"
17
+ end
18
+
19
+ private
20
+
21
+ def preset_key
22
+ @preset_key ||= begin
23
+ key = preset.to_s.downcase.to_sym
24
+ if VALID_PRESETS.include?(key)
25
+ key
26
+ else
27
+ raise ::Thor::Error,
28
+ "unknown preset #{preset.inspect}; choose one of: fiat, metals, crypto, all"
29
+ end
30
+ end
31
+ end
32
+
33
+ def preset_categories
34
+ preset_key == :all ? %i[fiat metals crypto] : [preset_key]
35
+ end
36
+
37
+ def preset_name
38
+ preset_key.to_s
39
+ end
40
+
41
+ def registered_symbols
42
+ @registered_symbols ||= preset_body.scan(/Amount\.register :([A-Z0-9_]+),/).flatten
43
+ end
44
+
45
+ def registry_guard_symbol
46
+ registered_symbols.first
47
+ end
48
+
49
+ def preset_body
50
+ @preset_body ||= File.read(
51
+ File.join(self.class.source_root, "presets", "#{preset_name}.fragment")
52
+ ).chomp
53
+ end
54
+
55
+ def preset_source_label
56
+ preset_name
57
+ end
58
+ end
59
+ end
60
+ end