Packages

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
beancount_ex lib beancount engine elixir ledger.ex
Raw

lib/beancount/engine/elixir/ledger.ex

defmodule Beancount.Engine.Elixir.Ledger do
@moduledoc false
alias Beancount.CostSpec
alias Beancount.Directives.{
Balance,
Close,
Include,
Open,
Option,
Pad,
PopTag,
PushTag,
Transaction
}
alias Beancount.Engine.Elixir.{
BalanceCheck,
DirectiveSort,
Inventory,
Options,
PadResolver,
PostingAmount,
Tolerance
}
alias Beancount.Directives.Posting
defstruct options: Options.new(),
opens: %{},
closes: %{},
closed: MapSet.new(),
inventory: Inventory.new(),
pending_pads: %{},
balance_assertions: %{},
parent_balance_accounts: MapSet.new(),
include_base: nil,
errors: []
@type t :: %__MODULE__{}
@type seen_includes :: %{optional(String.t()) => true}
@spec new(keyword()) :: t()
def new(opts \\ []) do
%__MODULE__{include_base: Keyword.get(opts, :include_base)}
end
@spec process(t(), [Beancount.Directive.t()]) :: t()
def process(%__MODULE__{} = ledger, directives) do
{directives, ledger} = expand_includes(directives, ledger)
directives = apply_tag_scopes(directives)
ledger = index_accounts(ledger, directives)
ordered = DirectiveSort.order(directives)
Enum.reduce(ordered, ledger, fn directive, ledger ->
apply_directive(directive, ledger)
end)
end
# Beancount applies pushtag/poptag scopes at parse time, in file order. We bake
# the active tags into each transaction here (before the date sort) so scoping
# follows authored order rather than chronological order.
defp apply_tag_scopes(directives) do
{tagged, _stack} =
Enum.map_reduce(directives, [], fn
%PushTag{tag: tag}, stack ->
{%PushTag{tag: tag}, [tag | stack]}
%PopTag{tag: tag}, stack ->
{%PopTag{tag: tag}, List.delete(stack, tag)}
%Transaction{tags: tags} = txn, stack ->
{%{txn | tags: Enum.uniq(tags ++ Enum.reverse(stack))}, stack}
other, stack ->
{other, stack}
end)
tagged
end
defp expand_includes(directives, ledger) do
seen =
case ledger.include_base do
nil -> %{}
base -> %{Path.expand(base) => true}
end
{expanded, errors} = do_expand(directives, ledger.include_base, seen)
{expanded, Enum.reduce(errors, ledger, &add_error(&2, &1))}
end
@spec do_expand([Beancount.directive()], String.t() | nil, seen_includes()) ::
{[Beancount.directive()], [String.t()]}
defp do_expand(directives, base, seen) do
Enum.reduce(directives, {[], []}, fn
%Include{path: path}, {acc, errs} ->
{child_dirs, child_errs} = load_include(path, base, seen)
{acc ++ child_dirs, errs ++ child_errs}
other, {acc, errs} ->
{acc ++ [other], errs}
end)
end
@spec load_include(String.t(), String.t() | nil, seen_includes()) ::
{[Beancount.directive()], [String.t()]}
defp load_include(path, base, seen) do
case resolve_include_path(path, base) do
{:ok, resolved} ->
expand_resolved_include(path, resolved, seen)
:error ->
{[], ["File glob \"#{path}\" does not match any files"]}
end
end
@spec expand_resolved_include(String.t(), String.t(), seen_includes()) ::
{[Beancount.directive()], [String.t()]}
defp expand_resolved_include(path, resolved, seen) do
absolute = Path.expand(resolved)
if Map.has_key?(seen, absolute) do
{[], ["Include cycle detected for #{path}"]}
else
read_and_expand_include(path, resolved, Map.put(seen, absolute, true))
end
end
@spec read_and_expand_include(String.t(), String.t(), seen_includes()) ::
{[Beancount.directive()], [String.t()]}
defp read_and_expand_include(path, resolved, seen) do
with {:ok, text} <- File.read(resolved),
{:ok, child} <- Beancount.Parser.parse_text(text) do
do_expand(child, resolved, seen)
else
{:error, %{message: message}} ->
{[], ["Include #{path}: #{message}"]}
{:error, reason} ->
{[], ["Cannot read include #{path}: #{inspect(reason)}"]}
end
end
defp index_accounts(ledger, directives) do
Enum.reduce(directives, ledger, fn
%Open{} = open, ledger -> index_open(ledger, open)
%Close{} = close, ledger -> index_close(ledger, close)
_, ledger -> ledger
end)
end
defp index_open(ledger, %Open{} = open) do
if Map.has_key?(ledger.opens, open.account) do
add_error(ledger, "Duplicate open directive for #{open.account}")
else
%{ledger | opens: Map.put(ledger.opens, open.account, open)}
end
end
defp index_close(ledger, %Close{} = close) do
cond do
Map.has_key?(ledger.closes, close.account) ->
add_error(ledger, "Duplicate close for account #{close.account}")
not Map.has_key?(ledger.opens, close.account) ->
add_error(ledger, "Unopened account #{close.account} closed")
true ->
%{
ledger
| closes: Map.put(ledger.closes, close.account, close),
closed: MapSet.put(ledger.closed, close.account)
}
end
end
@spec errors(t()) :: [map()]
def errors(%__MODULE__{errors: errors}), do: Enum.reverse(errors)
@spec inventory(t()) :: Inventory.t()
def inventory(%__MODULE__{inventory: inventory}), do: inventory
defp apply_directive(%Option{} = option, ledger) do
{options, errors} = Options.apply(ledger.options, option)
ledger = %{ledger | options: options}
Enum.reduce(errors, ledger, fn %{message: message}, ledger -> add_error(ledger, message) end)
end
defp apply_directive(%Open{}, ledger), do: ledger
defp apply_directive(%Close{}, ledger), do: ledger
defp apply_directive(%Pad{} = pad, ledger) do
%{ledger | pending_pads: Map.put(ledger.pending_pads, pad.account, pad)}
end
defp apply_directive(%Balance{} = balance, ledger) do
ledger =
case Map.get(ledger.pending_pads, balance.account) do
nil ->
ledger
pad ->
ledger
|> apply_pending_pad(pad, balance)
|> then(&%{&1 | pending_pads: Map.delete(&1.pending_pads, balance.account)})
end
ledger =
if parent_account?(balance.account) do
%{
ledger
| parent_balance_accounts: MapSet.put(ledger.parent_balance_accounts, balance.account)
}
else
ledger
end
errors =
BalanceCheck.check(
balance,
ledger.inventory,
ledger.options,
ledger.balance_assertions,
ledger.parent_balance_accounts,
ledger.opens
)
ledger = %{
ledger
| balance_assertions:
Map.put(ledger.balance_assertions, balance_key(balance), balance.amount)
}
Enum.reduce(errors, ledger, &add_error(&2, &1.message))
end
defp apply_directive(%Transaction{} = transaction, ledger) do
ledger
|> validate_transaction(transaction)
|> apply_transaction(transaction)
end
defp apply_directive(_other, ledger), do: ledger
defp apply_pending_pad(
ledger,
%Pad{account: account} = pad,
%Balance{account: account} = balance
) do
{:ok, inventory, pad_txn} = PadResolver.resolve_pad(pad, balance, ledger.inventory)
ledger = %{ledger | inventory: inventory}
apply_optional_pad_transaction(ledger, balance, pad_txn)
end
@dialyzer {:nowarn_function, apply_optional_pad_transaction: 3}
defp apply_optional_pad_transaction(ledger, _balance, nil), do: ledger
defp apply_optional_pad_transaction(ledger, balance, txn) do
apply_transaction(ledger, %{txn | date: balance.date})
end
defp validate_transaction(ledger, %Transaction{date: date, postings: postings}) do
postings = PostingAmount.expand_postings(postings)
ledger
|> validate_posting_accounts(postings, date)
|> validate_posting_units(postings)
|> validate_transaction_balance(postings)
end
defp validate_posting_units(ledger, postings) do
Enum.reduce(postings, ledger, fn posting, ledger ->
case posting do
%Posting{amount: %Decimal{}, currency: nil} ->
add_error(ledger, "Could not resolve units currency")
_ ->
ledger
end
end)
end
defp validate_posting_accounts(ledger, postings, date) do
Enum.reduce(postings, ledger, fn posting, ledger ->
if posting_material?(posting) do
validate_posting_account(ledger, posting, date)
else
ledger
end
end)
end
defp validate_posting_account(ledger, %Posting{account: account}, date) do
cond do
not account_open_at?(ledger, account, date) ->
add_error(ledger, "Invalid reference to unknown account '#{account}'")
account_closed_at?(ledger, account, date) ->
add_error(ledger, "Account #{account} used after close")
true ->
ledger
end
end
defp account_open_at?(ledger, account, date) do
case Map.get(ledger.opens, account) do
%Open{date: open_date} -> Date.compare(open_date, date) != :gt
_ -> false
end
end
defp account_closed_at?(ledger, account, date) do
case Map.get(ledger.closes, account) do
%Close{date: close_date} -> Date.compare(close_date, date) != :gt
_ -> false
end
end
defp validate_transaction_balance(ledger, postings) do
if skip_transaction_balance?(ledger, postings) do
ledger
else
validate_totals_balanced(ledger, postings)
end
end
defp validate_totals_balanced(ledger, postings) do
totals = PostingAmount.transaction_totals(postings)
amounts = tolerance_amounts(postings, ledger.options.infer_tolerance_from_cost)
Enum.reduce(totals, ledger, fn {currency, total}, ledger ->
apply_balance_tolerance(ledger, currency, total, amounts)
end)
end
defp apply_balance_tolerance(ledger, currency, total, amounts) do
tolerance = Tolerance.infer(ledger.options, currency, amounts)
if Decimal.abs(total) |> Decimal.lte?(tolerance) do
ledger
else
add_error(
ledger,
"Transaction does not balance: (#{Decimal.to_string(total, :normal)} #{currency})"
)
end
end
defp skip_transaction_balance?(ledger, postings) do
Enum.any?(postings, fn
%Posting{amount: %Decimal{} = amount, cost: %CostSpec{}, account: account} ->
Decimal.negative?(amount) and strict_booking_account?(ledger, account)
_ ->
false
end)
end
defp strict_booking_account?(ledger, account) do
case Map.get(ledger.opens, account) do
%Open{booking: booking} when is_binary(booking) ->
String.upcase(booking) == "STRICT"
_ ->
false
end
end
defp tolerance_amounts(postings, infer_from_cost) do
expanded = PostingAmount.expand_postings(postings)
base = Enum.flat_map(expanded, &posting_tolerance_amounts/1)
if infer_from_cost do
base ++ Enum.flat_map(expanded, &cost_tolerance_amounts/1)
else
base
end
end
defp cost_tolerance_amounts(%Posting{cost: %CostSpec{per_amount: %Decimal{} = per}}), do: [per]
defp cost_tolerance_amounts(%Posting{cost: %CostSpec{total_amount: %Decimal{} = total}}),
do: [total]
defp cost_tolerance_amounts(_), do: []
defp posting_tolerance_amounts(%Posting{amount: %Decimal{} = amount, currency: currency})
when is_binary(currency),
do: [amount]
defp posting_tolerance_amounts(%Posting{
amount: %Decimal{} = amount,
price: %{amount: price_amount, type: :unit}
}) do
[amount, price_amount]
end
defp posting_tolerance_amounts(%Posting{amount: %Decimal{} = amount}), do: [amount]
defp posting_tolerance_amounts(_), do: []
defp apply_transaction(ledger, %Transaction{postings: postings}) do
postings = PostingAmount.expand_postings(postings)
starting_inventory = ledger.inventory
case apply_postings(starting_inventory, ledger, postings) do
{:ok, inventory} ->
%{ledger | inventory: inventory}
{:error, messages} ->
Enum.reduce(messages, %{ledger | inventory: starting_inventory}, &add_error(&2, &1))
end
end
defp apply_postings(inventory, ledger, postings) do
Enum.reduce_while(postings, {:ok, inventory, []}, fn posting, {:ok, inv, _errors} ->
case apply_posting_to_inventory(inv, ledger, posting) do
{:ok, new_inv} -> {:cont, {:ok, new_inv, []}}
{:error, message} -> {:halt, {:error, [message]}}
end
end)
|> case do
{:ok, inventory, _} -> {:ok, inventory}
{:error, messages} -> {:error, messages}
end
end
defp apply_posting_to_inventory(inventory, ledger, %Posting{account: account} = posting) do
booking =
case Map.get(ledger.opens, account) do
%Open{booking: booking} -> booking
_ -> nil
end
Inventory.apply_posting(inventory, account, posting, booking)
end
defp posting_material?(%Posting{amount: nil, currency: nil}), do: false
defp posting_material?(%Posting{amount: %Decimal{} = amount, currency: nil}),
do: not Decimal.equal?(amount, 0)
defp posting_material?(_), do: true
defp parent_account?(account) do
not String.contains?(account, ":")
end
defp balance_key(%Balance{account: account, currency: currency, date: date}),
do: {account, currency, date}
defp resolve_include_path(path, base) do
candidates =
case base do
nil -> [path]
base -> [path, Path.join(Path.dirname(base), path)]
end
case Enum.find(candidates, &File.exists?/1) do
nil -> :error
resolved -> {:ok, resolved}
end
end
defp add_error(ledger, message) when is_binary(message) do
%{ledger | errors: [%{line: nil, message: message} | ledger.errors]}
end
defp add_error(ledger, %{message: message}) do
add_error(ledger, message)
end
end