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 --%> +
{@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}
+
+
+