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