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>
103 lines
2.8 KiB
Elixir
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
|