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>
This commit is contained in:
jamey
2026-02-14 10:48:00 +00:00
parent 44933acebb
commit 5c2f70ce44
26 changed files with 1707 additions and 38 deletions

View File

@@ -0,0 +1,102 @@
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

View File

@@ -18,6 +18,7 @@ defmodule SimpleshopTheme.Orders.Order do
field :customer_email, :string
field :shipping_address, :map, default: %{}
field :subtotal, :integer
field :shipping_cost, :integer
field :total, :integer
field :currency, :string, default: "gbp"
field :metadata, :map, default: %{}
@@ -48,6 +49,7 @@ defmodule SimpleshopTheme.Orders.Order do
:customer_email,
:shipping_address,
:subtotal,
:shipping_cost,
:total,
:currency,
:metadata

View File

@@ -10,6 +10,8 @@ defmodule SimpleshopTheme.Providers.Printify do
alias SimpleshopTheme.Clients.Printify, as: Client
alias SimpleshopTheme.Products.ProviderConnection
require Logger
@impl true
def provider_type, do: "printify"
@@ -114,6 +116,101 @@ defmodule SimpleshopTheme.Providers.Printify do
end
end
# =============================================================================
# Shipping Rates
# =============================================================================
@impl true
def fetch_shipping_rates(%ProviderConnection{} = conn, products) when is_list(products) do
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
:ok <- set_api_key(api_key) do
pairs = extract_blueprint_provider_pairs(products)
Logger.info("Fetching shipping rates for #{length(pairs)} blueprint/provider pairs")
rates =
pairs
|> Enum.with_index()
|> Enum.flat_map(fn {pair, index} ->
# Rate limit: 100ms between requests
if index > 0, do: Process.sleep(100)
fetch_shipping_for_pair(pair)
end)
{:ok, rates}
else
nil -> {:error, :no_api_key}
end
end
defp extract_blueprint_provider_pairs(products) do
products
|> Enum.flat_map(fn product ->
provider_data = product[:provider_data] || %{}
blueprint_id = provider_data[:blueprint_id] || provider_data["blueprint_id"]
print_provider_id = provider_data[:print_provider_id] || provider_data["print_provider_id"]
if blueprint_id && print_provider_id do
[{blueprint_id, print_provider_id}]
else
[]
end
end)
|> Enum.uniq()
end
defp fetch_shipping_for_pair({blueprint_id, print_provider_id}) do
case Client.get_shipping(blueprint_id, print_provider_id) do
{:ok, response} ->
normalize_shipping_response(blueprint_id, print_provider_id, response)
{:error, reason} ->
Logger.warning(
"Failed to fetch shipping for blueprint #{blueprint_id}, " <>
"provider #{print_provider_id}: #{inspect(reason)}"
)
[]
end
end
defp normalize_shipping_response(blueprint_id, print_provider_id, response) do
handling_time_days =
case response["handling_time"] do
%{"value" => value, "unit" => "day"} -> value
_ -> nil
end
profiles = response["profiles"] || []
# For each profile, expand countries into individual rate maps.
# Then group by country and take the max first_item_cost across profiles
# (conservative estimate across variant groups).
profiles
|> Enum.flat_map(fn profile ->
countries = profile["countries"] || []
first_cost = get_in(profile, ["first_item", "cost"]) || 0
additional_cost = get_in(profile, ["additional_items", "cost"]) || 0
currency = get_in(profile, ["first_item", "currency"]) || "USD"
Enum.map(countries, fn country ->
%{
blueprint_id: blueprint_id,
print_provider_id: print_provider_id,
country_code: country,
first_item_cost: first_cost,
additional_item_cost: additional_cost,
currency: currency,
handling_time_days: handling_time_days
}
end)
end)
|> Enum.group_by(& &1.country_code)
|> Enum.map(fn {_country, country_rates} ->
# Take the max first_item_cost across variant groups for this country
Enum.max_by(country_rates, & &1.first_item_cost)
end)
end
# =============================================================================
# Webhook Registration
# =============================================================================

View File

