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

View File

@@ -9,6 +9,7 @@ defmodule SimpleshopThemeWeb.CartHook do
- `open_cart_drawer` / `close_cart_drawer` - toggle drawer visibility
- `remove_item` - remove item from cart
- `increment` / `decrement` - change item quantity
- `change_country` - update shipping country
- `{:cart_updated, cart}` info - cross-tab cart sync via PubSub
LiveViews with custom cart logic (e.g. add_to_cart) can call
@@ -19,12 +20,17 @@ defmodule SimpleshopThemeWeb.CartHook do
import Phoenix.LiveView, only: [attach_hook: 4, connected?: 1, push_event: 3]
alias SimpleshopTheme.Cart
alias SimpleshopTheme.Shipping
def on_mount(:mount_cart, _params, session, socket) do
cart_items = Cart.get_from_session(session)
country_code = session["country_code"] || "GB"
available_countries = Shipping.list_available_countries_with_names()
socket =
socket
|> assign(:country_code, country_code)
|> assign(:available_countries, available_countries)
|> update_cart_assigns(cart_items)
|> assign(:cart_drawer_open, false)
|> assign(:cart_status, nil)
@@ -54,6 +60,16 @@ defmodule SimpleshopThemeWeb.CartHook do
{:halt, assign(socket, :cart_drawer_open, false)}
end
defp handle_cart_event("change_country", %{"country" => code}, socket) do
socket =
socket
|> assign(:country_code, code)
|> update_cart_assigns(socket.assigns.raw_cart)
|> push_event("persist_country", %{code: code})
{:halt, socket}
end
defp handle_cart_event("remove_item", %{"id" => variant_id}, socket) do
cart = Cart.remove_item(socket.assigns.raw_cart, variant_id)
@@ -107,12 +123,25 @@ defmodule SimpleshopThemeWeb.CartHook do
"""
def update_cart_assigns(socket, cart) do
%{items: items, count: count, subtotal: subtotal} = Cart.build_state(cart)
country_code = socket.assigns[:country_code] || "GB"
subtotal_pence = Cart.calculate_subtotal(items)
shipping_estimate =
case Shipping.calculate_for_cart(items, country_code) do
{:ok, cost} when cost > 0 -> cost
_ -> nil
end
cart_total =
Cart.format_price(subtotal_pence + (shipping_estimate || 0))
socket
|> assign(:raw_cart, cart)
|> assign(:cart_items, items)
|> assign(:cart_count, count)
|> assign(:cart_subtotal, subtotal)
|> assign(:cart_total, cart_total)
|> assign(:shipping_estimate, shipping_estimate)
end
@doc """

View File

@@ -24,7 +24,13 @@
</div>
<div>
<.order_summary subtotal={@cart_page_subtotal} mode={@mode} />
<.order_summary
subtotal={@cart_page_subtotal}
shipping_estimate={assigns[:shipping_estimate]}
country_code={assigns[:country_code] || "GB"}
available_countries={assigns[:available_countries] || []}
mode={@mode}
/>
</div>
</div>
<% end %>

View File

