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.ex
defmodule Beancount do
@moduledoc """
Idiomatic Elixir interface to [Beancount](https://beancount.github.io/).
`beancount_ex` is **not** a General Ledger. It is a compatibility layer and
*behavioral oracle*: it constructs Beancount directives as typed Elixir
structs, renders them to deterministic `.bean` text, and validates them
through a configurable engine. The default engine wraps real Beancount
(`bean-check` / `bean-query`); the native `Beancount.Engine.Elixir` can
replace it **without changing this public API**.
Optional persistence: `Beancount.Storage` stores directives in SQLite via
Ecto; `Beancount.Queries` reads them back without running the booking engine.
## Quick start
ledger = [
Beancount.open(~D[2026-01-01], "Assets:Bank", ["USD"]),
Beancount.open(~D[2026-01-01], "Income:Salary", ["USD"]),
Beancount.transaction(~D[2026-01-31], "*", "Employer", "Salary", [
Beancount.posting("Assets:Bank", Decimal.new("5000"), "USD"),
Beancount.posting("Income:Salary", Decimal.new("-5000"), "USD")
])
]
bean = Beancount.render(ledger)
{:ok, result} = Beancount.check(ledger)
The constructor functions (`open/4`, `transaction/6`, `posting/4`, ...) build
the typed structs under `Beancount.Directives`. You never need to reference
that namespace directly.
"""
alias Beancount.{CostSpec, Engine, Value}
alias Beancount.Directives.{
Balance,
Close,
Commodity,
Custom,
Document,
Event,
Include,
Note,
Open,
Option,
Pad,
Plugin,
PopTag,
Posting,
Price,
PushTag,
Query,
Transaction
}
@typedoc "A renderable Beancount directive struct."
@type directive :: Beancount.Directive.t()
# Directive constructors
@doc """
Build an `open` directive.
## Options
* `:booking` - booking method, e.g. `"STRICT"`.
* `:metadata` - a map of metadata key/values.
## Examples
iex> Beancount.open(~D[2026-01-01], "Assets:Bank", ["USD"])
%Beancount.Directives.Open{
date: ~D[2026-01-01],
account: "Assets:Bank",
currencies: ["USD"],
booking: nil,
metadata: %{}
}
"""
@spec open(Date.t(), String.t(), [String.t()], keyword()) :: Open.t()
def open(date, account, currencies \\ [], opts \\ []) do
%Open{
date: date,
account: account,
currencies: currencies,
booking: Keyword.get(opts, :booking),
metadata: Keyword.get(opts, :metadata, %{})
}
end
@doc """
Build a `close` directive.
## Examples
iex> Beancount.close(~D[2026-12-31], "Assets:Bank").account
"Assets:Bank"
"""
@spec close(Date.t(), String.t(), keyword()) :: Close.t()
def close(date, account, opts \\ []) do
%Close{date: date, account: account, metadata: Keyword.get(opts, :metadata, %{})}
end
@doc """
Build a `commodity` directive.
## Examples
iex> Beancount.commodity(~D[2026-01-01], "USD").currency
"USD"
"""
@spec commodity(Date.t(), String.t(), keyword()) :: Commodity.t()
def commodity(date, currency, opts \\ []) do
%Commodity{date: date, currency: currency, metadata: Keyword.get(opts, :metadata, %{})}
end
@doc """
Build a `transaction` directive.
`payee` may be `nil` to render only a narration. Options:
* `:tags` - list of tag strings (rendered as `#tag`).
* `:links` - list of link strings (rendered as `^link`).
* `:metadata` - a map of metadata key/values.
## Examples
iex> txn = Beancount.transaction(~D[2026-01-31], "*", "Employer", "Salary", [])
iex> txn.flag
"*"
"""
@spec transaction(Date.t(), String.t(), String.t() | nil, String.t(), [Posting.t()], keyword()) ::
Transaction.t()
def transaction(date, flag, payee, narration, postings, opts \\ []) do
%Transaction{
date: date,
flag: flag,
payee: payee,
narration: narration,
postings: postings,
tags: Keyword.get(opts, :tags, []),
links: Keyword.get(opts, :links, []),
metadata: Keyword.get(opts, :metadata, %{})
}
end
@doc """
Build a `posting` (a leg of a transaction).
`amount` and `currency` may be `nil` for an elided amount. Options:
* `:cost` - a `Beancount.CostSpec` struct or legacy `%{amount:, currency:}` map.
* `:price` - a `%{amount: Decimal.t(), currency: String.t(), type: :unit | :total}` price.
* `:flag` - a per-posting flag.
* `:metadata` - a map of metadata key/values.
## Examples
iex> Beancount.posting("Assets:Bank", Decimal.new("5000"), "USD").account
"Assets:Bank"
"""
@spec posting(String.t(), Decimal.t() | nil, String.t() | nil, keyword()) :: Posting.t()
def posting(account, amount \\ nil, currency \\ nil, opts \\ []) do
%Posting{
account: account,
amount: amount,
currency: currency,
cost: opts |> Keyword.get(:cost) |> CostSpec.normalize(),
price: Keyword.get(opts, :price),
flag: Keyword.get(opts, :flag),
metadata: Keyword.get(opts, :metadata, %{})
}
end
@doc """
Build a `balance` assertion directive.
Options:
* `:tolerance` - explicit tolerance, rendered as `AMOUNT ~ TOLERANCE CURRENCY`.
* `:metadata` - a map of metadata key/values.
## Examples
iex> Beancount.balance(~D[2026-01-31], "Assets:Bank", Decimal.new("5000"), "USD").currency
"USD"
"""
@spec balance(Date.t(), String.t(), Decimal.t(), String.t(), keyword()) :: Balance.t()
def balance(date, account, amount, currency, opts \\ []) do
%Balance{
date: date,
account: account,
amount: amount,
currency: currency,
tolerance: Keyword.get(opts, :tolerance),
metadata: Keyword.get(opts, :metadata, %{})
}
end
@doc """
Build a `price` directive.
## Examples
iex> Beancount.price(~D[2026-01-01], "USD", Decimal.new("1.20"), "CAD").commodity
"USD"
"""
@spec price(Date.t(), String.t(), Decimal.t(), String.t(), keyword()) :: Price.t()
def price(date, commodity, amount, currency, opts \\ []) do
%Price{
date: date,
commodity: commodity,
amount: amount,
currency: currency,
metadata: Keyword.get(opts, :metadata, %{})
}
end
@doc """
Build a `note` directive.
## Examples
iex> Beancount.note(~D[2026-01-01], "Assets:Bank", "Called about fees").comment
"Called about fees"
"""
@spec note(Date.t(), String.t(), String.t(), keyword()) :: Note.t()
def note(date, account, comment, opts \\ []) do
%Note{
date: date,
account: account,
comment: comment,
metadata: Keyword.get(opts, :metadata, %{})
}
end
@doc """
Build a `document` directive.
## Examples
iex> Beancount.document(~D[2026-01-01], "Assets:Bank", "stmt.pdf").path
"stmt.pdf"
"""
@spec document(Date.t(), String.t(), String.t(), keyword()) :: Document.t()
def document(date, account, path, opts \\ []) do
%Document{
date: date,
account: account,
path: path,
metadata: Keyword.get(opts, :metadata, %{})
}
end
@doc """
Build an `event` directive.
## Examples
iex> Beancount.event(~D[2026-01-01], "location", "New York").type
"location"
"""
@spec event(Date.t(), String.t(), String.t(), keyword()) :: Event.t()
def event(date, type, description, opts \\ []) do
%Event{
date: date,
type: type,
description: description,
metadata: Keyword.get(opts, :metadata, %{})
}
end
@doc """
Build a `custom` directive.
## Examples
iex> Beancount.custom(~D[2026-01-01], "budget", ["monthly"]).type
"budget"
"""
@spec custom(Date.t(), String.t(), [term()], keyword()) :: Custom.t()
def custom(date, type, values \\ [], opts \\ []) do
%Custom{
date: date,
type: type,
values: values,
metadata: Keyword.get(opts, :metadata, %{})
}
end
@doc """
Build a `pad` directive.
## Examples
iex> Beancount.pad(~D[2025-12-20], "Assets:Cash", "Equity:Opening").account
"Assets:Cash"
"""
@spec pad(Date.t(), String.t(), String.t(), keyword()) :: Pad.t()
def pad(date, account, source_account, opts \\ []) do
%Pad{
date: date,
account: account,
source_account: source_account,
metadata: Keyword.get(opts, :metadata, %{})
}
end
@doc """
Build an `include` directive.
## Examples
iex> Beancount.include("accounts.bean").path
"accounts.bean"
"""
@spec include(String.t()) :: Include.t()
def include(path) when is_binary(path), do: %Include{path: path}
@doc """
Build an `option` directive.
Common keys: `title`, `operating_currency`, `inferred_tolerance_default`,
`inferred_tolerance_multiplier`, `infer_tolerance_from_cost`, `tolerance_multiplier`.
## Examples
iex> Beancount.option("title", "My Ledger").name
"title"
"""
@spec option(String.t(), term()) :: Option.t()
def option(name, value) when is_binary(name), do: %Option{name: name, value: value}
@doc """
Wrap an account name for use in `custom/4` values.
## Examples
iex> Beancount.account_value("Assets:Bank").name
"Assets:Bank"
"""
@spec account_value(String.t()) :: Value.Account.t()
def account_value(name) when is_binary(name), do: %Value.Account{name: name}
@doc """
Wrap a tag name for use in `custom/4` values (rendered as `#tag`).
## Examples
iex> Beancount.tag_value("trip").name
"trip"
"""
@spec tag_value(String.t()) :: Value.Tag.t()
def tag_value(name) when is_binary(name), do: %Value.Tag{name: name}
@doc """
Wrap a commodity amount for use in `custom/4` values.
## Examples
iex> v = Beancount.amount_value(Decimal.new("42"), "USD")
iex> v.currency
"USD"
"""
@spec amount_value(Decimal.t(), String.t()) :: Value.Amount.t()
def amount_value(%Decimal{} = number, currency) when is_binary(currency) do
%Value.Amount{number: number, currency: currency}
end
@doc """
Build a `query` directive storing a named BQL query in the ledger.
## Examples
iex> q = Beancount.query_directive(~D[2026-01-01], "balances", "SELECT account")
iex> q.name
"balances"
"""
@spec query_directive(Date.t(), String.t(), String.t(), keyword()) :: Query.t()
def query_directive(date, name, bql, opts \\ []) when is_binary(name) and is_binary(bql) do
%Query{date: date, name: name, bql: bql, metadata: Keyword.get(opts, :metadata, %{})}
end
@doc """
Build a `plugin` directive.
## Examples
iex> Beancount.plugin("beancount.plugins.auto_accounts").module
"beancount.plugins.auto_accounts"
"""
@spec plugin(String.t(), String.t() | nil) :: Plugin.t()
def plugin(module, config \\ nil) when is_binary(module) do
%Plugin{module: module, config: config}
end
@doc """
Build a `pushtag` directive.
## Examples
iex> Beancount.push_tag("vacation").tag
"vacation"
"""
@spec push_tag(String.t()) :: PushTag.t()
def push_tag(tag) when is_binary(tag), do: %PushTag{tag: tag}
@doc """
Build a `poptag` directive.
## Examples
iex> Beancount.pop_tag("vacation").tag
"vacation"
"""
@spec pop_tag(String.t()) :: PopTag.t()
def pop_tag(tag) when is_binary(tag), do: %PopTag{tag: tag}
# Parsing
@doc """
Parse a directive list or `.bean` text. See `Beancount.Parser.parse/1`.
## Examples
iex> {:ok, directives} = Beancount.parse([Beancount.open(~D[2026-01-01], "Assets:Bank", ["USD"])])
iex> hd(directives).account
"Assets:Bank"
iex> {:ok, directives} = Beancount.parse_text("2026-01-01 open Assets:Bank USD\\n")
iex> hd(directives).account
"Assets:Bank"
"""
@spec parse([directive()] | binary()) ::
{:ok, [directive()]} | {:error, Beancount.Parser.Error.t()}
defdelegate parse(input), to: Beancount.Parser
@doc """
Parse `.bean` text into directives. See `Beancount.Parser.parse_text/1`.
## Examples
iex> {:ok, directives} = Beancount.parse_text("2026-01-01 commodity USD\\n")
iex> hd(directives).currency
"USD"
"""
@spec parse_text(binary()) :: {:ok, [directive()]} | {:error, Beancount.Parser.Error.t()}
defdelegate parse_text(text), to: Beancount.Parser
@doc """
Parse a `.bean` file from disk. See `Beancount.Parser.parse_file/1`.
## Examples
path = Path.join(System.tmp_dir!(), "parse_example.bean")
File.write!(path, "2026-01-01 open Assets:Bank USD\\n")
{:ok, [%Beancount.Directives.Open{} = open]} = Beancount.parse_file(path)
open.account
# => "Assets:Bank"
"""
@spec parse_file(Path.t()) ::
{:ok, [directive()]} | {:error, Beancount.Parser.Error.t() | term()}
defdelegate parse_file(path), to: Beancount.Parser
@doc """
Parse `.bean` text, raising on failure. See `Beancount.Parser.parse!/1`.
## Examples
iex> Beancount.parse!("2026-01-01 open Assets:Bank USD\\n")
...> |> hd()
...> |> Map.get(:account)
"Assets:Bank"
"""
@spec parse!(binary()) :: [directive()]
defdelegate parse!(text), to: Beancount.Parser
# Engine dispatch
@doc """
Render a directive stream to deterministic `.bean` text.
Dispatches to the configured engine's `c:Beancount.Engine.render/1`.
## Examples
iex> Beancount.render([Beancount.open(~D[2026-01-01], "Assets:Bank", ["USD"])])
"2026-01-01 open Assets:Bank USD\\n"
"""
@spec render([directive()]) :: binary()
def render(directives) when is_list(directives) do
Engine.configured().render(directives)
end
@doc """
Render `directives` and validate the result through the configured engine.
Returns `{:ok, result}` for a valid ledger and `{:error, result}` otherwise.
## Examples
With the configured engine:
ledger = [
Beancount.open(~D[2026-01-01], "Assets:Bank", ["USD"]),
Beancount.open(~D[2026-01-01], "Income:Salary", ["USD"]),
Beancount.transaction(~D[2026-01-31], "*", nil, "Salary", [
Beancount.posting("Assets:Bank", Decimal.new("100"), "USD"),
Beancount.posting("Income:Salary", Decimal.new("-100"), "USD")
])
]
{:ok, %Beancount.Result{status: :ok}} = Beancount.check(ledger)
"""
@spec check([directive()]) :: {:ok, Beancount.Result.t()} | {:error, Beancount.Result.t()}
def check(directives) when is_list(directives) do
directives |> render() |> check_text()
end
@doc """
Validate raw `.bean` text through the configured engine.
## Examples
text = \"\"\"
2026-01-01 open Assets:Bank USD
2026-01-01 open Income:Salary USD
2026-01-31 * "Employer" "Salary"
Assets:Bank 100 USD
Income:Salary -100 USD
\"\"\"
{:ok, %Beancount.Result{status: :ok}} = Beancount.Engine.Elixir.check(text)
"""
@spec check_text(binary()) :: {:ok, Beancount.Result.t()} | {:error, Beancount.Result.t()}
def check_text(text) when is_binary(text) do
Engine.configured().check(text)
end
@doc """
Validate a `.bean` file on disk through the configured engine.
## Examples
path = Path.join(System.tmp_dir!(), "check_example.bean")
File.write!(path, \"\"\"
2026-01-01 open Assets:Bank USD
2026-01-01 open Income:Salary USD
2026-01-31 * "Employer" "Salary"
Assets:Bank 100 USD
Income:Salary -100 USD
\"\"\")
{:ok, %Beancount.Result{status: :ok}} = Beancount.Engine.Elixir.check_file(path)
"""
@spec check_file(Path.t()) :: {:ok, Beancount.Result.t()} | {:error, Beancount.Result.t()}
def check_file(path) do
Engine.configured().check_file(path)
end
@typedoc "A successful BQL query result or a failure result."
@type query_return :: {:ok, Beancount.Query.Result.t()} | {:error, Beancount.Result.t()}
@doc """
Run a BQL query against a directive stream.
The directives are rendered to `.bean` text and the query is dispatched
through the configured engine's `c:Beancount.Engine.query/2`.
## Examples
ledger = [
Beancount.open(~D[2026-01-01], "Assets:Bank", ["USD"]),
Beancount.open(~D[2026-01-01], "Income:Salary", ["USD"]),
Beancount.open(~D[2026-01-01], "Equity:Opening", ["USD"]),
Beancount.transaction(~D[2026-01-31], "*", nil, "Salary", [
Beancount.posting("Assets:Bank", Decimal.new("100"), "USD"),
Beancount.posting("Income:Salary", Decimal.new("-100"), "USD")
])
]
bql = "SELECT account, sum(position) AS balance GROUP BY account ORDER BY account"
{:ok, %Beancount.Query.Result{columns: columns}} =
ledger |> Beancount.render() |> then(&Beancount.Engine.Elixir.query(&1, bql))
columns
# => ["account", "balance"]
"""
@spec query([directive()], binary()) :: query_return()
def query(directives, bql) when is_list(directives) and is_binary(bql) do
directives |> render() |> query_text(bql)
end
@doc """
Run a BQL query against raw `.bean` text through the configured engine.
## Examples
text = \"\"\"
2026-01-01 open Assets:Bank USD
2026-01-01 open Income:Salary USD
2026-01-01 open Equity:Opening USD
2026-01-31 * "Employer" "Salary"
Assets:Bank 100 USD
Income:Salary -100 USD
\"\"\"
{:ok, result} =
Beancount.Engine.Elixir.query(
text,
"SELECT account, sum(position) AS balance GROUP BY account ORDER BY account"
)
result.columns
# => ["account", "balance"]
"""
@spec query_text(binary(), binary()) :: query_return()
def query_text(text, bql) when is_binary(text) and is_binary(bql) do
Engine.configured().query(text, bql)
end
@doc """
Run a BQL query against a `.bean` file on disk through the configured engine.
## Examples
path = Path.join(System.tmp_dir!(), "query_example.bean")
File.write!(path, \"\"\"
2026-01-01 open Assets:Bank USD
2026-01-01 open Income:Salary USD
2026-01-01 open Equity:Opening USD
2026-01-31 * "Employer" "Salary"
Assets:Bank 100 USD
Income:Salary -100 USD
\"\"\")
{:ok, _} =
Beancount.Engine.Elixir.query(
File.read!(path),
"SELECT account, sum(position) AS balance GROUP BY account ORDER BY account"
)
"""
@spec query_file(Path.t(), binary()) :: query_return()
def query_file(path, bql) when is_binary(bql) do
path |> File.read!() |> query_text(bql)
end
# Reporting (delegates to Beancount.Report)
@doc """
Account balances report. See `Beancount.Report.balances/1`.
## Examples
ledger = [
Beancount.open(~D[2026-01-01], "Assets:Bank", ["USD"]),
Beancount.open(~D[2026-01-01], "Income:Salary", ["USD"]),
Beancount.open(~D[2026-01-01], "Equity:Opening", ["USD"]),
Beancount.transaction(~D[2026-01-31], "*", nil, "Salary", [
Beancount.posting("Assets:Bank", Decimal.new("100"), "USD"),
Beancount.posting("Income:Salary", Decimal.new("-100"), "USD")
])
]
{:ok, %Beancount.Query.Result{}} =
ledger |> Beancount.render() |> then(&Beancount.Engine.Elixir.query(&1, "SELECT account, sum(position) GROUP BY account"))
"""
@spec balances([directive()] | binary()) :: query_return()
defdelegate balances(ledger), to: Beancount.Report
@doc """
Balance sheet report. See `Beancount.Report.balance_sheet/1`.
## Examples
ledger = [
Beancount.open(~D[2026-01-01], "Assets:Bank", ["USD"]),
Beancount.open(~D[2026-01-01], "Equity:Opening", ["USD"]),
Beancount.transaction(~D[2026-01-31], "*", nil, "Open", [
Beancount.posting("Assets:Bank", Decimal.new("100"), "USD"),
Beancount.posting("Equity:Opening", Decimal.new("-100"), "USD")
])
]
{:ok, %Beancount.Query.Result{}} =
ledger |> Beancount.render() |> then(&Beancount.Report.balance_sheet/1)
"""
@spec balance_sheet([directive()] | binary()) :: query_return()
defdelegate balance_sheet(ledger), to: Beancount.Report
@doc """
Income statement report. See `Beancount.Report.income_statement/1`.
## Examples
ledger = [
Beancount.open(~D[2026-01-01], "Income:Salary", ["USD"]),
Beancount.open(~D[2026-01-01], "Assets:Bank", ["USD"]),
Beancount.transaction(~D[2026-01-31], "*", nil, "Salary", [
Beancount.posting("Assets:Bank", Decimal.new("100"), "USD"),
Beancount.posting("Income:Salary", Decimal.new("-100"), "USD")
])
]
{:ok, %Beancount.Query.Result{}} = Beancount.Report.income_statement(ledger)
"""
@spec income_statement([directive()] | binary()) :: query_return()
defdelegate income_statement(ledger), to: Beancount.Report
@doc """
Holdings report. See `Beancount.Report.holdings/1`.
## Examples
ledger = [
Beancount.open(~D[2026-01-01], "Assets:Stocks", ["AAPL"], booking: "FIFO"),
Beancount.open(~D[2026-01-01], "Assets:Cash", ["USD"]),
Beancount.transaction(~D[2026-01-02], "*", nil, "Buy", [
Beancount.posting("Assets:Stocks", Decimal.new("10"), "AAPL",
cost: %{amount: Decimal.new("150"), currency: "USD"}
),
Beancount.posting("Assets:Cash", Decimal.new("-1500"), "USD")
])
]
{:ok, %Beancount.Query.Result{columns: cols}} = Beancount.Report.holdings(ledger)
cols
# => ["account", "units", "cost"]
"""
@spec holdings([directive()] | binary()) :: query_return()
defdelegate holdings(ledger), to: Beancount.Report
@doc """
Per-account journal report. See `Beancount.Report.journal/2`.
## Examples
ledger = [
Beancount.open(~D[2026-01-01], "Assets:Bank", ["USD"]),
Beancount.open(~D[2026-01-01], "Income:Salary", ["USD"]),
Beancount.transaction(~D[2026-01-31], "*", nil, "Salary", [
Beancount.posting("Assets:Bank", Decimal.new("100"), "USD"),
Beancount.posting("Income:Salary", Decimal.new("-100"), "USD")
])
]
{:ok, %Beancount.Query.Result{}} = Beancount.Report.journal(ledger, "Assets:Bank")
"""
@spec journal([directive()] | binary(), String.t()) :: query_return()
defdelegate journal(ledger, account), to: Beancount.Report
end