berrypod/lib/simpleshop_theme/providers/printful.ex
jamey 4e19d4c4a9 fetch catalog color hex codes during Printful sync
Printful sync variants don't include color_code — hex values are only
available from the catalog product endpoint. Fetch catalog colors per
unique product type during sync (cached in process dictionary to avoid
duplicate calls) and store as "colors" array in option values, matching
the Printify format that Product.option_types expects.

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

681 lines
21 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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} ->
sync_variants = detail["sync_variants"] || []
catalog_colors = fetch_catalog_colors(sync_variants)
normalize_product(detail["sync_product"], sync_variants, catalog_colors)
{: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
# Fetch catalog color hex codes for a product's catalog type.
# Caches per catalog_product_id in the process dictionary to avoid duplicate calls.
defp fetch_catalog_colors(sync_variants) do
catalog_product_id = extract_catalog_product_id_from_variants(sync_variants)
cache_key = {:catalog_colors, catalog_product_id}
case Process.get(cache_key) do
nil ->
colors = do_fetch_catalog_colors(catalog_product_id)
Process.put(cache_key, colors)
colors
cached ->
cached
end
end
defp do_fetch_catalog_colors(0), do: %{}
defp do_fetch_catalog_colors(catalog_product_id) do
Process.sleep(100)
case Client.get_catalog_product(catalog_product_id) do
{:ok, catalog} ->
(catalog["colors"] || [])
|> Map.new(fn c -> {c["name"], c["value"]} end)
{:error, reason} ->
Logger.warning(
"Failed to fetch catalog colors for product #{catalog_product_id}: #{inspect(reason)}"
)
%{}
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, catalog_colors) 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, catalog_colors),
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.
# catalog_colors is a %{"Black" => "#0b0b0b", ...} map from the catalog API.
defp build_option_types(sync_variants, catalog_colors) do
colors =
sync_variants
|> Enum.reject(fn sv -> is_nil(sv["color"]) end)
|> Enum.uniq_by(fn sv -> sv["color"] end)
|> Enum.map(fn sv ->
title = normalize_text(sv["color"])
base = %{"title" => title}
hex = Map.get(catalog_colors, sv["color"])
if hex, do: Map.put(base, "colors", [hex]), 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