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

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

View File

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

View 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