berrypod/lib/simpleshop_theme/providers/printful.ex
jamey 1aceaf9444 add Printful mockup generator and post-sync angle enrichment
New PrintfulGenerator module creates demo products in Printful with
multi-variant support (multiple colours/sizes per product type).
Mix task gets --provider flag to choose between Printify and Printful.

After syncing Printful products, MockupEnricher Oban worker calls the
legacy mockup generator API to produce extra angle images (back, left,
right) and appends them as product images. Jobs are staggered 45s apart
with snooze-based 429 handling. Flat products (canvas, poster) get no
extras — apparel and 3D products get 1-5 extra angles each.

Also fixes:
- cross-provider slug uniqueness (appends -2, -3 suffix)
- category mapping order (Accessories before Canvas Prints)
- image dedup by URL instead of colour (fixes canvas variants)
- artwork URL stored in provider_data for enricher access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 16:52:53 +00:00

618 lines
19 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)
%{
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,
# 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.join(parts, " / ")
end
defp build_variant_options(sv) do
opts = %{}
opts = if sv["color"], do: Map.put(opts, "Color", sv["color"]), else: opts
opts = if sv["size"], do: Map.put(opts, "Size", 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
%{src: img.src, position: index, alt: alt}
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
colors =
sync_variants
|> Enum.map(& &1["color"])
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
|> Enum.map(fn color -> %{"title" => color} end)
sizes =
sync_variants
|> Enum.map(& &1["size"])
|> Enum.reject(&is_nil/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)
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