Stripe-hosted Checkout integration with full order lifecycle: - stripity_stripe ~> 3.2 with sandbox/prod config via env vars - Order and OrderItem schemas with price snapshots at purchase time - CheckoutController creates pending order then redirects to Stripe - StripeWebhookController verifies signatures and confirms payment - Success page with real-time PubSub updates from webhook - Shop flash messages for checkout error feedback - Cart cleared after successful payment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
135 lines
3.4 KiB
Elixir
135 lines
3.4 KiB
Elixir
defmodule SimpleshopTheme.Orders do
|
|
@moduledoc """
|
|
The Orders context.
|
|
|
|
Handles order creation, payment status tracking, and order retrieval.
|
|
Payment-provider agnostic — all Stripe-specific logic lives in controllers.
|
|
"""
|
|
|
|
import Ecto.Query
|
|
alias SimpleshopTheme.Repo
|
|
alias SimpleshopTheme.Orders.{Order, OrderItem}
|
|
|
|
@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
|
|
end
|