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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user