Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1a42eef
Add explicit decimal precision to Money
derikthiessen-shopify Sep 3, 2026
48107d4
Add decimal precision compatibility coverage
derikthiessen-shopify Sep 3, 2026
44356bf
Preserve implicit Money precision semantics
derikthiessen-shopify Sep 3, 2026
f481b2c
Add explicit precision allocation coverage
derikthiessen-shopify Sep 3, 2026
3d0a48a
Preserve explicit precision in allocations
derikthiessen-shopify Sep 3, 2026
be381e2
Add Binks precision edge case coverage
derikthiessen-shopify Sep 3, 2026
8f7bec4
Handle Binks precision edge cases
derikthiessen-shopify Sep 3, 2026
7edd80a
Add final Binks regression coverage
derikthiessen-shopify Sep 4, 2026
f2aa337
Preserve precision for caps and zero arithmetic
derikthiessen-shopify Sep 4, 2026
0f0a877
Add mixed allocation unit coverage
derikthiessen-shopify Sep 4, 2026
1360351
Normalize maximum allocation units
derikthiessen-shopify Sep 4, 2026
5313bee
Move allocation conversions into Money::Helpers
derikthiessen-shopify Sep 9, 2026
aa129ba
Use fetch for optional decimal precision when deserializing
derikthiessen-shopify Sep 9, 2026
a94edb6
Revert "Use fetch for optional decimal precision when deserializing"
derikthiessen-shopify Sep 9, 2026
130b8d8
Add decimal precision edge case coverage
derikthiessen-shopify Sep 14, 2026
47704b1
Define precision contracts for allocation and money columns
derikthiessen-shopify Sep 14, 2026
37e8ab4
Trigger CI after rebase
derikthiessen-shopify Sep 18, 2026
1d4edaa
Defer explicit precision rounding
derikthiessen-shopify Sep 18, 2026
a0354c5
Separate computation precision from currency presentment
derikthiessen-shopify Sep 24, 2026
da0b0c3
Keep implicit null currency neutral for precision promotion
derikthiessen-shopify Sep 24, 2026
eddc1e9
Cover precision promotion and currency output contracts
derikthiessen-shopify Sep 28, 2026
c694424
Cover raw Money values across persistence and job boundaries
derikthiessen-shopify Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ Money.new(1000, "USD") + Money.new(500, "USD") == Money.new(1500, "USD")
Money.new(1000, "USD") - Money.new(200, "USD") == Money.new(800, "USD")
Money.new(1000, "USD") * 5 == Money.new(5000, "USD")

# Explicit precision for values smaller than a currency subunit
unit_price = Money.new("0.057", "USD", decimal_precision: 3)
(unit_price * 100).to_s #=> "5.70"

# Explicit-precision values retain additional digits during calculations and
# round to currency precision when rendered
fractional_unit_price = Money.new("0.0057", "USD", decimal_precision: 3)
(fractional_unit_price * 100).to_s #=> "0.57"

# Money arithmetic uses the highest operand and currency precision
total = Money.new(1, "USD") + Money.new("0.057", "USD", decimal_precision: 3)
total.value.to_s("F") #=> "1.057"
total.to_s #=> "1.06"
total.decimal_precision #=> 3

m = Money.new(1000, "USD")
# Splitting money evenly
m.split(2) == [Money.new(500, "USD"), Money.new(500, "USD")]
Expand Down Expand Up @@ -269,6 +284,7 @@ end
| currency | string | hardcoded currency value |
| currency_read_only | boolean | when true, `currency_column` won't write the currency back into the db. Must be set to true if `currency_column` is an attr_reader or delegate. Default: false |
| coerce_null | boolean | when true, a nil value will be returned as Money.zero. Default: false |
| decimal_precision | integer | model-level computation precision used when reconstructing raw stored values, with currency precision as a minimum. No precision database column is needed. Default: the currency's minor units |

You can use multiple `money_column` calls to achieve the desired effects with
currency on the model or attribute level.
Expand Down
58 changes: 48 additions & 10 deletions lib/money/allocator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,13 @@ def allocate(splits, strategy = nil)
amounts[order[i]][:whole_subunits] += 1
end

amounts.map { |amount| Money.from_subunits(amount[:whole_subunits], currency) }
amounts.map do |amount|
Helpers.money_from_units(
amount[:whole_subunits],
currency,
decimal_precision: allocation_decimal_precision,
)
end
end

