-
Notifications
You must be signed in to change notification settings - Fork 41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Create new :nearest strategy for Money::Allocator #284
Conversation
Current StateCurrently, this repository contains a method Challenges of Current StateRecently, @raphpap noted that this algorithm results in an allocation of leftover pennies that may be deemed unfair in certain situations, and merchants have occasionally wrongly perceived the resulting output as an overcharge. For instance, given the operation A merchant who expects the first value to equal one-ninth of the total may reasonably expect that line item to equal .11, and it is not always clear to merchants that this perceived "overcharge" is always offset by an exactly equivalent "undercharge." In this case, .11 and .89 would also be legitimate allocations, but the connection is not obvious to merchants. This issue compounds when sums are split multiple times, ultimately resulting in the occasional complaint from merchants that "I'm being overcharged 2 cents on every transaction." In reality, there is no overcharge, but it can appear that way to the merchant. Solution@raphpap proposed that the more fair allocation would give the leftover penny to whichever chunk is nearest to the next whole penny. For instance, given the chunks .111... and .888... the extra penny would go to the .88. Not because it is larger but because eighty-eight and eight-tenths of a cent is very nearly 89 cents, while eleven and one-tenth of a cent is comparatively further away from twelve cents. This pull request achieves that outcome, and provides test coverage for the new nearest neighbor rounding strategy. Next stepsThis pull request provides backwards compatibility by leaving round robin as the default rounding strategy, and obligating applications to "opt in" to the new strategy. Thus, the immediate impact of this PR is nothing, but when combined with https://github.com/Shopify/shopify/pull/494547 it has the effect of updating the rounding strategy on the transaction fee page. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good! Left some non-blocking comments
@jenny-codes thank you for the above feedback, I have implemented all of the changes you requested, plus one or two additional changes that I believe are in the spirit of your feedback, you can see these changes in ed8228a. |
Looks good! |
idx % length | ||
when :roundrobin_reverse | ||
length - (idx % length) - 1 | ||
else |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
as a note - we didn't re-implement the % length
in the def allocate
above as it should be mathematically impossible to have more left_over
s to re-distribute than we have amounts.length
.
The left_over
value should sum up to the amounts fractionnal_subunits
91ad338
to
b0e92c4
Compare
There's no reference to the allocation strategies in the README. Now that there are 3 strategies, maybe we should add a section to the readme explaining the differences between them? |
spec/allocator_spec.rb
Outdated
end | ||
|
||
specify "#allocate fills pennies from end to beginning with roundrobin_reverse strategy" do | ||
#round robin reverse for 1 penny | ||
moneys = new_allocator(0.05).allocate([0.3,0.7], :roundrobin_reverse) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sidenote: I am kinda confused why the gem supports both roundrobin
and roundrobin_revers
. I probably just don't have context. But it'd be simpler for gem consumers to just reverse their array, wouldn't it?
moneys = new_allocator(0.05).allocate([0.3,0.7].reverse)
Then the gem has one less thing to support.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I completely agree, this implementation would have made more sense. I even looked into removing it, but this would be a breaking change for core, as the roundrobin_reverse strategy is indeed used occasionally, therefore I'd suggest leaving it as-is.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I think we should leave it as-is in this PR. But maybe deprecate it and remove it in a future release on a separate PR 🤔
Maintaining this is kinda silly imo
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you think @elfassy ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
my memory is a bit blurry, but I think there was a valid reason. Would need to have a closer look (on monday 😅 ). Here's the original PR #186
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
> require "shopify-money"
=> true
> allocator = Money::Allocator.new(Money.new(0.05, "USD"))
> allocator.allocate([0.3,0.7], :roundrobin_reverse)
=> [#<Money value:0.01 currency:USD>, #<Money value:0.04 currency:USD>]
> allocator.allocate([0.3,0.7].reverse, :roundrobin)
=> [#<Money value:0.04 currency:USD>, #<Money value:0.01 currency:USD>]
> allocator.allocate([0.3,0.7].reverse, :roundrobin).reverse
=> [#<Money value:0.01 currency:USD>, #<Money value:0.04 currency:USD>]
Not sure if the ordering matters in this case
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 | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I assume that this isn't breaking any existing behavior, right?
It looks like the math for fractional_subunits has changed a little, so it's unclear to me if this has some unintended behavior changes, or if you controlled for that during development.
A lot of the tests for allocation behavior are new, so this would just mean writing the tests for the old behavior first, before making changes, ensuring they don't break, then building the functionality for nearest
on top if it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, that assumption is correct, this change is non-breaking. amounts_from_splits
is a private method, it is only called twice, both times within this file. The general nature of this change is to alter the return value to add the "fractional_subunits" value, which was absent from the return value previously:
return value before:
[
#amounts:
[123, 456, 789],
#leftover
2
]
return value after:
[
#amounts:
[
{
:whole_subunits => 123,
:fractional_subunits => .5
},
{
:whole_subunits => 456,
:fractional_subunits => .75
},
{
:whole_subunits => 789,
:fractional_subunits => .75
}
]
#leftover
2
]
In order to confirm that this is non-breaking, I have just now done as you suggested, and run the applicable new tests against the old application logic, and the result is all passing tests:

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Left a bunch of nitpicks and points of consideration, but this looks good to merge once the README is updated, IMO.
Ping me again when that's done please 🙏🏼
@@ -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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Useful callout 👍🏼
@@ -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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❤️
## 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 | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎉
e4d1335
to
c2961c8
Compare
see comment from @mkorostoff-shopify below