From 3e19887499c0c111e966ca3b67b9a04457a9994d Mon Sep 17 00:00:00 2001 From: jamey Date: Sun, 8 Feb 2026 09:51:51 +0000 Subject: [PATCH] feat: add Printify order submission and fulfilment tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PROGRESS.md | 30 ++- config/config.exs | 6 +- lib/simpleshop_theme/orders.ex | 191 +++++++++++++++++- .../orders/fulfilment_status_worker.ex | 46 +++++ lib/simpleshop_theme/orders/order.ex | 30 +++ .../orders/order_submission_worker.ex | 56 +++++ lib/simpleshop_theme/providers/printify.ex | 92 ++++++--- lib/simpleshop_theme/providers/provider.ex | 26 ++- lib/simpleshop_theme/webhooks.ex | 70 +++++++ .../controllers/stripe_webhook_controller.ex | 32 ++- .../live/admin_live/order_show.ex | 150 +++++++++++++- .../live/admin_live/orders.ex | 42 ++++ mix.exs | 1 + mix.lock | 2 + ...234225_add_fulfilment_fields_to_orders.exs | 20 ++ .../orders/fulfilment_status_worker_test.exs | 84 ++++++++ .../orders/order_submission_worker_test.exs | 112 ++++++++++ test/simpleshop_theme/orders_test.exs | 158 ++++++++++++++- test/simpleshop_theme/webhooks_test.exs | 80 +++++++- .../live/admin_live/orders_test.exs | 71 +++++++ test/support/fixtures/orders_fixtures.ex | 70 ++++++- test/support/mocks.ex | 3 + 22 files changed, 1318 insertions(+), 54 deletions(-) create mode 100644 lib/simpleshop_theme/orders/fulfilment_status_worker.ex create mode 100644 lib/simpleshop_theme/orders/order_submission_worker.ex create mode 100644 priv/repo/migrations/20260207234225_add_fulfilment_fields_to_orders.exs create mode 100644 test/simpleshop_theme/orders/fulfilment_status_worker_test.exs create mode 100644 test/simpleshop_theme/orders/order_submission_worker_test.exs create mode 100644 test/support/mocks.ex diff --git a/PROGRESS.md b/PROGRESS.md index 2ba1d0d..f28aa94 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -19,14 +19,14 @@ - Search modal with keyboard shortcut - 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 ### 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. -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. +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**~~ — ✅ 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. 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) #### 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/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) ### 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] Stripe Checkout integration with webhook handling -- [ ] Order management admin UI (Roadmap #1) -- [ ] Printify order submission (Roadmap #2) -- [ ] Fulfilment status tracking (Roadmap #2) +- [x] Order management admin UI (02cdc81, Roadmap #1) + - Order list with status filter tabs (all/paid/pending/failed/refunded) and counts + - 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) 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 | |---------|--------|-------| +| 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 | | Stripe checkout & orders | ff1bc48 | Stripe Checkout, webhooks, order persistence | | Demo content & link fixes | cff2170 | Broken links, placeholder text, responsive about image | diff --git a/config/config.exs b/config/config.exs index 5403b10..36434ba 100644 --- a/config/config.exs +++ b/config/config.exs @@ -92,7 +92,11 @@ config :simpleshop_theme, Oban, repo: SimpleshopTheme.Repo, plugins: [ {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] diff --git a/lib/simpleshop_theme/orders.ex b/lib/simpleshop_theme/orders.ex index 855a335..9af9278 100644 --- a/lib/simpleshop_theme/orders.ex +++ b/lib/simpleshop_theme/orders.ex @@ -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 diff --git a/lib/simpleshop_theme/orders/fulfilment_status_worker.ex b/lib/simpleshop_theme/orders/fulfilment_status_worker.ex new file mode 100644 index 0000000..0287795 --- /dev/null +++ b/lib/simpleshop_theme/orders/fulfilment_status_worker.ex @@ -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 diff --git a/lib/simpleshop_theme/orders/order.ex b/lib/simpleshop_theme/orders/order.ex index fa3754a..929f907 100644 --- a/lib/simpleshop_theme/orders/order.ex +++ b/lib/simpleshop_theme/orders/order.ex @@ -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 diff --git a/lib/simpleshop_theme/orders/order_submission_worker.ex b/lib/simpleshop_theme/orders/order_submission_worker.ex new file mode 100644 index 0000000..be2354c --- /dev/null +++ b/lib/simpleshop_theme/orders/order_submission_worker.ex @@ -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 diff --git a/lib/simpleshop_theme/providers/printify.ex b/lib/simpleshop_theme/providers/printify.ex index 35fcf64..9e56962 100644 --- a/lib/simpleshop_theme/providers/printify.ex +++ b/lib/simpleshop_theme/providers/printify.ex @@ -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 # ============================================================================= diff --git a/lib/simpleshop_theme/providers/provider.ex b/lib/simpleshop_theme/providers/provider.ex index c6e8b39..d535c2c 100644 --- a/lib/simpleshop_theme/providers/provider.ex +++ b/lib/simpleshop_theme/providers/provider.ex @@ -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. diff --git a/lib/simpleshop_theme/webhooks.ex b/lib/simpleshop_theme/webhooks.ex index 982c1b0..d3ba4b0 100644 --- a/lib/simpleshop_theme/webhooks.ex +++ b/lib/simpleshop_theme/webhooks.ex @@ -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 diff --git a/lib/simpleshop_theme_web/controllers/stripe_webhook_controller.ex b/lib/simpleshop_theme_web/controllers/stripe_webhook_controller.ex index d5685e1..d510365 100644 --- a/lib/simpleshop_theme_web/controllers/stripe_webhook_controller.ex +++ b/lib/simpleshop_theme_web/controllers/stripe_webhook_controller.ex @@ -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 diff --git a/lib/simpleshop_theme_web/live/admin_live/order_show.ex b/lib/simpleshop_theme_web/live/admin_live/order_show.ex index e5174b0..b4fafdd 100644 --- a/lib/simpleshop_theme_web/live/admin_live/order_show.ex +++ b/lib/simpleshop_theme_web/live/admin_live/order_show.ex @@ -79,7 +79,7 @@ defmodule SimpleshopThemeWeb.AdminLive.OrderShow do <:item :if={@order.shipping_address["city"]} title="City"> {@order.shipping_address["city"]} - <:item :if={@order.shipping_address["state"]} title="State"> + <:item :if={@order.shipping_address["state"] not in [nil, ""]} title="State"> {@order.shipping_address["state"]} <:item :if={@order.shipping_address["postal_code"]} title="Postcode"> @@ -96,6 +96,64 @@ defmodule SimpleshopThemeWeb.AdminLive.OrderShow do + <%!-- fulfilment --%> +
+
+
+

