Skip to content

Commit 26be635

Browse files
authored
Merge pull request #175 from merchant-ly/jd/add-spread
add spread/3 to portion out a given amount without a remainder
2 parents 42faefc + b0be3ce commit 26be635

4 files changed

Lines changed: 144 additions & 0 deletions

File tree

lib/money.ex

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2053,6 +2053,87 @@ defmodule Money do
20532053
{div, remainder}
20542054
end
20552055

2056+
@doc """
2057+
Proportionally spreads a given amount across the given portions with no remainder.
2058+
2059+
## Arguments
2060+
2061+
* `amount` is any `t:Money.t/0`
2062+
2063+
* `portions` may be a list of `t:Money.t/0`, a list of numbers, or an integer
2064+
into which the `money` is spread
2065+
2066+
* `opts` is a keyword list of options, as defined by `Money.round/2`
2067+
2068+
Returns a %Money{} list the same length (or value of the integer), with the amount spread
2069+
as evenly as the currency's smallest unit allows. The result is derived as follows:
2070+
2071+
1. Round the amount to the currency's default precision
2072+
2073+
2. Calculate partial sums of the given portions
2074+
2075+
3. Starting with the last portion, calculate the expected remaining amount then
2076+
subtract and round that portion's value from the current remaining amount.
2077+
2078+
eg. with [2, 1] as portions and $1 to spread, we calculate that 2/3 of the amount
2079+
should remain after `1` receives its portion, so we subtract the unrounded Money amount of
2080+
0.666666, and we round the share to $0.33. Then $1.00 - 0.33 is the new remaining amount.
2081+
This approach avoids numerical instability by using the expected remaining amount,
2082+
rather than summing up values as they are doled out.
2083+
2084+
## Examples
2085+
2086+
iex> Money.spread([Money.new(:usd, 10), Money.new(:usd, 1)], Money.new(:usd, 10))
2087+
[Money.new(:USD, "9.09"), Money.new(:USD, "0.91")]
2088+
2089+
iex> Money.spread([2.5, 1, 1], Money.new(:usd, "2.50"))
2090+
[Money.new(:USD, "1.39"), Money.new(:USD, "0.55"), Money.new(:USD, "0.56")]
2091+
2092+
iex> Money.spread(3, Money.new(:usd, 2))
2093+
[Money.new(:USD, "0.67"), Money.new(:USD, "0.66"), Money.new(:USD, "0.67")]
2094+
2095+
"""
2096+
@spec spread(list(Money.t()) | list(number()) | integer(), Money.t()) :: list(Money.t())
2097+
def spread(portions, amount, opts \\ [])
2098+
def spread([], _, _), do: []
2099+
2100+
def spread(portions, amount, opts) when is_integer(portions) do
2101+
spread(List.duplicate(1, portions), amount, opts)
2102+
end
2103+
2104+
def spread([h | _] = portions, %Money{} = amount, opts) do
2105+
{shares, _, _} = recurse_spread(portions, spread_zero(h), round(amount), opts)
2106+
shares
2107+
end
2108+
2109+
def spread(_, _, _), do: raise("Amount to spread must be Money.t()")
2110+
2111+
defp recurse_spread([], total, amount, _opts), do: {[], amount, total}
2112+
2113+
defp recurse_spread([head | tail], curr_sum, amount, opts) do
2114+
partial_sum = spread_sum(head, curr_sum)
2115+
{shares, remaining, total} = recurse_spread(tail, partial_sum, amount, opts)
2116+
2117+
proportion_remaining = prop_remaining(curr_sum, total)
2118+
unrounded_now_remaining = mult!(amount, proportion_remaining)
2119+
2120+
share = sub!(remaining, unrounded_now_remaining) |> round(opts)
2121+
now_remaining = sub!(remaining, share)
2122+
2123+
{[share | shares], now_remaining, total}
2124+
end
2125+
2126+
defp prop_remaining(%Money{} = partial_sum, total),
2127+
do: Decimal.div(to_decimal(partial_sum), to_decimal(total))
2128+
2129+
defp prop_remaining(partial_sum, total), do: partial_sum / total
2130+
2131+
defp spread_sum(%Money{} = head, sum), do: add!(head, sum)
2132+
defp spread_sum(head, sum), do: head + sum
2133+
2134+
defp spread_zero(%Money{} = head), do: zero(head)
2135+
defp spread_zero(_head), do: 0
2136+
20562137
@doc """
20572138
Round a `Money` value into the acceptable range for the requested currency.
20582139

test/spread_integer_test.exs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
defmodule MoneySpreadIntegerTest do
2+
use ExUnit.Case, async: true
3+
use ExUnitProperties
4+
5+
property "spread/2 works with a single integer as number of portions" do
6+
check all(
7+
portions <- integer(1..300),
8+
spread_pennies <- positive_integer(),
9+
max_runs: 1_000
10+
) do
11+
amount = Money.from_integer(spread_pennies, :usd)
12+
splits = Money.spread(portions, amount)
13+
14+
{:ok, sum} = Money.sum(splits)
15+
assert Money.equal?(sum, amount)
16+
end
17+
end
18+
end

test/spread_money_structs_test.exs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
defmodule MoneySpreadMoneyStructsTest do
2+
use ExUnit.Case, async: true
3+
use ExUnitProperties
4+
5+
property "spread/2 works with Money.t() portions" do
6+
check all(
7+
portions <- list_of(integer(1..1000), min_length: 1, max_length: 100),
8+
spread_pennies <- positive_integer(),
9+
max_runs: 1_000
10+
) do
11+
code = :usd
12+
amount = Money.from_integer(spread_pennies, code)
13+
portions = Enum.map(portions, &Money.from_integer(&1, code))
14+
15+
splits = Money.spread(portions, amount)
16+
17+
{:ok, sum} = Money.sum(splits)
18+
assert Money.equal?(sum, amount)
19+
end
20+
end
21+
end

test/spread_numbers_test.exs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
defmodule MoneySpreadNumbersTest do
2+
use ExUnit.Case, async: true
3+
use ExUnitProperties
4+
5+
property "spread/2 works with number portions" do
6+
# Max length is small; apparently float generation in stream_data is notably slow.
7+
8+
check all(
9+
portions <-
10+
list_of(one_of([float(min: 0.001, max: 1.0e16), positive_integer()]),
11+
min_length: 1,
12+
max_length: 10
13+
),
14+
spread_pennies <- positive_integer(),
15+
max_runs: 1_000
16+
) do
17+
amount = Money.from_integer(spread_pennies, :usd)
18+
splits = Money.spread(portions, amount)
19+
20+
{:ok, sum} = Money.sum(splits)
21+
assert Money.equal?(sum, amount)
22+
end
23+
end
24+
end

0 commit comments

Comments
 (0)