Printful HTTP client (v2 + v1 for sync products), Provider behaviour implementation with all callbacks (test_connection, fetch_products, submit_order, get_order_status, fetch_shipping_rates), and multi-provider order routing that looks up the provider connection from the order's product instead of hardcoding "printify". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
541 lines
16 KiB
Elixir
541 lines
16 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
|
|
variant_ids = extract_catalog_variant_ids(products)
|
|
|
|
Logger.info(
|
|
"Fetching Printful shipping rates for #{length(variant_ids)} catalogue variants"
|
|
)
|
|
|
|
rates =
|
|
target_countries()
|
|
|> Enum.with_index()
|
|
|> Enum.flat_map(fn {country_code, index} ->
|
|
if index > 0, do: Process.sleep(100)
|
|
fetch_rates_for_country(variant_ids, country_code, products)
|
|
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_rates_for_country(variant_ids, country_code, products) do
|
|
items =
|
|
Enum.map(variant_ids, fn vid ->
|
|
%{source: "catalog", catalog_variant_id: vid, quantity: 1}
|
|
end)
|
|
|
|
case Client.calculate_shipping(%{country_code: country_code}, items) do
|
|
{:ok, rates} when is_list(rates) ->
|
|
# Take the STANDARD rate (cheapest)
|
|
standard = Enum.find(rates, &(&1["id"] == "STANDARD")) || List.first(rates)
|
|
|
|
if standard do
|
|
catalog_product_id = extract_first_catalog_product_id(products)
|
|
rate_cents = parse_price(standard["rate"])
|
|
|
|
[
|
|
%{
|
|
blueprint_id: catalog_product_id,
|
|
print_provider_id: 0,
|
|
country_code: country_code,
|
|
first_item_cost: rate_cents,
|
|
additional_item_cost: 0,
|
|
currency: String.upcase(standard["currency"] || "USD"),
|
|
handling_time_days: standard["maxDeliveryDays"]
|
|
}
|
|
]
|
|
else
|
|
[]
|
|
end
|
|
|
|
{:error, reason} ->
|
|
Logger.warning(
|
|
"Failed to fetch Printful shipping for #{country_code}: #{inspect(reason)}"
|
|
)
|
|
|
|
[]
|
|
end
|
|
end
|
|
|
|
defp extract_catalog_variant_ids(products) do
|
|
products
|
|
|> Enum.flat_map(fn product ->
|
|
provider_data = product[:provider_data] || %{}
|
|
|
|
catalog_variant_ids =
|
|
provider_data[:catalog_variant_ids] || provider_data["catalog_variant_ids"] || []
|
|
|
|
if catalog_variant_ids == [] do
|
|
# Fall back to extracting from raw data
|
|
raw = provider_data[:raw] || provider_data["raw"] || %{}
|
|
sync_variants = raw["sync_variants"] || raw[:sync_variants] || []
|
|
Enum.map(sync_variants, fn sv -> sv["variant_id"] || sv[:variant_id] end)
|
|
else
|
|
catalog_variant_ids
|
|
end
|
|
end)
|
|
|> Enum.reject(&is_nil/1)
|
|
|> Enum.uniq()
|
|
end
|
|
|
|
defp extract_first_catalog_product_id(products) do
|
|
products
|
|
|> Enum.find_value(fn product ->
|
|
provider_data = product[:provider_data] || %{}
|
|
provider_data[:catalog_product_id] || provider_data["catalog_product_id"]
|
|
end) || 0
|
|
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,
|
|
thumbnail_url: sync_product["thumbnail_url"],
|
|
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 colour)
|
|
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"]
|
|
}
|
|
end)
|
|
end)
|
|
|> Enum.uniq_by(& &1.color)
|
|
|> Enum.with_index()
|
|
|> Enum.map(fn {img, index} ->
|
|
%{
|
|
src: img.src,
|
|
position: index,
|
|
alt: img.color
|
|
}
|
|
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[canvas poster print frame]) -> "Canvas Prints"
|
|
has_keyword?(name_lower, ~w[mug cup blanket pillow cushion]) -> "Homewares"
|
|
has_keyword?(name_lower, ~w[notebook journal]) -> "Stationery"
|
|
has_keyword?(name_lower, ~w[phone case bag tote hat cap]) -> "Accessories"
|
|
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
|
|
|
|
# =============================================================================
|
|
# 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
|