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. """ import Ecto.Query alias SimpleshopTheme.Repo alias SimpleshopTheme.Orders.{Order, OrderItem} @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 end