berrypod/lib/simpleshop_theme/orders.ex
jamey ff1bc483b9 feat: add Stripe checkout, order persistence, and webhook handling
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>
2026-02-07 08:30:17 +00:00

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