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
                leftover_amount = Money.add!(steps_accumulator.leftover_amount, 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
        Money.add!(accumulator, account.minimum_payment)
      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.add!(extra_cash_per_month)
      |> Money.add!(rollover_amount)
      |> Money.round(currency_digits: 4)

    {principal_payment, leftover_funds} =
      if Money.compare(principal_payment, balance) == :gt do
        extras = Money.add!(extra_cash_per_month, rollover_amount)
        {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 :sweat_smile:

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