Fulfilment

+ <.fulfilment_badge status={@order.fulfilment_status} /> +
+ <.list> + <:item :if={@order.provider_order_id} title="Provider order ID"> + {@order.provider_order_id} + + <:item :if={@order.provider_status} title="Provider status"> + {@order.provider_status} + + <:item :if={@order.submitted_at} title="Submitted"> + {format_date(@order.submitted_at)} + + <:item :if={@order.tracking_number} title="Tracking"> + <%= if @order.tracking_url do %> + + {@order.tracking_number} + + <% else %> + {@order.tracking_number} + <% end %> + + <:item :if={@order.shipped_at} title="Shipped"> + {format_date(@order.shipped_at)} + + <:item :if={@order.delivered_at} title="Delivered"> + {format_date(@order.delivered_at)} + + <:item :if={@order.fulfilment_error} title="Error"> + {@order.fulfilment_error} + + +
+ + +
+
+
+ <%!-- line items --%>
@@ -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""" + + <.icon name={@icon} class="size-3" /> {@status} + + """ + end + defp status_badge(assigns) do {bg, text, ring, icon} = case assigns.status do diff --git a/lib/simpleshop_theme_web/live/admin_live/orders.ex b/lib/simpleshop_theme_web/live/admin_live/orders.ex index d551391..0d0c6e7 100644 --- a/lib/simpleshop_theme_web/live/admin_live/orders.ex +++ b/lib/simpleshop_theme_web/live/admin_live/orders.ex @@ -86,6 +86,9 @@ defmodule SimpleshopThemeWeb.AdminLive.Orders do <:col :let={order} label="Customer">{order.customer_email || "—"} <:col :let={order} label="Total">{Cart.format_price(order.total)} <:col :let={order} label="Status"><.status_badge status={order.payment_status} /> + <:col :let={order} label="Fulfilment"> + <.fulfilment_badge status={order.fulfilment_status} /> +
@@ -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""" + + <.icon name={@icon} class="size-3" /> {@status} + + """ + end + defp total_count(counts) do counts |> Map.values() |> Enum.sum() end diff --git a/mix.exs b/mix.exs index c6e8293..9aab2fd 100644 --- a/mix.exs +++ b/mix.exs @@ -48,6 +48,7 @@ defmodule SimpleshopTheme.MixProject do {:phoenix_html, "~> 4.1"}, {:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_view, "~> 1.1.0"}, + {:mox, "~> 1.0", only: :test}, {:lazy_html, ">= 0.1.0", only: :test}, {:phoenix_live_dashboard, "~> 0.8.3"}, {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, diff --git a/mix.lock b/mix.lock index 16d3ff6..8e8a2fd 100644 --- a/mix.lock +++ b/mix.lock @@ -37,7 +37,9 @@ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "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"}, + "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_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "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"}, diff --git a/priv/repo/migrations/20260207234225_add_fulfilment_fields_to_orders.exs b/priv/repo/migrations/20260207234225_add_fulfilment_fields_to_orders.exs new file mode 100644 index 0000000..295c7ff --- /dev/null +++ b/priv/repo/migrations/20260207234225_add_fulfilment_fields_to_orders.exs @@ -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 diff --git a/test/simpleshop_theme/orders/fulfilment_status_worker_test.exs b/test/simpleshop_theme/orders/fulfilment_status_worker_test.exs new file mode 100644 index 0000000..1483301 --- /dev/null +++ b/test/simpleshop_theme/orders/fulfilment_status_worker_test.exs @@ -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 diff --git a/test/simpleshop_theme/orders/order_submission_worker_test.exs b/test/simpleshop_theme/orders/order_submission_worker_test.exs new file mode 100644 index 0000000..6631482 --- /dev/null +++ b/test/simpleshop_theme/orders/order_submission_worker_test.exs @@ -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 diff --git a/test/simpleshop_theme/orders_test.exs b/test/simpleshop_theme/orders_test.exs index 86cf614..b81d820 100644 --- a/test/simpleshop_theme/orders_test.exs +++ b/test/simpleshop_theme/orders_test.exs @@ -1,10 +1,14 @@ defmodule SimpleshopTheme.OrdersTest do use SimpleshopTheme.DataCase, async: false - alias SimpleshopTheme.Orders - + import Mox import SimpleshopTheme.OrdersFixtures + alias SimpleshopTheme.Orders + alias SimpleshopTheme.Providers.MockProvider + + setup :verify_on_exit! + describe "list_orders/1" do test "returns all orders" do order1 = order_fixture() @@ -62,4 +66,154 @@ defmodule SimpleshopTheme.OrdersTest do assert counts["failed"] == 1 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 diff --git a/test/simpleshop_theme/webhooks_test.exs b/test/simpleshop_theme/webhooks_test.exs index 2c0ee55..e729e62 100644 --- a/test/simpleshop_theme/webhooks_test.exs +++ b/test/simpleshop_theme/webhooks_test.exs @@ -1,19 +1,19 @@ defmodule SimpleshopTheme.WebhooksTest do - use SimpleshopTheme.DataCase + use SimpleshopTheme.DataCase, async: false + alias SimpleshopTheme.Orders alias SimpleshopTheme.Webhooks import SimpleshopTheme.ProductsFixtures + import SimpleshopTheme.OrdersFixtures setup do conn = provider_connection_fixture(%{provider_type: "printify"}) {:ok, provider_connection: conn} end - describe "handle_printify_event/2" do + describe "handle_printify_event/2 — product events" 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 = Webhooks.handle_printify_event( "product:updated", @@ -52,7 +52,6 @@ defmodule SimpleshopTheme.WebhooksTest do end test "returns error when no provider connection" do - # Delete all connections first SimpleshopTheme.Repo.delete_all(SimpleshopTheme.Products.ProviderConnection) assert {:error, :no_connection} = @@ -62,4 +61,75 @@ defmodule SimpleshopTheme.WebhooksTest do ) 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 diff --git a/test/simpleshop_theme_web/live/admin_live/orders_test.exs b/test/simpleshop_theme_web/live/admin_live/orders_test.exs index 06720ee..bde4809 100644 --- a/test/simpleshop_theme_web/live/admin_live/orders_test.exs +++ b/test/simpleshop_theme_web/live/admin_live/orders_test.exs @@ -115,5 +115,76 @@ defmodule SimpleshopThemeWeb.AdminLive.OrdersTest do {:error, {:live_redirect, %{to: "/admin/orders"}}} = live(conn, ~p"/admin/orders/#{fake_id}") 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 diff --git a/test/support/fixtures/orders_fixtures.ex b/test/support/fixtures/orders_fixtures.ex index 0fe781d..a61ea95 100644 --- a/test/support/fixtures/orders_fixtures.ex +++ b/test/support/fixtures/orders_fixtures.ex @@ -5,12 +5,14 @@ defmodule SimpleshopTheme.OrdersFixtures do alias SimpleshopTheme.Orders + import SimpleshopTheme.ProductsFixtures + def order_fixture(attrs \\ %{}) do attrs = Enum.into(attrs, %{}) 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"), variant: Map.get(attrs, :variant_title, "Red / Large"), price: Map.get(attrs, :unit_price, 1999), @@ -26,6 +28,17 @@ defmodule SimpleshopTheme.OrdersFixtures do {: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 "paid" -> {:ok, order} = Orders.mark_paid(order, "pi_test_#{System.unique_integer([:positive])}") @@ -39,4 +52,59 @@ defmodule SimpleshopTheme.OrdersFixtures do order 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 diff --git a/test/support/mocks.ex b/test/support/mocks.ex new file mode 100644 index 0000000..0961abb --- /dev/null +++ b/test/support/mocks.ex @@ -0,0 +1,3 @@ +Mox.defmock(SimpleshopTheme.Providers.MockProvider, + for: SimpleshopTheme.Providers.Provider +)