Dashboard at /admin shows setup progress (when not live), stat cards (orders, revenue, products), and recent paid orders table. Replaces the old AdminController redirect. Add Dashboard to sidebar nav as first item, update admin bar and theme editor links to /admin. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
373 lines
10 KiB
Elixir
373 lines
10 KiB
Elixir
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, OrderNotifier}
|
|
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 """
|
|
Returns total revenue (in minor units) from paid orders.
|
|
"""
|
|
def total_revenue do
|
|
Order
|
|
|> where(payment_status: "paid")
|
|
|> select([o], sum(o.total))
|
|
|> Repo.one() || 0
|
|
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)
|
|
|
|
with {:ok, updated_order} <- update_fulfilment(order, attrs) do
|
|
if attrs[:fulfilment_status] == "shipped" and order.fulfilment_status != "shipped" do
|
|
OrderNotifier.deliver_shipping_notification(updated_order)
|
|
end
|
|
|
|
{:ok, updated_order}
|
|
end
|
|
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
|