From 8a9e166db4462862b0554b789861ad45935caec0 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 14 Nov 2024 16:24:25 -0500 Subject: [PATCH 1/4] add spread/3 to portion out a given amount without a remainder --- lib/money.ex | 81 ++++++++++++++++++++++++++++++ test/spread_integer_test.exs | 18 +++++++ test/spread_money_structs_test.exs | 21 ++++++++ test/spread_numbers_test.exs | 24 +++++++++ 4 files changed, 144 insertions(+) create mode 100644 test/spread_integer_test.exs create mode 100644 test/spread_money_structs_test.exs create mode 100644 test/spread_numbers_test.exs diff --git a/lib/money.ex b/lib/money.ex index 47ce208..594248c 100644 --- a/lib/money.ex +++ b/lib/money.ex @@ -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 an unrounded Money amount of + 0.666666, and we round it (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), Money.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 = Money.mult!(amount, proportion_remaining) + + share = Money.sub!(remaining, unrounded_now_remaining) |> Money.round(opts) + now_remaining = Money.sub!(remaining, share) + + {[share | shares], now_remaining, total} + end + + defp prop_remaining(%Money{} = partial_sum, total), + do: Decimal.div(Money.to_decimal(partial_sum), Money.to_decimal(total)) + + defp prop_remaining(partial_sum, total), do: partial_sum / total + + defp spread_sum(%Money{} = head, sum), do: Money.add!(head, sum) + defp spread_sum(head, sum), do: head + sum + + defp spread_zero(%Money{} = head), do: Money.zero(head) + defp spread_zero(_head), do: 0 + @doc """ Round a `Money` value into the acceptable range for the requested currency. diff --git a/test/spread_integer_test.exs b/test/spread_integer_test.exs new file mode 100644 index 0000000..541032a --- /dev/null +++ b/test/spread_integer_test.exs @@ -0,0 +1,18 @@ +defmodule MoneySpreadIntegerTest do + use ExUnit.Case, async: true + use ExUnitProperties + + test "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 diff --git a/test/spread_money_structs_test.exs b/test/spread_money_structs_test.exs new file mode 100644 index 0000000..955a225 --- /dev/null +++ b/test/spread_money_structs_test.exs @@ -0,0 +1,21 @@ +defmodule MoneySpreadMoneyStructsTest do + use ExUnit.Case, async: true + use ExUnitProperties + + test "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 diff --git a/test/spread_numbers_test.exs b/test/spread_numbers_test.exs new file mode 100644 index 0000000..efe4163 --- /dev/null +++ b/test/spread_numbers_test.exs @@ -0,0 +1,24 @@ +defmodule MoneySpreadNumbersTest do + use ExUnit.Case, async: true + use ExUnitProperties + + test "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 From 573f5b76382b96cf7f633974cba7bb8fe54bc1ee Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 14 Nov 2024 20:21:46 -0500 Subject: [PATCH 2/4] remove unnecessary references to the module we're in --- lib/money.ex | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/money.ex b/lib/money.ex index 594248c..8097b85 100644 --- a/lib/money.ex +++ b/lib/money.ex @@ -2024,7 +2024,7 @@ defmodule Money do end def spread([h | _] = portions, %Money{} = amount, opts) do - {shares, _, _} = recurse_spread(portions, spread_zero(h), Money.round(amount), opts) + {shares, _, _} = recurse_spread(portions, spread_zero(h), round(amount), opts) shares end @@ -2037,23 +2037,23 @@ defmodule Money do {shares, remaining, total} = recurse_spread(tail, partial_sum, amount, opts) proportion_remaining = prop_remaining(curr_sum, total) - unrounded_now_remaining = Money.mult!(amount, proportion_remaining) + unrounded_now_remaining = mult!(amount, proportion_remaining) - share = Money.sub!(remaining, unrounded_now_remaining) |> Money.round(opts) - now_remaining = Money.sub!(remaining, share) + 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(Money.to_decimal(partial_sum), Money.to_decimal(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: Money.add!(head, sum) + defp spread_sum(%Money{} = head, sum), do: add!(head, sum) defp spread_sum(head, sum), do: head + sum - defp spread_zero(%Money{} = head), do: Money.zero(head) + defp spread_zero(%Money{} = head), do: zero(head) defp spread_zero(_head), do: 0 @doc """ From 3665ef7ea7e502f75a08e74e57c3bf90fb512819 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Fri, 15 Nov 2024 09:36:55 -0500 Subject: [PATCH 3/4] correctly use 'property' test def --- test/spread_integer_test.exs | 2 +- test/spread_money_structs_test.exs | 2 +- test/spread_numbers_test.exs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/spread_integer_test.exs b/test/spread_integer_test.exs index 541032a..e813395 100644 --- a/test/spread_integer_test.exs +++ b/test/spread_integer_test.exs @@ -2,7 +2,7 @@ defmodule MoneySpreadIntegerTest do use ExUnit.Case, async: true use ExUnitProperties - test "spread/2 works with a single integer as number of portions" do + property "spread/2 works with a single integer as number of portions" do check all( portions <- integer(1..300), spread_pennies <- positive_integer(), diff --git a/test/spread_money_structs_test.exs b/test/spread_money_structs_test.exs index 955a225..3c4eb8c 100644 --- a/test/spread_money_structs_test.exs +++ b/test/spread_money_structs_test.exs @@ -2,7 +2,7 @@ defmodule MoneySpreadMoneyStructsTest do use ExUnit.Case, async: true use ExUnitProperties - test "spread/2 works with Money.t() portions" do + 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(), diff --git a/test/spread_numbers_test.exs b/test/spread_numbers_test.exs index efe4163..6d377c9 100644 --- a/test/spread_numbers_test.exs +++ b/test/spread_numbers_test.exs @@ -2,7 +2,7 @@ defmodule MoneySpreadNumbersTest do use ExUnit.Case, async: true use ExUnitProperties - test "spread/2 works with number portions" do + property "spread/2 works with number portions" do # Max length is small; apparently float generation in stream_data is notably slow. check all( From b0be3ce3edb99e1aff41b2e08ddf71c9152bfd15 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Fri, 15 Nov 2024 09:50:55 -0500 Subject: [PATCH 4/4] doc rewording for clarity --- lib/money.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/money.ex b/lib/money.ex index 8097b85..a227165 100644 --- a/lib/money.ex +++ b/lib/money.ex @@ -1998,8 +1998,8 @@ defmodule Money do 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 an unrounded Money amount of - 0.666666, and we round it (to 0.33). Then $1.00 - 0.33 is the new remaining 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.