berrypod/lib/simpleshop_theme/orders.ex
jamey 3c788bff78 add Printful provider integration with HTTP client and order routing
Printful HTTP client (v2 + v1 for sync products), Provider behaviour
implementation with all callbacks (test_connection, fetch_products,
submit_order, get_order_status, fetch_shipping_rates), and multi-provider
order routing that looks up the provider connection from the order's
product instead of hardcoding "printify".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 09:01:05 +00:00

388 lines
11 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_for_order(order),
{: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
order = Repo.preload(order, :items)
with {:ok, conn} <- get_provider_connection_for_order(order),
{: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_for_order(%Order{items: items}) do
first_item = List.first(items)
variants_map = Products.get_variants_with_products([first_item.variant_id])
case Map.get(variants_map, first_item.variant_id) do
nil ->
{:error, :variant_not_found}
variant ->
case Products.get_provider_connection(variant.product.provider_connection_id) do
nil -> {:error, :no_provider_connection}
%{enabled: false} -> {:error, :provider_disabled}
conn -> {:ok, conn}
end
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(:variant_not_found) do
"Order variant 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