defmodule SimpleshopTheme.ExchangeRate do @moduledoc """ Fetches and caches exchange rates for shipping cost conversion. Uses the frankfurter.app API (ECB data, free, no API key). Rates are fetched during product sync and cached in Settings so they survive restarts without an API call. """ alias SimpleshopTheme.Settings require Logger @api_base "https://api.frankfurter.app" @settings_prefix "exchange_rate_" @default_rates %{"USD" => 0.80, "EUR" => 0.86} @doc """ Fetches the latest exchange rates to GBP from the API and caches them. Returns `{:ok, rates_map}` or `{:error, reason}`. The rates map has currency codes as keys and GBP multipliers as values, e.g. `%{"USD" => 0.7892, "EUR" => 0.8534}`. """ def fetch_and_cache do case fetch_from_api() do {:ok, rates} -> cache_rates(rates) {:ok, rates} {:error, reason} -> Logger.warning("Exchange rate fetch failed: #{inspect(reason)}, using cached rates") {:ok, get_cached_rates()} end end @doc """ Returns cached exchange rates from Settings, falling back to defaults. """ def get_cached_rates do Enum.reduce(@default_rates, %{}, fn {currency, default}, acc -> key = @settings_prefix <> String.downcase(currency) <> "_to_gbp" rate = case Settings.get_setting(key) do nil -> default val when is_binary(val) -> String.to_float(val) val when is_number(val) -> val / 1 end Map.put(acc, currency, rate) end) end @doc """ Returns the GBP rate for a given currency, using cached values. """ def rate_for(currency) when currency in ["GBP", "gbp"], do: 1.0 def rate_for(currency) do rates = get_cached_rates() Map.get( rates, String.upcase(currency), Map.get(@default_rates, String.upcase(currency), 0.80) ) end # Fetch from frankfurter.app API defp fetch_from_api do url = "#{@api_base}/latest?from=USD&to=GBP,EUR" case Req.get(url, receive_timeout: 10_000) do {:ok, %{status: 200, body: %{"rates" => rates}}} -> # API returns rates FROM USD, so "GBP" value is the USD→GBP multiplier gbp_per_usd = rates["GBP"] # Derive EUR→GBP: if 1 USD = X GBP and 1 USD = Y EUR, then 1 EUR = X/Y GBP eur_rate = rates["EUR"] eur_to_gbp = if eur_rate && eur_rate > 0, do: gbp_per_usd / eur_rate, else: @default_rates["EUR"] {:ok, %{"USD" => gbp_per_usd, "EUR" => eur_to_gbp}} {:ok, %{status: status}} -> {:error, {:http_status, status}} {:error, reason} -> {:error, reason} end end defp cache_rates(rates) do Enum.each(rates, fn {currency, rate} -> key = @settings_prefix <> String.downcase(currency) <> "_to_gbp" Settings.put_setting(key, Float.to_string(rate)) end) end end