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>
This commit is contained in:
134
lib/simpleshop_theme/orders.ex
Normal file
134
lib/simpleshop_theme/orders.ex
Normal file
@@ -0,0 +1,134 @@
|
||||
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
|
||||
48
lib/simpleshop_theme/orders/order.ex
Normal file
48
lib/simpleshop_theme/orders/order.ex
Normal file
@@ -0,0 +1,48 @@
|
||||
defmodule SimpleshopTheme.Orders.Order do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
@payment_statuses ~w(pending paid failed refunded)
|
||||
|
||||
schema "orders" do
|
||||
field :order_number, :string
|
||||
field :stripe_session_id, :string
|
||||
field :stripe_payment_intent_id, :string
|
||||
field :payment_status, :string, default: "pending"
|
||||
field :customer_email, :string
|
||||
field :shipping_address, :map, default: %{}
|
||||
field :subtotal, :integer
|
||||
field :total, :integer
|
||||
field :currency, :string, default: "gbp"
|
||||
field :metadata, :map, default: %{}
|
||||
|
||||
has_many :items, SimpleshopTheme.Orders.OrderItem
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(order, attrs) do
|
||||
order
|
||||
|> cast(attrs, [
|
||||
:order_number,
|
||||
:stripe_session_id,
|
||||
:stripe_payment_intent_id,
|
||||
:payment_status,
|
||||
:customer_email,
|
||||
:shipping_address,
|
||||
:subtotal,
|
||||
:total,
|
||||
:currency,
|
||||
:metadata
|
||||
])
|
||||
|> validate_required([:order_number, :subtotal, :total, :currency])
|
||||
|> validate_inclusion(:payment_status, @payment_statuses)
|
||||
|> validate_number(:subtotal, greater_than_or_equal_to: 0)
|
||||
|> validate_number(:total, greater_than_or_equal_to: 0)
|
||||
|> unique_constraint(:order_number)
|
||||
|> unique_constraint(:stripe_session_id)
|
||||
end
|
||||
end
|
||||
27
lib/simpleshop_theme/orders/order_item.ex
Normal file
27
lib/simpleshop_theme/orders/order_item.ex
Normal file
@@ -0,0 +1,27 @@
|
||||
defmodule SimpleshopTheme.Orders.OrderItem do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "order_items" do
|
||||
field :variant_id, :string
|
||||
field :product_name, :string
|
||||
field :variant_title, :string
|
||||
field :quantity, :integer
|
||||
field :unit_price, :integer
|
||||
|
||||
belongs_to :order, SimpleshopTheme.Orders.Order
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(item, attrs) do
|
||||
item
|
||||
|> cast(attrs, [:variant_id, :product_name, :variant_title, :quantity, :unit_price, :order_id])
|
||||
|> validate_required([:variant_id, :product_name, :quantity, :unit_price])
|
||||
|> validate_number(:quantity, greater_than: 0)
|
||||
|> validate_number(:unit_price, greater_than_or_equal_to: 0)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user