Skip to content
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

add spread/3 to portion out a given amount without a remainder #175

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
81 changes: 81 additions & 0 deletions lib/money.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1975,6 +1975,87 @@ defmodule Money do
{div, remainder}
end

@doc """
Proportionally spreads a given amount across the given portions with no remainder.

## Arguments

* `amount` is any `t:Money.t/0`

* `portions` may be a list of `t:Money.t/0`, a list of numbers, or an integer
into which the `money` is spread

* `opts` is a keyword list of options, as defined by `Money.round/2`

Returns a %Money{} list the same length (or value of the integer), with the amount spread
as evenly as the currency's smallest unit allows. The result is derived as follows:

1. Round the amount to the currency's default precision

2. Calculate partial sums of the given portions

3. Starting with the last portion, calculate the expected remaining amount then
subtract and round that portion's value from the current remaining amount.

eg. with [2, 1] as portions and $1 to spread, we calculate that 2/3 of the amount
should remain after `1` receives its portion, so we subtract the unrounded Money amount of
0.666666, and we round the share to $0.33. Then $1.00 - 0.33 is the new remaining amount.
This approach avoids numerical instability by using the expected remaining amount,
rather than summing up values as they are doled out.

## Examples

iex> Money.spread([Money.new(:usd, 10), Money.new(:usd, 1)], Money.new(:usd, 10))
[Money.new(:USD, "9.09"), Money.new(:USD, "0.91")]

iex> Money.spread([2.5, 1, 1], Money.new(:usd, "2.50"))
[Money.new(:USD, "1.39"), Money.new(:USD, "0.55"), Money.new(:USD, "0.56")]

iex> Money.spread(3, Money.new(:usd, 2))
[Money.new(:USD, "0.67"), Money.new(:USD, "0.66"), Money.new(:USD, "0.67")]

"""
@spec spread(list(Money.t()) | list(number()) | integer(), Money.t()) :: list(Money.t())
def spread(portions, amount, opts \\ [])
def spread([], _, _), do: []

def spread(portions, amount, opts) when is_integer(portions) do
spread(List.duplicate(1, portions), amount, opts)
end

def spread([h | _] = portions, %Money{} = amount, opts) do
{shares, _, _} = recurse_spread(portions, spread_zero(h), round(amount), opts)
shares
end

def spread(_, _, _), do: raise("Amount to spread must be Money.t()")

defp recurse_spread([], total, amount, _opts), do: {[], amount, total}

defp recurse_spread([head | tail], curr_sum, amount, opts) do
partial_sum = spread_sum(head, curr_sum)
{shares, remaining, total} = recurse_spread(tail, partial_sum, amount, opts)

proportion_remaining = prop_remaining(curr_sum, total)
unrounded_now_remaining = mult!(amount, proportion_remaining)

share = sub!(remaining, unrounded_now_remaining) |> round(opts)
now_remaining = sub!(remaining, share)

{[share | shares], now_remaining, total}
end

defp prop_remaining(%Money{} = partial_sum, total),
do: Decimal.div(to_decimal(partial_sum), to_decimal(total))

defp prop_remaining(partial_sum, total), do: partial_sum / total

defp spread_sum(%Money{} = head, sum), do: add!(head, sum)
defp spread_sum(head, sum), do: head + sum

defp spread_zero(%Money{} = head), do: zero(head)
defp spread_zero(_head), do: 0

@doc """
Round a `Money` value into the acceptable range for the requested currency.

Expand Down
18 changes: 18 additions & 0 deletions test/spread_integer_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
defmodule MoneySpreadIntegerTest do
use ExUnit.Case, async: true
use ExUnitProperties

property "spread/2 works with a single integer as number of portions" do
check all(
portions <- integer(1..300),
spread_pennies <- positive_integer(),
max_runs: 1_000
) do
amount = Money.from_integer(spread_pennies, :usd)
splits = Money.spread(portions, amount)

{:ok, sum} = Money.sum(splits)
assert Money.equal?(sum, amount)
end
end
end
21 changes: 21 additions & 0 deletions test/spread_money_structs_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
defmodule MoneySpreadMoneyStructsTest do
use ExUnit.Case, async: true
use ExUnitProperties

property "spread/2 works with Money.t() portions" do
check all(
portions <- list_of(integer(1..1000), min_length: 1, max_length: 100),
spread_pennies <- positive_integer(),
max_runs: 1_000
) do
code = :usd
amount = Money.from_integer(spread_pennies, code)
portions = Enum.map(portions, &Money.from_integer(&1, code))

splits = Money.spread(portions, amount)

{:ok, sum} = Money.sum(splits)
assert Money.equal?(sum, amount)
end
end
end
24 changes: 24 additions & 0 deletions test/spread_numbers_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
defmodule MoneySpreadNumbersTest do
use ExUnit.Case, async: true
use ExUnitProperties

property "spread/2 works with number portions" do
# Max length is small; apparently float generation in stream_data is notably slow.

check all(
portions <-
list_of(one_of([float(min: 0.001, max: 1.0e16), positive_integer()]),
min_length: 1,
max_length: 10
),
spread_pennies <- positive_integer(),
max_runs: 1_000
) do
amount = Money.from_integer(spread_pennies, :usd)
splits = Money.spread(portions, amount)

{:ok, sum} = Money.sum(splits)
assert Money.equal?(sum, amount)
end
end
end