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

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