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:
jamey
2026-02-08 09:51:51 +00:00
parent 02cdc810f2
commit 3e19887499
22 changed files with 1318 additions and 54 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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