# Allocates money between different parties up to the maximum amounts specified.
Expand All @@ -114,30 +120,48 @@ def allocate(splits, strategy = nil)
# #=> [Money.new(5), Money.new(2)]
def allocate_max_amounts(maximums)
allocation_currency = extract_currency(maximums + [__getobj__])
maximums = maximums.map { |max| max.to_money(allocation_currency) }
maximums_total = maximums.reduce(Money.new(0, allocation_currency), :+)
money_values = maximums.grep(Money) + [__getobj__]
money_values = money_values.reject { |money| money.no_currency? && !money.explicit_decimal_precision? }
precision = if money_values.any?(&:explicit_decimal_precision?)
(money_values.map(&:decimal_precision) + [allocation_currency.minor_units]).max
end
maximums = maximums.map { |max| coerce_maximum(max, allocation_currency, precision) }
maximums_units = maximums.map do |maximum|
if precision
(maximum.value * 10**precision).floor
else
maximum.subunits
end
end
maximums_total_units = maximums_units.sum

splits = maximums.map do |max_amount|
next(Rational(0)) if maximums_total.zero?
Money.rational(max_amount, maximums_total)
splits = maximums_units.map do |max_units|
next(Rational(0)) if maximums_total_units.zero?
Rational(max_units, maximums_total_units)
end

total_allocatable = [maximums_total.subunits, subunits].min
total_allocatable = [maximums_total_units, Helpers.money_to_units(__getobj__, decimal_precision: precision)].min

@elfassy elfassy Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The maximums are rounded down (floor, line 129), but the receiver is rounded to the nearest unit here. So total_allocatable can be larger than the receiver.

Example

m = Money.new("0.0057", "USD", decimal_precision: 3)
m.allocate_max_amounts([Money.new("0.01", "USD"), Money.new("0.01", "USD")])
  .map { |p| p.value.to_s("F") }
# => ["0.003", "0.003"]   sum 0.006, but m is only 0.0057

Why this matters

  • allocate_max_amounts should never hand out more than the amount being split. This is the same "money out of nothing" problem as in split.
  • The method is also much harder to read now. It filters out null currencies, picks a precision, coerces the maximums, then either rounds down or reads subunits. Each step is another place where the maximums and the receiver can drift apart, which is how this bug got in.

Suggested fix
With values rounded when they are built, the start of the method gets much shorter:

def allocate_max_amounts(maximums)
  allocation_currency = extract_currency(maximums + [__getobj__])
  precision = shared_decimal_precision(maximums.grep(Money) + [__getobj__], allocation_currency)
  maximums = maximums.map { |max| coerce_maximum(max, allocation_currency, precision) }
  maximums_units = maximums.map { |max| Helpers.money_to_units(max, decimal_precision: precision) }
  maximums_total_units = maximums_units.sum
  # ... rest unchanged
end

private

def coerce_maximum(maximum, allocation_currency, precision)
  return maximum.to_money(allocation_currency) if maximum.is_a?(Money) || precision.nil?

  # Round caps down so a cap is never exceeded.
  value = Helpers.value_to_decimal(maximum).floor(precision)
  Money.new(value, allocation_currency, decimal_precision: precision)
end

def shared_decimal_precision(money_values, allocation_currency)
  explicit = money_values.filter_map(&:explicit_decimal_precision)
  [*explicit, allocation_currency.minor_units].max if explicit.any?
end

A null-currency value without explicit precision returns nil from explicit_decimal_precision, so the separate reject step is no longer needed. floor only has to be applied to caps given as plain numbers or strings, because Money caps are already whole units.


subunits_amounts, left_over = amounts_from_splits(1, splits, total_allocatable)
subunits_amounts.map! { |amount| amount[:whole_subunits] }

subunits_amounts.each_with_index do |amount, index|
break if left_over <= 0

max_amount = maximums[index].value * allocation_currency.subunit_to_unit
max_amount = maximums_units[index]
next if amount >= max_amount

left_over -= 1
subunits_amounts[index] += 1
end

subunits_amounts.map { |cents| Money.from_subunits(cents, allocation_currency) }
subunits_amounts.map do |amount|
Helpers.money_from_units(
amount,
allocation_currency,
decimal_precision: precision,
)
end
end

private
Expand All @@ -153,7 +177,13 @@ def extract_currency(money_array)
currencies.first || NULL_CURRENCY
end

