berrypod/lib/simpleshop_theme/exchange_rate.ex
jamey 5c2f70ce44 add shipping costs with live exchange rates and country detection
Shipping rates fetched from Printify during product sync, converted to
GBP at sync time using frankfurter.app ECB exchange rates with 5%
buffer. Cached in shipping_rates table per blueprint/provider/country.

Cart page shows shipping estimate with country selector (detected from
Accept-Language header, persisted in cookie). Stripe Checkout includes
shipping_options for UK domestic and international delivery. Order
shipping_cost extracted from Stripe on payment.

ScheduledSyncWorker runs every 6 hours via Oban cron to keep rates
and exchange rates fresh. REST_OF_THE_WORLD fallback covers unlisted
countries. 780 tests, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:48:00 +00:00

103 lines
2.8 KiB
Elixir

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