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>
641 lines
20 KiB
Elixir
641 lines
20 KiB
Elixir
defmodule SimpleshopTheme.Providers.Printful do
|
||
@moduledoc """
|
||
Printful provider implementation.
|
||
|
||
Handles product sync, order submission, and shipping rate lookups for Printful.
|
||
Uses v2 API endpoints where available, v1 for sync products.
|
||
"""
|
||
|
||
@behaviour SimpleshopTheme.Providers.Provider
|
||
|
||
alias SimpleshopTheme.Clients.Printful, as: Client
|
||
alias SimpleshopTheme.Products.ProviderConnection
|
||
|
||
require Logger
|
||
|
||
@impl true
|
||
def provider_type, do: "printful"
|
||
|
||
# =============================================================================
|
||
# Connection
|
||
# =============================================================================
|
||
|
||
@impl true
|
||
def test_connection(%ProviderConnection{} = conn) do
|
||
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
||
:ok <- set_credentials(api_key, nil),
|
||
{:ok, stores} <- Client.get_stores() do
|
||
store = List.first(stores)
|
||
|
||
{:ok,
|
||
%{
|
||
store_id: store["id"],
|
||
store_name: store["name"]
|
||
}}
|
||
else
|
||
nil -> {:error, :no_api_key}
|
||
{:error, _} = error -> error
|
||
end
|
||
end
|
||
|
||
# =============================================================================
|
||
# Products
|
||
# =============================================================================
|
||
|
||
@impl true
|
||
def fetch_products(%ProviderConnection{config: config} = conn) do
|
||
store_id = config["store_id"]
|
||
|
||
if is_nil(store_id) do
|
||
{:error, :no_store_id}
|
||
else
|
||
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
||
:ok <- set_credentials(api_key, store_id),
|
||
{:ok, products} <- fetch_all_sync_products() do
|
||
{:ok, products}
|
||
else
|
||
nil -> {:error, :no_api_key}
|
||
{:error, _} = error -> error
|
||
end
|
||
end
|
||
end
|
||
|
||
defp fetch_all_sync_products do
|
||
fetch_sync_products_page(0, [])
|
||
end
|
||
|
||
defp fetch_sync_products_page(offset, acc) do
|
||
case Client.list_sync_products(offset: offset) do
|
||
{:ok, products} when is_list(products) ->
|
||
# Fetch full details for each product (includes variants + files)
|
||
detailed =
|
||
products
|
||
|> Enum.with_index()
|
||
|> Enum.map(fn {product, index} ->
|
||
if index > 0, do: Process.sleep(100)
|
||
|
||
case Client.get_sync_product(product["id"]) do
|
||
{:ok, detail} ->
|
||
normalize_product(detail["sync_product"], detail["sync_variants"] || [])
|
||
|
||
{:error, reason} ->
|
||
Logger.warning(
|
||
"Failed to fetch Printful product #{product["id"]}: #{inspect(reason)}"
|
||
)
|
||
|
||
nil
|
||
end
|
||
end)
|
||
|> Enum.reject(&is_nil/1)
|
||
|
||
all_products = acc ++ detailed
|
||
|
||
# Printful paginates at 20 per page by default
|
||
if length(products) >= 20 do
|
||
Process.sleep(100)
|
||
fetch_sync_products_page(offset + 20, all_products)
|
||
else
|
||
{:ok, all_products}
|
||
end
|
||
|
||
{:ok, _} ->
|
||
{:ok, acc}
|
||
|
||
{:error, _} = error ->
|
||
error
|
||
end
|
||
end
|
||
|
||
# =============================================================================
|
||
# Orders
|
||
# =============================================================================
|
||
|
||
@impl true
|
||
def submit_order(%ProviderConnection{config: config} = conn, order) do
|
||
store_id = config["store_id"]
|
||
|
||
if is_nil(store_id) do
|
||
{:error, :no_store_id}
|
||
else
|
||
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
||
:ok <- set_credentials(api_key, store_id),
|
||
order_data <- build_order_payload(order),
|
||
{:ok, response} <- Client.create_order(order_data),
|
||
order_id <- response["id"],
|
||
{:ok, _confirmed} <- Client.confirm_order(order_id) do
|
||
{:ok, %{provider_order_id: to_string(order_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
|
||
store_id = config["store_id"]
|
||
|
||
if is_nil(store_id) do
|
||
{:error, :no_store_id}
|
||
else
|
||
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
||
:ok <- set_credentials(api_key, store_id),
|
||
{:ok, response} <- Client.get_order(provider_order_id) do
|
||
shipments = fetch_shipments(provider_order_id)
|
||
{:ok, normalize_order_status(response, shipments)}
|
||
else
|
||
nil -> {:error, :no_api_key}
|
||
{:error, _} = error -> error
|
||
end
|
||
end
|
||
end
|
||
|
||
defp fetch_shipments(order_id) do
|
||
case Client.get_order_shipments(order_id) do
|
||
{:ok, shipments} when is_list(shipments) -> shipments
|
||
_ -> []
|
||
end
|
||
end
|
||
|
||
# =============================================================================
|
||
# Shipping Rates
|
||
# =============================================================================
|
||
|
||
@impl true
|
||
def fetch_shipping_rates(%ProviderConnection{config: config} = conn, products)
|
||
when is_list(products) do
|
||
store_id = config["store_id"]
|
||
|
||
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
||
:ok <- set_credentials(api_key, store_id) do
|
||
# Build per-product items: one representative variant per product
|
||
product_items = extract_per_product_items(products)
|
||
|
||
Logger.info(
|
||
"Fetching Printful shipping rates for #{length(product_items)} product(s), " <>
|
||
"#{length(target_countries())} countries"
|
||
)
|
||
|
||
rates =
|
||
for {catalog_product_id, variant_id} <- product_items,
|
||
{country_code, _index} <- Enum.with_index(target_countries()),
|
||
reduce: [] do
|
||
acc ->
|
||
if acc != [], do: Process.sleep(100)
|
||
acc ++ fetch_rate_for_product(catalog_product_id, variant_id, country_code)
|
||
end
|
||
|
||
{:ok, rates}
|
||
else
|
||
nil -> {:error, :no_api_key}
|
||
end
|
||
end
|
||
|
||
# Countries to pre-cache rates for
|
||
defp target_countries do
|
||
["GB", "US", "DE", "FR", "CA", "AU", "IE", "NL", "AT", "BE"]
|
||
end
|
||
|
||
defp fetch_rate_for_product(catalog_product_id, variant_id, country_code) do
|
||
items = [%{source: "catalog", catalog_variant_id: variant_id, quantity: 1}]
|
||
recipient = build_recipient(country_code)
|
||
|
||
case Client.calculate_shipping(recipient, items) do
|
||
{:ok, rates} when is_list(rates) ->
|
||
standard = Enum.find(rates, &(&1["shipping"] == "STANDARD")) || List.first(rates)
|
||
|
||
if standard do
|
||
[
|
||
%{
|
||
blueprint_id: catalog_product_id,
|
||
print_provider_id: 0,
|
||
country_code: country_code,
|
||
first_item_cost: parse_price(standard["rate"]),
|
||
additional_item_cost: 0,
|
||
currency: String.upcase(standard["currency"] || "USD"),
|
||
handling_time_days: standard["max_delivery_days"]
|
||
}
|
||
]
|
||
else
|
||
[]
|
||
end
|
||
|
||
{:error, reason} ->
|
||
Logger.warning(
|
||
"Failed to fetch Printful shipping for #{country_code}: #{inspect(reason)}"
|
||
)
|
||
|
||
[]
|
||
end
|
||
end
|
||
|
||
# Printful requires state_code for US, CA, and AU
|
||
@default_state_codes %{"US" => "NY", "CA" => "ON", "AU" => "NSW"}
|
||
|
||
defp build_recipient(country_code) do
|
||
base = %{country_code: country_code}
|
||
|
||
case @default_state_codes[country_code] do
|
||
nil -> base
|
||
state -> Map.put(base, :state_code, state)
|
||
end
|
||
end
|
||
|
||
# Returns {catalog_product_id, first_catalog_variant_id} per product
|
||
defp extract_per_product_items(products) do
|
||
products
|
||
|> Enum.flat_map(fn product ->
|
||
provider_data = product[:provider_data] || %{}
|
||
|
||
catalog_product_id =
|
||
provider_data[:catalog_product_id] || provider_data["catalog_product_id"]
|
||
|
||
catalog_variant_ids =
|
||
provider_data[:catalog_variant_ids] || provider_data["catalog_variant_ids"] || []
|
||
|
||
case {catalog_product_id, catalog_variant_ids} do
|
||
{nil, _} -> []
|
||
{_, []} -> []
|
||
{cpid, [vid | _]} -> [{cpid, vid}]
|
||
end
|
||
end)
|
||
end
|
||
|
||
# =============================================================================
|
||
# Option Types (for frontend display)
|
||
# =============================================================================
|
||
|
||
@doc """
|
||
Extracts option types from Printful provider_data for frontend display.
|
||
|
||
Builds option types from the stored options list, which contains
|
||
distinct colour and size values with optional hex codes.
|
||
"""
|
||
def extract_option_types(%{"options" => options}) when is_list(options) do
|
||
Enum.map(options, fn opt ->
|
||
%{
|
||
name: opt["name"],
|
||
type: option_type_atom(opt["type"]),
|
||
values:
|
||
Enum.map(opt["values"] || [], fn val ->
|
||
base = %{title: val["title"]}
|
||
if val["hex"], do: Map.put(base, :hex, val["hex"]), else: base
|
||
end)
|
||
}
|
||
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
|
||
|
||
# =============================================================================
|
||
# Data Normalization
|
||
# =============================================================================
|
||
|
||
defp normalize_product(sync_product, sync_variants) do
|
||
images = extract_preview_images(sync_variants)
|
||
catalog_product_id = extract_catalog_product_id_from_variants(sync_variants)
|
||
catalog_variant_ids = Enum.map(sync_variants, & &1["variant_id"]) |> Enum.reject(&is_nil/1)
|
||
|
||
color_variant_map =
|
||
sync_variants
|
||
|> Enum.reject(fn sv -> is_nil(sv["color"]) end)
|
||
|> Enum.uniq_by(fn sv -> sv["color"] end)
|
||
|> Map.new(fn sv -> {normalize_text(sv["color"]), sv["variant_id"]} end)
|
||
|
||
%{
|
||
provider_product_id: to_string(sync_product["id"]),
|
||
title: sync_product["name"],
|
||
description: "",
|
||
category: extract_category(sync_variants),
|
||
images: images,
|
||
variants: Enum.map(sync_variants, &normalize_variant/1),
|
||
provider_data: %{
|
||
catalog_product_id: catalog_product_id,
|
||
catalog_variant_ids: catalog_variant_ids,
|
||
color_variant_map: color_variant_map,
|
||
# Shipping calc uses these generic keys (shared with Printify)
|
||
blueprint_id: catalog_product_id,
|
||
print_provider_id: 0,
|
||
thumbnail_url: sync_product["thumbnail_url"],
|
||
artwork_url: extract_artwork_url(sync_variants),
|
||
options: build_option_types(sync_variants),
|
||
raw: %{sync_product: sync_product}
|
||
}
|
||
}
|
||
end
|
||
|
||
defp normalize_variant(sv) do
|
||
%{
|
||
provider_variant_id: to_string(sv["id"]),
|
||
title: build_variant_title(sv),
|
||
sku: sv["sku"],
|
||
price: parse_price(sv["retail_price"]),
|
||
cost: nil,
|
||
options: build_variant_options(sv),
|
||
is_enabled: sv["synced"] == true,
|
||
is_available: sv["availability_status"] == "active"
|
||
}
|
||
end
|
||
|
||
defp build_variant_title(sv) do
|
||
parts = [sv["color"], sv["size"]] |> Enum.reject(&is_nil/1) |> Enum.map(&normalize_text/1)
|
||
Enum.join(parts, " / ")
|
||
end
|
||
|
||
defp build_variant_options(sv) do
|
||
opts = %{}
|
||
opts = if sv["color"], do: Map.put(opts, "Color", normalize_text(sv["color"])), else: opts
|
||
opts = if sv["size"], do: Map.put(opts, "Size", normalize_text(sv["size"])), else: opts
|
||
opts
|
||
end
|
||
|
||
# Extract unique preview images from sync variants (one per unique image URL)
|
||
defp extract_preview_images(sync_variants) do
|
||
sync_variants
|
||
|> Enum.flat_map(fn sv ->
|
||
(sv["files"] || [])
|
||
|> Enum.filter(&(&1["type"] == "preview"))
|
||
|> Enum.map(fn file ->
|
||
%{
|
||
src: file["preview_url"] || file["thumbnail_url"],
|
||
color: sv["color"],
|
||
name: sv["name"]
|
||
}
|
||
end)
|
||
end)
|
||
|> Enum.uniq_by(& &1.src)
|
||
|> Enum.with_index()
|
||
|> Enum.map(fn {img, index} ->
|
||
alt = if img.color not in [nil, ""], do: img.color, else: img.name
|
||
color = if img.color not in [nil, ""], do: normalize_text(img.color), else: nil
|
||
%{src: img.src, position: index, alt: alt, color: color}
|
||
end)
|
||
end
|
||
|
||
# Find the artwork (design file) URL from the first variant's "default" file
|
||
defp extract_artwork_url(sync_variants) do
|
||
sync_variants
|
||
|> Enum.find_value(fn sv ->
|
||
(sv["files"] || [])
|
||
|> Enum.find_value(fn
|
||
%{"type" => "default", "url" => url} when is_binary(url) -> url
|
||
_ -> nil
|
||
end)
|
||
end)
|
||
end
|
||
|
||
defp extract_catalog_product_id_from_variants(sync_variants) do
|
||
sync_variants
|
||
|> Enum.find_value(fn sv ->
|
||
get_in(sv, ["product", "product_id"])
|
||
end) || 0
|
||
end
|
||
|
||
# Build option types from variants for frontend display
|
||
defp build_option_types(sync_variants) do
|
||
# Build colour values with hex codes from sync variant data
|
||
colors =
|
||
sync_variants
|
||
|> Enum.reject(fn sv -> is_nil(sv["color"]) end)
|
||
|> Enum.uniq_by(fn sv -> sv["color"] end)
|
||
|> Enum.map(fn sv ->
|
||
base = %{"title" => normalize_text(sv["color"])}
|
||
if sv["color_code"], do: Map.put(base, "hex", sv["color_code"]), else: base
|
||
end)
|
||
|
||
sizes =
|
||
sync_variants
|
||
|> Enum.map(& &1["size"])
|
||
|> Enum.reject(&is_nil/1)
|
||
|> Enum.map(&normalize_text/1)
|
||
|> Enum.uniq()
|
||
|> Enum.map(fn size -> %{"title" => size} end)
|
||
|
||
opts = []
|
||
|
||
opts =
|
||
if colors != [],
|
||
do: opts ++ [%{"name" => "Color", "type" => "color", "values" => colors}],
|
||
else: opts
|
||
|
||
opts =
|
||
if sizes != [],
|
||
do: opts ++ [%{"name" => "Size", "type" => "size", "values" => sizes}],
|
||
else: opts
|
||
|
||
opts
|
||
end
|
||
|
||
defp extract_category(sync_variants) do
|
||
case sync_variants do
|
||
[sv | _] ->
|
||
product_name = get_in(sv, ["product", "name"]) || ""
|
||
categorize_from_name(product_name)
|
||
|
||
[] ->
|
||
nil
|
||
end
|
||
end
|
||
|
||
defp categorize_from_name(name) do
|
||
name_lower = String.downcase(name)
|
||
|
||
cond do
|
||
has_keyword?(name_lower, ~w[t-shirt tshirt shirt hoodie sweatshirt jogger]) -> "Apparel"
|
||
has_keyword?(name_lower, ~w[bag tote hat cap sleeve phone case]) -> "Accessories"
|
||
has_keyword?(name_lower, ~w[mug cup blanket pillow cushion throw]) -> "Homewares"
|
||
has_keyword?(name_lower, ~w[canvas poster print frame]) -> "Canvas Prints"
|
||
has_keyword?(name_lower, ~w[notebook journal]) -> "Stationery"
|
||
true -> "Apparel"
|
||
end
|
||
end
|
||
|
||
defp has_keyword?(text, keywords) do
|
||
Enum.any?(keywords, &String.contains?(text, &1))
|
||
end
|
||
|
||
# =============================================================================
|
||
# Order Normalization
|
||
# =============================================================================
|
||
|
||
defp normalize_order_status(raw, shipments) do
|
||
%{
|
||
status: map_order_status(raw["status"]),
|
||
provider_status: raw["status"],
|
||
tracking_number: extract_tracking(shipments),
|
||
tracking_url: extract_tracking_url(shipments),
|
||
shipments: shipments
|
||
}
|
||
end
|
||
|
||
defp map_order_status("draft"), do: "submitted"
|
||
defp map_order_status("pending"), do: "submitted"
|
||
defp map_order_status("inprocess"), do: "processing"
|
||
defp map_order_status("fulfilled"), do: "shipped"
|
||
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("failed"), do: "submitted"
|
||
defp map_order_status("onhold"), do: "submitted"
|
||
defp map_order_status(_), do: "submitted"
|
||
|
||
defp extract_tracking([shipment | _]) do
|
||
shipment["tracking_number"] || get_in(shipment, ["tracking", "number"])
|
||
end
|
||
|
||
defp extract_tracking(_), do: nil
|
||
|
||
defp extract_tracking_url([shipment | _]) do
|
||
shipment["tracking_url"] || get_in(shipment, ["tracking", "url"])
|
||
end
|
||
|
||
defp extract_tracking_url(_), do: nil
|
||
|
||
# =============================================================================
|
||
# Order Building
|
||
# =============================================================================
|
||
|
||
defp build_order_payload(order_data) do
|
||
%{
|
||
external_id: order_data.order_number,
|
||
shipping: "STANDARD",
|
||
recipient: build_recipient(order_data.shipping_address, order_data.customer_email),
|
||
items:
|
||
Enum.map(order_data.line_items, fn item ->
|
||
%{
|
||
sync_variant_id: parse_int(item.provider_variant_id),
|
||
quantity: item.quantity
|
||
}
|
||
end)
|
||
}
|
||
end
|
||
|
||
defp build_recipient(address, email) when is_map(address) do
|
||
%{
|
||
name: address["name"] || "",
|
||
address1: address["line1"] || address["address1"] || "",
|
||
address2: address["line2"] || address["address2"] || "",
|
||
city: address["city"] || "",
|
||
country_code: address["country"] || "",
|
||
state_code: address["state"] || address["region"] || "",
|
||
zip: address["postal_code"] || address["zip"] || "",
|
||
email: email
|
||
}
|
||
end
|
||
|
||
defp build_recipient(_address, email) do
|
||
%{
|
||
name: "",
|
||
address1: "",
|
||
address2: "",
|
||
city: "",
|
||
country_code: "",
|
||
state_code: "",
|
||
zip: "",
|
||
email: email
|
||
}
|
||
end
|
||
|
||
# =============================================================================
|
||
# Webhooks
|
||
# =============================================================================
|
||
|
||
@webhook_events [
|
||
"package_shipped",
|
||
"package_returned",
|
||
"order_failed",
|
||
"order_canceled",
|
||
"product_synced",
|
||
"product_updated",
|
||
"product_deleted"
|
||
]
|
||
|
||
@doc """
|
||
Registers webhooks with Printful for this store.
|
||
|
||
The webhook URL should include a token query param for verification,
|
||
e.g. `https://example.com/webhooks/printful?token=SECRET`.
|
||
"""
|
||
def register_webhooks(%ProviderConnection{config: config} = conn, webhook_url) do
|
||
store_id = config["store_id"]
|
||
secret = config["webhook_secret"]
|
||
|
||
cond do
|
||
is_nil(store_id) ->
|
||
{:error, :no_store_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_credentials(api_key, store_id) do
|
||
url_with_token = append_token(webhook_url, secret)
|
||
Client.setup_webhooks(url_with_token, @webhook_events)
|
||
else
|
||
nil -> {:error, :no_api_key}
|
||
end
|
||
end
|
||
end
|
||
|
||
@doc """
|
||
Lists currently registered webhooks for this store.
|
||
"""
|
||
def list_webhooks(%ProviderConnection{config: config} = conn) do
|
||
store_id = config["store_id"]
|
||
|
||
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
||
:ok <- set_credentials(api_key, store_id) do
|
||
Client.get_webhooks()
|
||
else
|
||
nil -> {:error, :no_api_key}
|
||
end
|
||
end
|
||
|
||
defp append_token(url, token) do
|
||
uri = URI.parse(url)
|
||
params = URI.decode_query(uri.query || "")
|
||
query = URI.encode_query(Map.put(params, "token", token))
|
||
URI.to_string(%{uri | query: query})
|
||
end
|
||
|
||
# =============================================================================
|
||
# Helpers
|
||
# =============================================================================
|
||
|
||
# Parse a price string like "13.50" into integer pence (1350)
|
||
defp parse_price(price) when is_binary(price) do
|
||
case Float.parse(price) do
|
||
{float, _} -> round(float * 100)
|
||
:error -> 0
|
||
end
|
||
end
|
||
|
||
defp parse_price(price) when is_number(price), do: round(price * 100)
|
||
defp parse_price(_), do: 0
|
||
|
||
defp parse_int(value) when is_integer(value), do: value
|
||
defp parse_int(value) when is_binary(value), do: String.to_integer(value)
|
||
|
||
# Printful uses Unicode double prime (″) and multiplication sign (×) in size
|
||
# labels. These break LiveView's phx-value-* attribute serialization, so we
|
||
# strip inch marks and normalise the multiplication sign.
|
||
defp normalize_text(text) when is_binary(text) do
|
||
text
|
||
|> String.replace("″", "")
|
||
|> String.replace("×", "x")
|
||
end
|
||
|
||
defp normalize_text(text), do: text
|
||
|
||
defp set_credentials(api_key, store_id) do
|
||
Process.put(:printful_api_key, api_key)
|
||
if store_id, do: Process.put(:printful_store_id, store_id)
|
||
:ok
|
||
end
|
||
end
|