Skip to content

Commit

Permalink
Merge pull request #284 from Shopify/money-allocator-nearest-strategy
Browse files Browse the repository at this point in the history
Create new :nearest strategy for Money::Allocator
  • Loading branch information
mkorostoff-shopify authored Apr 5, 2024
2 parents 37ce840 + c2961c8 commit 10d2f00
Show file tree
Hide file tree
Showing 3 changed files with 250 additions and 51 deletions.
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ money_column expects a DECIMAL(21,3) database field.
- Provides a `Money::Currency` class which encapsulates all information about a monetary unit.
- Represents monetary values as decimals. No need to convert your amounts every time you use them. Easily understand the data in your DB.
- Does NOT provide APIs for exchanging money from one currency to another.
- Will not lose pennies during divisions
- Will not lose pennies during divisions. For instance, given $1 / 3 the resulting chunks will be .34, .33, and .33. Notice that one chunk is larger than the others, so the result still adds to $1.
- Allows callers to select a rounding strategy when dividing, to determine the order in which leftover pennies are given out.

## Installation

Expand Down Expand Up @@ -57,6 +58,30 @@ m.allocate([Rational(2, 3), Rational(1, 3)]).map(&:value) == [666.67, 333.33]
m.allocate_max_amounts([500, 300, 200]).map(&:value) == [500, 300, 200]
m.allocate_max_amounts([500, 300, 300]).map(&:value) == [454.55, 272.73, 272.72]

## Selectable rounding strategies during division

# Assigns leftover subunits left to right
m = Money::Allocator.new(Money.new(10.55, "USD"))
monies = m.allocate([0.25, 0.5, 0.25], :roundrobin)
#monies[0] == 2.64 <-- gets 1 penny
#monies[1] == 5.28 <-- gets 1 penny
#monies[2] == 2.63 <-- gets no penny

# Assigns leftover subunits right to left
m = Money::Allocator.new(Money.new(10.55, "USD"))
monies = m.allocate([0.25, 0.5, 0.25], :roundrobin_reverse)
#monies[0] == 2.63 <-- gets no penny
#monies[1] == 5.28 <-- gets 1 penny
#monies[2] == 2.64 <-- gets 1 penny

# Assigns leftover subunits to the nearest whole subunit
m = Money::Allocator.new(Money.new(10.55, "USD"))
monies = m.allocate([0.25, 0.5, 0.25], :nearest)
#monies[0] == 2.64 <-- gets 1 penny
#monies[1] == 5.27 <-- gets no penny
#monies[2] == 2.64 <-- gets 1 penny
# $2.6375 is closer to the next whole penny than $5.275

# Clamp
Money.new(50, "USD").clamp(1, 100) == Money.new(50, "USD")

Expand Down
78 changes: 53 additions & 25 deletions lib/money/allocator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,27 @@ def initialize(money)

ONE = BigDecimal("1")

# Allocates money between different parties without losing pennies.
# After the mathematically split has been performed, left over pennies will
# be distributed round-robin amongst the parties. This means that parties
# listed first will likely receive more pennies than ones that are listed later
# Allocates money between different parties without losing subunits. A "subunit"
# in this context is the smallest unit of a currency that can be divided no
# further. In USD the unit is dollars and the subunit is cents. In JPY the unit
# is yen and the subunit is also yen. So given $1 divided by 3, the resulting subunits
# should be [34¢, 33¢, 33¢]. Notice that one of these chunks is larger than the other
# two, because we cannot transact in amounts less than 1 subunit.
#
# After the mathematically split has been performed, left over subunits will
# be distributed round-robin or nearest-subunit strategy amongst the parties.
# Round-robin strategy has the virtue of being easier to understand, while
# nearest-subunit is a more complex alogirthm that results in the most fair
# distribution.
#
# @param splits [Array<Numeric>]
# @param strategy Symbol
# @return [Array<Money>]
#
# Strategies:
# - `:roundrobin` (default): leftover pennies will be accumulated starting from the first allocation left to right
# - `:roundrobin_reverse`: leftover pennies will be accumulated starting from the last allocation right to left
# - `:roundrobin` (default): leftover subunits will be accumulated starting from the first allocation left to right
# - `:roundrobin_reverse`: leftover subunits will be accumulated starting from the last allocation right to left
# - `:nearest`: leftover subunits will by given first to the party closest to the next whole subunit
#
# @example
# Money.new(5, "USD").allocate([0.50, 0.25, 0.25])
Expand All @@ -38,29 +47,45 @@ def initialize(money)
# Money.new(30, "USD").allocate([Rational(2, 3), Rational(1, 3)])
# #=> [#<Money value:20.00 currency:USD>, #<Money value:10.00 currency:USD>]

