berrypod/lib/simpleshop_theme/orders.ex
jamey 02cdc810f2 feat: add order management admin with list and detail views
Admin UI at /admin/orders to view, filter, and inspect orders.
Adds list_orders/1 and count_orders_by_status/0 to the Orders
context, status filter tabs, clickable order table with streams,
and a detail page showing items, totals, and shipping address.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 21:59:14 +00:00

170 lines
4.3 KiB
Elixir

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