defmodule SimpleshopTheme.Orders do @moduledoc """ The Orders context. 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. ## Options * `:status` - filter by payment_status ("paid", "pending", "failed", "refunded") Pass nil or "all" to return all orders. Returns orders sorted by newest first, with items preloaded. """ def list_orders(opts \\ []) do status = opts[:status] Order |> maybe_filter_status(status) |> order_by([o], desc: o.inserted_at) |> preload(:items) |> Repo.all() end defp maybe_filter_status(query, nil), do: query defp maybe_filter_status(query, "all"), do: query defp maybe_filter_status(query, status), do: where(query, [o], o.payment_status == ^status) @doc """ Returns a map of payment_status => count for all orders. """ def count_orders_by_status do Order |> group_by(:payment_status) |> select([o], {o.payment_status, count(o.id)}) |> Repo.all() |> Map.new() end @doc """ Creates an order with line items from hydrated cart data. Expects a map with :items (list of hydrated cart item maps) and optional fields like :customer_email. Returns {:ok, order} with items preloaded. """ def create_order(attrs) do items = attrs[:items] || [] subtotal = Enum.reduce(items, 0, fn item, acc -> acc + item.price * item.quantity end) order_attrs = %{ order_number: generate_order_number(), subtotal: subtotal, total: subtotal, currency: Map.get(attrs, :currency, "gbp"), customer_email: attrs[:customer_email], payment_status: "pending" } Repo.transaction(fn -> case %Order{} |> Order.changeset(order_attrs) |> Repo.insert() do {:ok, order} -> order_items = Enum.map(items, fn item -> %{ order_id: order.id, variant_id: item.variant_id, product_name: item.name, variant_title: item.variant, quantity: item.quantity, unit_price: item.price, inserted_at: order.inserted_at, updated_at: order.updated_at } end) Repo.insert_all(OrderItem, order_items) Repo.preload(order, :items) {:error, changeset} -> Repo.rollback(changeset) end end) end @doc """ Sets the stripe_session_id on an order after creating the Stripe checkout session. """ def set_stripe_session(order, session_id) do order |> Order.changeset(%{stripe_session_id: session_id}) |> Repo.update() end @doc """ Finds an order by its Stripe checkout session ID. """ def get_order_by_stripe_session(session_id) do Order |> where([o], o.stripe_session_id == ^session_id) |> preload(:items) |> Repo.one() end @doc """ Marks an order as paid and stores the Stripe payment intent ID. Returns {:ok, order} or {:error, :already_paid} if idempotency check fails. """ def mark_paid(order, payment_intent_id) do if order.payment_status == "paid" do {:ok, order} else order |> Order.changeset(%{ payment_status: "paid", stripe_payment_intent_id: payment_intent_id }) |> Repo.update() end end @doc """ Marks an order as failed. """ def mark_failed(order) do order |> Order.changeset(%{payment_status: "failed"}) |> Repo.update() end @doc """ Gets an order by ID with items preloaded. """ def get_order(id) do Order |> preload(:items) |> Repo.get(id) end @doc """ Updates an order with the given attributes. """ def update_order(order, attrs) do order |> Order.changeset(attrs) |> Repo.update() end @doc """ Generates a human-readable order number. Format: SS-YYMMDD-XXXX where XXXX is a random alphanumeric string. """ def generate_order_number do date = Date.utc_today() |> Calendar.strftime("%y%m%d") 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