Trouble understanding how to model this with functional reduce

I’m trying to practice some reduce_while and reduce functions in Elixir. I’m trying to model a debt payoff method using Elixir.

The problem I see is that I’m using the `account.current_balance` for the `calculate_step` function, but that value is not the real value that is updated on each loop as I pay off the debts.

I think that OK, I’ll carry over some variable in the reduce and then my brain starts getting looped and I go cross-eyed.

Do you guys have any tips on how I can complete this function?

Some pseudo-code of what I think I should do.

Here’s my code:

``````@doc """
Calculates the debt payoff using the snowball method with rollover.

The snowball method focuses on paying off the account with the lowest balance first,
while making minimum payments on all other accounts. Once the lowest balance is paid off,
the extra cash and the minimum payment from the paid off account are rolled over to the
next lowest balance account. This process is repeated until all accounts are paid off.

Although the snowball method is less efficient than the avalanche method, which prioritizes
paying off the account with the highest interest rate first, it can be more motivating for
some individuals.

The method follows these rules:
- Make minimum payments on all accounts.
- Apply any extra cash per month towards the account with the lowest balance.
- Once the lowest balance account is paid off, roll over the extra cash and the minimum
payment from the paid off account to the next lowest balance account.
- Repeat the process until all accounts are paid off.

Parameters:
- `extra_cash_per_month`: The additional amount available each month to pay off debt.
- `user`: The User.t() for whom the debt payoff is being calculated.

Returns:
- A list of maps representing the debt payoff steps for each account.
"""
def snowball_method(extra_cash_per_month, user) do
bank_accounts = Banks.list_nonzero_bank_accounts_for_user(user)

# Create a map where the keys are the account IDs and the values are empty lists
payoff_steps =
bank_accounts
|> Enum.map(fn account -> {account.id, []} end)
|> Enum.into(%{})

result =
Enum.reduce_while(
1..3,
%{
bank_accounts: bank_accounts,
payoff_steps: payoff_steps,
rollover_amount: Money.new(:USD, 0)
},
fn _i, acc ->
result =
acc.bank_accounts
|> Enum.sort_by(fn account -> Money.to_decimal(account.current_balance) end)
|> Enum.reduce(
%{
bank_accounts: acc.bank_accounts,
steps: [],
rollover_amount: Money.new(:USD, "0"),
leftover_amount: Money.new(:USD, "0"),
extra_cash_per_month: extra_cash_per_month
},
fn account, steps_accumulator ->
IO.inspect(steps_accumulator, label: "======== STARTING WITH ===========")
rollover_amount = get_rollover_amount(steps_accumulator.bank_accounts)
low_account = lowest_balance_account(steps_accumulator.bank_accounts)

# For the lowest balance account, pay the minimum payment and
# rollover_amount, leftover_amount and extra_cash_per_month.
step =
if account.id == low_account.id do
calculate_step(
# Here's the problem -- I'm using the `account.current_balance` which isn't carrying over the real balance as I calculate it each loop.
account.current_balance,
account.current_apr,
account.minimum_payment,
steps_accumulator.extra_cash_per_month,
steps_accumulator.rollover_amount
)
else
# For other accounts, pay the minimum_payment. Only if
# if the account has a positive non-zero balance.
calculate_step(
account.current_balance,
account.current_apr,
account.minimum_payment,
Money.new(:USD, "0"),
steps_accumulator.rollover_amount
)
end

step = Map.put(step, :account_id, account.id)

# Grab leftover funds from the step and add it to the leftover_amount
# in the steps_accumulator.
leftover_funds = step.leftover_funds
steps_accumulator = Map.put(steps_accumulator, :leftover_amount, leftover_amount)

# Finally update the account's balance with the new_balance from the step
# in the steps_accumulator.
updated_bank_accounts =
Enum.map(steps_accumulator.bank_accounts, fn account ->
if account.id == step.account_id do
%{account | current_balance: step.new_balance}
else
account
end
end)

steps_accumulator =
Map.put(steps_accumulator, :bank_accounts, updated_bank_accounts)

IO.inspect(steps_accumulator)
IO.inspect("=============")

steps_accumulator
end
)

#
{:cont, acc}

end
)

