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>
366 lines
9.9 KiB
Elixir
366 lines
9.9 KiB
Elixir
defmodule SimpleshopTheme.Clients.Printful do
|
|
@moduledoc """
|
|
HTTP client for the Printful API.
|
|
|
|
Uses v2 endpoints where available, v1 for sync products (store products).
|
|
Handles authentication via Bearer tokens stored in the process dictionary.
|
|
"""
|
|
|
|
@base_url "https://api.printful.com"
|
|
|
|
# =============================================================================
|
|
# Auth
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Get the API token.
|
|
|
|
Checks process dictionary first (for provider connections with stored credentials),
|
|
then falls back to environment variable (for development/testing).
|
|
"""
|
|
def api_token do
|
|
Process.get(:printful_api_key) ||
|
|
System.get_env("PRINTFUL_API_TOKEN") ||
|
|
raise "PRINTFUL_API_TOKEN environment variable is not set"
|
|
end
|
|
|
|
@doc """
|
|
Get the store ID from the process dictionary.
|
|
"""
|
|
def store_id do
|
|
Process.get(:printful_store_id)
|
|
end
|
|
|
|
# =============================================================================
|
|
# Core HTTP
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Make a GET request to the Printful API.
|
|
"""
|
|
def get(path, _opts \\ []) do
|
|
url = @base_url <> path
|
|
|
|
case Req.get(url, headers: auth_headers(), receive_timeout: 30_000) do
|
|
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
|
|
{:ok, unwrap_response(path, body)}
|
|
|
|
{:ok, %Req.Response{status: status, body: body}} ->
|
|
{:error, {status, body}}
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Make a POST request to the Printful API.
|
|
"""
|
|
def post(path, body, _opts \\ []) do
|
|
url = @base_url <> path
|
|
|
|
case Req.post(url, json: body, headers: auth_headers(), receive_timeout: 60_000) do
|
|
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
|
|
{:ok, unwrap_response(path, body)}
|
|
|
|
{:ok, %Req.Response{status: status, body: body}} ->
|
|
{:error, {status, body}}
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Make a DELETE request to the Printful API.
|
|
"""
|
|
def delete(path, _opts \\ []) do
|
|
url = @base_url <> path
|
|
|
|
case Req.delete(url, headers: auth_headers(), receive_timeout: 30_000) do
|
|
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
|
|
{:ok, unwrap_response(path, body)}
|
|
|
|
{:ok, %Req.Response{status: 204}} ->
|
|
{:ok, nil}
|
|
|
|
{:ok, %Req.Response{status: status, body: body}} ->
|
|
{:error, {status, body}}
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
# v1 responses wrap data in "result", v2 wraps in "data"
|
|
defp unwrap_response("/v2/" <> _, %{"data" => data}), do: data
|
|
defp unwrap_response(_path, %{"code" => _, "result" => result}), do: result
|
|
defp unwrap_response(_path, body), do: body
|
|
|
|
# =============================================================================
|
|
# Stores (v2)
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
List all stores for the authenticated account.
|
|
"""
|
|
def get_stores do
|
|
get("/v2/stores")
|
|
end
|
|
|
|
@doc """
|
|
Get the first store's ID.
|
|
"""
|
|
def get_store_id do
|
|
case get_stores() do
|
|
{:ok, stores} when is_list(stores) and stores != [] ->
|
|
{:ok, hd(stores)["id"]}
|
|
|
|
{:ok, []} ->
|
|
{:error, :no_stores}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
# =============================================================================
|
|
# Catalogue (v2)
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Get a catalogue product by ID.
|
|
"""
|
|
def get_catalog_product(product_id) do
|
|
get("/v2/catalog-products/#{product_id}")
|
|
end
|
|
|
|
@doc """
|
|
Get variants for a catalogue product.
|
|
"""
|
|
def get_catalog_variants(product_id) do
|
|
get("/v2/catalog-products/#{product_id}/catalog-variants")
|
|
end
|
|
|
|
@doc """
|
|
Get stock availability for a catalogue product.
|
|
"""
|
|
def get_product_availability(product_id) do
|
|
get("/v2/catalog-products/#{product_id}/availability")
|
|
end
|
|
|
|
# =============================================================================
|
|
# Sync Products (v1 — no v2 equivalent)
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
List the seller's configured products (sync products).
|
|
|
|
These are products the seller has set up in Printful's dashboard with designs.
|
|
Supports pagination via `offset` and `limit` options.
|
|
"""
|
|
def list_sync_products(opts \\ []) do
|
|
offset = Keyword.get(opts, :offset, 0)
|
|
limit = Keyword.get(opts, :limit, 20)
|
|
get("/store/products?offset=#{offset}&limit=#{limit}")
|
|
end
|
|
|
|
@doc """
|
|
Get a single sync product with all its variants and files.
|
|
"""
|
|
def get_sync_product(product_id) do
|
|
get("/store/products/#{product_id}")
|
|
end
|
|
|
|
@doc """
|
|
Create a sync product with variants and design files.
|
|
|
|
## Example
|
|
|
|
create_sync_product(%{
|
|
sync_product: %{name: "My T-Shirt"},
|
|
sync_variants: [%{
|
|
variant_id: 4011,
|
|
retail_price: "29.99",
|
|
files: [%{url: "https://example.com/design.png", type: "default"}]
|
|
}]
|
|
})
|
|
"""
|
|
def create_sync_product(product_data) do
|
|
post("/store/products", product_data)
|
|
end
|
|
|
|
@doc """
|
|
Delete a sync product and all its variants.
|
|
"""
|
|
def delete_sync_product(product_id) do
|
|
delete("/store/products/#{product_id}")
|
|
end
|
|
|
|
# =============================================================================
|
|
# Shipping (v2)
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Calculate shipping rates for a set of items to a recipient.
|
|
|
|
## Example
|
|
|
|
calculate_shipping(
|
|
%{country_code: "GB"},
|
|
[%{source: "catalog", catalog_variant_id: 474, quantity: 1}]
|
|
)
|
|
"""
|
|
def calculate_shipping(recipient, items) do
|
|
post("/v2/shipping-rates", %{recipient: recipient, order_items: items})
|
|
end
|
|
|
|
# =============================================================================
|
|
# Orders (v2)
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Create a new order (draft status).
|
|
"""
|
|
def create_order(order_data) do
|
|
post("/v2/orders", order_data)
|
|
end
|
|
|
|
@doc """
|
|
Confirm an order for fulfilment. This triggers charges and production.
|
|
"""
|
|
def confirm_order(order_id) do
|
|
post("/v2/orders/#{order_id}/confirmation", %{})
|
|
end
|
|
|
|
@doc """
|
|
Get an order by ID.
|
|
"""
|
|
def get_order(order_id) do
|
|
get("/v2/orders/#{order_id}")
|
|
end
|
|
|
|
@doc """
|
|
Get shipments for an order.
|
|
"""
|
|
def get_order_shipments(order_id) do
|
|
get("/v2/orders/#{order_id}/shipments")
|
|
end
|
|
|
|
# =============================================================================
|
|
# Mockups (v2)
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Create a mockup generation task.
|
|
"""
|
|
def create_mockup_task(body) do
|
|
post("/v2/mockup-tasks", body)
|
|
end
|
|
|
|
@doc """
|
|
Get mockup task results. Pass `task_id` to poll a specific task.
|
|
"""
|
|
def get_mockup_tasks(params \\ %{}) do
|
|
query = URI.encode_query(params)
|
|
path = if query == "", do: "/v2/mockup-tasks", else: "/v2/mockup-tasks?#{query}"
|
|
get(path)
|
|
end
|
|
|
|
# =============================================================================
|
|
# Mockup generator (legacy, multi-angle)
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Create a mockup generator task for a catalog product.
|
|
|
|
Returns `{:ok, %{"task_key" => "gt-...", "status" => "pending"}}`.
|
|
"""
|
|
def create_mockup_generator_task(catalog_product_id, body) do
|
|
post("/mockup-generator/create-task/#{catalog_product_id}", body)
|
|
end
|
|
|
|
@doc """
|
|
Poll a mockup generator task by task key.
|
|
|
|
Returns `{:ok, %{"status" => "completed", "mockups" => [...]}}` when done.
|
|
"""
|
|
def get_mockup_generator_task(task_key) do
|
|
get("/mockup-generator/task?task_key=#{task_key}")
|
|
end
|
|
|
|
# =============================================================================
|
|
# Files (v2)
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Upload a file to the Printful file library.
|
|
"""
|
|
def upload_file(url) do
|
|
post("/v2/files", %{url: url})
|
|
end
|
|
|
|
# =============================================================================
|
|
# Webhooks (v2)
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Set up webhook configuration.
|
|
"""
|
|
def setup_webhooks(url, events) do
|
|
post("/v2/webhooks", %{url: url, events: events})
|
|
end
|
|
|
|
@doc """
|
|
Get current webhook configuration.
|
|
"""
|
|
def get_webhooks do
|
|
get("/v2/webhooks")
|
|
end
|
|
|
|
@doc """
|
|
Disable webhook support.
|
|
"""
|
|
def delete_webhooks do
|
|
delete("/v2/webhooks")
|
|
end
|
|
|
|
# =============================================================================
|
|
# File Downloads
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Download a file from a URL to a local path.
|
|
"""
|
|
def download_file(url, output_path) do
|
|
case Req.get(url, into: File.stream!(output_path), receive_timeout: 60_000) do
|
|
{:ok, %Req.Response{status: status}} when status in 200..299 ->
|
|
{:ok, output_path}
|
|
|
|
{:ok, %Req.Response{status: status}} ->
|
|
File.rm(output_path)
|
|
{:error, {:http_error, status}}
|
|
|
|
{:error, reason} ->
|
|
File.rm(output_path)
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
# =============================================================================
|
|
# Private
|
|
# =============================================================================
|
|
|
|
defp auth_headers do
|
|
headers = [
|
|
{"Authorization", "Bearer #{api_token()}"},
|
|
{"Content-Type", "application/json"}
|
|
]
|
|
|
|
case store_id() do
|
|
nil -> headers
|
|
id -> [{"X-PF-Store-Id", to_string(id)} | headers]
|
|
end
|
|
end
|
|
end
|