Plain text emails via Swoosh OrderNotifier module. Order confirmation triggered from Stripe webhook after payment, shipping notification from Printify shipment webhook with polling fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
363 lines
10 KiB
Elixir
363 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 """
|
|
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
|