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

@@ -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