result
end

@doc """
Adds up every minimum payment from all accounts with a balance of 0.
"""
defp get_rollover_amount(bank_accounts) do
Enum.reduce(bank_accounts, Money.new(:USD, 0), fn account, accumulator ->
if Money.zero?(account.current_balance) do
else
accumulator
end
end)
end

@doc """
Calculates the next step in the debt payoff process for a single account balance.

This function takes the following parameters:
- `balance`: The current balance of the account.
- `apr`: The Annual Percentage Rate (APR) of the account.
- `scheduled_minimum_payment`: The minimum payment required for the account.
- `extra_cash_per_month`: (Optional) The additional amount the user has allocated to pay off debts.
- `rollover_amount`: (Optional) The minimum payment from other paid off accounts, used when rollover is enabled.

The function returns a map containing the following keys:
- `:new_balance`: The updated balance after applying the payment.
- `:interest_paid`: The amount of interest paid in this step.
- `:principal_paid`: The amount of principal paid in this step.
- `:old_balance`: The balance before applying the payment.
- `:leftover_funds`: The amount of leftover funds after applying the payment.

## Examples

iex> calculate_step(Money.new(:USD, "100"), Decimal.new("2.5"), Money.new(:USD, "20"), Money.new(:USD, "120"))
%{
interest_paid: Money.new(:USD, "0.2083"),
leftover_funds: Money.new(:USD, "119.7917"),
new_balance: Money.new(:USD, "0"),
old_balance: Money.new(:USD, "100"),
principal_paid: Money.new(:USD, "100")
}

"""
def calculate_step(
balance,
apr,
scheduled_minimum_payment,
extra_cash_per_month \\ Money.new(:USD, 0),
rollover_amount \\ Money.new(:USD, 0)
) do
interest_rate = monthly_interest_rate_for_apr(apr)

interest_payment =
Money.mult!(balance, interest_rate) |> Money.div!(100) |> Money.round(currency_digits: 4)

principal_payment =
Money.sub!(scheduled_minimum_payment, interest_payment)
|> Money.round(currency_digits: 4)

{principal_payment, leftover_funds} =
if Money.compare(principal_payment, balance) == :gt do
{balance, Money.sub!(extras, interest_payment) |> Money.round(currency_digits: 4)}
else
{principal_payment, Money.new(:USD, "0")}
end

new_balance = Money.sub!(balance, principal_payment) |> Money.round(currency_digits: 4)

new_balance =
if Money.negative?(new_balance) do
Money.new(:USD, "0")
else
new_balance
end

%{
interest_paid: interest_payment,
principal_paid: principal_payment,
new_balance: new_balance,
old_balance: balance,
leftover_funds: leftover_funds
}
end

def lowest_balance_account([]), do: nil

def lowest_balance_account(accounts) do
positive_accounts =
accounts
|> Enum.filter(fn account -> Money.positive?(account.current_balance) end)

if positive_accounts == [] do
nil
else
positive_accounts
|> Enum.min_by(fn account -> account.current_balance end)
end
end

def monthly_interest_rate_for_apr(apr) do
Decimal.div(apr, 12)
|> Decimal.round(4)
end
end
``````

Side note: this is sorting `Decimal` structs with the default Erlang term ordering. You can pass `Decimal` as a second argument to use the correct sorting instead.

The result of this `reduce` is discarded - presumably there should be code that takes it and updates `acc`. The result is that the first step of the calculation runs over and over.

@sergio I think you want something like `Stream.unfold/2` instead of `Enum.reduce_while/3` for these reasons:

• The number of steps is unrelated to how many accounts are present (so there’s no enumerable to reduce). This is just wrong. See my second response.
• You don’t know how many steps it will take.

I’ve simplified the problem and given an example implementation:

``````defmodule Example do
def snowball(accounts_list, amount) do
accounts_before_min = Map.new(accounts_list, &{&1.id, &1})

# Remove minimums.
{accounts_after_min, remaining_after_min} =
Enum.map_reduce(accounts_before_min, amount, fn {_, account}, remaining ->
paid = Enum.min([remaining, account.minimum, abs(account.balance)])
{Map.update!(account, :balance, & &1 + paid), remaining - paid}
end)