@@ -34,15 +34,19 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
attr :cart_items, :list, default: []
attr :subtotal, :string, default: nil
attr :total, :string, default: nil
attr :cart_count, :integer, default: 0
attr :cart_status, :string, default: nil
attr :mode, :atom, default: :live
attr :open, :boolean, default: false
attr :shipping_estimate, :integer, default: nil
attr :country_code, :string, default: "GB"
attr :available_countries, :list, default: []
def cart_drawer(assigns) do
assigns =
assign_new(assigns, :display_subtotal, fn ->
assigns.subtotal || "£0.00"
assign_new(assigns, :display_total, fn ->
assigns.total || assigns.subtotal || "£0.00"
end)
~H"""
@@ -126,16 +130,18 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
class="cart-drawer-footer"
style="padding: 1rem 1.5rem; border-top: 1px solid var(--t-border-default); background: var(--t-surface-sunken);"
>
<div style="display: flex; justify-content: space-between; font-family: var(--t-font-body); font-size: var(--t-text-small); color: var(--t-text-secondary); margin-bottom: 0.5rem;">
<span>Delivery</span>
<span>Calculated at checkout</span>
</div>
<.delivery_line
shipping_estimate={@shipping_estimate}
country_code={@country_code}
available_countries={@available_countries}
mode={@mode}
/>
<div
class="cart-drawer-total"
style="display: flex; justify-content: space-between; font-family: var(--t-font-body); font-size: var(--t-text-base); font-weight: 600; color: var(--t-text-primary); margin-bottom: 1rem;"
>
<span>Subtotal</span>
<span>{@display_subtotal}</span>
<span>{if @shipping_estimate, do: "Estimated total", else: "Subtotal"}</span>
<span>{@display_total}</span>
</div>
<%= if @mode == :preview do %>
<button
@@ -411,15 +417,55 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
ProductImage.direct_url(Product.primary_image(product), 400)
end
# Shared delivery line used by both cart_drawer and order_summary.
# Shows a country <select> when rates are available, falls back to plain text.
attr :shipping_estimate, :integer, default: nil
attr :country_code, :string, default: "GB"
attr :available_countries, :list, default: []
attr :mode, :atom, default: :live
defp delivery_line(assigns) do
~H"""
<div
class="flex justify-between items-center"
style="font-family: var(--t-font-body); font-size: var(--t-text-small); color: var(--t-text-secondary);"
>
<span class="flex items-center gap-1">
Delivery to
<%= if @available_countries != [] and @mode != :preview do %>
<form phx-change="change_country" style="display: inline;">
<select
name="country"
aria-label="Delivery country"
style="appearance: auto; background: transparent; border: none; color: inherit; font: inherit; padding: 0; cursor: pointer; text-decoration: underline; text-underline-offset: 2px;"
>
<%= for {code, name} <- @available_countries do %>
<option value={code} selected={code == @country_code}>{name}</option>
<% end %>
</select>
</form>
<% else %>
<span>{SimpleshopTheme.Shipping.country_name(@country_code)}</span>
<% end %>
</span>
<%= if @shipping_estimate do %>
<span>{SimpleshopTheme.Cart.format_price(@shipping_estimate)}</span>
<% else %>
<span>Calculated at checkout</span>
<% end %>
</div>
"""
end
@doc """
Renders the order summary card.
## Attributes
* `subtotal` - Required. Subtotal amount (in pence/cents).
* `delivery` - Optional. Delivery cost. Defaults to 800 (£8.00).
* `vat` - Optional. VAT amount. Defaults to 720 (£7.20).
* `currency` - Optional. Currency symbol. Defaults to "£".
* `shipping_estimate` - Optional. Shipping estimate in pence.
* `country_code` - Optional. Current country code. Default "GB".
* `available_countries` - Optional. List of `{code, name}` tuples.
* `mode` - Either `:live` (default) or `:preview`.
## Examples
@@ -427,9 +473,15 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
<.order_summary subtotal={3600} />
"""
attr :subtotal, :integer, required: true
attr :shipping_estimate, :integer, default: nil
attr :country_code, :string, default: "GB"
attr :available_countries, :list, default: []
attr :mode, :atom, default: :live
def order_summary(assigns) do
assigns =
assign(assigns, :estimated_total, assigns.subtotal + (assigns.shipping_estimate || 0))
~H"""
<.shop_card class="p-6 sticky top-4">
<h2
@@ -446,17 +498,19 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
{SimpleshopTheme.Cart.format_price(@subtotal)}
</span>
</div>
<div class="flex justify-between">
<span style="color: var(--t-text-secondary);">Delivery</span>
<span class="text-sm" style="color: var(--t-text-secondary);">
Calculated at checkout
</span>
</div>
<.delivery_line
shipping_estimate={@shipping_estimate}
country_code={@country_code}
available_countries={@available_countries}
mode={@mode}
/>
<div class="border-t pt-3" style="border-color: var(--t-border-default);">
<div class="flex justify-between text-lg">
<span class="font-semibold" style="color: var(--t-text-primary);">Subtotal</span>
<span class="font-semibold" style="color: var(--t-text-primary);">
{if @shipping_estimate, do: "Estimated total", else: "Subtotal"}
</span>
<span class="font-bold" style="color: var(--t-text-primary);">
{SimpleshopTheme.Cart.format_price(@subtotal)}
{SimpleshopTheme.Cart.format_price(@estimated_total)}
</span>
</div>
</div>

View File

@@ -52,8 +52,9 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
# Keys accepted by shop_layout — used by layout_assigns/1 so page templates
# can spread assigns without listing each one explicitly.
@layout_keys ~w(theme_settings logo_image header_image mode cart_items cart_count
cart_subtotal cart_drawer_open cart_status active_page error_page is_admin
search_query search_results search_open categories)a
cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin
search_query search_results search_open categories shipping_estimate
country_code available_countries)a
@doc """
Extracts the assigns relevant to `shop_layout` from a full assigns map.
@@ -82,6 +83,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
attr :cart_items, :list, required: true
attr :cart_count, :integer, required: true
attr :cart_subtotal, :string, required: true
attr :cart_total, :string, default: nil
attr :cart_drawer_open, :boolean, default: false
attr :cart_status, :string, default: nil
attr :active_page, :string, required: true
@@ -90,6 +92,9 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
attr :search_query, :string, default: ""
attr :search_results, :list, default: []
attr :search_open, :boolean, default: false
attr :shipping_estimate, :integer, default: nil
attr :country_code, :string, default: "GB"
attr :available_countries, :list, default: []
slot :inner_block, required: true
@@ -128,10 +133,14 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
<.cart_drawer
cart_items={@cart_items}
subtotal={@cart_subtotal}
total={@cart_total}
cart_count={@cart_count}
mode={@mode}
open={@cart_drawer_open}
cart_status={@cart_status}
shipping_estimate={@shipping_estimate}
country_code={@country_code}
available_countries={@available_countries}
/>
<.search_modal

View File

@@ -3,6 +3,7 @@ defmodule SimpleshopThemeWeb.CheckoutController do
alias SimpleshopTheme.Cart
alias SimpleshopTheme.Orders
alias SimpleshopTheme.Shipping
require Logger
@@ -54,16 +55,18 @@ defmodule SimpleshopThemeWeb.CheckoutController do
base_url = SimpleshopThemeWeb.Endpoint.url()
params = %{
mode: "payment",
line_items: line_items,
success_url: "#{base_url}/checkout/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "#{base_url}/cart",
metadata: %{"order_id" => order.id},
shipping_address_collection: %{
allowed_countries: ["GB", "US", "CA", "AU", "DE", "FR", "NL", "IE", "AT", "BE"]
params =
%{
mode: "payment",
line_items: line_items,
success_url: "#{base_url}/checkout/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "#{base_url}/cart",
metadata: %{"order_id" => order.id},
shipping_address_collection: %{
allowed_countries: ["GB", "US", "CA", "AU", "DE", "FR", "NL", "IE", "AT", "BE"]
}
}
}
|> maybe_add_shipping_options(hydrated_items)
case Stripe.Checkout.Session.create(params) do
{:ok, session} ->
@@ -89,4 +92,38 @@ defmodule SimpleshopThemeWeb.CheckoutController do
|> redirect(to: ~p"/cart")
end
end
defp maybe_add_shipping_options(params, hydrated_items) do
gb_result = Shipping.calculate_for_cart(hydrated_items, "GB")
us_result = Shipping.calculate_for_cart(hydrated_items, "US")
options =
[]
|> maybe_add_option(gb_result, "UK delivery", 5, 10)
|> maybe_add_option(us_result, "International delivery", 10, 20)
if options == [] do
params
else
Map.put(params, :shipping_options, options)
end
end
defp maybe_add_option(options, {:ok, cost}, name, min_days, max_days) when cost > 0 do
option = %{
shipping_rate_data: %{
type: "fixed_amount",
display_name: name,
fixed_amount: %{amount: cost, currency: "gbp"},
delivery_estimate: %{
minimum: %{unit: "business_day", value: min_days},
maximum: %{unit: "business_day", value: max_days}
}
}
}
options ++ [option]
end
defp maybe_add_option(options, _result, _name, _min, _max), do: options
end

View File

@@ -36,6 +36,9 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
payment_intent_id = session.payment_intent
{:ok, order} = Orders.mark_paid(order, payment_intent_id)
# Update shipping cost from Stripe (if shipping options were presented)
order = update_shipping_cost(order, session)
# Update shipping address if collected by Stripe
order =
if session.shipping_details do
@@ -111,4 +114,19 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
Orders.update_order(order, %{shipping_address: shipping_address})
end
defp update_shipping_cost(order, session) do
shipping_amount = get_in(session, [Access.key(:shipping_cost), Access.key(:amount_total)])
if is_integer(shipping_amount) and shipping_amount > 0 do
new_total = order.subtotal + shipping_amount
case Orders.update_order(order, %{shipping_cost: shipping_amount, total: new_total}) do
{:ok, updated} -> updated
{:error, _} -> order
end
else
order
end
end
end

View File

@@ -5,7 +5,7 @@ defmodule SimpleshopThemeWeb.Shop.Cart do
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :page_title, "Cart")}
{:ok, assign(socket, page_title: "Cart")}
end
@impl true

View File

@@ -0,0 +1,55 @@
defmodule SimpleshopThemeWeb.Plugs.CountryDetect do
@moduledoc """
Plug that detects the visitor's country from cookies or Accept-Language.
Priority:
1. `shipping_country` cookie (set when user explicitly changes country)
2. Accept-Language header (locale tags like `en-GB` → `GB`)
3. Falls back to `"GB"`
The result is stored in the session as `country_code` so LiveViews can
read it. Only runs once per session — skips if `country_code` is already
set (unless the cookie has changed).
"""
import Plug.Conn
@default_country "GB"
@cookie_name "shipping_country"
def init(opts), do: opts
def call(conn, _opts) do
cookie_country = conn.cookies[@cookie_name]
session_country = get_session(conn, "country_code")
cond do
# Cookie takes priority — user explicitly chose this country
cookie_country not in [nil, ""] and cookie_country != session_country ->
put_session(conn, "country_code", cookie_country)
# Session already set and no cookie override
session_country != nil ->
conn
# First visit: detect from Accept-Language
true ->
country = detect_from_header(conn)
put_session(conn, "country_code", country)
end
end
defp detect_from_header(conn) do
conn
|> get_req_header("accept-language")
|> List.first("")
|> parse_country()
end
defp parse_country(header) do
case Regex.run(~r/[a-z]{2}-([A-Z]{2})/, header) do
[_, country] -> country
nil -> @default_country
end
end
end

View File

@@ -13,6 +13,7 @@ defmodule SimpleshopThemeWeb.Router do
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_scope_for_user
plug SimpleshopThemeWeb.Plugs.CountryDetect
end
pipeline :api do