amounts
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.
Installation
bundle add amounts
or:
gem install amounts
The library entrypoint is:
require "amount"
Load the Rails adapter only when needed:
require "amount/active_record"
Quickstart
require "amount"
Amount.register :USDC,
decimals: 6,
display_symbol: "$",
display_position: :prefix,
ui_decimals: 2
Amount.register :USD,
decimals: 2,
display_symbol: "$",
display_position: :prefix,
ui_decimals: 2
Amount.register_default_rate :USD, :USDC, 1
usdc = Amount.usdc("10.00")
usd = Amount.new("5.00", :USD)
(usdc + usd).ui
# => "$15.00"
Concepts
Atomic vs. UI values
Amount stores values as atomic integers in the smallest registered unit for that type.
Amount.register :USDC, decimals: 6
amount = Amount.new("1.5", :USDC)
amount.atomic
# => 1500000
amount.decimal.to_s("F")
# => "1.5"
Construction rules:
Integerdefaults to atomic unitsStringdefaults to UI decimal valuesFloat,BigDecimal, andRationalare treated as decimal UI valuesfrom: :atomic,:ui, or:floatoverrides inference- registering
:USDCalso definesAmount.usdc(...)when the symbol is a valid Ruby method name
Registry
The registry defines the full behavior of each type:
Amount.register :GOLD,
decimals: 8,
display_symbol: "oz t",
display_position: :suffix,
ui_decimals: 4,
display_units: {
oz_t: { scale: 1, symbol: "oz t", ui_decimals: 4 },
gram: { scale: "31.1035", symbol: "g", ui_decimals: 2 }
},
default_display: :oz_t
Boot-time registry configuration can be frozen once setup is complete:
Amount.registry.lock!
Amount.registry.locked? # => true
Display units are not conversions
Display units scale presentation only:
gold = Amount.new("1.5", :GOLD)
gold.ui(unit: :gram)
# => "46.65 g"
The value is still :GOLD.
Directional conversion rates
Cross-type +, -, and <=> only work when the right-hand side can be converted into the left-hand side using a registered default rate.
Amount.register_default_rate :USD, :USDC, 1
Amount.new("10", :USDC) + Amount.new("5", :USD)
# => #<Amount USDC|15.0>
Rates are directional. :USD -> :USDC does not imply :USDC -> :USD.
Split vs. division
Scalar division returns one scaled amount:
Amount.new("10", :USDC) / 2
# => #<Amount USDC|5.0>
split and allocate return explicit remainders:
parts, remainder = Amount.new(10, :LOGS).split(3)
parts.map(&:atomic) # => [3, 3, 3]
remainder.atomic # => 1
Negative values follow the same rules with rounding toward zero:
parts, remainder = Amount.new(-10, :LOGS).allocate([1, 1, 1])
parts.map(&:atomic) # => [-3, -3, -3]
remainder.atomic # => -1
Core API
Construction
Amount.new(1_500_000, :USDC)
Amount.new("1.50", :USDC)
Amount.usdc("1.50")
Amount.parse("USDC|1.50")
Amount.load(atomic: 1_500_000, symbol: :USDC)
Math
a = Amount.new("10", :USDC)
b = Amount.new("2", :USDC)
a + b
a - b
a * 2
a / 2
a / b
Amount * Amount raises Amount::TypeMismatch.
Comparison
Amount.new("1", :USDC) < Amount.new("2", :USDC)
Cross-type comparison returns nil when no directional rate exists.
Conversion
Amount.new("10", :USDC).to(:USD, rate: "1")
Display
amount.formatted
amount.ui
amount.ui(direction: :ceil)
amount.ui(unit: :gram)
amount.in_unit(:gram)
Serialization
payload = amount.to_h
Amount.load(payload)
Registry API
Amount.register(...)
Amount.register_default_rate(:USD, :USDC, "1")
Amount.registry.lookup(:USDC)
Amount.registry.symbols
Amount.registry.clear!
Amount.registry.lock!
Amount.registry.locked?
ActiveRecord Integration
Load the adapter explicitly:
require "amount/active_record"
Migration DSL
create_table :holdings do |t|
t.amount :amount
t.amount :default_amount, default: "USDC|1.25"
t.amount :fee, symbol: :SOL
t.amount :reserve, precision: 40
end
This generates:
*_atomicasnumeric(78, 0)by default*_symbolasstring(10)for multi-symbol amountsprecision:override support for the atomic column
Model Macro
class Holding < ApplicationRecord
has_amount :amount
has_amount :fee, symbol: :SOL
end
holding = Holding.new
holding.amount = "USDC|1.50"
holding.fee = 0.25
Writers accept:
AmountStringlike"USDC|1.50"Hashpayloads- raw numeric values for fixed-symbol attributes only
Scopes:
Holding.where_amount("USDC|1.50")
Holding.where_amount_gt("USDC|1.00")
Holding.where_amount_gte("USDC|1.00")
Holding.where_amount_lt("USDC|5.00")
Holding.where_amount_lte("USDC|5.00")
Holding.where_amount_between("USDC|1.00", "USDC|5.00")
Holding.amount_in(:USDC)
Suggested check constraint for multi-symbol amounts:
ALTER TABLE holdings ADD CONSTRAINT amount_both_or_neither
CHECK ((amount_atomic IS NULL) = (amount_symbol IS NULL));
SQLite Note
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.
PostgreSQL Dummy App
A minimal Rails dummy app lives under test/dummy for PostgreSQL-backed integration testing.
Run the default suite:
bundle exec rake
Run the PostgreSQL integration test explicitly:
AMOUNTS_POSTGRES_URL=postgresql://localhost/amounts_test \
bundle exec ruby -Ilib:test test/postgresql_integration_test.rb
If AMOUNTS_POSTGRES_URL is not set, the PostgreSQL test file skips cleanly.
Open a console against the dummy app:
AMOUNTS_POSTGRES_URL=postgresql://localhost/amounts_test \
test/dummy/bin/rails console
Testing
The gem ships opt-in RSpec matchers for app-level specs:
# spec/spec_helper.rb
require "amount/rspec"
require "amount/active_record/rspec"
Core matcher examples:
expect(holding.amount).to eq_amount("USDC|1.50")
expect(holding.amount).to be_amount_of(:USDC)
expect(holding.amount).to be_positive_amount
expect(converted).to be_approximately_amount(:GOLD, "0.0042", within: "0.0001")
ActiveRecord matcher examples:
expect(holding).to have_amount_column(:amount, "USDC|1.50")
expect(Holding.group(:amount_symbol).sum(:amount_atomic))
.to match_amounts(USDC: "10500.00", SOL: "12.5")
The gem's own test suite runs both Minitest and RSpec:
bundle exec rake
bundle exec rspec
Releases
RubyGems publishing is intended to run from GitHub Releases using RubyGems trusted publishing.
Workflow:
- create and push a version tag such as
v0.0.1 - publish a GitHub Release for that tag
- GitHub Actions runs
.github/workflows/release.yml - the workflow verifies the test suite and publishes the gem to RubyGems.org
RubyGems setup:
- on RubyGems.org, configure a trusted publisher for the
amountsgem - repository owner:
zarpay - repository name:
amounts - workflow filename:
release.yml - GitHub Actions environment:
release
This uses OIDC trusted publishing, so no RubyGems API token needs to be stored in GitHub Actions. See the official RubyGems trusted publishing guide:
Compared to money
money is excellent for fiat currency workflows and has a mature ecosystem. amounts takes a different tradeoff:
| Concern | money |
amounts |
|---|---|---|
| Internal storage | integer subunits | arbitrary-precision atomic integer |
| Non-fiat tokens | awkward at high decimals | first-class |
| Cross-type math | money-oriented exchange features | explicit directional rates only |
| Display units | currency formatting | arbitrary per-type display scaling |
| Rails dependency | common usage path | optional adapter only |
Contributing
- Install dependencies with
bin/setup - Run
bundle exec rake - Keep the core gem Rails-agnostic
- Add tests for behavioral changes
License
Released under the MIT License. See LICENSE.txt.