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

@ -19,14 +19,14 @@
- Search modal with keyboard shortcut - Search modal with keyboard shortcut
- Demo content polished and ready for production - Demo content polished and ready for production
**Next up:** Order management and fulfilment (Tier 1) **Next up:** Transactional emails — order confirmation and shipping notifications (Tier 1, Roadmap #3)
## Roadmap ## Roadmap
### Tier 1 — MVP (can take real orders and fulfil them) ### Tier 1 — MVP (can take real orders and fulfil them)
1. **Order management admin** — Admin UI to view orders, filter by status, see order details and line items. The orders context and schemas exist, this is purely the admin LiveView. 1. ~~**Order management admin**~~ — ✅ Complete (02cdc81). Admin UI at `/admin/orders` with status filter tabs, streamed order table, and detail view showing items, totals, and shipping address.
2. **Orders & fulfilment** — Submit paid orders to Printify via their API. Track fulfilment status (submitted → in production → shipped → delivered). Handle shipping notifications and tracking numbers. 2. ~~**Orders & fulfilment**~~ — ✅ Complete. Submit paid orders to Printify, track fulfilment status (submitted → processing → shipped → delivered), webhook-driven status updates with polling fallback, admin UI with submit/refresh actions.
3. **Transactional emails** — Order confirmation email on payment. Shipping notification with tracking link. Use Swoosh (already configured) with a simple HTML template. 3. **Transactional emails** — Order confirmation email on payment. Shipping notification with tracking link. Use Swoosh (already configured) with a simple HTML template.
4. **Default content pages** — Static pages for terms of service, delivery & refunds policy, and privacy policy. Needed for legal compliance before taking real orders. Can be simple markdown-rendered pages initially, upgraded to editable via page editor later. 4. **Default content pages** — Static pages for terms of service, delivery & refunds policy, and privacy policy. Needed for legal compliance before taking real orders. Can be simple markdown-rendered pages initially, upgraded to editable via page editor later.
@ -117,7 +117,7 @@ See: [docs/plans/image-optimization.md](docs/plans/image-optimization.md) for im
- [ ] OAuth platform integration (appear in Printify's "Publish to" UI) - [ ] OAuth platform integration (appear in Printify's "Publish to" UI)
#### Technical Debt #### Technical Debt
- [ ] Add HTTP mocking (Mox/Bypass) for Printify API tests - [x] Mox provider mocking for fulfilment tests (Provider.for_type configurable via app env)
See: [docs/plans/products-context.md](docs/plans/products-context.md) for implementation details See: [docs/plans/products-context.md](docs/plans/products-context.md) for implementation details
See: [docs/plans/printify-integration-research.md](docs/plans/printify-integration-research.md) for API research & risk analysis See: [docs/plans/printify-integration-research.md](docs/plans/printify-integration-research.md) for API research & risk analysis
@ -172,13 +172,25 @@ See: [ROADMAP.md](ROADMAP.md) for design notes
- CSSCache test startup crash fixed (handle_continue pattern) - CSSCache test startup crash fixed (handle_continue pattern)
### Orders & Fulfilment ### Orders & Fulfilment
**Status:** In progress — schemas and checkout done, fulfilment pending (Tier 1) **Status:** Complete (checkout, admin, fulfilment). Transactional emails pending (Roadmap #3).
- [x] Orders context with schemas (ff1bc48) - [x] Orders context with schemas (ff1bc48)
- [x] Stripe Checkout integration with webhook handling - [x] Stripe Checkout integration with webhook handling
- [ ] Order management admin UI (Roadmap #1) - [x] Order management admin UI (02cdc81, Roadmap #1)
- [ ] Printify order submission (Roadmap #2) - Order list with status filter tabs (all/paid/pending/failed/refunded) and counts
- [ ] Fulfilment status tracking (Roadmap #2) - Streamed table with row click navigation to detail
- Order detail with info card, shipping address, line items table with totals
- Nav link in admin bar, 15 tests
- [x] Printify order submission and fulfilment tracking (Roadmap #2)
- 9 fulfilment fields on orders (status, provider_order_id, tracking, timestamps)
- `submit_to_provider/1` with idempotent guard, error handling, address mapping
- `refresh_fulfilment_status/1` polls provider for status updates
- OrderSubmissionWorker (Oban, :checkout queue, max_attempts: 3)
- FulfilmentStatusWorker (Oban Cron, every 30 mins, :sync queue)
- Printify order webhook handlers (sent-to-production, shipment:created, shipment:delivered)
- Stripe webhook auto-enqueues submission after payment confirmed
- Admin UI: fulfilment badge column, fulfilment card with tracking, submit/refresh buttons
- Mox provider mocking for test isolation, 33 new tests (555 total)
- [ ] Transactional emails (Roadmap #3) - [ ] Transactional emails (Roadmap #3)
See: [docs/plans/products-context.md](docs/plans/products-context.md) for schema design See: [docs/plans/products-context.md](docs/plans/products-context.md) for schema design
@ -196,6 +208,8 @@ See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design
| Feature | Commit | Notes | | Feature | Commit | Notes |
|---------|--------|-------| |---------|--------|-------|
| Printify order submission & fulfilment | — | Submit, track, webhooks, polling, admin UI, 33 tests |
| Order management admin | 02cdc81 | List/detail views, status filters, 15 tests |
| Encrypted settings & Stripe setup | eede9bb | Guided setup flow, encrypted secrets, admin credentials page | | Encrypted settings & Stripe setup | eede9bb | Guided setup flow, encrypted secrets, admin credentials page |
| Stripe checkout & orders | ff1bc48 | Stripe Checkout, webhooks, order persistence | | Stripe checkout & orders | ff1bc48 | Stripe Checkout, webhooks, order persistence |
| Demo content & link fixes | cff2170 | Broken links, placeholder text, responsive about image | | Demo content & link fixes | cff2170 | Broken links, placeholder text, responsive about image |

View File

@ -92,7 +92,11 @@ config :simpleshop_theme, Oban,
repo: SimpleshopTheme.Repo, repo: SimpleshopTheme.Repo,
plugins: [ plugins: [
{Oban.Plugins.Pruner, max_age: 60}, {Oban.Plugins.Pruner, max_age: 60},
{Oban.Plugins.Lifeline, rescue_after: :timer.minutes(5)} {Oban.Plugins.Lifeline, rescue_after: :timer.minutes(5)},
{Oban.Plugins.Cron,
crontab: [
{"*/30 * * * *", SimpleshopTheme.Orders.FulfilmentStatusWorker}
]}
], ],
queues: [images: 2, sync: 1, checkout: 1] queues: [images: 2, sync: 1, checkout: 1]

View File

@ -2,13 +2,18 @@ defmodule SimpleshopTheme.Orders do
@moduledoc """ @moduledoc """
The Orders context. The Orders context.
Handles order creation, payment status tracking, and order retrieval. Handles order creation, payment status tracking, fulfilment submission,
Payment-provider agnostic all Stripe-specific logic lives in controllers. and order retrieval. Payment-provider agnostic all Stripe-specific
logic lives in controllers.
""" """
import Ecto.Query import Ecto.Query
alias SimpleshopTheme.Repo alias SimpleshopTheme.Repo
alias SimpleshopTheme.Orders.{Order, OrderItem} alias SimpleshopTheme.Orders.{Order, OrderItem}
alias SimpleshopTheme.Products
alias SimpleshopTheme.Providers.Provider
require Logger
@doc """ @doc """
Lists orders, optionally filtered by payment status. Lists orders, optionally filtered by payment status.
@ -166,4 +171,186 @@ defmodule SimpleshopTheme.Orders do
random = :crypto.strong_rand_bytes(2) |> Base.encode16() random = :crypto.strong_rand_bytes(2) |> Base.encode16()
"SS-#{date}-#{random}" "SS-#{date}-#{random}"
end 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 end

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 @foreign_key_type :binary_id
@payment_statuses ~w(pending paid failed refunded) @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 schema "orders" do
field :order_number, :string field :order_number, :string
@ -19,6 +22,17 @@ defmodule SimpleshopTheme.Orders.Order do
field :currency, :string, default: "gbp" field :currency, :string, default: "gbp"
field :metadata, :map, default: %{} 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 has_many :items, SimpleshopTheme.Orders.OrderItem
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
@ -45,4 +59,20 @@ defmodule SimpleshopTheme.Orders.Order do
|> unique_constraint(:order_number) |> unique_constraint(:order_number)
|> unique_constraint(:stripe_session_id) |> unique_constraint(:stripe_session_id)
end 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 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

View File

@ -118,7 +118,14 @@ defmodule SimpleshopTheme.Providers.Printify do
# Webhook Registration # 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 """ @doc """
Registers webhooks for product events with Printify. Registers webhooks for product events with Printify.
@ -337,15 +344,16 @@ defmodule SimpleshopTheme.Providers.Printify do
} }
end end
defp map_order_status("pending"), do: "pending" defp map_order_status("pending"), do: "submitted"
defp map_order_status("on-hold"), do: "pending" defp map_order_status("on-hold"), do: "submitted"
defp map_order_status("payment-not-received"), do: "pending" 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("in-production"), do: "processing"
defp map_order_status("partially-shipped"), do: "processing" defp map_order_status("partially-shipped"), do: "processing"
defp map_order_status("shipped"), do: "shipped" defp map_order_status("shipped"), do: "shipped"
defp map_order_status("delivered"), do: "delivered" defp map_order_status("delivered"), do: "delivered"
defp map_order_status("canceled"), do: "cancelled" defp map_order_status("canceled"), do: "cancelled"
defp map_order_status(_), do: "pending" defp map_order_status(_), do: "submitted"
defp extract_tracking(raw) do defp extract_tracking(raw) do
case raw["shipments"] do case raw["shipments"] do
@ -365,34 +373,72 @@ defmodule SimpleshopTheme.Providers.Printify do
# Order Building # Order Building
# ============================================================================= # =============================================================================
defp build_order_payload(order) do defp build_order_payload(order_data) do
%{ %{
external_id: order.order_number, external_id: order_data.order_number,
label: order.order_number, label: order_data.order_number,
line_items: 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, product_id: item.provider_product_id,
variant_id: String.to_integer(item.product_variant.provider_variant_id), variant_id: parse_variant_id(item.provider_variant_id),
quantity: item.quantity quantity: item.quantity
} }
end), end),
shipping_method: 1, shipping_method: 1,
address_to: %{ address_to: build_address(order_data.shipping_address, order_data.customer_email)
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"]
}
} }
end 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 # API Key Management
# ============================================================================= # =============================================================================

View File

@ -59,12 +59,28 @@ defmodule SimpleshopTheme.Providers.Provider do
@doc """ @doc """
Returns the provider module for a given provider type. 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(type) do
def for_type("gelato"), do: {:error, :not_implemented} case Application.get_env(:simpleshop_theme, :provider_modules, %{}) do
def for_type("prodigi"), do: {:error, :not_implemented} modules when is_map(modules) ->
def for_type("printful"), do: {:error, :not_implemented} case Map.get(modules, type) do
def for_type(type), do: {:error, {:unknown_provider, type}} 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 """ @doc """
Returns the provider module for a provider connection. Returns the provider module for a provider connection.

View File

@ -3,6 +3,7 @@ defmodule SimpleshopTheme.Webhooks do
Handles incoming webhook events from POD providers. Handles incoming webhook events from POD providers.
""" """
alias SimpleshopTheme.Orders
alias SimpleshopTheme.Products alias SimpleshopTheme.Products
alias SimpleshopTheme.Sync.ProductSyncWorker alias SimpleshopTheme.Sync.ProductSyncWorker
alias SimpleshopTheme.Webhooks.ProductDeleteWorker alias SimpleshopTheme.Webhooks.ProductDeleteWorker
@ -14,6 +15,9 @@ defmodule SimpleshopTheme.Webhooks do
Returns :ok or {:ok, job} on success, {:error, reason} on failure. Returns :ok or {:ok, job} on success, {:error, reason} on failure.
""" """
# --- Product events ---
def handle_printify_event("product:updated", %{"id" => _product_id}) do def handle_printify_event("product:updated", %{"id" => _product_id}) do
enqueue_product_sync() enqueue_product_sync()
end end
@ -26,6 +30,43 @@ defmodule SimpleshopTheme.Webhooks do
ProductDeleteWorker.enqueue(product_id) ProductDeleteWorker.enqueue(product_id)
end 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 def handle_printify_event("shop:disconnected", _resource) do
Logger.warning("Printify shop disconnected - manual intervention needed") Logger.warning("Printify shop disconnected - manual intervention needed")
:ok :ok
@ -36,10 +77,39 @@ defmodule SimpleshopTheme.Webhooks do
:ok :ok
end end
# --- Private helpers ---
defp enqueue_product_sync do defp enqueue_product_sync do
case Products.get_provider_connection_by_type("printify") do case Products.get_provider_connection_by_type("printify") do
nil -> {:error, :no_connection} nil -> {:error, :no_connection}
conn -> ProductSyncWorker.enqueue(conn.id) conn -> ProductSyncWorker.enqueue(conn.id)
end end
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 end

View File

@ -2,6 +2,7 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
use SimpleshopThemeWeb, :controller use SimpleshopThemeWeb, :controller
alias SimpleshopTheme.Orders alias SimpleshopTheme.Orders
alias SimpleshopTheme.Orders.OrderSubmissionWorker
require Logger require Logger
@ -36,13 +37,23 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
{:ok, order} = Orders.mark_paid(order, payment_intent_id) {:ok, order} = Orders.mark_paid(order, payment_intent_id)
# Update shipping address if collected by Stripe # Update shipping address if collected by Stripe
order =
if session.shipping_details do if session.shipping_details do
update_shipping(order, session.shipping_details) {:ok, updated} = update_shipping(order, session.shipping_details)
updated
else
order
end end
# Update customer email from Stripe session # Update customer email from Stripe session
order =
if session.customer_details && session.customer_details.email do if session.customer_details && session.customer_details.email do
{:ok, updated} =
Orders.update_order(order, %{customer_email: session.customer_details.email}) Orders.update_order(order, %{customer_email: session.customer_details.email})
updated
else
order
end end
# Broadcast to success page via PubSub # Broadcast to success page via PubSub
@ -52,6 +63,15 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
{:order_paid, order} {: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") Logger.info("Order #{order.order_number} marked as paid")
end end
end end

View File

@ -79,7 +79,7 @@ defmodule SimpleshopThemeWeb.AdminLive.OrderShow do
<:item :if={@order.shipping_address["city"]} title="City"> <:item :if={@order.shipping_address["city"]} title="City">
{@order.shipping_address["city"]} {@order.shipping_address["city"]}
</:item> </:item>
<:item :if={@order.shipping_address["state"]} title="State"> <:item :if={@order.shipping_address["state"] not in [nil, ""]} title="State">
{@order.shipping_address["state"]} {@order.shipping_address["state"]}
</:item> </:item>
<:item :if={@order.shipping_address["postal_code"]} title="Postcode"> <:item :if={@order.shipping_address["postal_code"]} title="Postcode">
@ -96,6 +96,64 @@ defmodule SimpleshopThemeWeb.AdminLive.OrderShow do
</div> </div>
</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 --%> <%!-- line items --%>
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6"> <div class="card bg-base-100 shadow-sm border border-base-200 mt-6">
<div class="card-body"> <div class="card-body">
@ -136,6 +194,96 @@ defmodule SimpleshopThemeWeb.AdminLive.OrderShow do
""" """
end 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 defp status_badge(assigns) do
{bg, text, ring, icon} = {bg, text, ring, icon} =
case assigns.status do 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="Customer">{order.customer_email || ""}</:col>
<:col :let={order} label="Total">{Cart.format_price(order.total)}</: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="Status"><.status_badge status={order.payment_status} /></:col>
<:col :let={order} label="Fulfilment">
<.fulfilment_badge status={order.fulfilment_status} />
</:col>
</.table> </.table>
<div :if={@order_count == 0} class="text-center py-12 text-base-content/60"> <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") Calendar.strftime(datetime, "%d %b %Y %H:%M")
end 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 defp total_count(counts) do
counts |> Map.values() |> Enum.sum() counts |> Map.values() |> Enum.sum()
end end

View File

@ -48,6 +48,7 @@ defmodule SimpleshopTheme.MixProject do
{:phoenix_html, "~> 4.1"}, {:phoenix_html, "~> 4.1"},
{:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 1.1.0"}, {:phoenix_live_view, "~> 1.1.0"},
{:mox, "~> 1.0", only: :test},
{:lazy_html, ">= 0.1.0", only: :test}, {:lazy_html, ">= 0.1.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.3"}, {:phoenix_live_dashboard, "~> 0.8.3"},
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, {:esbuild, "~> 0.10", runtime: Mix.env() == :dev},

View File

@ -37,7 +37,9 @@
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"oban": {:hex, :oban, "2.20.2", "f23313d83b578305cafa825a036cad84e7e2d61549ecbece3a2e6526d347cc3b", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "523365ef0217781c061d15f496e3200a5f1b43e08b1a27c34799ef8bfe95815f"}, "oban": {:hex, :oban, "2.20.2", "f23313d83b578305cafa825a036cad84e7e2d61549ecbece3a2e6526d347cc3b", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "523365ef0217781c061d15f496e3200a5f1b43e08b1a27c34799ef8bfe95815f"},

View File

@ -0,0 +1,20 @@
defmodule SimpleshopTheme.Repo.Migrations.AddFulfilmentFieldsToOrders do
use Ecto.Migration
def change do
alter table(:orders) do
add :fulfilment_status, :string, null: false, default: "unfulfilled"
add :provider_order_id, :string
add :provider_status, :string
add :fulfilment_error, :string
add :tracking_number, :string
add :tracking_url, :string
add :submitted_at, :utc_datetime
add :shipped_at, :utc_datetime
add :delivered_at, :utc_datetime
end
create index(:orders, [:fulfilment_status])
create index(:orders, [:provider_order_id])
end
end

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

View File

@ -1,10 +1,14 @@
defmodule SimpleshopTheme.OrdersTest do defmodule SimpleshopTheme.OrdersTest do
use SimpleshopTheme.DataCase, async: false use SimpleshopTheme.DataCase, async: false
alias SimpleshopTheme.Orders import Mox
import SimpleshopTheme.OrdersFixtures import SimpleshopTheme.OrdersFixtures
alias SimpleshopTheme.Orders
alias SimpleshopTheme.Providers.MockProvider
setup :verify_on_exit!
describe "list_orders/1" do describe "list_orders/1" do
test "returns all orders" do test "returns all orders" do
order1 = order_fixture() order1 = order_fixture()
@ -62,4 +66,154 @@ defmodule SimpleshopTheme.OrdersTest do
assert counts["failed"] == 1 assert counts["failed"] == 1
end end
end end
describe "submit_to_provider/1" do
setup do
Application.put_env(:simpleshop_theme, :provider_modules, %{
"printify" => MockProvider
})
on_exit(fn -> Application.delete_env(:simpleshop_theme, :provider_modules) end)
:ok
end
test "submits order and sets fulfilment status" 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 is_list(order_data.line_items)
assert hd(order_data.line_items).quantity == 1
{:ok, %{provider_order_id: "pfy_123"}}
end)
assert {:ok, updated} = Orders.submit_to_provider(order)
assert updated.fulfilment_status == "submitted"
assert updated.provider_order_id == "pfy_123"
assert updated.submitted_at != nil
assert updated.fulfilment_error == nil
end
test "is idempotent when already submitted" do
{order, _variant, _product, _conn} = submitted_order_fixture()
# No mock expectations — provider should not be called
assert {:ok, ^order} = Orders.submit_to_provider(order)
end
test "sets failed status on provider error" do
{order, _variant, _product, _conn} = paid_order_with_products_fixture()
expect(MockProvider, :submit_order, fn _conn, _data ->
{:error, {500, %{"message" => "Server error"}}}
end)
assert {:error, {500, _}} = Orders.submit_to_provider(order)
updated = Orders.get_order(order.id)
assert updated.fulfilment_status == "failed"
assert updated.fulfilment_error =~ "Provider API error (500)"
end
end
describe "refresh_fulfilment_status/1" do
setup do
Application.put_env(:simpleshop_theme, :provider_modules, %{
"printify" => MockProvider
})
on_exit(fn -> Application.delete_env(:simpleshop_theme, :provider_modules) end)
:ok
end
test "updates tracking info from provider" do
{order, _variant, _product, _conn} = submitted_order_fixture()
expect(MockProvider, :get_order_status, fn _conn, pid ->
assert pid == order.provider_order_id
{:ok,
%{
status: "shipped",
provider_status: "shipped",
tracking_number: "TRACK123",
tracking_url: "https://track.example.com/TRACK123"
}}
end)
assert {:ok, updated} = Orders.refresh_fulfilment_status(order)
assert updated.fulfilment_status == "shipped"
assert updated.tracking_number == "TRACK123"
assert updated.tracking_url == "https://track.example.com/TRACK123"
assert updated.shipped_at != nil
end
test "no-op when no provider_order_id" do
order = order_fixture(payment_status: "paid")
assert is_nil(order.provider_order_id)
assert {:ok, ^order} = Orders.refresh_fulfilment_status(order)
end
end
describe "update_fulfilment/2" do
test "updates fulfilment fields" do
order = order_fixture()
assert {:ok, updated} =
Orders.update_fulfilment(order, %{
fulfilment_status: "submitted",
provider_order_id: "test_123"
})
assert updated.fulfilment_status == "submitted"
assert updated.provider_order_id == "test_123"
end
test "validates fulfilment status inclusion" do
order = order_fixture()
assert {:error, changeset} =
Orders.update_fulfilment(order, %{fulfilment_status: "bogus"})
assert errors_on(changeset).fulfilment_status != []
end
end
describe "list_submitted_orders/0" do
test "returns orders with submitted or processing status" do
{submitted, _v, _p, _c} = submitted_order_fixture()
{processing, _v2, _p2, _c2} = submitted_order_fixture()
{:ok, processing} =
Orders.update_fulfilment(processing, %{fulfilment_status: "processing"})
_unfulfilled = order_fixture(payment_status: "paid")
orders = Orders.list_submitted_orders()
ids = Enum.map(orders, & &1.id)
assert submitted.id in ids
assert processing.id in ids
assert length(orders) == 2
end
test "returns empty list when no submitted orders" do
order_fixture()
assert Orders.list_submitted_orders() == []
end
end
describe "get_order_by_number/1" do
test "finds order by order number" do
order = order_fixture()
found = Orders.get_order_by_number(order.order_number)
assert found.id == order.id
end
test "returns nil for unknown order number" do
assert is_nil(Orders.get_order_by_number("SS-000000-XXXX"))
end
end
end end

View File

@ -1,19 +1,19 @@
defmodule SimpleshopTheme.WebhooksTest do defmodule SimpleshopTheme.WebhooksTest do
use SimpleshopTheme.DataCase use SimpleshopTheme.DataCase, async: false
alias SimpleshopTheme.Orders
alias SimpleshopTheme.Webhooks alias SimpleshopTheme.Webhooks
import SimpleshopTheme.ProductsFixtures import SimpleshopTheme.ProductsFixtures
import SimpleshopTheme.OrdersFixtures
setup do setup do
conn = provider_connection_fixture(%{provider_type: "printify"}) conn = provider_connection_fixture(%{provider_type: "printify"})
{:ok, provider_connection: conn} {:ok, provider_connection: conn}
end end
describe "handle_printify_event/2" do describe "handle_printify_event/2 — product events" do
test "product:updated triggers sync", %{provider_connection: _conn} do test "product:updated triggers sync", %{provider_connection: _conn} do
# With inline Oban, the job executes immediately (and fails due to no real API key)
# But the handler should still return {:ok, _} after inserting the job
result = result =
Webhooks.handle_printify_event( Webhooks.handle_printify_event(
"product:updated", "product:updated",
@ -52,7 +52,6 @@ defmodule SimpleshopTheme.WebhooksTest do
end end
test "returns error when no provider connection" do test "returns error when no provider connection" do
# Delete all connections first
SimpleshopTheme.Repo.delete_all(SimpleshopTheme.Products.ProviderConnection) SimpleshopTheme.Repo.delete_all(SimpleshopTheme.Products.ProviderConnection)
assert {:error, :no_connection} = assert {:error, :no_connection} =
@ -62,4 +61,75 @@ defmodule SimpleshopTheme.WebhooksTest do
) )
end end
end end
describe "handle_printify_event/2 — order events" do
test "order:sent-to-production updates fulfilment status" do
{order, _v, _p, _c} = submitted_order_fixture()
assert {:ok, updated} =
Webhooks.handle_printify_event("order:sent-to-production", %{
"id" => "printify_abc",
"external_id" => order.order_number
})
assert updated.fulfilment_status == "processing"
assert updated.provider_status == "in-production"
end
test "order:shipment:created sets tracking info and shipped_at" do
{order, _v, _p, _c} = submitted_order_fixture()
assert {:ok, updated} =
Webhooks.handle_printify_event("order:shipment:created", %{
"id" => "printify_abc",
"external_id" => order.order_number,
"shipments" => [
%{
"tracking_number" => "1Z999AA1",
"tracking_url" => "https://ups.com/track/1Z999AA1"
}
]
})
assert updated.fulfilment_status == "shipped"
assert updated.tracking_number == "1Z999AA1"
assert updated.tracking_url == "https://ups.com/track/1Z999AA1"
assert updated.shipped_at != nil
end
test "order:shipment:delivered sets delivered_at" do
{order, _v, _p, _c} = submitted_order_fixture()
# First mark as shipped
{:ok, order} =
Orders.update_fulfilment(order, %{
fulfilment_status: "shipped",
shipped_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
assert {:ok, updated} =
Webhooks.handle_printify_event("order:shipment:delivered", %{
"id" => "printify_abc",
"external_id" => order.order_number
})
assert updated.fulfilment_status == "delivered"
assert updated.delivered_at != nil
end
test "order event with unknown external_id returns error" do
assert {:error, :order_not_found} =
Webhooks.handle_printify_event("order:sent-to-production", %{
"id" => "printify_abc",
"external_id" => "SS-000000-NOPE"
})
end
test "order event with missing external_id returns error" do
assert {:error, :missing_external_id} =
Webhooks.handle_printify_event("order:sent-to-production", %{
"id" => "printify_abc"
})
end
end
end end

View File

@ -115,5 +115,76 @@ defmodule SimpleshopThemeWeb.AdminLive.OrdersTest do
{:error, {:live_redirect, %{to: "/admin/orders"}}} = {:error, {:live_redirect, %{to: "/admin/orders"}}} =
live(conn, ~p"/admin/orders/#{fake_id}") live(conn, ~p"/admin/orders/#{fake_id}")
end end
test "shows fulfilment card", %{conn: conn} do
order = order_fixture(payment_status: "paid")
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
assert html =~ "Fulfilment"
assert html =~ "unfulfilled"
end
test "shows submit button for paid unfulfilled orders", %{conn: conn} do
order = order_fixture(payment_status: "paid")
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
assert html =~ "Submit to provider"
end
test "shows retry button for failed fulfilment", %{conn: conn} do
order = order_fixture(payment_status: "paid")
{:ok, order} =
SimpleshopTheme.Orders.update_fulfilment(order, %{fulfilment_status: "failed"})
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
assert html =~ "Retry submission"
end
test "shows refresh button for submitted orders", %{conn: conn} do
{order, _v, _p, _c} =
SimpleshopTheme.OrdersFixtures.submitted_order_fixture()
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
assert html =~ "Refresh status"
end
test "shows tracking info when available", %{conn: conn} do
{order, _v, _p, _c} =
SimpleshopTheme.OrdersFixtures.submitted_order_fixture()
{:ok, order} =
SimpleshopTheme.Orders.update_fulfilment(order, %{
fulfilment_status: "shipped",
tracking_number: "TRACK123",
tracking_url: "https://track.example.com/TRACK123",
shipped_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
assert html =~ "TRACK123"
assert html =~ "https://track.example.com/TRACK123"
end
end
describe "order list fulfilment column" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn}
end
test "shows fulfilment badge in table", %{conn: conn} do
order_fixture(payment_status: "paid")
{:ok, _view, html} = live(conn, ~p"/admin/orders")
assert html =~ "Fulfilment"
assert html =~ "unfulfilled"
end
end end
end end

View File

@ -5,12 +5,14 @@ defmodule SimpleshopTheme.OrdersFixtures do
alias SimpleshopTheme.Orders alias SimpleshopTheme.Orders
import SimpleshopTheme.ProductsFixtures
def order_fixture(attrs \\ %{}) do def order_fixture(attrs \\ %{}) do
attrs = Enum.into(attrs, %{}) attrs = Enum.into(attrs, %{})
items = [ items = [
%{ %{
variant_id: "var_#{System.unique_integer([:positive])}", variant_id: Map.get(attrs, :variant_id, "var_#{System.unique_integer([:positive])}"),
name: Map.get(attrs, :product_name, "Test product"), name: Map.get(attrs, :product_name, "Test product"),
variant: Map.get(attrs, :variant_title, "Red / Large"), variant: Map.get(attrs, :variant_title, "Red / Large"),
price: Map.get(attrs, :unit_price, 1999), price: Map.get(attrs, :unit_price, 1999),
@ -26,6 +28,17 @@ defmodule SimpleshopTheme.OrdersFixtures do
{:ok, order} = Orders.create_order(order_attrs) {:ok, order} = Orders.create_order(order_attrs)
# Apply shipping address if provided
order =
case attrs[:shipping_address] do
nil ->
order
addr ->
{:ok, order} = Orders.update_order(order, %{shipping_address: addr})
order
end
case attrs[:payment_status] do case attrs[:payment_status] do
"paid" -> "paid" ->
{:ok, order} = Orders.mark_paid(order, "pi_test_#{System.unique_integer([:positive])}") {:ok, order} = Orders.mark_paid(order, "pi_test_#{System.unique_integer([:positive])}")
@ -39,4 +52,59 @@ defmodule SimpleshopTheme.OrdersFixtures do
order order
end end
end end
@doc """
Creates a paid order with real product variants in the DB,
so enrich_items/1 can look up provider IDs. Also sets a shipping
address matching Stripe's format.
Returns `{order, variant, product, conn}`.
"""
def paid_order_with_products_fixture(attrs \\ %{}) do
attrs = Enum.into(attrs, %{})
conn = provider_connection_fixture(%{provider_type: "printify"})
product = product_fixture(%{provider_connection: conn})
variant = product_variant_fixture(%{product: product})
shipping =
Map.get(attrs, :shipping_address, %{
"name" => "Jane Doe",
"line1" => "42 Test Street",
"line2" => nil,
"city" => "London",
"postal_code" => "SW1A 1AA",
"state" => nil,
"country" => "GB"
})
order =
order_fixture(%{
variant_id: variant.id,
payment_status: "paid",
shipping_address: shipping,
customer_email: Map.get(attrs, :customer_email, "buyer@example.com")
})
{order, variant, product, conn}
end
@doc """
Creates a submitted order (has provider_order_id set).
Returns `{order, variant, product, conn}`.
"""
def submitted_order_fixture(attrs \\ %{}) do
{order, variant, product, conn} = paid_order_with_products_fixture(attrs)
attrs = Enum.into(attrs, %{})
{:ok, order} =
Orders.update_fulfilment(order, %{
fulfilment_status: "submitted",
provider_order_id:
Map.get(attrs, :provider_order_id, "printify_#{System.unique_integer([:positive])}"),
submitted_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
{order, variant, product, conn}
end
end end

3
test/support/mocks.ex Normal file
View File

@ -0,0 +1,3 @@
Mox.defmock(SimpleshopTheme.Providers.MockProvider,
for: SimpleshopTheme.Providers.Provider
)