Submit paid orders to Printify via provider API with idempotent guards, Stripe address mapping, and error handling. Track fulfilment status through submitted → processing → shipped → delivered via webhook-driven updates (primary) and Oban Cron polling fallback. - 9 fulfilment fields on orders (status, provider IDs, tracking, timestamps) - OrderSubmissionWorker with retry logic, auto-enqueued after Stripe payment - FulfilmentStatusWorker polls every 30 mins for missed webhook events - Printify order webhook handlers (sent-to-production, shipment, delivered) - Admin UI: fulfilment column in table, fulfilment card with tracking info, submit/retry and refresh buttons on order detail - Mox provider mocking for test isolation (Provider.for_type configurable) - 33 new tests (555 total), verified against real Printify API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
453 lines
13 KiB
Elixir
453 lines
13 KiB
Elixir
defmodule SimpleshopTheme.Providers.Printify do
|
|
@moduledoc """
|
|
Printify provider implementation.
|
|
|
|
Handles product sync and order submission for Printify.
|
|
"""
|
|
|
|
@behaviour SimpleshopTheme.Providers.Provider
|
|
|
|
alias SimpleshopTheme.Clients.Printify, as: Client
|
|
alias SimpleshopTheme.Products.ProviderConnection
|
|
|
|
@impl true
|
|
def provider_type, do: "printify"
|
|
|
|
@impl true
|
|
def test_connection(%ProviderConnection{} = conn) do
|
|
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
|
:ok <- set_api_key(api_key),
|
|
{:ok, shops} <- Client.get_shops() do
|
|
shop = List.first(shops)
|
|
|
|
{:ok,
|
|
%{
|
|
shop_id: shop["id"],
|
|
shop_name: shop["title"],
|
|
shop_count: length(shops)
|
|
}}
|
|
else
|
|
nil -> {:error, :no_api_key}
|
|
{:error, _} = error -> error
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def fetch_products(%ProviderConnection{config: config} = conn) do
|
|
shop_id = config["shop_id"]
|
|
|
|
if is_nil(shop_id) do
|
|
{:error, :no_shop_id}
|
|
else
|
|
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
|
:ok <- set_api_key(api_key),
|
|
{:ok, products} <- fetch_all_products(shop_id) do
|
|
{:ok, products}
|
|
else
|
|
nil -> {:error, :no_api_key}
|
|
{:error, _} = error -> error
|
|
end
|
|
end
|
|
end
|
|
|
|
# Fetches all products by paginating through the API
|
|
defp fetch_all_products(shop_id) do
|
|
fetch_products_page(shop_id, 1, [])
|
|
end
|
|
|
|
defp fetch_products_page(shop_id, page, acc) do
|
|
case Client.list_products(shop_id, page: page) do
|
|
{:ok, response} ->
|
|
products = Enum.map(response["data"] || [], &normalize_product/1)
|
|
all_products = acc ++ products
|
|
|
|
current_page = response["current_page"] || page
|
|
last_page = response["last_page"] || 1
|
|
|
|
if current_page < last_page do
|
|
# Small delay to be nice to rate limits (600/min = 10/sec)
|
|
Process.sleep(100)
|
|
fetch_products_page(shop_id, page + 1, all_products)
|
|
else
|
|
{:ok, all_products}
|
|
end
|
|
|
|
{:error, _} = error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def submit_order(%ProviderConnection{config: config} = conn, order) do
|
|
shop_id = config["shop_id"]
|
|
|
|
if is_nil(shop_id) do
|
|
{:error, :no_shop_id}
|
|
else
|
|
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
|
:ok <- set_api_key(api_key),
|
|
order_data <- build_order_payload(order),
|
|
{:ok, response} <- Client.create_order(shop_id, order_data) do
|
|
{:ok, %{provider_order_id: response["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
|
|
shop_id = config["shop_id"]
|
|
|
|
if is_nil(shop_id) do
|
|
{:error, :no_shop_id}
|
|
else
|
|
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
|
:ok <- set_api_key(api_key),
|
|
{:ok, response} <- Client.get_order(shop_id, provider_order_id) do
|
|
{:ok, normalize_order_status(response)}
|
|
else
|
|
nil -> {:error, :no_api_key}
|
|
{:error, _} = error -> error
|
|
end
|
|
end
|
|
end
|
|
|
|
# =============================================================================
|
|
# Webhook Registration
|
|
# =============================================================================
|
|
|
|
@webhook_events [
|
|
"product:updated",
|
|
"product:deleted",
|
|
"product:publish:started",
|
|
"order:sent-to-production",
|
|
"order:shipment:created",
|
|
"order:shipment:delivered"
|
|
]
|
|
|
|
@doc """
|
|
Registers webhooks for product events with Printify.
|
|
|
|
Returns {:ok, results} or {:error, reason}.
|
|
"""
|
|
def register_webhooks(%ProviderConnection{config: config} = conn, webhook_url) do
|
|
shop_id = config["shop_id"]
|
|
secret = config["webhook_secret"]
|
|
|
|
cond do
|
|
is_nil(shop_id) ->
|
|
{:error, :no_shop_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_api_key(api_key) do
|
|
results =
|
|
Enum.map(@webhook_events, fn event ->
|
|
case Client.create_webhook(shop_id, webhook_url, event, secret) do
|
|
{:ok, response} -> {:ok, event, response}
|
|
{:error, reason} -> {:error, event, reason}
|
|
end
|
|
end)
|
|
|
|
{:ok, results}
|
|
else
|
|
nil -> {:error, :no_api_key}
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Lists registered webhooks for the shop.
|
|
"""
|
|
def list_webhooks(%ProviderConnection{config: config} = conn) do
|
|
shop_id = config["shop_id"]
|
|
|
|
if is_nil(shop_id) do
|
|
{:error, :no_shop_id}
|
|
else
|
|
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
|
:ok <- set_api_key(api_key),
|
|
{:ok, webhooks} <- Client.list_webhooks(shop_id) do
|
|
{:ok, webhooks}
|
|
else
|
|
nil -> {:error, :no_api_key}
|
|
{:error, _} = error -> error
|
|
end
|
|
end
|
|
end
|
|
|
|
# =============================================================================
|
|
# Option Types Extraction (for frontend)
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Extracts option types from Printify provider_data for frontend display.
|
|
|
|
Returns a list of option type maps with normalized names, types, and values
|
|
including hex color codes for color-type options.
|
|
|
|
## Examples
|
|
|
|
iex> extract_option_types(%{"options" => [
|
|
...> %{"name" => "Colors", "type" => "color", "values" => [
|
|
...> %{"id" => 1, "title" => "Black", "colors" => ["#000000"]}
|
|
...> ]}
|
|
...> ]})
|
|
[%{name: "Color", type: :color, values: [%{id: 1, title: "Black", hex: "#000000"}]}]
|
|
"""
|
|
def extract_option_types(%{"options" => options}) when is_list(options) do
|
|
Enum.map(options, fn opt ->
|
|
%{
|
|
name: singularize_option_name(opt["name"]),
|
|
type: option_type_atom(opt["type"]),
|
|
values: extract_option_values(opt)
|
|
}
|
|
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
|
|
|
|
defp extract_option_values(%{"values" => values, "type" => "color"}) do
|
|
Enum.map(values, fn val ->
|
|
%{
|
|
id: val["id"],
|
|
title: val["title"],
|
|
hex: List.first(val["colors"])
|
|
}
|
|
end)
|
|
end
|
|
|
|
defp extract_option_values(%{"values" => values}) do
|
|
Enum.map(values, fn val ->
|
|
%{id: val["id"], title: val["title"]}
|
|
end)
|
|
end
|
|
|
|
# =============================================================================
|
|
# Data Normalization
|
|
# =============================================================================
|
|
|
|
defp normalize_product(raw) do
|
|
options = raw["options"] || []
|
|
|
|
%{
|
|
provider_product_id: to_string(raw["id"]),
|
|
title: raw["title"],
|
|
description: raw["description"],
|
|
category: extract_category(raw),
|
|
images: normalize_images(raw["images"] || []),
|
|
variants: normalize_variants(raw["variants"] || [], options),
|
|
provider_data: %{
|
|
blueprint_id: raw["blueprint_id"],
|
|
print_provider_id: raw["print_provider_id"],
|
|
tags: raw["tags"] || [],
|
|
options: options,
|
|
raw: raw
|
|
}
|
|
}
|
|
end
|
|
|
|
defp normalize_images(images) do
|
|
images
|
|
|> Enum.with_index()
|
|
|> Enum.map(fn {img, index} ->
|
|
# Printify returns position as a label string (e.g., "front", "back")
|
|
# We use the index as the numeric position instead
|
|
%{
|
|
src: img["src"],
|
|
position: index,
|
|
alt: img["position"]
|
|
}
|
|
end)
|
|
end
|
|
|
|
defp normalize_variants(variants, options) do
|
|
option_names = extract_option_names(options)
|
|
|
|
Enum.map(variants, fn var ->
|
|
%{
|
|
provider_variant_id: to_string(var["id"]),
|
|
title: var["title"],
|
|
sku: var["sku"],
|
|
price: var["price"],
|
|
cost: var["cost"],
|
|
options: normalize_variant_options(var, option_names),
|
|
is_enabled: var["is_enabled"] == true,
|
|
is_available: var["is_available"] == true
|
|
}
|
|
end)
|
|
end
|
|
|
|
# Extract option names from product options, singularizing common plurals
|
|
defp extract_option_names(options) do
|
|
Enum.map(options, fn opt ->
|
|
singularize_option_name(opt["name"])
|
|
end)
|
|
end
|
|
|
|
defp singularize_option_name("Colors"), do: "Color"
|
|
defp singularize_option_name("Sizes"), do: "Size"
|
|
defp singularize_option_name(name), do: name
|
|
|
|
defp normalize_variant_options(variant, option_names) do
|
|
# Build human-readable map from variant title
|
|
# Title format matches product options order: "Navy / S" for [Colors, Sizes]
|
|
title = variant["title"] || ""
|
|
parts = String.split(title, " / ")
|
|
|
|
parts
|
|
|> Enum.with_index()
|
|
|> Enum.reduce(%{}, fn {value, index}, acc ->
|
|
key = Enum.at(option_names, index) || "Option #{index + 1}"
|
|
Map.put(acc, key, value)
|
|
end)
|
|
end
|
|
|
|
defp extract_category(raw) do
|
|
# Try to extract category from tags (case-insensitive)
|
|
tags =
|
|
(raw["tags"] || [])
|
|
|> Enum.map(&String.downcase/1)
|
|
|
|
cond do
|
|
has_tag?(tags, ~w[t-shirt tshirt shirt hoodie sweatshirt apparel clothing]) -> "Apparel"
|
|
has_tag?(tags, ~w[mug cup blanket pillow cushion homeware homewares home]) -> "Homewares"
|
|
has_tag?(tags, ~w[notebook journal stationery]) -> "Stationery"
|
|
has_tag?(tags, ~w[phone case bag tote accessories]) -> "Accessories"
|
|
has_tag?(tags, ~w[art print poster canvas wall]) -> "Art Prints"
|
|
true -> List.first(raw["tags"])
|
|
end
|
|
end
|
|
|
|
defp has_tag?(tags, keywords) do
|
|
Enum.any?(tags, fn tag ->
|
|
Enum.any?(keywords, fn keyword -> String.contains?(tag, keyword) end)
|
|
end)
|
|
end
|
|
|
|
defp normalize_order_status(raw) do
|
|
%{
|
|
status: map_order_status(raw["status"]),
|
|
provider_status: raw["status"],
|
|
tracking_number: extract_tracking(raw),
|
|
tracking_url: extract_tracking_url(raw),
|
|
shipments: raw["shipments"] || []
|
|
}
|
|
end
|
|
|
|
defp map_order_status("pending"), do: "submitted"
|
|
defp map_order_status("on-hold"), do: "submitted"
|
|
defp map_order_status("payment-not-received"), do: "submitted"
|
|
defp map_order_status("cost-calculation"), do: "submitted"
|
|
defp map_order_status("in-production"), do: "processing"
|
|
defp map_order_status("partially-shipped"), do: "processing"
|
|
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(_), do: "submitted"
|
|
|
|
defp extract_tracking(raw) do
|
|
case raw["shipments"] do
|
|
[shipment | _] -> shipment["tracking_number"]
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp extract_tracking_url(raw) do
|
|
case raw["shipments"] do
|
|
[shipment | _] -> shipment["tracking_url"]
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
# =============================================================================
|
|
# Order Building
|
|
# =============================================================================
|
|
|
|
defp build_order_payload(order_data) do
|
|
%{
|
|
external_id: order_data.order_number,
|
|
label: order_data.order_number,
|
|
line_items:
|
|
Enum.map(order_data.line_items, fn item ->
|
|
%{
|
|
product_id: item.provider_product_id,
|
|
variant_id: parse_variant_id(item.provider_variant_id),
|
|
quantity: item.quantity
|
|
}
|
|
end),
|
|
shipping_method: 1,
|
|
address_to: build_address(order_data.shipping_address, order_data.customer_email)
|
|
}
|
|
end
|
|
|
|
# Maps Stripe shipping_details address fields to Printify's expected format.
|
|
# Stripe gives us: name, line1, line2, city, postal_code, state, country
|
|
# Printify wants: first_name, last_name, address1, address2, city, zip, region, country
|
|
defp build_address(address, email) when is_map(address) do
|
|
{first, last} = split_name(address["name"])
|
|
|
|
%{
|
|
first_name: first,
|
|
last_name: last,
|
|
email: email,
|
|
phone: address["phone"] || "",
|
|
country: address["country"] || "",
|
|
region: address["state"] || address["region"] || "",
|
|
address1: address["line1"] || address["address1"] || "",
|
|
address2: address["line2"] || address["address2"] || "",
|
|
city: address["city"] || "",
|
|
zip: address["postal_code"] || address["zip"] || ""
|
|
}
|
|
end
|
|
|
|
defp build_address(_address, email) do
|
|
%{
|
|
first_name: "",
|
|
last_name: "",
|
|
email: email,
|
|
phone: "",
|
|
country: "",
|
|
region: "",
|
|
address1: "",
|
|
address2: "",
|
|
city: "",
|
|
zip: ""
|
|
}
|
|
end
|
|
|
|
defp split_name(nil), do: {"", ""}
|
|
defp split_name(""), do: {"", ""}
|
|
|
|
defp split_name(name) do
|
|
case String.split(name, " ", parts: 2) do
|
|
[first] -> {first, ""}
|
|
[first, last] -> {first, last}
|
|
end
|
|
end
|
|
|
|
# Printify variant IDs are integers, but we store them as strings
|
|
defp parse_variant_id(id) when is_integer(id), do: id
|
|
defp parse_variant_id(id) when is_binary(id), do: String.to_integer(id)
|
|
|
|
# =============================================================================
|
|
# API Key Management
|
|
# =============================================================================
|
|
|
|
# Temporarily sets the API key for the request
|
|
# In a production system, this would use a connection pool or request context
|
|
defp set_api_key(api_key) do
|
|
Process.put(:printify_api_key, api_key)
|
|
:ok
|
|
end
|
|
end
|