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 require Logger @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 # ============================================================================= # 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 # ============================================================================= @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"] || [] raw_variants = raw["variants"] || [] color_lookup = build_image_color_lookup(raw_variants, options) %{ provider_product_id: to_string(raw["id"]), title: raw["title"], description: raw["description"], category: extract_category(raw), images: normalize_images(raw["images"] || [], color_lookup), 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, color_lookup) do images |> Enum.map(fn img -> color = resolve_image_color(img["variant_ids"] || [], color_lookup) %{src: img["src"], alt: img["position"], color: color, raw_position: img["position"]} end) |> select_images_per_color() |> Enum.with_index() |> Enum.map(fn {img, index} -> %{src: img.src, position: index, alt: img.alt, color: img.color} end) end # Hero colour (first seen) keeps all images. # Other colours keep only front + back views to avoid bloating the DB. defp select_images_per_color(images) do hero_color = images |> Enum.find_value(fn img -> img.color end) Enum.filter(images, fn img -> img.color == hero_color || is_nil(img.color) || img.raw_position in ["front", "back"] end) end defp build_image_color_lookup(raw_variants, options) do option_names = Enum.map(options, & &1["name"]) color_index = Enum.find_index(option_names, &(&1 in ["Colors", "Color"])) if color_index do Map.new(raw_variants, fn var -> parts = String.split(var["title"] || "", " / ") color = Enum.at(parts, color_index) {var["id"], color} end) else %{} end end defp resolve_image_color(variant_ids, color_lookup) do Enum.find_value(variant_ids, fn vid -> Map.get(color_lookup, vid) 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[canvas]) -> "Canvas Prints" 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 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