feat: add Printify order submission and fulfilment tracking
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>
This commit is contained in:
@@ -118,7 +118,14 @@ defmodule SimpleshopTheme.Providers.Printify do
|
||||
# Webhook Registration
|
||||
# =============================================================================
|
||||
|
||||
@webhook_events ["product:updated", "product:deleted", "product:publish:started"]
|
||||
@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.
|
||||
@@ -337,15 +344,16 @@ defmodule SimpleshopTheme.Providers.Printify do
|
||||
}
|
||||
end
|
||||
|
||||
defp map_order_status("pending"), do: "pending"
|
||||
defp map_order_status("on-hold"), do: "pending"
|
||||
defp map_order_status("payment-not-received"), do: "pending"
|
||||
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: "pending"
|
||||
defp map_order_status(_), do: "submitted"
|
||||
|
||||
defp extract_tracking(raw) do
|
||||
case raw["shipments"] do
|
||||
@@ -365,34 +373,72 @@ defmodule SimpleshopTheme.Providers.Printify do
|
||||
# Order Building
|
||||
# =============================================================================
|
||||
|
||||
defp build_order_payload(order) do
|
||||
defp build_order_payload(order_data) do
|
||||
%{
|
||||
external_id: order.order_number,
|
||||
label: order.order_number,
|
||||
external_id: order_data.order_number,
|
||||
label: order_data.order_number,
|
||||
line_items:
|
||||
Enum.map(order.line_items, fn item ->
|
||||
Enum.map(order_data.line_items, fn item ->
|
||||
%{
|
||||
product_id: item.product_variant.product.provider_product_id,
|
||||
variant_id: String.to_integer(item.product_variant.provider_variant_id),
|
||||
product_id: item.provider_product_id,
|
||||
variant_id: parse_variant_id(item.provider_variant_id),
|
||||
quantity: item.quantity
|
||||
}
|
||||
end),
|
||||
shipping_method: 1,
|
||||
address_to: %{
|
||||
first_name: order.shipping_address["first_name"],
|
||||
last_name: order.shipping_address["last_name"],
|
||||
email: order.customer_email,
|
||||
phone: order.shipping_address["phone"],
|
||||
country: order.shipping_address["country"],
|
||||
region: order.shipping_address["state"] || order.shipping_address["region"],
|
||||
address1: order.shipping_address["address1"],
|
||||
address2: order.shipping_address["address2"],
|
||||
city: order.shipping_address["city"],
|
||||
zip: order.shipping_address["zip"] || order.shipping_address["postal_code"]
|
||||
}
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
@@ -59,12 +59,28 @@ defmodule SimpleshopTheme.Providers.Provider do
|
||||
|
||||
@doc """
|
||||
Returns the provider module for a given provider type.
|
||||
|
||||
Checks `:provider_modules` application config first, allowing test
|
||||
overrides via Mox. Falls back to hardcoded dispatch.
|
||||
"""
|
||||
def for_type("printify"), do: {:ok, SimpleshopTheme.Providers.Printify}
|
||||
def for_type("gelato"), do: {:error, :not_implemented}
|
||||
def for_type("prodigi"), do: {:error, :not_implemented}
|
||||
def for_type("printful"), do: {:error, :not_implemented}
|
||||
def for_type(type), do: {:error, {:unknown_provider, type}}
|
||||
def for_type(type) do
|
||||
case Application.get_env(:simpleshop_theme, :provider_modules, %{}) do
|
||||
modules when is_map(modules) ->
|
||||
case Map.get(modules, type) do
|
||||
nil -> default_for_type(type)
|
||||
module -> {:ok, module}
|
||||
end
|
||||
|
||||
_ ->
|
||||
default_for_type(type)
|
||||
end
|
||||
end
|
||||
|
||||
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(type), do: {:error, {:unknown_provider, type}}
|
||||
|
||||
@doc """
|
||||
Returns the provider module for a provider connection.
|
||||
|
||||
Reference in New Issue
Block a user