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:
@@ -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
|
||||
|
||||
34
lib/simpleshop_theme/sync/scheduled_sync_worker.ex
Normal file
34
lib/simpleshop_theme/sync/scheduled_sync_worker.ex
Normal 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
|
||||
Reference in New Issue
Block a user