defmodule SimpleshopTheme.Providers.Printful do @moduledoc """ Printful provider implementation. Handles product sync, order submission, and shipping rate lookups for Printful. Uses v2 API endpoints where available, v1 for sync products. """ @behaviour SimpleshopTheme.Providers.Provider alias SimpleshopTheme.Clients.Printful, as: Client alias SimpleshopTheme.Products.ProviderConnection require Logger @impl true def provider_type, do: "printful" # ============================================================================= # Connection # ============================================================================= @impl true def test_connection(%ProviderConnection{} = conn) do with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), :ok <- set_credentials(api_key, nil), {:ok, stores} <- Client.get_stores() do store = List.first(stores) {:ok, %{ store_id: store["id"], store_name: store["name"] }} else nil -> {:error, :no_api_key} {:error, _} = error -> error end end # ============================================================================= # Products # ============================================================================= @impl true def fetch_products(%ProviderConnection{config: config} = conn) do store_id = config["store_id"] if is_nil(store_id) do {:error, :no_store_id} else with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), :ok <- set_credentials(api_key, store_id), {:ok, products} <- fetch_all_sync_products() do {:ok, products} else nil -> {:error, :no_api_key} {:error, _} = error -> error end end end defp fetch_all_sync_products do fetch_sync_products_page(0, []) end defp fetch_sync_products_page(offset, acc) do case Client.list_sync_products(offset: offset) do {:ok, products} when is_list(products) -> # Fetch full details for each product (includes variants + files) detailed = products |> Enum.with_index() |> Enum.map(fn {product, index} -> if index > 0, do: Process.sleep(100) case Client.get_sync_product(product["id"]) do {:ok, detail} -> normalize_product(detail["sync_product"], detail["sync_variants"] || []) {:error, reason} -> Logger.warning( "Failed to fetch Printful product #{product["id"]}: #{inspect(reason)}" ) nil end end) |> Enum.reject(&is_nil/1) all_products = acc ++ detailed # Printful paginates at 20 per page by default if length(products) >= 20 do Process.sleep(100) fetch_sync_products_page(offset + 20, all_products) else {:ok, all_products} end {:ok, _} -> {:ok, acc} {:error, _} = error -> error end end # ============================================================================= # Orders # ============================================================================= @impl true def submit_order(%ProviderConnection{config: config} = conn, order) do store_id = config["store_id"] if is_nil(store_id) do {:error, :no_store_id} else with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), :ok <- set_credentials(api_key, store_id), order_data <- build_order_payload(order), {:ok, response} <- Client.create_order(order_data), order_id <- response["id"], {:ok, _confirmed} <- Client.confirm_order(order_id) do {:ok, %{provider_order_id: to_string(order_id)}} else nil -> {:error, :no_api_key} {:error, _} = error -> error end end end @impl true def get_order_status(%ProviderConnection{config: config} = conn, provider_order_id) do store_id = config["store_id"] if is_nil(store_id) do {:error, :no_store_id} else with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), :ok <- set_credentials(api_key, store_id), {:ok, response} <- Client.get_order(provider_order_id) do shipments = fetch_shipments(provider_order_id) {:ok, normalize_order_status(response, shipments)} else nil -> {:error, :no_api_key} {:error, _} = error -> error end end end defp fetch_shipments(order_id) do case Client.get_order_shipments(order_id) do {:ok, shipments} when is_list(shipments) -> shipments _ -> [] end end # ============================================================================= # Shipping Rates # ============================================================================= @impl true def fetch_shipping_rates(%ProviderConnection{config: config} = conn, products) when is_list(products) do store_id = config["store_id"] with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), :ok <- set_credentials(api_key, store_id) do # Build per-product items: one representative variant per product product_items = extract_per_product_items(products) Logger.info( "Fetching Printful shipping rates for #{length(product_items)} product(s), " <> "#{length(target_countries())} countries" ) rates = for {catalog_product_id, variant_id} <- product_items, {country_code, _index} <- Enum.with_index(target_countries()), reduce: [] do acc -> if acc != [], do: Process.sleep(100) acc ++ fetch_rate_for_product(catalog_product_id, variant_id, country_code) end {:ok, rates} else nil -> {:error, :no_api_key} end end # Countries to pre-cache rates for defp target_countries do ["GB", "US", "DE", "FR", "CA", "AU", "IE", "NL", "AT", "BE"] end defp fetch_rate_for_product(catalog_product_id, variant_id, country_code) do items = [%{source: "catalog", catalog_variant_id: variant_id, quantity: 1}] case Client.calculate_shipping(%{country_code: country_code}, items) do {:ok, rates} when is_list(rates) -> standard = Enum.find(rates, &(&1["shipping"] == "STANDARD")) || List.first(rates) if standard do [ %{ blueprint_id: catalog_product_id, print_provider_id: 0, country_code: country_code, first_item_cost: parse_price(standard["rate"]), additional_item_cost: 0, currency: String.upcase(standard["currency"] || "USD"), handling_time_days: standard["max_delivery_days"] } ] else [] end {:error, reason} -> Logger.warning( "Failed to fetch Printful shipping for #{country_code}: #{inspect(reason)}" ) [] end end # Returns {catalog_product_id, first_catalog_variant_id} per product defp extract_per_product_items(products) do products |> Enum.flat_map(fn product -> provider_data = product[:provider_data] || %{} catalog_product_id = provider_data[:catalog_product_id] || provider_data["catalog_product_id"] catalog_variant_ids = provider_data[:catalog_variant_ids] || provider_data["catalog_variant_ids"] || [] case {catalog_product_id, catalog_variant_ids} do {nil, _} -> [] {_, []} -> [] {cpid, [vid | _]} -> [{cpid, vid}] end end) end # ============================================================================= # Option Types (for frontend display) # ============================================================================= @doc """ Extracts option types from Printful provider_data for frontend display. Builds option types from the stored options list, which contains distinct colour and size values with optional hex codes. """ def extract_option_types(%{"options" => options}) when is_list(options) do Enum.map(options, fn opt -> %{ name: opt["name"], type: option_type_atom(opt["type"]), values: Enum.map(opt["values"] || [], fn val -> base = %{title: val["title"]} if val["hex"], do: Map.put(base, :hex, val["hex"]), else: base end) } end) end def extract_option_types(_), do: [] defp option_type_atom("color"), do: :color defp option_type_atom("size"), do: :size defp option_type_atom(_), do: :other # ============================================================================= # Data Normalization # ============================================================================= defp normalize_product(sync_product, sync_variants) do images = extract_preview_images(sync_variants) catalog_product_id = extract_catalog_product_id_from_variants(sync_variants) catalog_variant_ids = Enum.map(sync_variants, & &1["variant_id"]) |> Enum.reject(&is_nil/1) %{ provider_product_id: to_string(sync_product["id"]), title: sync_product["name"], description: "", category: extract_category(sync_variants), images: images, variants: Enum.map(sync_variants, &normalize_variant/1), provider_data: %{ catalog_product_id: catalog_product_id, catalog_variant_ids: catalog_variant_ids, # Shipping calc uses these generic keys (shared with Printify) blueprint_id: catalog_product_id, print_provider_id: 0, thumbnail_url: sync_product["thumbnail_url"], options: build_option_types(sync_variants), raw: %{sync_product: sync_product} } } end defp normalize_variant(sv) do %{ provider_variant_id: to_string(sv["id"]), title: build_variant_title(sv), sku: sv["sku"], price: parse_price(sv["retail_price"]), cost: nil, options: build_variant_options(sv), is_enabled: sv["synced"] == true, is_available: sv["availability_status"] == "active" } end defp build_variant_title(sv) do parts = [sv["color"], sv["size"]] |> Enum.reject(&is_nil/1) Enum.join(parts, " / ") end defp build_variant_options(sv) do opts = %{} opts = if sv["color"], do: Map.put(opts, "Color", sv["color"]), else: opts opts = if sv["size"], do: Map.put(opts, "Size", sv["size"]), else: opts opts end # Extract unique preview images from sync variants (one per unique colour) defp extract_preview_images(sync_variants) do sync_variants |> Enum.flat_map(fn sv -> (sv["files"] || []) |> Enum.filter(&(&1["type"] == "preview")) |> Enum.map(fn file -> %{ src: file["preview_url"] || file["thumbnail_url"], color: sv["color"] } end) end) |> Enum.uniq_by(& &1.color) |> Enum.with_index() |> Enum.map(fn {img, index} -> %{ src: img.src, position: index, alt: img.color } end) end defp extract_catalog_product_id_from_variants(sync_variants) do sync_variants |> Enum.find_value(fn sv -> get_in(sv, ["product", "product_id"]) end) || 0 end # Build option types from variants for frontend display defp build_option_types(sync_variants) do colors = sync_variants |> Enum.map(& &1["color"]) |> Enum.reject(&is_nil/1) |> Enum.uniq() |> Enum.map(fn color -> %{"title" => color} end) sizes = sync_variants |> Enum.map(& &1["size"]) |> Enum.reject(&is_nil/1) |> Enum.uniq() |> Enum.map(fn size -> %{"title" => size} end) opts = [] opts = if colors != [], do: opts ++ [%{"name" => "Color", "type" => "color", "values" => colors}], else: opts opts = if sizes != [], do: opts ++ [%{"name" => "Size", "type" => "size", "values" => sizes}], else: opts opts end defp extract_category(sync_variants) do case sync_variants do [sv | _] -> product_name = get_in(sv, ["product", "name"]) || "" categorize_from_name(product_name) [] -> nil end end defp categorize_from_name(name) do name_lower = String.downcase(name) cond do has_keyword?(name_lower, ~w[t-shirt tshirt shirt hoodie sweatshirt jogger]) -> "Apparel" has_keyword?(name_lower, ~w[canvas poster print frame]) -> "Canvas Prints" has_keyword?(name_lower, ~w[mug cup blanket pillow cushion]) -> "Homewares" has_keyword?(name_lower, ~w[notebook journal]) -> "Stationery" has_keyword?(name_lower, ~w[phone case bag tote hat cap]) -> "Accessories" true -> "Apparel" end end defp has_keyword?(text, keywords) do Enum.any?(keywords, &String.contains?(text, &1)) end # ============================================================================= # Order Normalization # ============================================================================= defp normalize_order_status(raw, shipments) do %{ status: map_order_status(raw["status"]), provider_status: raw["status"], tracking_number: extract_tracking(shipments), tracking_url: extract_tracking_url(shipments), shipments: shipments } end defp map_order_status("draft"), do: "submitted" defp map_order_status("pending"), do: "submitted" defp map_order_status("inprocess"), do: "processing" defp map_order_status("fulfilled"), do: "shipped" defp map_order_status("shipped"), do: "shipped" defp map_order_status("delivered"), do: "delivered" defp map_order_status("canceled"), do: "cancelled" defp map_order_status("failed"), do: "submitted" defp map_order_status("onhold"), do: "submitted" defp map_order_status(_), do: "submitted" defp extract_tracking([shipment | _]) do shipment["tracking_number"] || get_in(shipment, ["tracking", "number"]) end defp extract_tracking(_), do: nil defp extract_tracking_url([shipment | _]) do shipment["tracking_url"] || get_in(shipment, ["tracking", "url"]) end defp extract_tracking_url(_), do: nil # ============================================================================= # Order Building # ============================================================================= defp build_order_payload(order_data) do %{ external_id: order_data.order_number, shipping: "STANDARD", recipient: build_recipient(order_data.shipping_address, order_data.customer_email), items: Enum.map(order_data.line_items, fn item -> %{ sync_variant_id: parse_int(item.provider_variant_id), quantity: item.quantity } end) } end defp build_recipient(address, email) when is_map(address) do %{ name: address["name"] || "", address1: address["line1"] || address["address1"] || "", address2: address["line2"] || address["address2"] || "", city: address["city"] || "", country_code: address["country"] || "", state_code: address["state"] || address["region"] || "", zip: address["postal_code"] || address["zip"] || "", email: email } end defp build_recipient(_address, email) do %{ name: "", address1: "", address2: "", city: "", country_code: "", state_code: "", zip: "", email: email } end # ============================================================================= # Helpers # ============================================================================= # Parse a price string like "13.50" into integer pence (1350) defp parse_price(price) when is_binary(price) do case Float.parse(price) do {float, _} -> round(float * 100) :error -> 0 end end defp parse_price(price) when is_number(price), do: round(price * 100) defp parse_price(_), do: 0 defp parse_int(value) when is_integer(value), do: value defp parse_int(value) when is_binary(value), do: String.to_integer(value) defp set_credentials(api_key, store_id) do Process.put(:printful_api_key, api_key) if store_id, do: Process.put(:printful_store_id, store_id) :ok end end