defmodule Forex do @external_resource "README.md" @moduledoc "README.md" |> File.read!() |> String.split("") |> Enum.fetch!(1) |> String.replace("### Usage Information", "### Usage Information {: .info}") import Forex.Support import Forex.Options, only: [rates_options: 1] alias __MODULE__ alias Forex.Currency alias Forex.Fetcher alias Forex.Options defstruct [:base, :date, :rates] @typedoc """ A currency rate, represented as a map with the currency code as the key and the rate amount as the value. """ @type rate :: %{Currency.code() => Currency.output_amount()} @typedoc """ A Forex struct, representing the exchange rates for a given date. """ @type t :: %__MODULE__{ base: Currency.code(), date: Date.t(), rates: rate() } # A date that can be either a Date struct, a string in the ISO 8601 format, # or a tuple with the date components in the format `{year, month, day}`. @typep parsable_date() :: Date.t() | DateTime.t() | String.t() | {integer(), integer(), integer()} @base_currency_rate %{currency: "EUR", rate: "1.00000"} @doc """ Returns the configured JSON encoding library for Forex. The default is the `Jason` library. The JSON library must implement the `encode/1` function. The JSON library is only required when using the mix tasks to export the exchange rates to a JSON file, otherwise this setting can be ignored. To customize the JSON library, including the following in your `config/config.exs`: config :forex, :json_library, AlternativeJsonLibrary The library must implement the `encode_to_iodata!/2` function. """ def json_library do Application.get_env(:forex, :json_library, JSON) end ## Currencies @doc """ Return a list of all available currencies ISO 4217 codes. """ @spec available_currencies(keys :: :atoms | :strings) :: [Currency.code()] def available_currencies(keys \\ :atoms) def available_currencies(:strings) do Currency.available(:strings) |> Map.keys() end def available_currencies(:atoms) do Currency.available(:atoms) |> Map.keys() end @doc """ Return a list of all available currencies. """ @spec list_currencies(keys :: :atoms | :strings) :: %{Currency.code() => Currency.t()} def list_currencies(keys \\ :atoms) def list_currencies(:atoms), do: Currency.available(:atoms) def list_currencies(:strings), do: Currency.available(:strings) @doc """ Return a list of all available currencies in the format `%{currency_code() => currency_name()}`. Useful for input forms, selects, etc. """ @spec currency_options(keys :: :atoms | :strings) :: [{String.t(), Currency.code()}] def currency_options(keys \\ :atoms) def currency_options(:atoms) do list_currencies(:atoms) |> Enum.map(fn {code, currency} -> {currency.name, code} end) |> Enum.sort() end def currency_options(:strings) do list_currencies(:strings) |> Enum.map(fn {code, currency} -> {currency.name, code} end) |> Enum.sort() end @doc """ Get the currency information for the given ISO code. """ @spec get_currency(Currency.code()) :: {:ok, Currency.t()} | {:error, term} def get_currency(currency_code), do: Currency.get(currency_code) @doc """ Get the currency information for the given ISO code. Like `get_currency/1`, but raises an error if the currency is not found. """ @spec get_currency!(Currency.code()) :: Currency.t() def get_currency!(currency_code), do: Currency.get!(currency_code) ## Exchange Rates @doc """ Fetch the latest exchange rates from the European Central Bank (ECB). ## Options #{NimbleOptions.docs(Forex.Options.rates_schema())} ## Examples ```elixir {:ok, %Forex{ base: :eur, date: ~D[2025-03-12], rates: %{usd: Decimal.new("1.1234"), jpy: Decimal.new("120.1234"), ...}} } ``` """ @spec latest_rates(opts :: [Options.rates_option()]) :: {:ok, t()} | {:error, term} def latest_rates(opts \\ []) when is_list(opts) do opts = rates_options(opts) base = Keyword.get(opts, :base) case Fetcher.get(:latest_rates, use_cache: opts[:use_cache], feed_fn: opts[:feed_fn]) do {:ok, entries} -> Enum.map(entries, fn %{time: datetime, rates: rates} -> %Forex{ base: base, date: map_date(datetime), rates: map_rates(rates, opts) } end) |> List.first() |> then(&{:ok, &1}) {:error, reason} -> {:error, reason} end end @doc """ Same as `latest_rates/1`, but raises an error if the request fails. """ @spec latest_rates!(opts :: [Options.rates_option()]) :: t() def latest_rates!(opts \\ []) when is_list(opts) do case latest_rates(opts) do {:ok, rates} -> rates {:error, reason} -> raise reason end end @doc """ Fetch the exchange rates for the last ninety days from the European Central Bank (ECB). Note that rates are only available on working days. ## Options #{NimbleOptions.docs(Forex.Options.rates_schema())} """ @spec last_ninety_days_rates(opts :: [Options.rates_option()]) :: {:ok, [t()]} | {:error, term} def last_ninety_days_rates(opts \\ []) when is_list(opts) do opts = rates_options(opts) base = Keyword.get(opts, :base) case Fetcher.get(:last_ninety_days_rates, use_cache: opts[:use_cache], feed_fn: opts[:feed_fn] ) do {:ok, entries} -> results = entries |> Enum.map(fn %{time: datetime, rates: rates} -> %Forex{ base: base, date: map_date(datetime), rates: map_rates(rates, opts) } end) |> Enum.filter(fn %{date: date} -> date != nil end) {:ok, results} {:error, reason} -> {:error, reason} end end @doc """ Same as `last_ninety_days_rates/1`, but raises an error if the request fails. """ @spec last_ninety_days_rates!(opts :: [Options.rates_option()]) :: [t()] def last_ninety_days_rates!(opts \\ []) when is_list(opts) do case last_ninety_days_rates(opts) do {:ok, entries} -> entries {:error, reason} -> raise reason end end @doc """ Fetch the historic exchange rates feed from the European Central Bank (ECB) for any working day since 4 January 1999. By default, the historic rates are not automatically fetched when using the Fetcher (scheduler) module, since the whole file is returned this avoids excessive memory usage when caching the results if not needed. To fetch and cache the historic rates, you need to manually call this function. ## Options #{NimbleOptions.docs(Forex.Options.rates_schema())} """ @spec historic_rates(opts :: [Options.rates_option()]) :: {:ok, [t()]} | {:error, term} def historic_rates(opts \\ []) when is_list(opts) do opts = rates_options(opts) base = Keyword.get(opts, :base) case Fetcher.get(:historic_rates, use_cache: opts[:use_cache], feed_fn: opts[:feed_fn]) do {:ok, entries} -> results = entries |> Enum.map(fn %{time: datetime, rates: rates} -> %Forex{ base: base, date: map_date(datetime), rates: map_rates(rates, opts) } end) |> Enum.filter(fn %{date: date} -> date != nil end) {:ok, results} {:error, reason} -> {:error, reason} end end @doc """ Same as `historic_rates/1`, but raises an error if the request fails. """ @spec historic_rates!(opts :: [Options.rates_option()]) :: [t()] def historic_rates!(opts \\ []) when is_list(opts) do case historic_rates(opts) do {:ok, entries} -> entries {:error, reason} -> raise reason end end @doc """ Get a specific date from the historic exchange rates feed. It returns either an `{:ok, rate()}` if the rate was successfully retrieved or an `{:error, reasons}` if the rate was not found. ## Options #{NimbleOptions.docs(Forex.Options.rates_schema())} """ @spec get_historic_rate(parsable_date(), opts :: [Options.rates_option()]) :: {:ok, t()} | {:error, term} def get_historic_rate(date, opts \\ []) def get_historic_rate(date, opts) when is_binary(date) or is_tuple(date) do case parse_date(date) do {:ok, date} -> get_historic_rate(date, opts) {:error, :invalid_date} -> {:error, :invalid_date} end end def get_historic_rate(%Date{} = date, opts) do case historic_rates(opts) do {:ok, rates} -> case find_historic_rate_date(rates, date) do nil -> {:error, {Forex.FeedError, "Rate not found for date: #{Date.to_iso8601(date)}"}} historic_rate -> {:ok, historic_rate} end {:error, reason} -> {:error, reason} end end @spec find_historic_rate_date(rates :: [t()], date :: Date.t()) :: t() | nil defp find_historic_rate_date(rates, date) when is_list(rates) do Enum.find(rates, fn %{date: %Date{} = d} -> Date.compare(date, d) == :eq _ -> nil end) end @doc """ Same as `get_historic_rate/2`, but raises an error if the request fails. """ @spec get_historic_rate!(parsable_date(), opts :: [Options.rates_option()]) :: t() def get_historic_rate!(date, opts \\ []) def get_historic_rate!(date, opts) when is_binary(date) or is_tuple(date) do case parse_date(date) do {:ok, date} -> get_historic_rate!(date, opts) {:error, :invalid_date} -> raise Forex.DateError, "Invalid date format" end end def get_historic_rate!(%Date{} = date, opts) do case get_historic_rate(date, opts) do {:ok, rates} -> rates {:error, {exception, reason}} -> raise exception, reason {:error, reason} -> raise Forex.FeedError, reason end end @doc """ Get exchange rates between two dates from the historic exchange rates feed. Returns a list of exchange rates for each working day between the start and end date. ## Options #{NimbleOptions.docs(Forex.Options.rates_schema())} ## Examples ```elixir iex> Forex.get_historic_rates_between("2023-01-01", "2023-01-05") {:ok, [ %Forex{date: ~D[2023-01-02], base: :eur, rates: %{usd: Decimal.new("1.0678", ...}}, %Forex{date: ~D[2023-01-03], base: :eur, rates: %{usd: Decimal.new("1.0545", ...}}, %Forex{date: ~D[2023-01-04], base: :eur, rates: %{usd: Decimal.new("1.0599", ...}}, %Forex{date: ~D[2023-01-05], base: :eur, rates: %{usd: Decimal.new("1.0556", ...}} ]} ``` """ @spec get_historic_rates_between( start_date :: parsable_date(), end_date :: parsable_date(), opts :: [Options.rates_option()] ) :: {:ok, [t()]} | {:error, term} def get_historic_rates_between(start_date, end_date, opts \\ []) def get_historic_rates_between(start_date, end_date, opts) when is_binary(start_date) and is_binary(end_date) do with {:ok, start_date} <- parse_date(start_date), {:ok, end_date} <- parse_date(end_date) do get_historic_rates_between(start_date, end_date, opts) end end def get_historic_rates_between(%Date{} = start_date, %Date{} = end_date, opts) when is_list(opts) do case historic_rates(opts) do {:ok, entries} -> entries_range = Enum.filter(entries, fn %{date: %Date{} = date} -> Date.compare(date, start_date) != :lt and Date.compare(date, end_date) != :gt _ -> false end) {:ok, entries_range} end end @doc """ Same as `get_historic_rates_between/3`, but raises an error if the request fails. """ @spec get_historic_rates_between!( start_date :: parsable_date(), end_date :: parsable_date(), opts :: [Options.rates_option()] ) :: [t()] def get_historic_rates_between!(start_date, end_date, opts \\ []) def get_historic_rates_between!(start_date, end_date, opts) when is_binary(start_date) and is_binary(end_date) do with {:ok, start_date} <- parse_date(start_date), {:ok, end_date} <- parse_date(end_date) do get_historic_rates_between!(start_date, end_date, opts) else {:error, _} -> raise Forex.DateError, "Invalid date format" end end def get_historic_rates_between!( %Date{} = start_date, %Date{} = end_date, opts ) when is_list(opts) do case get_historic_rates_between(start_date, end_date, opts) do {:ok, rates} -> rates {:error, reason} -> raise Forex.FeedError, reason end end @doc """ Last updated date of the exchange rates feed. Lists the last date the exchange rates were updated from the cache. Example: iex> Forex.last_updated() [ latest_rates: ~U[2024-11-23 18:19:38.974337Z], historic_rates: ~U[2024-11-23 18:27:07.391035Z], last_ninety_days_rates: ~U[2024-11-23 18:19:39.111818Z], ] """ @spec last_updated() :: [ latest_rates: DateTime.t(), historic_rates: DateTime.t(), last_ninety_days_rates: DateTime.t() ] | nil def last_updated do if Forex.Cache.initialized?() do Forex.Cache.last_updated() else nil end end ## Currency Exchange @doc """ Exchange a given amount from one currency to another. It will use the cached exchange rates from the European Central Bank (ECB) or fetch the latest rates if the cache is disabled. ## Options #{NimbleOptions.docs(Forex.Options.currency_schema())} ## Examples ```elixir iex> Forex.exchange(100, "USD", "EUR") {:ok, Decimal.new("91.86100")} iex> Forex.exchange(420, :eur, :gbp, format: :string) {:ok, "353.12760"} iex> Forex.exchange(123, :gbp, :usd, format: :string, round: 1) {:ok, "159.3"} ``` """ @spec exchange( amount :: Currency.input_amount(), from_currency :: Currency.code(), to_currency :: Currency.code(), opts :: [Options.currency_option()] ) :: {:ok, Currency.output_amount()} | {:error, term} def exchange(amount, from_currency, to_currency, opts \\ []) do with {:ok, _} <- Currency.validate_currencies(from_currency, to_currency), {:ok, %{rates: rates}} <- Forex.latest_rates(base: from_currency, keys: :strings) do Currency.exchange_rates(rates, amount, from_currency, to_currency, opts) end end @doc """ Same as `exchange/3`, but raises an error if the request fails. """ @spec exchange!( amount :: Currency.input_amount(), from_currency :: Currency.code(), to_currency :: Currency.code(), opts :: [Options.currency_option()] ) :: Currency.output_amount() def exchange!(amount, from_currency, to_currency, opts \\ []) do case exchange(amount, from_currency, to_currency, opts) do {:ok, result} -> result {:error, reason} -> raise Forex.CurrencyError, "Currency exchange failed: #{reason}" end end @doc """ Given a specific date, amount, and two currencies, it will return the exchange rate for that date. ## Options #{NimbleOptions.docs(Forex.Options.currency_schema())} ## Examples ```elixir iex> Forex.exchange_historic_rate("2023-01-01", 100, "USD", "EUR") {:ok, Decimal.new("91.86100")} iex> Forex.exchange_historic_rate(~D[2023-01-01], 420, :eur, :gbp, format: :string) {:ok, "353.12760"} ``` """ @spec exchange_historic_rate( date :: parsable_date(), amount :: Currency.input_amount(), from :: Currency.code(), to :: Currency.code(), opts :: [Options.rates_option()] ) :: {:ok, Currency.output_amount()} | {:error, term} def exchange_historic_rate(date, amount, from, to, opts \\ []) do case get_historic_rate(date, opts) do {:ok, %Forex{rates: rates}} -> Currency.exchange_rates(rates, amount, from, to, opts) {:error, reason} -> {:error, reason} end end @doc """ Same as `exchange_historic_rate/5`, but raises an error if the request fails. """ def exchange_historic_rate!(date, amount, from, to, opts \\ []) do case get_historic_rate(date, opts) do {:ok, %Forex{rates: rates}} -> Currency.exchange_rates!(rates, amount, from, to, opts) {:error, {exception, reason}} -> raise exception, reason {:error, reason} -> raise Forex.FeedError, reason end end ## Private Functions # Map the rates response to the format %{currency_code() => Decimal.t()} # If not EUR based currency we rebase the rates to the new base currency @spec map_rates( rates :: {:ok, [%{currency: Currency.code(), rate: String.t()}]} | [%{currency: Currency.code(), rate: String.t()}] | {:error, term}, opts :: [Options.rates_option()] ) :: {:ok, rate()} | {:error, term} defp map_rates({:error, reason}, _), do: {:error, reason} defp map_rates({:ok, rates}, opts), do: map_rates(rates, opts) defp map_rates(rates, opts) when is_list(rates) do [@base_currency_rate | rates] |> maybe_filter_currencies(opts[:symbols]) |> Currency.maybe_rebase(opts[:base]) |> case do {:ok, rebased_rates} -> rebased_rates |> Enum.map(fn %{currency: currency, rate: value} -> {maybe_atomize_code(currency, opts[:keys]), rate_value(value, opts)} end) |> Enum.into(%{}) {:error, reason} -> {:error, reason} end end defp map_rates(_, _), do: [] # Format the rate value based on the options defp rate_value(value, opts) do value |> format_value(opts[:format]) |> round_value(opts[:round]) end # Filter the rates based on the symbols option defp maybe_filter_currencies(rates, nil), do: rates defp maybe_filter_currencies(rates, []), do: rates defp maybe_filter_currencies(rates, symbols) when is_list(symbols) do symbols = Enum.map(symbols, &stringify_code/1) Enum.filter(rates, fn %{currency: currency} -> Enum.member?(symbols, currency) end) end defp maybe_filter_currencies(rates, _), do: rates defp maybe_atomize_code(code, :atoms), do: atomize_code(code) defp maybe_atomize_code(code, :strings), do: stringify_code(code) end