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>
170 lines
4.3 KiB
Elixir
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
|