Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
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

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
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
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