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>
618 lines
19 KiB
Elixir
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
|