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,84 @@
defmodule SimpleshopTheme.Orders.FulfilmentStatusWorkerTest do
use SimpleshopTheme.DataCase, async: false
import Mox
import SimpleshopTheme.OrdersFixtures
alias SimpleshopTheme.Orders
alias SimpleshopTheme.Orders.FulfilmentStatusWorker
alias SimpleshopTheme.Providers.MockProvider
setup :verify_on_exit!
setup do
Application.put_env(:simpleshop_theme, :provider_modules, %{
"printify" => MockProvider
})
on_exit(fn -> Application.delete_env(:simpleshop_theme, :provider_modules) end)
:ok
end
describe "perform/1" do
test "no-op when no submitted orders" do
assert :ok = FulfilmentStatusWorker.perform(%Oban.Job{args: %{}})
end
test "updates status from submitted to processing" do
{order, _variant, _product, _conn} = submitted_order_fixture()
expect(MockProvider, :get_order_status, fn _conn, provider_order_id ->
assert provider_order_id == order.provider_order_id
{:ok,
%{
status: "processing",
provider_status: "in-production",
tracking_number: nil,
tracking_url: nil,
shipments: []
}}
end)
assert :ok = FulfilmentStatusWorker.perform(%Oban.Job{args: %{}})
updated = Orders.get_order(order.id)
assert updated.fulfilment_status == "processing"
assert updated.provider_status == "in-production"
end
test "sets tracking info when shipped" do
{order, _variant, _product, _conn} = submitted_order_fixture()
expect(MockProvider, :get_order_status, fn _conn, _pid ->
{:ok,
%{
status: "shipped",
provider_status: "shipped",
tracking_number: "1Z999AA10123456784",
tracking_url: "https://tracking.example.com/1Z999AA10123456784",
shipments: []
}}
end)
assert :ok = FulfilmentStatusWorker.perform(%Oban.Job{args: %{}})
updated = Orders.get_order(order.id)
assert updated.fulfilment_status == "shipped"
assert updated.tracking_number == "1Z999AA10123456784"
assert updated.tracking_url == "https://tracking.example.com/1Z999AA10123456784"
assert updated.shipped_at != nil
end
test "handles provider error gracefully" do
{_order, _variant, _product, _conn} = submitted_order_fixture()
expect(MockProvider, :get_order_status, fn _conn, _pid ->
{:error, {500, %{"message" => "Internal error"}}}
end)
# Should not raise, just log the error
assert :ok = FulfilmentStatusWorker.perform(%Oban.Job{args: %{}})
end
end
end

View File

@@ -0,0 +1,112 @@
defmodule SimpleshopTheme.Orders.OrderSubmissionWorkerTest do
use SimpleshopTheme.DataCase, async: false
import Mox
import SimpleshopTheme.OrdersFixtures
alias SimpleshopTheme.Orders
alias SimpleshopTheme.Orders.OrderSubmissionWorker
alias SimpleshopTheme.Providers.MockProvider
setup :verify_on_exit!
setup do
Application.put_env(:simpleshop_theme, :provider_modules, %{
"printify" => MockProvider
})
on_exit(fn -> Application.delete_env(:simpleshop_theme, :provider_modules) end)
:ok
end
describe "perform/1" do
test "cancels if order not found" do
fake_id = Ecto.UUID.generate()
assert {:cancel, :order_not_found} =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => fake_id}})
end
test "cancels if order is not paid" do
order = order_fixture()
assert order.payment_status == "pending"
assert {:cancel, :not_paid} =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
end
test "returns :ok if already submitted" do
{order, _variant, _product, _conn} = submitted_order_fixture()
assert :ok =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
end
test "errors if no shipping address (will retry)" do
order = order_fixture(payment_status: "paid")
assert order.shipping_address == %{}
assert {:error, :no_shipping_address} =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
end
test "submits to provider successfully" do
{order, _variant, _product, _conn} = paid_order_with_products_fixture()
expect(MockProvider, :submit_order, fn _conn, order_data ->
assert order_data.order_number == order.order_number
assert length(order_data.line_items) == 1
{:ok, %{provider_order_id: "printify_order_123"}}
end)
assert :ok =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
updated = Orders.get_order(order.id)
assert updated.fulfilment_status == "submitted"
assert updated.provider_order_id == "printify_order_123"
assert updated.submitted_at != nil
end
test "sets failed status when provider returns error" do
{order, _variant, _product, _conn} = paid_order_with_products_fixture()
expect(MockProvider, :submit_order, fn _conn, _order_data ->
{:error, {422, %{"message" => "Invalid address"}}}
end)
assert {:error, {422, _}} =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
updated = Orders.get_order(order.id)
assert updated.fulfilment_status == "failed"
assert updated.fulfilment_error =~ "Provider API error (422)"
end
test "sets failed status when variant not found in local DB" do
# Create order with a variant_id that doesn't exist in product variants
order =
order_fixture(%{
variant_id: Ecto.UUID.generate(),
payment_status: "paid",
shipping_address: %{
"name" => "Test",
"line1" => "1 Street",
"city" => "London",
"postal_code" => "SW1",
"country" => "GB"
}
})
# Need a printify connection for the lookup to proceed
SimpleshopTheme.ProductsFixtures.provider_connection_fixture(%{provider_type: "printify"})
assert {:error, {:variant_not_found, _, _}} =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
updated = Orders.get_order(order.id)
assert updated.fulfilment_status == "failed"
assert updated.fulfilment_error =~ "no longer exists"
end
end
end