simpleshop_theme/lib/simpleshop_theme/orders.ex
jamey 0af8997623 feat: add transactional emails for order confirmation and shipping
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>
2026-02-08 10:17:19 +00:00

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