ExLedger.Exchange (ex_ledger v0.5.6)

Currency conversion using price database.

Provides functions to convert amounts, postings, and transactions to a target currency using historical price data.

Limitations

  • Single-hop transitive paths: When no direct conversion exists (e.g., EUR→CHF), the module attempts conversion through one intermediate currency (EUR→USD→CHF). Multi-hop paths (EUR→USD→GBP→CHF) are not supported.

  • Commodity aliases: Commodity aliases parsed from declarations are not automatically resolved. Use canonical commodity symbols in transactions.

Summary

Functions

Checks if a conversion path exists between two currencies.

Converts a single amount to the target currency.

Converts a single posting to the target currency.

Converts all postings in a transaction to the target currency.

Converts a numeric value from one currency to another.

Returns all currencies that can be converted to the target currency.

Converts all transactions to a target currency.

Types

amount()

@type amount() :: %{
  value: Decimal.t(),
  currency: String.t() | nil,
  currency_position: atom() | nil
}

posting()

@type posting() :: map()

price_db()

@type price_db() :: ExLedger.Parser.Price.price_db()

transaction()

@type transaction() :: map()

Functions

can_convert?(from_currency, to_currency, date, price_db)

@spec can_convert?(String.t(), String.t(), Date.t(), price_db()) :: boolean()

Checks if a conversion path exists between two currencies.

Returns true if there's a direct, inverse, or transitive path.

convert_amount(amount, target_currency, date, price_db)

@spec convert_amount(amount(), String.t(), Date.t(), price_db()) ::
  {:ok, amount()} | {:error, {:no_conversion_path, String.t(), String.t()}}

Converts a single amount to the target currency.

Examples

iex> amount = %{value: 100.0, currency: "EUR", currency_position: :leading}
iex> Exchange.convert_amount(amount, "CHF", ~D[2026-01-15], price_db)
{:ok, %{value: 94.32, currency: "CHF", currency_position: :leading}}

convert_posting(posting, target_currency, date, price_db)

@spec convert_posting(posting(), String.t(), Date.t(), price_db()) ::
  {:ok, posting()} | {:error, {:no_conversion_path, String.t(), String.t()}}

Converts a single posting to the target currency.

If the posting is already in the target currency, it's returned unchanged. If no direct conversion path exists, attempts transitive conversion.

convert_transaction(transaction, target_currency, price_db)

@spec convert_transaction(transaction(), String.t(), price_db()) ::
  {:ok, transaction()} | {:error, {:no_conversion_path, String.t(), String.t()}}

Converts all postings in a transaction to the target currency.

Uses the transaction date for price lookup. Raises if transaction has no date.

convert_value(value, from_currency, to_currency, date, price_db)

@spec convert_value(
  Decimal.t() | number(),
  String.t(),
  String.t(),
  Date.t(),
  price_db()
) ::
  {:ok, Decimal.t()} | {:error, {:no_conversion_path, String.t(), String.t()}}

Converts a numeric value from one currency to another.

Attempts direct conversion first, then tries transitive paths.

convertible_currencies(target_currency, date, price_db)

@spec convertible_currencies(String.t(), Date.t(), price_db()) :: [String.t()]

Returns all currencies that can be converted to the target currency.

Includes currencies with direct, inverse, and transitive paths.

exchange(transactions, target_currency, price_db)

@spec exchange([transaction()], String.t(), price_db()) ::
  {:ok, [transaction()]}
  | {:error, {:no_conversion_path, String.t(), String.t()}}

Converts all transactions to a target currency.

This is the main entry point for currency conversion, similar to ledger-cli's --exchange flag.

Examples

iex> prices = [
...>   %{date: ~D[2026-01-15], commodity: "EUR", price: %{value: 0.9432, currency: "CHF"}}
...> ]
iex> price_db = Price.build_price_db(prices)
iex> transactions = [%{date: ~D[2026-01-15], postings: [...]}]
iex> Exchange.exchange(transactions, "CHF", price_db)
{:ok, converted_transactions}