berrypod/lib/simpleshop_theme/providers/printify.ex
jamey daa6d3de71 add per-colour product images and gallery colour filtering
Tag product images with their colour during sync (both Printful and
Printify providers). Printify images are cherry-picked: hero colour
keeps all angles, other colours keep front + back only. Printful
MockupEnricher now generates mockups per colour from the
color_variant_map.

PDP gallery filters by the selected colour, falling back to all
images when the selected colour has none. Fix option name mismatch
(Printify "Colors" vs variant "Color") by singularizing in
Product.option_types.

Generator creates multi-colour apparel products so mock data matches
real sync behaviour.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:21:22 +00:00

585 lines
18 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)
%{
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