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

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