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:
@@ -2,13 +2,18 @@ defmodule SimpleshopTheme.Orders do
|
||||
@moduledoc """
|
||||
The Orders context.
|
||||
|
||||
Handles order creation, payment status tracking, and order retrieval.
|
||||
Payment-provider agnostic — all Stripe-specific logic lives in controllers.
|
||||
Handles order creation, payment status tracking, fulfilment submission,
|
||||
and order retrieval. Payment-provider agnostic — all Stripe-specific
|
||||
logic lives in controllers.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
alias SimpleshopTheme.Repo
|
||||
alias SimpleshopTheme.Orders.{Order, OrderItem}
|
||||
alias SimpleshopTheme.Products
|
||||
alias SimpleshopTheme.Providers.Provider
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Lists orders, optionally filtered by payment status.
|
||||
@@ -166,4 +171,186 @@ defmodule SimpleshopTheme.Orders do
|
||||
random = :crypto.strong_rand_bytes(2) |> Base.encode16()
|
||||
"SS-#{date}-#{random}"
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Fulfilment
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Submits an order to the fulfilment provider.
|
||||
|
||||
Looks up product variant data from order items, builds the provider payload,
|
||||
and calls the provider's submit_order callback. Idempotent — returns {:ok, order}
|
||||
if already submitted.
|
||||
"""
|
||||
def submit_to_provider(%Order{provider_order_id: pid} = order) when not is_nil(pid) do
|
||||
{:ok, order}
|
||||
end
|
||||
|
||||
def submit_to_provider(%Order{} = order) do
|
||||
order = Repo.preload(order, :items)
|
||||
|
||||
with {:ok, conn} <- get_provider_connection(),
|
||||
{:ok, provider} <- Provider.for_connection(conn),
|
||||
{:ok, enriched_items} <- enrich_items(order.items),
|
||||
order_data <- build_submission_data(order, enriched_items),
|
||||
{:ok, %{provider_order_id: pid}} <- provider.submit_order(conn, order_data) do
|
||||
update_fulfilment(order, %{
|
||||
fulfilment_status: "submitted",
|
||||
provider_order_id: pid,
|
||||
fulfilment_error: nil,
|
||||
submitted_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
})
|
||||
else
|
||||
{:error, reason} ->
|
||||
error_msg = format_submission_error(reason)
|
||||
|
||||
update_fulfilment(order, %{
|
||||
fulfilment_status: "failed",
|
||||
fulfilment_error: error_msg
|
||||
})
|
||||
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Polls the provider for the current fulfilment status of an order.
|
||||
Updates tracking info and timestamps on status transitions.
|
||||
"""
|
||||
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(),
|
||||
{:ok, provider} <- Provider.for_connection(conn),
|
||||
{:ok, status_data} <- provider.get_order_status(conn, order.provider_order_id) do
|
||||
attrs =
|
||||
%{
|
||||
fulfilment_status: status_data.status,
|
||||
provider_status: status_data.provider_status,
|
||||
tracking_number: status_data.tracking_number,
|
||||
tracking_url: status_data.tracking_url
|
||||
}
|
||||
|> maybe_set_timestamp(order)
|
||||
|
||||
update_fulfilment(order, attrs)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates an order's fulfilment fields.
|
||||
"""
|
||||
def update_fulfilment(%Order{} = order, attrs) do
|
||||
order
|
||||
|> Order.fulfilment_changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists orders that need fulfilment status polling (submitted or processing).
|
||||
"""
|
||||
def list_submitted_orders do
|
||||
from(o in Order,
|
||||
where: o.fulfilment_status in ["submitted", "processing"],
|
||||
where: not is_nil(o.provider_order_id),
|
||||
order_by: [asc: o.submitted_at],
|
||||
preload: :items
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets an order by its order number.
|
||||
"""
|
||||
def get_order_by_number(order_number) do
|
||||
Order
|
||||
|> where([o], o.order_number == ^order_number)
|
||||
|> preload(:items)
|
||||
|> 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}
|
||||
end
|
||||
end
|
||||
|
||||
defp enrich_items(items) do
|
||||
variant_ids = Enum.map(items, & &1.variant_id)
|
||||
variants_map = Products.get_variants_with_products(variant_ids)
|
||||
|
||||
results =
|
||||
Enum.map(items, fn item ->
|
||||
case Map.get(variants_map, item.variant_id) do
|
||||
nil -> {:error, {:variant_not_found, item.variant_id, item.product_name}}
|
||||
variant -> {:ok, %{item: item, variant: variant}}
|
||||
end
|
||||
end)
|
||||
|
||||
case Enum.find(results, &match?({:error, _}, &1)) do
|
||||
nil -> {:ok, Enum.map(results, fn {:ok, e} -> e end)}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_submission_data(order, enriched_items) do
|
||||
%{
|
||||
order_number: order.order_number,
|
||||
customer_email: order.customer_email,
|
||||
shipping_address: order.shipping_address,
|
||||
line_items:
|
||||
Enum.map(enriched_items, fn %{item: item, variant: variant} ->
|
||||
%{
|
||||
provider_product_id: variant.product.provider_product_id,
|
||||
provider_variant_id: variant.provider_variant_id,
|
||||
quantity: item.quantity
|
||||
}
|
||||
end)
|
||||
}
|
||||
end
|
||||
|
||||
defp format_submission_error({:variant_not_found, _id, name}) do
|
||||
"Variant for '#{name}' no longer exists in the product catalog"
|
||||
end
|
||||
|
||||
defp format_submission_error(:no_provider_connection) do
|
||||
"No fulfilment provider connected"
|
||||
end
|
||||
|
||||
defp format_submission_error(:provider_disabled) do
|
||||
"Fulfilment provider is disabled"
|
||||
end
|
||||
|
||||
defp format_submission_error(:no_api_key) do
|
||||
"Provider API key is missing"
|
||||
end
|
||||
|
||||
defp format_submission_error(:no_shop_id) do
|
||||
"Provider shop ID is not configured"
|
||||
end
|
||||
|
||||
defp format_submission_error({status, body}) when is_integer(status) do
|
||||
message = if is_map(body), do: body["message"] || body["error"], else: inspect(body)
|
||||
"Provider API error (#{status}): #{message}"
|
||||
end
|
||||
|
||||
defp format_submission_error(reason) do
|
||||
"Submission failed: #{inspect(reason)}"
|
||||
end
|
||||
|
||||
defp maybe_set_timestamp(attrs, order) do
|
||||
attrs
|
||||
|> maybe_set(:shipped_at, attrs[:fulfilment_status] == "shipped" and is_nil(order.shipped_at))
|
||||
|> maybe_set(
|
||||
:delivered_at,
|
||||
attrs[:fulfilment_status] == "delivered" and is_nil(order.delivered_at)
|
||||
)
|
||||
end
|
||||
|
||||
defp maybe_set(attrs, key, true),
|
||||
do: Map.put(attrs, key, DateTime.utc_now() |> DateTime.truncate(:second))
|
||||
|
||||
defp maybe_set(attrs, _key, false), do: attrs
|
||||
end
|
||||
|
||||
46
lib/simpleshop_theme/orders/fulfilment_status_worker.ex
Normal file
46
lib/simpleshop_theme/orders/fulfilment_status_worker.ex
Normal file
@@ -0,0 +1,46 @@
|
||||
defmodule SimpleshopTheme.Orders.FulfilmentStatusWorker do
|
||||
@moduledoc """
|
||||
Oban Cron worker that polls the fulfilment provider for status updates.
|
||||
|
||||
Runs every 30 minutes as a fallback for missed webhook events.
|
||||
Only checks orders that are submitted or processing (i.e. awaiting
|
||||
further status transitions).
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :sync, max_attempts: 1
|
||||
|
||||
alias SimpleshopTheme.Orders
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
orders = Orders.list_submitted_orders()
|
||||
|
||||
if orders == [] do
|
||||
:ok
|
||||
else
|
||||
Logger.info("Polling fulfilment status for #{length(orders)} order(s)")
|
||||
|
||||
Enum.each(orders, fn order ->
|
||||
case Orders.refresh_fulfilment_status(order) do
|
||||
{:ok, updated} ->
|
||||
if updated.fulfilment_status != order.fulfilment_status do
|
||||
Logger.info(
|
||||
"Order #{order.order_number} status: #{order.fulfilment_status} → #{updated.fulfilment_status}"
|
||||
)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"Failed to refresh status for order #{order.order_number}: #{inspect(reason)}"
|
||||
)
|
||||
end
|
||||
|
||||
Process.sleep(200)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,9 @@ defmodule SimpleshopTheme.Orders.Order do
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
@payment_statuses ~w(pending paid failed refunded)
|
||||
@fulfilment_statuses ~w(unfulfilled submitted processing shipped delivered failed cancelled)
|
||||
|
||||
def fulfilment_statuses, do: @fulfilment_statuses
|
||||
|
||||
schema "orders" do
|
||||
field :order_number, :string
|
||||
@@ -19,6 +22,17 @@ defmodule SimpleshopTheme.Orders.Order do
|
||||
field :currency, :string, default: "gbp"
|
||||
field :metadata, :map, default: %{}
|
||||
|
||||
# Fulfilment
|
||||
field :fulfilment_status, :string, default: "unfulfilled"
|
||||
field :provider_order_id, :string
|
||||
field :provider_status, :string
|
||||
field :fulfilment_error, :string
|
||||
field :tracking_number, :string
|
||||
field :tracking_url, :string
|
||||
field :submitted_at, :utc_datetime
|
||||
field :shipped_at, :utc_datetime
|
||||
field :delivered_at, :utc_datetime
|
||||
|
||||
has_many :items, SimpleshopTheme.Orders.OrderItem
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
@@ -45,4 +59,20 @@ defmodule SimpleshopTheme.Orders.Order do
|
||||
|> unique_constraint(:order_number)
|
||||
|> unique_constraint(:stripe_session_id)
|
||||
end
|
||||
|
||||
def fulfilment_changeset(order, attrs) do
|
||||
order
|
||||
|> cast(attrs, [
|
||||
:fulfilment_status,
|
||||
:provider_order_id,
|
||||
:provider_status,
|
||||
:fulfilment_error,
|
||||
:tracking_number,
|
||||
:tracking_url,
|
||||
:submitted_at,
|
||||
:shipped_at,
|
||||
:delivered_at
|
||||
])
|
||||
|> validate_inclusion(:fulfilment_status, @fulfilment_statuses)
|
||||
end
|
||||
end
|
||||
|
||||
56
lib/simpleshop_theme/orders/order_submission_worker.ex
Normal file
56
lib/simpleshop_theme/orders/order_submission_worker.ex
Normal file
@@ -0,0 +1,56 @@
|
||||
defmodule SimpleshopTheme.Orders.OrderSubmissionWorker do
|
||||
@moduledoc """
|
||||
Oban worker for submitting paid orders to the fulfilment provider.
|
||||
|
||||
Enqueued after Stripe webhook confirms payment. Guards against
|
||||
missing orders, unpaid orders, and already-submitted orders.
|
||||
Retries up to 3 times with backoff for transient failures.
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :checkout, max_attempts: 3
|
||||
|
||||
alias SimpleshopTheme.Orders
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"order_id" => order_id}}) do
|
||||
case Orders.get_order(order_id) do
|
||||
nil ->
|
||||
Logger.warning("Order submission: order #{order_id} not found")
|
||||
{:cancel, :order_not_found}
|
||||
|
||||
%{payment_status: status} when status != "paid" ->
|
||||
Logger.warning("Order submission: order #{order_id} not paid (#{status})")
|
||||
{:cancel, :not_paid}
|
||||
|
||||
%{provider_order_id: pid} when not is_nil(pid) ->
|
||||
Logger.info("Order submission: order #{order_id} already submitted")
|
||||
:ok
|
||||
|
||||
%{shipping_address: addr} when addr == %{} or is_nil(addr) ->
|
||||
Logger.warning("Order submission: order #{order_id} has no shipping address, will retry")
|
||||
{:error, :no_shipping_address}
|
||||
|
||||
order ->
|
||||
case Orders.submit_to_provider(order) do
|
||||
{:ok, updated} ->
|
||||
Logger.info(
|
||||
"Order #{updated.order_number} submitted to provider (#{updated.provider_order_id})"
|
||||
)
|
||||
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Order #{order.order_number} submission failed: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def enqueue(order_id) do
|
||||
%{order_id: order_id}
|
||||
|> new()
|
||||
|> Oban.insert()
|
||||
end
|
||||
end
|
||||
@@ -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.
|
||||
|
||||
@@ -3,6 +3,7 @@ defmodule SimpleshopTheme.Webhooks do
|
||||
Handles incoming webhook events from POD providers.
|
||||
"""
|
||||
|
||||
alias SimpleshopTheme.Orders
|
||||
alias SimpleshopTheme.Products
|
||||
alias SimpleshopTheme.Sync.ProductSyncWorker
|
||||
alias SimpleshopTheme.Webhooks.ProductDeleteWorker
|
||||
@@ -14,6 +15,9 @@ defmodule SimpleshopTheme.Webhooks do
|
||||
|
||||
Returns :ok or {:ok, job} on success, {:error, reason} on failure.
|
||||
"""
|
||||
|
||||
# --- Product events ---
|
||||
|
||||
def handle_printify_event("product:updated", %{"id" => _product_id}) do
|
||||
enqueue_product_sync()
|
||||
end
|
||||
@@ -26,6 +30,43 @@ defmodule SimpleshopTheme.Webhooks do
|
||||
ProductDeleteWorker.enqueue(product_id)
|
||||
end
|
||||
|
||||
# --- Order events ---
|
||||
|
||||
def handle_printify_event("order:sent-to-production", resource) do
|
||||
with {:ok, order} <- find_order_from_resource(resource) do
|
||||
Orders.update_fulfilment(order, %{
|
||||
fulfilment_status: "processing",
|
||||
provider_status: "in-production"
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
def handle_printify_event("order:shipment:created", resource) do
|
||||
shipment = extract_shipment(resource)
|
||||
|
||||
with {:ok, order} <- find_order_from_resource(resource) do
|
||||
Orders.update_fulfilment(order, %{
|
||||
fulfilment_status: "shipped",
|
||||
provider_status: "shipped",
|
||||
tracking_number: shipment.tracking_number,
|
||||
tracking_url: shipment.tracking_url,
|
||||
shipped_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
def handle_printify_event("order:shipment:delivered", resource) do
|
||||
with {:ok, order} <- find_order_from_resource(resource) do
|
||||
Orders.update_fulfilment(order, %{
|
||||
fulfilment_status: "delivered",
|
||||
provider_status: "delivered",
|
||||
delivered_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
# --- Catch-all ---
|
||||
|
||||
def handle_printify_event("shop:disconnected", _resource) do
|
||||
Logger.warning("Printify shop disconnected - manual intervention needed")
|
||||
:ok
|
||||
@@ -36,10 +77,39 @@ defmodule SimpleshopTheme.Webhooks do
|
||||
:ok
|
||||
end
|
||||
|
||||
# --- Private helpers ---
|
||||
|
||||
defp enqueue_product_sync do
|
||||
case Products.get_provider_connection_by_type("printify") do
|
||||
nil -> {:error, :no_connection}
|
||||
conn -> ProductSyncWorker.enqueue(conn.id)
|
||||
end
|
||||
end
|
||||
|
||||
# Printify order webhooks include external_id (our order_number) in the resource
|
||||
defp find_order_from_resource(%{"external_id" => external_id}) when is_binary(external_id) do
|
||||
case Orders.get_order_by_number(external_id) do
|
||||
nil ->
|
||||
Logger.warning("Order webhook: no order found for external_id=#{external_id}")
|
||||
{:error, :order_not_found}
|
||||
|
||||
order ->
|
||||
{:ok, order}
|
||||
end
|
||||
end
|
||||
|
||||
defp find_order_from_resource(resource) do
|
||||
Logger.warning("Order webhook: missing external_id in resource #{inspect(resource)}")
|
||||
{:error, :missing_external_id}
|
||||
end
|
||||
|
||||
defp extract_shipment(resource) do
|
||||
shipments = resource["shipments"] || []
|
||||
shipment = List.last(shipments) || %{}
|
||||
|
||||
%{
|
||||
tracking_number: shipment["tracking_number"],
|
||||
tracking_url: shipment["tracking_url"]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,7 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
|
||||
use SimpleshopThemeWeb, :controller
|
||||
|
||||
alias SimpleshopTheme.Orders
|
||||
alias SimpleshopTheme.Orders.OrderSubmissionWorker
|
||||
|
||||
require Logger
|
||||
|
||||
@@ -36,14 +37,24 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
|
||||
{:ok, order} = Orders.mark_paid(order, payment_intent_id)
|
||||
|
||||
# Update shipping address if collected by Stripe
|
||||
if session.shipping_details do
|
||||
update_shipping(order, session.shipping_details)
|
||||
end
|
||||
order =
|
||||
if session.shipping_details do
|
||||
{:ok, updated} = update_shipping(order, session.shipping_details)
|
||||
updated
|
||||
else
|
||||
order
|
||||
end
|
||||
|
||||
# Update customer email from Stripe session
|
||||
if session.customer_details && session.customer_details.email do
|
||||
Orders.update_order(order, %{customer_email: session.customer_details.email})
|
||||
end
|
||||
order =
|
||||
if session.customer_details && session.customer_details.email do
|
||||
{:ok, updated} =
|
||||
Orders.update_order(order, %{customer_email: session.customer_details.email})
|
||||
|
||||
updated
|
||||
else
|
||||
order
|
||||
end
|
||||
|
||||
# Broadcast to success page via PubSub
|
||||
Phoenix.PubSub.broadcast(
|
||||
@@ -52,6 +63,15 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
|
||||
{:order_paid, order}
|
||||
)
|
||||
|
||||
# Submit to fulfilment provider
|
||||
if order.shipping_address && order.shipping_address != %{} do
|
||||
OrderSubmissionWorker.enqueue(order.id)
|
||||
else
|
||||
Logger.warning(
|
||||
"Order #{order.order_number} paid but no shipping address — manual submit needed"
|
||||
)
|
||||
end
|
||||
|
||||
Logger.info("Order #{order.order_number} marked as paid")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -79,7 +79,7 @@ defmodule SimpleshopThemeWeb.AdminLive.OrderShow do
|
||||
<:item :if={@order.shipping_address["city"]} title="City">
|
||||
{@order.shipping_address["city"]}
|
||||
</:item>
|
||||
<:item :if={@order.shipping_address["state"]} title="State">
|
||||
<:item :if={@order.shipping_address["state"] not in [nil, ""]} title="State">
|
||||
{@order.shipping_address["state"]}
|
||||
</:item>
|
||||
<:item :if={@order.shipping_address["postal_code"]} title="Postcode">
|
||||
@@ -96,6 +96,64 @@ defmodule SimpleshopThemeWeb.AdminLive.OrderShow do
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- fulfilment --%>
|
||||
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="card-title text-base">Fulfilment</h3>
|
||||
<.fulfilment_badge status={@order.fulfilment_status} />
|
||||
</div>
|
||||
<.list>
|
||||
<:item :if={@order.provider_order_id} title="Provider order ID">
|
||||
<code class="text-xs">{@order.provider_order_id}</code>
|
||||
</:item>
|
||||
<:item :if={@order.provider_status} title="Provider status">
|
||||
{@order.provider_status}
|
||||
</:item>
|
||||
<:item :if={@order.submitted_at} title="Submitted">
|
||||
{format_date(@order.submitted_at)}
|
||||
</:item>
|
||||
<:item :if={@order.tracking_number} title="Tracking">
|
||||
<%= if @order.tracking_url do %>
|
||||
<a href={@order.tracking_url} target="_blank" class="link link-primary">
|
||||
{@order.tracking_number}
|
||||
</a>
|
||||
<% else %>
|
||||
{@order.tracking_number}
|
||||
<% end %>
|
||||
</:item>
|
||||
<:item :if={@order.shipped_at} title="Shipped">
|
||||
{format_date(@order.shipped_at)}
|
||||
</:item>
|
||||
<:item :if={@order.delivered_at} title="Delivered">
|
||||
{format_date(@order.delivered_at)}
|
||||
</:item>
|
||||
<:item :if={@order.fulfilment_error} title="Error">
|
||||
<span class="text-error text-sm">{@order.fulfilment_error}</span>
|
||||
</:item>
|
||||
</.list>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
:if={can_submit?(@order)}
|
||||
phx-click="submit_to_provider"
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
<.icon name="hero-paper-airplane-mini" class="size-4" />
|
||||
{if @order.fulfilment_status == "failed",
|
||||
do: "Retry submission",
|
||||
else: "Submit to provider"}
|
||||
</button>
|
||||
<button
|
||||
:if={can_refresh?(@order)}
|
||||
phx-click="refresh_status"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
<.icon name="hero-arrow-path-mini" class="size-4" /> Refresh status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- line items --%>
|
||||
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6">
|
||||
<div class="card-body">
|
||||
@@ -136,6 +194,96 @@ defmodule SimpleshopThemeWeb.AdminLive.OrderShow do
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("submit_to_provider", _params, socket) do
|
||||
order = socket.assigns.order
|
||||
|
||||
case Orders.submit_to_provider(order) do
|
||||
{:ok, updated} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:order, updated)
|
||||
|> put_flash(:info, "Order submitted to provider")
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _reason} ->
|
||||
order = Orders.get_order(order.id)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:order, order)
|
||||
|> put_flash(:error, order.fulfilment_error || "Submission failed")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("refresh_status", _params, socket) do
|
||||
order = socket.assigns.order
|
||||
|
||||
case Orders.refresh_fulfilment_status(order) do
|
||||
{:ok, updated} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:order, updated)
|
||||
|> put_flash(:info, "Status refreshed")
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to refresh: #{inspect(reason)}")}
|
||||
end
|
||||
end
|
||||
|
||||
defp can_submit?(order) do
|
||||
order.payment_status == "paid" and order.fulfilment_status in ["unfulfilled", "failed"]
|
||||
end
|
||||
|
||||
defp can_refresh?(order) do
|
||||
not is_nil(order.provider_order_id) and
|
||||
order.fulfilment_status in ["submitted", "processing", "shipped"]
|
||||
end
|
||||
|
||||
defp fulfilment_badge(assigns) do
|
||||
{bg, text, ring, icon} =
|
||||
case assigns.status do
|
||||
"submitted" ->
|
||||
{"bg-blue-50", "text-blue-700", "ring-blue-600/20", "hero-paper-airplane-mini"}
|
||||
|
||||
"processing" ->
|
||||
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-cog-6-tooth-mini"}
|
||||
|
||||
"shipped" ->
|
||||
{"bg-purple-50", "text-purple-700", "ring-purple-600/20", "hero-truck-mini"}
|
||||
|
||||
"delivered" ->
|
||||
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
|
||||
|
||||
"failed" ->
|
||||
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
|
||||
|
||||
"cancelled" ->
|
||||
{"bg-zinc-50", "text-zinc-600", "ring-zinc-500/10", "hero-no-symbol-mini"}
|
||||
|
||||
_ ->
|
||||
{"bg-zinc-50", "text-zinc-600", "ring-zinc-500/10", "hero-minus-circle-mini"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
||||
@bg,
|
||||
@text,
|
||||
@ring
|
||||
]}>
|
||||
<.icon name={@icon} class="size-3" /> {@status}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp status_badge(assigns) do
|
||||
{bg, text, ring, icon} =
|
||||
case assigns.status do
|
||||
|
||||
@@ -86,6 +86,9 @@ defmodule SimpleshopThemeWeb.AdminLive.Orders do
|
||||
<:col :let={order} label="Customer">{order.customer_email || "—"}</:col>
|
||||
<:col :let={order} label="Total">{Cart.format_price(order.total)}</:col>
|
||||
<:col :let={order} label="Status"><.status_badge status={order.payment_status} /></:col>
|
||||
<:col :let={order} label="Fulfilment">
|
||||
<.fulfilment_badge status={order.fulfilment_status} />
|
||||
</:col>
|
||||
</.table>
|
||||
|
||||
<div :if={@order_count == 0} class="text-center py-12 text-base-content/60">
|
||||
@@ -156,6 +159,45 @@ defmodule SimpleshopThemeWeb.AdminLive.Orders do
|
||||
Calendar.strftime(datetime, "%d %b %Y %H:%M")
|
||||
end
|
||||
|
||||
defp fulfilment_badge(assigns) do
|
||||
{bg, text, ring, icon} =
|
||||
case assigns.status do
|
||||
"submitted" ->
|
||||
{"bg-blue-50", "text-blue-700", "ring-blue-600/20", "hero-paper-airplane-mini"}
|
||||
|
||||
"processing" ->
|
||||
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-cog-6-tooth-mini"}
|
||||
|
||||
"shipped" ->
|
||||
{"bg-purple-50", "text-purple-700", "ring-purple-600/20", "hero-truck-mini"}
|
||||
|
||||
"delivered" ->
|
||||
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
|
||||
|
||||
"failed" ->
|
||||
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
|
||||
|
||||
"cancelled" ->
|
||||
{"bg-zinc-50", "text-zinc-600", "ring-zinc-500/10", "hero-no-symbol-mini"}
|
||||
|
||||
_ ->
|
||||
{"bg-zinc-50", "text-zinc-600", "ring-zinc-500/10", "hero-minus-circle-mini"}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
|
||||
|
||||
~H"""
|
||||
<span class={[
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
||||
@bg,
|
||||
@text,
|
||||
@ring
|
||||
]}>
|
||||
<.icon name={@icon} class="size-3" /> {@status}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp total_count(counts) do
|
||||
counts |> Map.values() |> Enum.sum()
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user