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:
318
lib/simpleshop_theme/clients/printful.ex
Normal file
318
lib/simpleshop_theme/clients/printful.ex
Normal file
@@ -0,0 +1,318 @@
|
||||
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
|
||||
|
||||
# =============================================================================
|
||||
# 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, 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
|
||||
|
||||
# =============================================================================
|
||||
# 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
|
||||
@@ -200,7 +200,7 @@ defmodule SimpleshopTheme.Orders do
|
||||
def submit_to_provider(%Order{} = order) do
|
||||
order = Repo.preload(order, :items)
|
||||
|
||||
with {:ok, conn} <- get_provider_connection(),
|
||||
with {:ok, conn} <- get_provider_connection_for_order(order),
|
||||
{:ok, provider} <- Provider.for_connection(conn),
|
||||
{:ok, enriched_items} <- enrich_items(order.items),
|
||||
order_data <- build_submission_data(order, enriched_items),
|
||||
@@ -231,7 +231,9 @@ defmodule SimpleshopTheme.Orders do
|
||||
def refresh_fulfilment_status(%Order{provider_order_id: nil} = order), do: {:ok, order}
|
||||
|
||||
def refresh_fulfilment_status(%Order{} = order) do
|
||||
with {:ok, conn} <- get_provider_connection(),
|
||||
order = Repo.preload(order, :items)
|
||||
|
||||
with {:ok, conn} <- get_provider_connection_for_order(order),
|
||||
{:ok, provider} <- Provider.for_connection(conn),
|
||||
{:ok, status_data} <- provider.get_order_status(conn, order.provider_order_id) do
|
||||
attrs =
|
||||
@@ -285,11 +287,20 @@ defmodule SimpleshopTheme.Orders do
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
defp get_provider_connection do
|
||||
case Products.get_provider_connection_by_type("printify") do
|
||||
nil -> {:error, :no_provider_connection}
|
||||
%{enabled: false} -> {:error, :provider_disabled}
|
||||
conn -> {:ok, conn}
|
||||
defp get_provider_connection_for_order(%Order{items: items}) do
|
||||
first_item = List.first(items)
|
||||
variants_map = Products.get_variants_with_products([first_item.variant_id])
|
||||
|
||||
case Map.get(variants_map, first_item.variant_id) do
|
||||
nil ->
|
||||
{:error, :variant_not_found}
|
||||
|
||||
variant ->
|
||||
case Products.get_provider_connection(variant.product.provider_connection_id) do
|
||||
nil -> {:error, :no_provider_connection}
|
||||
%{enabled: false} -> {:error, :provider_disabled}
|
||||
conn -> {:ok, conn}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -331,6 +342,10 @@ defmodule SimpleshopTheme.Orders do
|
||||
"Variant for '#{name}' no longer exists in the product catalog"
|
||||
end
|
||||
|
||||
defp format_submission_error(:variant_not_found) do
|
||||
"Order variant no longer exists in the product catalog"
|
||||
end
|
||||
|
||||
defp format_submission_error(:no_provider_connection) do
|
||||
"No fulfilment provider connected"
|
||||
end
|
||||
|
||||
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