All checks were successful
deploy / deploy (push) Successful in 1m38s
URL-based offset pagination with ?page=N for bookmarkable pages. Admin views use push_patch, shop collection uses navigate links. Responsive on mobile with horizontal-scroll tables and stacking pagination controls. Includes dev seed script for testing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
512 lines
14 KiB
Elixir
512 lines
14 KiB
Elixir
defmodule Berrypod.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 Berrypod.Repo
|
|
alias Berrypod.Orders.{AbandonedCart, EmailSuppression, Order, OrderItem, OrderNotifier}
|
|
alias Berrypod.Products
|
|
alias Berrypod.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
|
|
|
|
@doc """
|
|
Like `list_orders/1` but returns a `%Pagination{}` struct.
|
|
"""
|
|
def list_orders_paginated(opts \\ []) do
|
|
Order
|
|
|> maybe_filter_status(opts[:status])
|
|
|> order_by([o], desc: o.inserted_at)
|
|
|> preload(:items)
|
|
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
|
|
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 true if at least one paid order exists.
|
|
"""
|
|
def has_paid_orders? do
|
|
Order
|
|
|> where(payment_status: "paid")
|
|
|> limit(1)
|
|
|> Repo.exists?()
|
|
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_id: item[:product_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 """
|
|
Lists paid orders for a given customer email, newest first, with items preloaded.
|
|
|
|
Only returns paid orders — pending/failed orders aren't useful to show customers.
|
|
"""
|
|
def list_orders_by_email(email) when is_binary(email) do
|
|
normalised = String.downcase(String.trim(email))
|
|
|
|
Order
|
|
|> where([o], fragment("lower(trim(?))", o.customer_email) == ^normalised)
|
|
|> where([o], o.payment_status == "paid")
|
|
|> order_by([o], desc: o.inserted_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
|
|
|
|
# =============================================================================
|
|
# Abandoned cart
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Creates an abandoned cart record.
|
|
"""
|
|
def create_abandoned_cart(attrs) do
|
|
%AbandonedCart{}
|
|
|> AbandonedCart.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
@doc """
|
|
Gets an abandoned cart by ID.
|
|
"""
|
|
def get_abandoned_cart(id) do
|
|
Repo.get(AbandonedCart, id)
|
|
end
|
|
|
|
@doc """
|
|
Gets an abandoned cart by Stripe session ID, or nil if not found.
|
|
"""
|
|
def get_abandoned_cart_by_session(stripe_session_id) do
|
|
Repo.get_by(AbandonedCart, stripe_session_id: stripe_session_id)
|
|
end
|
|
|
|
@doc """
|
|
Stamps an abandoned cart as emailed.
|
|
"""
|
|
def mark_abandoned_cart_emailed(cart) do
|
|
cart
|
|
|> AbandonedCart.changeset(%{emailed_at: DateTime.utc_now() |> DateTime.truncate(:second)})
|
|
|> Repo.update()
|
|
end
|
|
|
|
@doc """
|
|
Returns true if the given email has a paid order within the last `hours` hours.
|
|
|
|
Used to skip recovery emails for customers who completed a purchase shortly
|
|
after their session expired (e.g. started over in a new tab).
|
|
"""
|
|
def has_recent_paid_order?(email, hours \\ 48) do
|
|
normalised = String.downcase(String.trim(email))
|
|
cutoff = DateTime.add(DateTime.utc_now(), -(hours * 3600), :second)
|
|
|
|
Order
|
|
|> where([o], fragment("lower(trim(?))", o.customer_email) == ^normalised)
|
|
|> where([o], o.payment_status == "paid")
|
|
|> where([o], o.inserted_at >= ^cutoff)
|
|
|> Repo.exists?()
|
|
end
|
|
|
|
# =============================================================================
|
|
# Email suppression
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Checks whether an email address is suppressed from marketing emails.
|
|
|
|
Returns `:ok` if the address is not suppressed, `:suppressed` if it is.
|
|
Only applies to marketing emails (cart recovery). Transactional emails
|
|
(order confirmation, shipping) are not affected.
|
|
"""
|
|
def check_suppression(email) do
|
|
normalised = String.downcase(String.trim(email))
|
|
|
|
case Repo.get_by(EmailSuppression, email: normalised) do
|
|
nil -> :ok
|
|
_suppression -> :suppressed
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Adds an email address to the suppression list.
|
|
|
|
Idempotent — inserting an already-suppressed address is a no-op.
|
|
"""
|
|
def add_suppression(email, reason \\ "unsubscribed") do
|
|
normalised = String.downcase(String.trim(email))
|
|
|
|
%EmailSuppression{}
|
|
|> EmailSuppression.changeset(%{email: normalised, reason: reason})
|
|
|> Repo.insert(on_conflict: :nothing, conflict_target: :email)
|
|
end
|
|
end
|