Printify's API returns all blueprint option values (e.g. 21 colours) even when only a few are enabled as variants. This caused the PDP to show phantom colour swatches with no images or purchasable variants. Now filter_options_by_variants strips option values to only those with enabled variants, ordered by first appearance so the hero/default colour leads the swatch list. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
639 lines
19 KiB
Elixir
639 lines
19 KiB
Elixir
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)
|
|
filtered_options = filter_options_by_variants(options, raw_variants)
|
|
|
|
%{
|
|
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: filtered_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
|
|
|
|
# Filter blueprint options to only include values present in enabled variants,
|
|
# ordered to match variant ordering (so the default variant's colour comes first).
|
|
defp filter_options_by_variants(options, raw_variants) do
|
|
enabled_order = enabled_option_value_order(options, raw_variants)
|
|
|
|
Enum.map(options, fn opt ->
|
|
name = opt["name"]
|
|
|
|
case Map.get(enabled_order, name) do
|
|
nil ->
|
|
opt
|
|
|
|
ordered_values ->
|
|
values_by_title = Map.new(opt["values"] || [], fn v -> {v["title"], v} end)
|
|
|
|
sorted =
|
|
ordered_values
|
|
|> Enum.map(&Map.get(values_by_title, &1))
|
|
|> Enum.reject(&is_nil/1)
|
|
|
|
Map.put(opt, "values", sorted)
|
|
end
|
|
end)
|
|
end
|
|
|
|
# Returns %{"Colors" => ["Dark Heather", "Navy", ...], "Sizes" => ["S", "2XL"]}
|
|
# preserving first-seen order from enabled variants.
|
|
defp enabled_option_value_order(options, raw_variants) do
|
|
option_names = Enum.map(options, & &1["name"])
|
|
|
|
raw_variants
|
|
|> Enum.filter(& &1["is_enabled"])
|
|
|> Enum.reduce(%{}, fn var, acc ->
|
|
parts = String.split(var["title"] || "", " / ")
|
|
|
|
option_names
|
|
|> Enum.with_index()
|
|
|> Enum.reduce(acc, fn {name, idx}, inner_acc ->
|
|
case Enum.at(parts, idx) do
|
|
nil ->
|
|
inner_acc
|
|
|
|
val ->
|
|
existing = Map.get(inner_acc, name, [])
|
|
|
|
if val in existing,
|
|
do: inner_acc,
|
|
else: Map.put(inner_acc, name, existing ++ [val])
|
|
end
|
|
end)
|
|
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
|