lib/bookk/interledger_entry.ex

defmodule Bookk.InterledgerEntry do
  @moduledoc """
  An interledger entry is a collection of journal entries affecting
  multiple ledgers that must be transacted under the same accounting
  transaction. It's somewhat analogous to an `Ecto.Multi` holding
  multiple operations or a git commit that affect multiple files.

  ## Related

  - `Bookk.Notation`;
  - `Bookk.NaiveState`;
  - `Bookk.JournalEntry`.
  """

  import Enum, only: [all?: 2, map: 2, to_list: 1]
  import List, only: [flatten: 1]
  import Map, only: [values: 1]

  alias __MODULE__, as: InterledgerEntry
  alias Bookk.JournalEntry, as: JournalEntry

  @typedoc """
  The struct that represents an interledger entry.

  ## Fields

  An interledger entry is composed of:
  - `entries_by_ledger`: the map of journal entries that are included
    in the interledger entry, grouped by the name of the ledger
    against which they should be posted.
  """
  @type t :: %Bookk.InterledgerEntry{
          entries_by_ledger: %{
            (ledger_name :: String.t()) => Bookk.JournalEntry.t()
          }
        }

  defstruct entries_by_ledger: %{}

  @doc """
  Checks whether the interledger entry is balanced. It is balance if
  all of its journal entries are balanced.

  ## Examples

  Balanced entry:

      iex> interledger = %Bookk.InterledgerEntry{
      iex>   entries_by_ledger: %{
      iex>     "acme" => [
      iex>       %Bookk.JournalEntry{
      iex>         operations: [
      iex>           fixture_account_head(:cash) |> debit(30_00),
      iex>           fixture_account_head(:deposits) |> credit(30_00)
      iex>         ]
      iex>       }
      iex>     ]
      iex>   }
      iex> }
      iex>
      iex> Bookk.InterledgerEntry.balanced?(interledger)
      true

  Unbalanced entry:

      iex> interledger = %Bookk.InterledgerEntry{
      iex>   entries_by_ledger: %{
      iex>     "acme" => [
      iex>       %Bookk.JournalEntry{
      iex>         operations: [
      iex>           fixture_account_head(:cash) |> debit(30_00),
      iex>         ]
      iex>       }
      iex>     ]
      iex>   }
      iex> }
      iex>
      iex> Bookk.InterledgerEntry.balanced?(interledger)
      false

  """
  @spec balanced?(t) :: boolean

  def balanced?(%InterledgerEntry{entries_by_ledger: %{} = entries_by_ledger}) do
    values(entries_by_ledger)
    |> flatten()
    |> all?(&JournalEntry.balanced?/1)
  end

  @doc """
  Checks whether an interledger entry is empty. It is empty when it
  has now journal entries or when all its journal entries are empty.

  See `Bookk.JournalEntry.empty?/1` to learn more about empty journal
  entries.

  ## Examples

  Is empty when there's no entries:

      iex> Bookk.InterledgerEntry.empty?(%Bookk.InterledgerEntry{})
      true

  Is empty when all entries are empty:

      iex> interledger = %Bookk.InterledgerEntry{
      iex>   entries_by_ledger: %{
      iex>     "acme" => [
      iex>       %Bookk.JournalEntry{
      iex>         operations: [%Bookk.Operation{amount: 0}]
      iex>       }
      iex>     ]
      iex>   }
      iex> }
      iex>
      iex> Bookk.InterledgerEntry.empty?(interledger)
      true

  Is not empty when at least one entry isn't empty:

      iex> interledger = %Bookk.InterledgerEntry{
      iex>   entries_by_ledger: %{
      iex>     "acme" => [
      iex>       %Bookk.JournalEntry{
      iex>         operations: [
      iex>           %Bookk.Operation{amount: 0},
      iex>           %Bookk.Operation{amount: 1},
      iex>         ]
      iex>       }
      iex>     ]
      iex>   }
      iex> }
      iex>
      iex> Bookk.InterledgerEntry.empty?(interledger)
      false

  """
  @spec empty?(t) :: boolean

  def empty?(%InterledgerEntry{entries_by_ledger: %{} = entries_by_ledger}) do
    values(entries_by_ledger)
    |> flatten()
    |> all?(&JournalEntry.empty?/1)
  end

  @doc """
  Produces a new interledger entry that is equaly opposite of the
  given interledger entry, meaning its capable of reverting all the
  changes that the given entry causes.

  ## Examples

  Reverses all of its journal entries:

      iex> interledger = %Bookk.InterledgerEntry{
      iex>   entries_by_ledger: %{
      iex>     "acme" => [
      iex>       %Bookk.JournalEntry{
      iex>         operations: [
      iex>           fixture_account_head(:cash) |> debit(10_00),
      iex>           fixture_account_head(:deposits) |> credit(10_00)
      iex>         ]
      iex>       }
      iex>     ]
      iex>   }
      iex> }
      iex>
      iex> Bookk.InterledgerEntry.reverse(interledger)
      %Bookk.InterledgerEntry{
        entries_by_ledger: %{
          "acme" => [
            %Bookk.JournalEntry{
              operations: [
                fixture_account_head(:deposits) |> debit(10_00),
                fixture_account_head(:cash) |> credit(10_00)
              ]
            }
          ]
        }
      }

  """
  @spec reverse(t) :: t

  def reverse(%InterledgerEntry{entries_by_ledger: %{} = entries_by_ledger} = entry) do
    entries_by_ledger =
      for {ledger, entries} <- to_list(entries_by_ledger),
          into: %{},
          do: {ledger, map(entries, &JournalEntry.reverse/1) |> :lists.reverse()}

    %{entry | entries_by_ledger: entries_by_ledger}
  end

  @doc """
  Given an interledger entry, it returns all its journal entries in
  the form of a list of tuples where the first element is the ledger's
  name and the second element is a list of journal entries that are
  meant to be posted to such ledger.

  ## Examples

  Returns a list of tuple where the first element is the ledger name
  and the second element is a journal entry:

      iex> user_id = "b13a81cf-ff78-414d-b5b2-042e9ecf2082"
      iex> cash = fixture_account_head(:cash)
      iex> deposits = fixture_account_head(:deposits)
      iex> unspent_cash = fixture_account_head({:unspent_cash, {:user, user_id}})
      iex>
      iex> interledger = %Bookk.InterledgerEntry{
      iex>   entries_by_ledger: %{
      iex>     "acme" => [
      iex>       Bookk.JournalEntry.new([
      iex>         debit(cash, 50_00),
      iex>         credit(unspent_cash, 50_00)
      iex>       ])
      iex>     ],
      iex>     "user(b13a81cf-ff78-414d-b5b2-042e9ecf2082)" => [
      iex>       Bookk.JournalEntry.new([
      iex>         debit(cash, 50_00),
      iex>         credit(deposits, 50_00)
      iex>       ])
      iex>     ]
      iex>   }
      iex> }
      iex>
      iex> Bookk.InterledgerEntry.to_journal_entries(interledger)
      [
        {"acme", Bookk.JournalEntry.new([
          debit(fixture_account_head(:cash), 50_00),
          credit(fixture_account_head({:unspent_cash, {:user, "b13a81cf-ff78-414d-b5b2-042e9ecf2082"}}), 50_00)
        ])},
        {"user(b13a81cf-ff78-414d-b5b2-042e9ecf2082)", Bookk.JournalEntry.new([
          debit(fixture_account_head(:cash), 50_00),
          credit(fixture_account_head(:deposits), 50_00)
        ])}
      ]

  """
  @spec to_journal_entries(t) :: [{ledger_name :: String.t(), Bookk.JournalEntry.t()}]

  def to_journal_entries(%InterledgerEntry{} = interledger) do
    for {ledger_name, entries} <- to_list(interledger.entries_by_ledger),
        entry <- entries,
        do: {ledger_name, entry}
  end
end