# Keep removing while any cash remains.
Stream.unfold({remaining_after_min, Map.new(accounts_after_min, &{&1.id, &1})}, fn
:end ->
nil

{remaining, accounts} ->
lowest_balance_account_and_id =
accounts
|> Enum.filter(fn {_, account} -> account.balance < 0 end)
|> Enum.max_by(fn {_, account} -> account.balance end, fn -> :all_settled end)

cond do
lowest_balance_account_and_id == :all_settled ->
{{remaining, accounts}, :end}

remaining == 0 ->
{{remaining, accounts}, :end}

true ->
{id, %{balance: balance}} = lowest_balance_account_and_id
paid = min(remaining, abs(balance))

{
{remaining, accounts},
{remaining - paid, update_in(accounts[id][:balance], & &1 + paid)}
}
end
end)
|> Enum.to_list()
|> List.last()
end
end

accounts = [
%{id: 1, balance: -90, minimum: 1},
%{id: 2, balance: -80, minimum: 2},
%{id: 3, balance: -70, minimum: 3},
%{id: 4, balance: -60, minimum: 4}
]

{remaining, accounts_by_id} = Example.snowball(accounts, 80)
remaining
# 0
accounts_by_id
# %{
#   1 => %{id: 1, balance: -89, minimum: 1},
#   2 => %{id: 2, balance: -78, minimum: 2},
#   3 => %{id: 3, balance: -53, minimum: 3},
#   4 => %{id: 4, balance: 0, minimum: 4}
# }

{remaining, accounts_by_id} = Example.snowball(accounts, 310)
remaining
# 10
accounts_by_id
# %{
#   1 => %{id: 1, balance: 0, minimum: 1},
#   2 => %{id: 2, balance: 0, minimum: 2},
#   3 => %{id: 3, balance: 0, minimum: 3},
#   4 => %{id: 4, balance: 0, minimum: 4}
# }
``````

Caveat emptor, I didn’t test this very well…

EDIT: fixed at least one bug

1 Like

@sergio Hey sorry for all the churn, I think I have lingering vacation brain! A better approach is a double `map_reduce`:

``````defmodule Example2 do
def snowball(accounts_list, amount) do
# Remove minimums.
{accounts_after_min, remaining_after_min} =
Enum.map_reduce(accounts_list, amount, fn account, remaining ->
paid = Enum.min([remaining, account.minimum, abs(account.balance)])
{Map.update!(account, :balance, & &1 + paid), remaining - paid}
end)

# Keep removing while any cash remains.
accounts_after_min
|> Enum.sort_by(& &1.balance, :desc)
|> Enum.map_reduce(remaining_after_min, fn account, remaining ->
if remaining == 0 do
{account, 0}
else
paid = min(remaining, abs(account.balance))
{Map.update!(account, :balance, & &1 + paid), remaining - paid}
end
end)
end
end

accounts = [
%{id: 1, balance: -90, minimum: 1},
%{id: 2, balance: -80, minimum: 2},
%{id: 3, balance: -70, minimum: 3},
%{id: 4, balance: -60, minimum: 4}
]

{remaining80, accounts80} = Example2.snowball(accounts, 80)
remaining80
# 0
accounts80
# [
#   %{id: 4, balance: 0, minimum: 4},
#   %{id: 3, balance: -53, minimum: 3},
#   %{id: 2, balance: -78, minimum: 2},
#   %{id: 1, balance: -89, minimum: 1}
# ]

{remaining310, accounts310} = Example2.snowball(accounts, 310)
remaining310
# 10
accounts310
# [
#   %{id: 4, balance: 0, minimum: 4},
#   %{id: 3, balance: 0, minimum: 3},
#   %{id: 2, balance: 0, minimum: 2},
#   %{id: 1, balance: 0, minimum: 1}
# ]
``````

And to respond a bit closer to the original prompt: you could do this with `Enum.reduce_while/3`. But you’re updating a list of accounts every step which is inefficient relative to `Enum.map_reduce/3`, which does a single traversal through the list.

1 Like