berrypod/lib/simpleshop_theme/providers/printful.ex

531 lines
16 KiB
Elixir
Raw Normal View History

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
# Build per-product items: one representative variant per product
product_items = extract_per_product_items(products)
Logger.info(
"Fetching Printful shipping rates for #{length(product_items)} product(s), " <>
"#{length(target_countries())} countries"
)
rates =
for {catalog_product_id, variant_id} <- product_items,
{country_code, _index} <- Enum.with_index(target_countries()),
reduce: [] do
acc ->
if acc != [], do: Process.sleep(100)
acc ++ fetch_rate_for_product(catalog_product_id, variant_id, country_code)
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_rate_for_product(catalog_product_id, variant_id, country_code) do
items = [%{source: "catalog", catalog_variant_id: variant_id, quantity: 1}]
case Client.calculate_shipping(%{country_code: country_code}, items) do
{:ok, rates} when is_list(rates) ->
standard = Enum.find(rates, &(&1["shipping"] == "STANDARD")) || List.first(rates)
if standard do
[
%{
blueprint_id: catalog_product_id,
print_provider_id: 0,
country_code: country_code,
first_item_cost: parse_price(standard["rate"]),
additional_item_cost: 0,
currency: String.upcase(standard["currency"] || "USD"),
handling_time_days: standard["max_delivery_days"]
}
]
else
[]
end
{:error, reason} ->
Logger.warning(
"Failed to fetch Printful shipping for #{country_code}: #{inspect(reason)}"
)
[]
end
end
# Returns {catalog_product_id, first_catalog_variant_id} per product
defp extract_per_product_items(products) do
products
|> Enum.flat_map(fn product ->
provider_data = product[:provider_data] || %{}
catalog_product_id =
provider_data[:catalog_product_id] || provider_data["catalog_product_id"]
catalog_variant_ids =
provider_data[:catalog_variant_ids] || provider_data["catalog_variant_ids"] || []
case {catalog_product_id, catalog_variant_ids} do
{nil, _} -> []
{_, []} -> []
{cpid, [vid | _]} -> [{cpid, vid}]
end
end)
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,
# Shipping calc uses these generic keys (shared with Printify)
blueprint_id: catalog_product_id,
print_provider_id: 0,
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