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>
585 lines
18 KiB
Elixir
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
|