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>
358 lines
10 KiB
Elixir
358 lines
10 KiB
Elixir
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
|