# @example left over pennies distributed reverse order when using roundrobin_reverse strategy
# @example left over subunits distributed reverse order when using roundrobin_reverse strategy
# Money.new(10.01, "USD").allocate([0.5, 0.5], :roundrobin_reverse)
# #=> [#<Money value:5.00 currency:USD>, #<Money value:5.01 currency:USD>]

# @examples left over subunits distributed by nearest strategy
# Money.new(10.55, "USD").allocate([0.25, 0.5, 0.25], :nearest)
# #=> [#<Money value:2.64 currency:USD>, #<Money value:5.27 currency:USD>, #<Money value:2.64 currency:USD>]

def allocate(splits, strategy = :roundrobin)
splits.map!(&:to_r)
allocations = splits.inject(0, :+)

if (allocations - ONE) > Float::EPSILON
raise ArgumentError, "splits add to more than 100%"
raise ArgumentError, "allocations add to more than 100%"
end

amounts, left_over = amounts_from_splits(allocations, splits)

order = case strategy
when :roundrobin
(0...left_over).to_a
when :roundrobin_reverse
(0...amounts.length).to_a.reverse
when :nearest
rank_by_nearest(amounts)
else
raise ArgumentError, "Invalid strategy. Valid options: :roundrobin, :roundrobin_reverse, :nearest"
end

left_over.to_i.times do |i|
amounts[allocation_index_for(strategy, amounts.length, i)] += 1
amounts[order[i]][:whole_subunits] += 1
end

amounts.collect { |subunits| Money.from_subunits(subunits, currency) }
amounts.map { |amount| Money.from_subunits(amount[:whole_subunits], currency) }
end

# Allocates money between different parties up to the maximum amounts specified.
# Left over pennies will be assigned round-robin up to the maximum specified.
# Pennies are dropped when the maximums are attained.
# Left over subunits will be assigned round-robin up to the maximum specified.
# Subunits are dropped when the maximums are attained.
#
# @example
# Money.new(30.75).allocate_max_amounts([Money.new(26), Money.new(4.75)])
Expand Down Expand Up @@ -90,6 +115,7 @@ def allocate_max_amounts(maximums)
total_allocatable = [maximums_total.subunits, self.subunits].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 unless left_over > 0
Expand Down Expand Up @@ -119,10 +145,14 @@ def amounts_from_splits(allocations, splits, subunits_to_split = subunits)

left_over = subunits_to_split

amounts = splits.collect do |ratio|
frac = (subunits_to_split * ratio / allocations.to_r).floor
left_over -= frac
frac
amounts = splits.map do |ratio|
whole_subunits = (subunits_to_split * ratio / allocations.to_r).floor
fractional_subunits = (subunits_to_split * ratio / allocations.to_r).to_f - whole_subunits
left_over -= whole_subunits
{
:whole_subunits => whole_subunits,
:fractional_subunits => fractional_subunits
}
end

[amounts, left_over]
Expand All @@ -132,15 +162,13 @@ def all_rational?(splits)
splits.all? { |split| split.is_a?(Rational) }
end

def allocation_index_for(strategy, length, idx)
case strategy
when :roundrobin
idx % length
when :roundrobin_reverse
length - (idx % length) - 1
else
raise ArgumentError, "Invalid strategy. Valid options: :roundrobin, :roundrobin_reverse"
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
# the next whole number. Similarly, given the input [9.1, 5.5, 3.9] the correct ranking is *still* 2, 1, 0. This
# is because 3.9 is nearer to 4 than 9.1 is to 10.
def rank_by_nearest(amounts)
amounts.each_with_index.sort_by{ |amount, i| 1 - amount[:fractional_subunits] }.map(&:last)
end
end
end
Loading

0 comments on commit 10d2f00

Please sign in to comment.