Packages
beancount_ex
0.6.0
An idiomatic Elixir interface to Beancount that serves as the long-term behavioral oracle for a future native Elixir General Ledger.
Current section
Files
Jump to
Current section
Files
lib/beancount/engine/elixir/booking.ex
defmodule Beancount.Engine.Elixir.Booking do
@moduledoc false
alias Beancount.CostSpec
alias Beancount.Directives.Posting
alias Beancount.Engine.Elixir.{Inventory, Lot}
@spec reduce(Inventory.t(), String.t(), Posting.t(), String.t() | nil) ::
{:ok, Inventory.t()} | {:error, String.t()}
def reduce(inventory, account, posting, booking_method) do
currency = posting.currency
reduce_units = posting.amount |> Decimal.abs()
cost_spec = Inventory.cost_spec_from_posting(posting)
method = normalize_method(booking_method)
inventory
|> maybe_average(method, account, currency)
|> do_reduce(account, currency, reduce_units, cost_spec, method, posting)
end
defp normalize_method(nil), do: "NONE"
defp normalize_method(method), do: String.upcase(method)
defp maybe_average(inventory, "AVERAGE", account, currency) do
lots = get_lots(inventory, account, currency)
case merge_average(lots) do
nil -> inventory
merged -> Inventory.update_lots_at(inventory, account, currency, [merged])
end
end
defp maybe_average(inventory, _method, _account, _currency), do: inventory
defp do_reduce(inventory, account, currency, reduce_units, cost_spec, "STRICT", posting) do
lots = get_lots(inventory, account, currency)
if is_nil(cost_spec) do
fifo_consume(inventory, account, currency, reduce_units, lots)
else
strict_reduce(inventory, account, currency, reduce_units, cost_spec, lots, posting)
end
end
defp do_reduce(inventory, account, currency, reduce_units, cost_spec, method, posting)
when method in ["FIFO", "LIFO", "NONE"] and not is_nil(cost_spec) do
lots = get_lots(inventory, account, currency)
matches = matching_lots(lots, cost_spec)
case matches do
[] ->
no_match_error(posting, lots)
[{_lot, index}] ->
consume_lot_at_index(inventory, account, currency, index, reduce_units)
_ ->
fifo_consume(inventory, account, currency, reduce_units, order_lots(lots, method))
end
end
defp do_reduce(inventory, account, currency, reduce_units, _cost_spec, method, _posting) do
lots = get_lots(inventory, account, currency)
fifo_consume(inventory, account, currency, reduce_units, order_lots(lots, method))
end
defp strict_reduce(inventory, account, currency, reduce_units, cost_spec, lots, posting) do
case matching_lots(lots, cost_spec) do
[] ->
no_match_error(posting, lots)
matches when length(matches) > 1 ->
{:error,
"Ambiguous matches for #{inspect_posting_short(posting)}: #{format_matches(matches)}"}
[{%Lot{}, index}] ->
consume_lot_at_index(inventory, account, currency, index, reduce_units)
end
end
defp no_match_error(posting, lots) do
{:error,
"No position matches #{inspect_posting(posting)} against balance (#{format_balance(lots)})"}
end
defp fifo_consume(inventory, account, currency, remaining, lots) do
case consume_lots(remaining, lots) do
{:done, remaining_lots} ->
{:ok, Inventory.update_lots_at(inventory, account, currency, remaining_lots)}
{:short, leftover, remaining_lots} ->
short = %Lot{units: Decimal.negate(leftover), currency: currency, cost: nil}
{:ok, Inventory.update_lots_at(inventory, account, currency, remaining_lots ++ [short])}
end
end
defp consume_lots(remaining, lots) do
consume_lots(remaining, lots, [])
end
defp consume_lots(remaining, [], acc) do
if Decimal.equal?(remaining, 0) do
{:done, Enum.reverse(acc)}
else
{:short, remaining, Enum.reverse(acc)}
end
end
defp consume_lots(remaining, [lot | rest], acc) do
cond do
Decimal.equal?(remaining, 0) ->
{:done, Enum.reverse(acc) ++ [lot | rest]}
Decimal.lte?(lot.units, remaining) ->
consume_lots(Decimal.sub(remaining, lot.units), rest, acc)
true ->
leftover = Decimal.sub(lot.units, remaining)
{:done, Enum.reverse(acc) ++ [%{lot | units: leftover} | rest]}
end
end
defp consume_lot_at_index(inventory, account, currency, index, reduce_units) do
lots = get_lots(inventory, account, currency)
lot = Enum.at(lots, index)
if is_nil(lot) or Decimal.gt?(reduce_units, lot.units) do
{:error, "Insufficient units for reduction"}
else
{prefix, rest} = Enum.split(lots, index)
{matched, suffix} =
case rest do
[h | t] -> {[h], t}
[] -> {[], []}
end
case consume_lots(reduce_units, matched) do
{:done, consumed} ->
{:ok,
Inventory.update_lots_at(inventory, account, currency, prefix ++ consumed ++ suffix)}
{:short, _, _} ->
{:error, "Insufficient units for reduction"}
end
end
end
defp get_lots(inventory, account, currency) do
inventory |> Map.get(account, %{}) |> Map.get(currency, [])
end
defp order_lots(lots, "LIFO"), do: Enum.reverse(lots)
defp order_lots(lots, _), do: lots
defp matching_lots(lots, cost_spec) do
lots
|> Enum.with_index()
|> Enum.filter(fn {lot, _} -> lot_matches_cost?(lot, cost_spec) end)
end
defp lot_matches_cost?(%Lot{cost: lot_cost}, cost_spec) do
Inventory.cost_specs_match?(lot_cost, cost_spec) or
label_match?(lot_cost, cost_spec) or date_only_match?(lot_cost, cost_spec)
end
defp label_match?(%CostSpec{label: label}, %CostSpec{label: label})
when is_binary(label),
do: true
defp label_match?(_, _), do: false
defp date_only_match?(%CostSpec{date: date, per_amount: nil}, %CostSpec{
date: date,
per_amount: nil
})
when not is_nil(date),
do: true
defp date_only_match?(_, _), do: false
defp merge_average([first | rest]) do
Enum.reduce(rest, first, fn lot, acc ->
units = Decimal.add(acc.units, lot.units)
%Lot{
units: units,
currency: acc.currency,
cost: merge_cost(acc.cost, lot.cost, acc.units, lot.units)
}
end)
end
defp merge_average([]), do: nil
defp merge_cost(nil, cost, _, _), do: cost
defp merge_cost(cost, nil, _, _), do: cost
defp merge_cost(%CostSpec{} = left, %CostSpec{} = right, left_units, right_units) do
total =
Decimal.add(
Decimal.mult(left.per_amount || Decimal.new(0), left_units),
Decimal.mult(right.per_amount || Decimal.new(0), right_units)
)
total_units = Decimal.add(left_units, right_units)
per = Decimal.div(total, total_units)
%CostSpec{
per_amount: per,
per_currency: left.per_currency || right.per_currency,
merge: false
}
end
defp format_balance(lots) do
lots
|> Enum.map_join(", ", fn %Lot{units: u, currency: c} -> "#{u} #{c}" end)
end
defp format_matches(matches) do
matches
|> Enum.map_join(", ", fn {lot, _} -> format_lot(lot) end)
end
defp format_lot(%Lot{units: units, currency: currency, cost: cost}) do
"#{units} #{currency} #{format_cost(cost)}"
end
defp format_cost(nil), do: ""
defp format_cost(%CostSpec{label: label}) when is_binary(label), do: "{#{inspect(label)}}"
defp format_cost(%CostSpec{per_amount: per, per_currency: cur}),
do: "{#{per} #{cur}}"
defp inspect_posting(%Posting{} = posting) do
"Posting(account='#{posting.account}', units=#{posting.amount} #{posting.currency})"
end
defp inspect_posting_short(%Posting{} = posting) do
cost = Inventory.cost_spec_from_posting(posting)
"#{posting.amount} #{posting.currency} #{format_cost(cost)}"
end
end