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:
102
lib/simpleshop_theme/exchange_rate.ex
Normal file
102
lib/simpleshop_theme/exchange_rate.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
357
lib/simpleshop_theme/shipping.ex
Normal file
357
lib/simpleshop_theme/shipping.ex
Normal 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
|
||||
53
lib/simpleshop_theme/shipping/shipping_rate.ex
Normal file
53
lib/simpleshop_theme/shipping/shipping_rate.ex
Normal 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
|
||||
@@ -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