berrypod/lib/simpleshop_theme/providers/printify.ex
jamey af207d7a35 switch mockup generator to UK print providers and add --replace flag
- Prefer Print Clever (72) for canvas, Monster Digital (29) for
  apparel and mugs where available, fall back to default providers
- Rename Art Print products to Canvas (new Satin Canvas blueprint)
- Add Canvas Prints category in Printify tag extraction
- Add --replace/-r flag to purge existing Printify products before
  generating (with interactive confirmation)
- Add purge_all_products/1 to generator module
- Remove old art print mockups, add new canvas + extra provider mockups

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 13:00:59 +00:00

552 lines
17 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"] || []
%{
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[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