defmodule SimpleshopTheme.Providers.Printify do @moduledoc """ Printify provider implementation. Handles product sync and order submission for Printify. """ @behaviour SimpleshopTheme.Providers.Provider alias SimpleshopTheme.Clients.Printify, as: Client alias SimpleshopTheme.Products.ProviderConnection @impl true def provider_type, do: "printify" @impl true def test_connection(%ProviderConnection{} = conn) do with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), :ok <- set_api_key(api_key), {:ok, shops} <- Client.get_shops() do shop = List.first(shops) {:ok, %{ shop_id: shop["id"], shop_name: shop["title"], shop_count: length(shops) }} else nil -> {:error, :no_api_key} {:error, _} = error -> error end end @impl true def fetch_products(%ProviderConnection{config: config} = conn) do shop_id = config["shop_id"] if is_nil(shop_id) do {:error, :no_shop_id} else with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), :ok <- set_api_key(api_key), {:ok, products} <- fetch_all_products(shop_id) do {:ok, products} else nil -> {:error, :no_api_key} {:error, _} = error -> error end end end # Fetches all products by paginating through the API defp fetch_all_products(shop_id) do fetch_products_page(shop_id, 1, []) end defp fetch_products_page(shop_id, page, acc) do case Client.list_products(shop_id, page: page) do {:ok, response} -> products = Enum.map(response["data"] || [], &normalize_product/1) all_products = acc ++ products current_page = response["current_page"] || page last_page = response["last_page"] || 1 if current_page < last_page do # Small delay to be nice to rate limits (600/min = 10/sec) Process.sleep(100) fetch_products_page(shop_id, page + 1, all_products) else {:ok, all_products} end {:error, _} = error -> error end end @impl true def submit_order(%ProviderConnection{config: config} = conn, order) do shop_id = config["shop_id"] if is_nil(shop_id) do {:error, :no_shop_id} else with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), :ok <- set_api_key(api_key), order_data <- build_order_payload(order), {:ok, response} <- Client.create_order(shop_id, order_data) do {:ok, %{provider_order_id: response["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 shop_id = config["shop_id"] if is_nil(shop_id) do {:error, :no_shop_id} else with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), :ok <- set_api_key(api_key), {:ok, response} <- Client.get_order(shop_id, provider_order_id) do {:ok, normalize_order_status(response)} else nil -> {:error, :no_api_key} {:error, _} = error -> error end end end # ============================================================================= # Webhook Registration # ============================================================================= @webhook_events [ "product:updated", "product:deleted", "product:publish:started", "order:sent-to-production", "order:shipment:created", "order:shipment:delivered" ] @doc """ Registers webhooks for product events with Printify. Returns {:ok, results} or {:error, reason}. """ def register_webhooks(%ProviderConnection{config: config} = conn, webhook_url) do shop_id = config["shop_id"] secret = config["webhook_secret"] cond do is_nil(shop_id) -> {:error, :no_shop_id} is_nil(secret) or secret == "" -> {:error, :no_webhook_secret} true -> with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), :ok <- set_api_key(api_key) do {:ok, create_all_webhooks(shop_id, webhook_url, secret)} else nil -> {:error, :no_api_key} end end end defp create_all_webhooks(shop_id, webhook_url, secret) do Enum.map(@webhook_events, fn event -> case Client.create_webhook(shop_id, webhook_url, event, secret) do {:ok, response} -> {:ok, event, response} {:error, reason} -> {:error, event, reason} end end) end @doc """ Lists registered webhooks for the shop. """ def list_webhooks(%ProviderConnection{config: config} = conn) do shop_id = config["shop_id"] if is_nil(shop_id) do {:error, :no_shop_id} else with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), :ok <- set_api_key(api_key), {:ok, webhooks} <- Client.list_webhooks(shop_id) do {:ok, webhooks} else nil -> {:error, :no_api_key} {:error, _} = error -> error end end end # ============================================================================= # Option Types Extraction (for frontend) # ============================================================================= @doc """ Extracts option types from Printify provider_data for frontend display. Returns a list of option type maps with normalized names, types, and values including hex color codes for color-type options. ## Examples iex> extract_option_types(%{"options" => [ ...> %{"name" => "Colors", "type" => "color", "values" => [ ...> %{"id" => 1, "title" => "Black", "colors" => ["#000000"]} ...> ]} ...> ]}) [%{name: "Color", type: :color, values: [%{id: 1, title: "Black", hex: "#000000"}]}] """ def extract_option_types(%{"options" => options}) when is_list(options) do Enum.map(options, fn opt -> %{ name: singularize_option_name(opt["name"]), type: option_type_atom(opt["type"]), values: extract_option_values(opt) } 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 defp extract_option_values(%{"values" => values, "type" => "color"}) do Enum.map(values, fn val -> %{ id: val["id"], title: val["title"], hex: List.first(val["colors"]) } end) end defp extract_option_values(%{"values" => values}) do Enum.map(values, fn val -> %{id: val["id"], title: val["title"]} end) end # ============================================================================= # Data Normalization # ============================================================================= defp normalize_product(raw) do options = raw["options"] || [] %{ provider_product_id: to_string(raw["id"]), title: raw["title"], description: raw["description"], category: extract_category(raw), images: normalize_images(raw["images"] || []), variants: normalize_variants(raw["variants"] || [], options), provider_data: %{ blueprint_id: raw["blueprint_id"], print_provider_id: raw["print_provider_id"], tags: raw["tags"] || [], options: options, raw: raw } } end defp normalize_images(images) do images |> Enum.with_index() |> Enum.map(fn {img, index} -> # Printify returns position as a label string (e.g., "front", "back") # We use the index as the numeric position instead %{ src: img["src"], position: index, alt: img["position"] } end) end defp normalize_variants(variants, options) do option_names = extract_option_names(options) Enum.map(variants, fn var -> %{ provider_variant_id: to_string(var["id"]), title: var["title"], sku: var["sku"], price: var["price"], cost: var["cost"], options: normalize_variant_options(var, option_names), is_enabled: var["is_enabled"] == true, is_available: var["is_available"] == true } end) end # Extract option names from product options, singularizing common plurals defp extract_option_names(options) do Enum.map(options, fn opt -> singularize_option_name(opt["name"]) end) end defp singularize_option_name("Colors"), do: "Color" defp singularize_option_name("Sizes"), do: "Size" defp singularize_option_name(name), do: name defp normalize_variant_options(variant, option_names) do # Build human-readable map from variant title # Title format matches product options order: "Navy / S" for [Colors, Sizes] title = variant["title"] || "" parts = String.split(title, " / ") parts |> Enum.with_index() |> Enum.reduce(%{}, fn {value, index}, acc -> key = Enum.at(option_names, index) || "Option #{index + 1}" Map.put(acc, key, value) end) end defp extract_category(raw) do # Try to extract category from tags (case-insensitive) tags = (raw["tags"] || []) |> Enum.map(&String.downcase/1) cond do has_tag?(tags, ~w[t-shirt tshirt shirt hoodie sweatshirt apparel clothing]) -> "Apparel" has_tag?(tags, ~w[mug cup blanket pillow cushion homeware homewares home]) -> "Homewares" has_tag?(tags, ~w[notebook journal stationery]) -> "Stationery" has_tag?(tags, ~w[phone case bag tote accessories]) -> "Accessories" has_tag?(tags, ~w[art print poster canvas wall]) -> "Art Prints" true -> List.first(raw["tags"]) end end defp has_tag?(tags, keywords) do Enum.any?(tags, fn tag -> Enum.any?(keywords, fn keyword -> String.contains?(tag, keyword) end) end) end defp normalize_order_status(raw) do %{ status: map_order_status(raw["status"]), provider_status: raw["status"], tracking_number: extract_tracking(raw), tracking_url: extract_tracking_url(raw), shipments: raw["shipments"] || [] } end defp map_order_status("pending"), do: "submitted" defp map_order_status("on-hold"), do: "submitted" defp map_order_status("payment-not-received"), do: "submitted" defp map_order_status("cost-calculation"), do: "submitted" defp map_order_status("in-production"), do: "processing" defp map_order_status("partially-shipped"), do: "processing" 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(_), do: "submitted" defp extract_tracking(raw) do case raw["shipments"] do [shipment | _] -> shipment["tracking_number"] _ -> nil end end defp extract_tracking_url(raw) do case raw["shipments"] do [shipment | _] -> shipment["tracking_url"] _ -> nil end end # ============================================================================= # Order Building # ============================================================================= defp build_order_payload(order_data) do %{ external_id: order_data.order_number, label: order_data.order_number, line_items: Enum.map(order_data.line_items, fn item -> %{ product_id: item.provider_product_id, variant_id: parse_variant_id(item.provider_variant_id), quantity: item.quantity } end), shipping_method: 1, address_to: build_address(order_data.shipping_address, order_data.customer_email) } end # Maps Stripe shipping_details address fields to Printify's expected format. # Stripe gives us: name, line1, line2, city, postal_code, state, country # Printify wants: first_name, last_name, address1, address2, city, zip, region, country defp build_address(address, email) when is_map(address) do {first, last} = split_name(address["name"]) %{ first_name: first, last_name: last, email: email, phone: address["phone"] || "", country: address["country"] || "", region: address["state"] || address["region"] || "", address1: address["line1"] || address["address1"] || "", address2: address["line2"] || address["address2"] || "", city: address["city"] || "", zip: address["postal_code"] || address["zip"] || "" } end defp build_address(_address, email) do %{ first_name: "", last_name: "", email: email, phone: "", country: "", region: "", address1: "", address2: "", city: "", zip: "" } end defp split_name(nil), do: {"", ""} defp split_name(""), do: {"", ""} defp split_name(name) do case String.split(name, " ", parts: 2) do [first] -> {first, ""} [first, last] -> {first, last} end end # Printify variant IDs are integers, but we store them as strings defp parse_variant_id(id) when is_integer(id), do: id defp parse_variant_id(id) when is_binary(id), do: String.to_integer(id) # ============================================================================= # API Key Management # ============================================================================= # Temporarily sets the API key for the request # In a production system, this would use a connection pool or request context defp set_api_key(api_key) do Process.put(:printify_api_key, api_key) :ok end end