@@ -57,6 +57,21 @@ defmodule SimpleshopTheme.Providers.Provider do
@callback get_order_status(ProviderConnection.t(), provider_order_id :: String.t()) ::
{:ok, map()} | {:error, term()}
@doc """
Fetches shipping rates from the provider for the given products.
Takes the connection and the already-fetched product list (from fetch_products).
Returns normalized rate maps with keys: blueprint_id, print_provider_id,
country_code, first_item_cost, additional_item_cost, currency, handling_time_days.
Optional — providers that don't support shipping rate lookup can skip this.
The sync worker checks `function_exported?/3` before calling.
"""
@callback fetch_shipping_rates(ProviderConnection.t(), products :: [map()]) ::
{:ok, [map()]} | {:error, term()}
@optional_callbacks [fetch_shipping_rates: 2]
@doc """
Returns the provider module for a given provider type.

View File

@@ -0,0 +1,357 @@
defmodule SimpleshopTheme.Shipping do
@moduledoc """
The Shipping context.
Manages cached shipping rates from POD providers and calculates
shipping estimates for cart and checkout.
"""
import Ecto.Query
alias SimpleshopTheme.ExchangeRate
alias SimpleshopTheme.Repo
alias SimpleshopTheme.Shipping.ShippingRate
alias SimpleshopTheme.Products
alias SimpleshopTheme.Settings
require Logger
@country_names %{
"AT" => "Austria",
"AU" => "Australia",
"BE" => "Belgium",
"BG" => "Bulgaria",
"CA" => "Canada",
"CH" => "Switzerland",
"CY" => "Cyprus",
"CZ" => "Czechia",
"DE" => "Germany",
"DK" => "Denmark",
"EE" => "Estonia",
"ES" => "Spain",
"FI" => "Finland",
"FR" => "France",
"GB" => "United Kingdom",
"GR" => "Greece",
"HR" => "Croatia",
"HU" => "Hungary",
"IE" => "Ireland",
"IT" => "Italy",
"JP" => "Japan",
"LT" => "Lithuania",
"LU" => "Luxembourg",
"LV" => "Latvia",
"MT" => "Malta",
"NL" => "Netherlands",
"NO" => "Norway",
"NZ" => "New Zealand",
"PL" => "Poland",
"PT" => "Portugal",
"RO" => "Romania",
"SE" => "Sweden",
"SI" => "Slovenia",
"SK" => "Slovakia",
"US" => "United States"
}
# =============================================================================
# Rate Storage
# =============================================================================
@default_buffer_percent 5
@doc """
Bulk upserts shipping rates for a provider connection.
When `exchange_rates` are provided (e.g. `%{"USD" => 0.79}`), rates are
converted to GBP at insert time with a buffer (default #{@default_buffer_percent}%).
This locks in the exchange rate at sync time rather than at display time.
Existing rates for the same (connection, blueprint, provider, country) combo
are replaced. Returns the number of upserted rows.
"""
def upsert_rates(provider_connection_id, rates, exchange_rates \\ nil)
when is_list(rates) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
buffer = get_buffer_percent()
entries =
Enum.map(rates, fn rate ->
{first, additional, currency} =
convert_rate_to_gbp(rate, exchange_rates, buffer)
%{
id: Ecto.UUID.generate(),
provider_connection_id: provider_connection_id,
blueprint_id: rate.blueprint_id,
print_provider_id: rate.print_provider_id,
country_code: rate.country_code,
first_item_cost: first,
additional_item_cost: additional,
currency: currency,
handling_time_days: rate[:handling_time_days],
inserted_at: now,
updated_at: now
}
end)
{count, _} =
Repo.insert_all(ShippingRate, entries,
on_conflict:
{:replace,
[
:first_item_cost,
:additional_item_cost,
:currency,
:handling_time_days,
:updated_at
]},
conflict_target: [
:provider_connection_id,
:blueprint_id,
:print_provider_id,
:country_code
]
)
Logger.info("Upserted #{count} shipping rates for connection #{provider_connection_id}")
{:ok, count}
end
@doc """
Gets a single shipping rate for a blueprint/provider/country combo.
Falls back to `REST_OF_THE_WORLD` if no exact country match exists
(Printify uses this as a catch-all for unlisted countries).
"""
def get_rate(blueprint_id, print_provider_id, country_code) do
ShippingRate
|> where(
[r],
r.blueprint_id == ^blueprint_id and
r.print_provider_id == ^print_provider_id and
r.country_code == ^country_code
)
|> limit(1)
|> Repo.one()
|> case do
nil when country_code != "REST_OF_THE_WORLD" ->
get_rate(blueprint_id, print_provider_id, "REST_OF_THE_WORLD")
result ->
result
end
end
# =============================================================================
# Cart Calculation
# =============================================================================
@doc """
Calculates the shipping estimate for a cart.
Takes a list of hydrated cart items (with variant_id, quantity) and a
country code. Groups items by print provider, looks up rates, and
returns the total in the shop's currency (pence).
Returns `{:ok, cost_pence}` or `{:error, :rates_not_found}`.
"""
def calculate_for_cart([], _country_code), do: {:ok, 0}
def calculate_for_cart(cart_items, country_code) do
variant_ids = Enum.map(cart_items, & &1.variant_id)
variants_map = Products.get_variants_with_products(variant_ids)
# Build list of {print_provider_id, blueprint_id, quantity, variant_id}
items_with_provider =
Enum.flat_map(cart_items, fn item ->
case Map.get(variants_map, item.variant_id) do
nil ->
[]
variant ->
provider_data = variant.product.provider_data || %{}
blueprint_id = provider_data["blueprint_id"]
print_provider_id = provider_data["print_provider_id"]
if blueprint_id && print_provider_id do
[
%{
print_provider_id: print_provider_id,
blueprint_id: blueprint_id,
quantity: item.quantity
}
]
else
[]
end
end
end)
if Enum.empty?(items_with_provider) do
{:error, :rates_not_found}
else
calculate_grouped(items_with_provider, country_code)
end
end
defp calculate_grouped(items, country_code) do
# Group by print_provider_id — items from same provider ship together
groups = Enum.group_by(items, & &1.print_provider_id)
results =
Enum.map(groups, fn {_provider_id, group_items} ->
calculate_provider_group(group_items, country_code)
end)
case Enum.find(results, &match?({:error, _}, &1)) do
nil ->
total = results |> Enum.map(fn {:ok, cost} -> cost end) |> Enum.sum()
{:ok, total}
error ->
error
end
end
defp calculate_provider_group(group_items, country_code) do
# Look up rates for each unique blueprint in the group
rates =
group_items
|> Enum.uniq_by(& &1.blueprint_id)
|> Enum.map(fn item ->
rate = get_rate(item.blueprint_id, item.print_provider_id, country_code)
{item, rate}
end)
if Enum.any?(rates, fn {_item, rate} -> is_nil(rate) end) do
{:error, :rates_not_found}
else
# Take the highest first_item_cost across all blueprints in the group
{_best_item, best_rate} = Enum.max_by(rates, fn {_item, rate} -> rate.first_item_cost end)
total_qty = Enum.reduce(group_items, 0, fn item, acc -> acc + item.quantity end)
# Build a map of blueprint_id => rate for additional item costs
rate_by_blueprint = Map.new(rates, fn {item, rate} -> {item.blueprint_id, rate} end)
# First item uses the highest first_item_cost
# Remaining items each use their own blueprint's additional_item_cost
additional_cost =
group_items
|> Enum.flat_map(fn item ->
rate = Map.get(rate_by_blueprint, item.blueprint_id)
List.duplicate(rate.additional_item_cost, item.quantity)
end)
|> Enum.sort(:desc)
# Drop the first item (covered by first_item_cost)
|> Enum.drop(1)
|> Enum.sum()
cost =
if total_qty > 0 do
best_rate.first_item_cost + additional_cost
else
0
end
# If rates were converted at sync time, currency is GBP and we're done.
# For legacy unconverted rates, convert now using cached exchange rates.
gbp_cost =
if best_rate.currency == "GBP" do
cost
else
ExchangeRate.rate_for(best_rate.currency)
|> then(&ceil(cost * &1))
end
{:ok, gbp_cost}
end
end
# =============================================================================
# Sync-time Currency Conversion
# =============================================================================
# Converts a rate to GBP at sync time using live exchange rates + buffer.
# Returns {first_item_cost, additional_item_cost, "GBP"}.
defp convert_rate_to_gbp(rate, exchange_rates, buffer_percent)
defp convert_rate_to_gbp(rate, _exchange_rates, _buffer)
when rate.currency in ["GBP", "gbp"] do
{rate.first_item_cost, rate.additional_item_cost, "GBP"}
end
defp convert_rate_to_gbp(rate, exchange_rates, buffer) when is_map(exchange_rates) do
fx = Map.get(exchange_rates, String.upcase(rate.currency), 0.80)
multiplier = fx * (1 + buffer / 100)
{
ceil(rate.first_item_cost * multiplier),
ceil(rate.additional_item_cost * multiplier),
"GBP"
}
end
# No exchange rates provided — store in original currency (legacy path)
defp convert_rate_to_gbp(rate, nil, _buffer) do
{rate.first_item_cost, rate.additional_item_cost, rate.currency}
end
defp get_buffer_percent do
case Settings.get_setting("shipping_buffer_percent") do
nil -> @default_buffer_percent
val when is_binary(val) -> String.to_integer(val)
val when is_integer(val) -> val
end
end
# =============================================================================
# Queries
# =============================================================================
@doc """
Returns a list of distinct country codes that have shipping rates.
When `REST_OF_THE_WORLD` exists in the DB, all countries from the
known names map are included (they're covered by the fallback rate).
"""
def list_available_countries do
codes =
ShippingRate
|> distinct(true)
|> select([r], r.country_code)
|> order_by([r], r.country_code)
|> Repo.all()
has_rest_of_world = "REST_OF_THE_WORLD" in codes
codes
|> Enum.reject(&(&1 == "REST_OF_THE_WORLD"))
|> then(fn explicit ->
if has_rest_of_world do
Map.keys(@country_names) ++ explicit
else
explicit
end
end)
|> Enum.uniq()
|> Enum.sort()
end
@doc """
Returns `{code, name}` tuples for all countries with shipping rates,
sorted by name.
"""
def list_available_countries_with_names do
list_available_countries()
|> Enum.map(fn code -> {code, country_name(code)} end)
|> Enum.sort_by(&elem(&1, 1))
end
@doc """
Returns the display name for a country code.
"""
def country_name(code) when is_binary(code) do
Map.get(@country_names, String.upcase(code), code)
end
end

View File

@@ -0,0 +1,53 @@
defmodule SimpleshopTheme.Shipping.ShippingRate do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "shipping_rates" do
field :blueprint_id, :integer
field :print_provider_id, :integer
field :country_code, :string
field :first_item_cost, :integer
field :additional_item_cost, :integer
field :currency, :string, default: "USD"
field :handling_time_days, :integer
belongs_to :provider_connection, SimpleshopTheme.Products.ProviderConnection
timestamps(type: :utc_datetime)
end
def changeset(rate, attrs) do
rate
|> cast(attrs, [
:provider_connection_id,
:blueprint_id,
:print_provider_id,
:country_code,
:first_item_cost,
:additional_item_cost,
:currency,
:handling_time_days
])
|> validate_required([
:provider_connection_id,
:blueprint_id,
:print_provider_id,
:country_code,
:first_item_cost,
:additional_item_cost,
:currency
])
|> validate_number(:first_item_cost, greater_than_or_equal_to: 0)
|> validate_number(:additional_item_cost, greater_than_or_equal_to: 0)
|> foreign_key_constraint(:provider_connection_id)
|> unique_constraint([
:provider_connection_id,
:blueprint_id,
:print_provider_id,
:country_code
])
end
end

View File

@@ -96,6 +96,9 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
"#{created} created, #{updated} updated, #{unchanged} unchanged, #{errors} errors"
)
# Sync shipping rates (non-fatal — logged and skipped on failure)
sync_shipping_rates(conn, provider, products)
Products.update_sync_status(conn, "completed", DateTime.utc_now())
product_count = Products.count_products_for_connection(conn.id)
broadcast_sync(conn.id, {:sync_status, "completed", product_count})
@@ -200,4 +203,29 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
# Recompute denormalized fields (cheapest_price, in_stock, on_sale) from variants
Products.recompute_cached_fields(product)
end
defp sync_shipping_rates(conn, provider, products) do
if function_exported?(provider, :fetch_shipping_rates, 2) do
# Fetch live exchange rates so shipping costs are stored in GBP
{:ok, exchange_rates} = SimpleshopTheme.ExchangeRate.fetch_and_cache()
case provider.fetch_shipping_rates(conn, products) do
{:ok, rates} when rates != [] ->
SimpleshopTheme.Shipping.upsert_rates(conn.id, rates, exchange_rates)
{:ok, []} ->
Logger.info("No shipping rates returned for #{conn.provider_type}")
{:error, reason} ->
Logger.warning(
"Shipping rate sync failed for #{conn.provider_type}: #{inspect(reason)}"
)
end
end
rescue
e ->
Logger.error(
"Shipping rate sync crashed for #{conn.provider_type}: #{Exception.message(e)}"
)
end
end

View File

@@ -0,0 +1,34 @@
defmodule SimpleshopTheme.Sync.ScheduledSyncWorker do
@moduledoc """
Oban cron worker for periodic product + shipping rate sync.
Runs every 6 hours, enqueues a ProductSyncWorker for each enabled
provider connection. The :sync queue (concurrency 1) serialises
these with any manual syncs triggered from the admin UI.
"""
use Oban.Worker, queue: :sync, max_attempts: 1
alias SimpleshopTheme.Products
require Logger
@impl Oban.Worker
def perform(%Oban.Job{}) do
connections =
Products.list_provider_connections()
|> Enum.filter(& &1.enabled)
if Enum.empty?(connections) do
Logger.info("Scheduled sync: no enabled provider connections, skipping")
else
Logger.info("Scheduled sync: enqueuing sync for #{length(connections)} connection(s)")
Enum.each(connections, fn conn ->
Products.enqueue_sync(conn)
end)
end
:ok
end
end