add Printful provider integration with HTTP client and order routing
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>
This commit is contained in:
540
lib/simpleshop_theme/providers/printful.ex
Normal file
540
lib/simpleshop_theme/providers/printful.ex
Normal file
@@ -0,0 +1,540 @@
|
||||
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
|
||||
@@ -94,7 +94,7 @@ defmodule SimpleshopTheme.Providers.Provider do
|
||||
defp default_for_type("printify"), do: {:ok, SimpleshopTheme.Providers.Printify}
|
||||
defp default_for_type("gelato"), do: {:error, :not_implemented}
|
||||
defp default_for_type("prodigi"), do: {:error, :not_implemented}
|
||||
defp default_for_type("printful"), do: {:error, :not_implemented}
|
||||
defp default_for_type("printful"), do: {:ok, SimpleshopTheme.Providers.Printful}
|
||||
defp default_for_type(type), do: {:error, {:unknown_provider, type}}
|
||||
|
||||
@doc """
|
||||
|
||||
Reference in New Issue
Block a user