def amounts_from_splits(allocations, splits, subunits_to_split = subunits)
def coerce_maximum(maximum, allocation_currency, precision)
return maximum.to_money(allocation_currency) if maximum.is_a?(Money)

Money.new(maximum, allocation_currency, decimal_precision: precision)
end

def amounts_from_splits(allocations, splits, subunits_to_split = allocation_units)
raise ArgumentError, "All splits values must be of type Rational." unless all_rational?(splits)

left_over = subunits_to_split
Expand All @@ -175,6 +205,14 @@ def all_rational?(splits)
splits.all? { |split| split.is_a?(Rational) }
end

def allocation_decimal_precision

@elfassy elfassy Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same logic, "the explicit precision, or nil", is written in 4 places:

# allocator.rb
def allocation_decimal_precision
  decimal_precision if explicit_decimal_precision?
end

# money.rb
def precision_argument
  decimal_precision if explicit_decimal_precision?
end

# splitter.rb
decimal_precision = @money.decimal_precision if @money.explicit_decimal_precision?

# helpers.rb
def money_to_units(money, decimal_precision: money.explicit_decimal_precision? ? money.decimal_precision : nil)

Why this matters
When a rule is copied, the copies drift apart over time. For example, if someone changes how clamping works in one place, split and allocate would quietly start to disagree. A single name is also easier to read, because a reader only has to learn one concept.

Suggested fix
Clamp once in initialize (see my comment there) and expose one public reader on Money:

def explicit_decimal_precision
  @decimal_precision
end

Then delete allocation_decimal_precision and precision_argument, and use the reader everywhere:

# allocator.rb
def allocation_units
  Helpers.money_to_units(__getobj__)
end

# money.rb, for example
Money.new(-value, currency, decimal_precision: explicit_decimal_precision)

decimal_precision if explicit_decimal_precision?
end

def allocation_units(money = __getobj__)
Helpers.money_to_units(money, decimal_precision: allocation_decimal_precision)
end

# Given a list of decimal numbers, return a list ordered by which is nearest to the next whole number.
# For instance, given inputs [1.1, 1.5, 1.9] the correct ranking is 2, 1, 0. This is because 1.9 is nearly 2.
# Note that we are not ranking by absolute size, we only care about the distance between our input number and
Expand Down
2 changes: 1 addition & 1 deletion lib/money/converters/converter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module Converters
class Converter
def to_subunits(money)
raise ArgumentError, "money cannot be nil" if money.nil?
(money.value * subunit_to_unit(money.currency)).to_i
(money.value * subunit_to_unit(money.currency)).round.to_i

@elfassy elfassy Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes behavior for every user, not only for values with explicit precision.

Example (no decimal_precision used)

Money.new("1.235", "BHD").subunits(format: :legacy_dollar)
# main:     123
# this PR:  124

BHD has 3 minor units, but the legacy dollar converter always uses 100. So the result used to be cut off (123), and now it is rounded (124).

Why this matters
Code that sends subunits to a payment processor or saves them would silently get a different number after upgrading, even though it never opted in to the new feature. The pull request says the feature is opt-in, so this would surprise people.

Suggested fix
Only round when the caller opted in:

def to_subunits(money)
  raise ArgumentError, "money cannot be nil" if money.nil?

  units = money.value * subunit_to_unit(money.currency)
  money.explicit_decimal_precision? ? units.round.to_i : units.to_i
end

Note: with rounding at construction, Money.new("0.0149", "USD", decimal_precision: 3) is stored as 0.015, so subunits returns 2, not 1. That is correct for the stored value. The spec at converters_spec.rb:63 would need updating.

end

def from_subunits(subunits, currency)
Expand Down
13 changes: 13 additions & 0 deletions lib/money/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ def value_to_decimal(num)
value
end

def money_to_units(money, decimal_precision: money.explicit_decimal_precision? ? money.decimal_precision : nil)
return money.subunits if decimal_precision.nil?

(money.value.round(decimal_precision) * 10**decimal_precision).to_i
end

def money_from_units(units, currency, decimal_precision: nil)
return Money.from_subunits(units, currency) if decimal_precision.nil?

value = value_to_decimal(units) / 10**decimal_precision
Money.new(value, currency, decimal_precision: decimal_precision)
end

def value_to_currency(currency)
case currency
when Money::Currency, Money::NullCurrency
Expand Down
Loading
Loading