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:
jamey
2026-02-07 08:30:17 +00:00
parent cff21703f1
commit ff1bc483b9
19 changed files with 931 additions and 69 deletions

View File

@@ -1 +1,2 @@
<SimpleshopThemeWeb.ShopComponents.shop_flash_group flash={@flash} />
{@inner_content}

View File

@@ -0,0 +1,189 @@
<div
id="shop-container"
phx-hook="CartPersist"
class="shop-container min-h-screen pb-20 md:pb-0"
style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);"
>
<.skip_link />
<%= if @theme_settings.announcement_bar do %>
<.announcement_bar theme_settings={@theme_settings} />
<% end %>
<.shop_header
theme_settings={@theme_settings}
logo_image={@logo_image}
header_image={@header_image}
active_page="checkout"
mode={@mode}
cart_count={@cart_count}
/>
<main id="main-content" class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<%= if @order && @order.payment_status == "paid" do %>
<div class="text-center mb-12">
<div
class="inline-flex items-center justify-center w-16 h-16 rounded-full mb-6"
style="background-color: var(--t-accent); color: var(--t-accent-contrast);"
>
<svg
class="w-8 h-8"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.5"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</div>
<h1
class="text-3xl font-bold mb-3"
style="font-family: var(--t-font-heading); color: var(--t-text-primary);"
>
Thank you for your order
</h1>
<p class="text-lg mb-2" style="color: var(--t-text-secondary);">
Order <strong style="color: var(--t-text-primary);">{@order.order_number}</strong>
</p>
<%= if @order.customer_email do %>
<p style="color: var(--t-text-secondary);">
A confirmation will be sent to <strong>{@order.customer_email}</strong>
</p>
<% end %>
</div>
<.shop_card class="p-6 mb-8">
<h2
class="text-lg font-semibold mb-4"
style="font-family: var(--t-font-heading); color: var(--t-text-primary);"
>
Order details
</h2>
<ul class="flex flex-col gap-4 mb-6" style="list-style: none; margin: 0; padding: 0;">
<%= for item <- @order.items do %>
<li
class="flex justify-between items-start pb-4 border-b last:border-b-0 last:pb-0"
style="border-color: var(--t-border-default);"
>
<div>
<p class="font-medium" style="color: var(--t-text-primary);">
{item.product_name}
</p>
<%= if item.variant_title do %>
<p class="text-sm" style="color: var(--t-text-secondary);">
{item.variant_title}
</p>
<% end %>
<p class="text-sm" style="color: var(--t-text-secondary);">
Qty: {item.quantity}
</p>
</div>
<span class="font-medium" style="color: var(--t-text-primary);">
{SimpleshopTheme.Cart.format_price(item.unit_price * item.quantity)}
</span>
</li>
<% end %>
</ul>
<div class="border-t pt-4" style="border-color: var(--t-border-default);">
<div class="flex justify-between text-lg">
<span class="font-semibold" style="color: var(--t-text-primary);">Total</span>
<span class="font-bold" style="color: var(--t-text-primary);">
{SimpleshopTheme.Cart.format_price(@order.total)}
</span>
</div>
</div>
</.shop_card>
<%= if @order.shipping_address != %{} do %>
<.shop_card class="p-6 mb-8">
<h2
class="text-lg font-semibold mb-3"
style="font-family: var(--t-font-heading); color: var(--t-text-primary);"
>
Shipping to
</h2>
<div style="color: var(--t-text-secondary);">
<p>{@order.shipping_address["name"]}</p>
<p>{@order.shipping_address["line1"]}</p>
<%= if @order.shipping_address["line2"] do %>
<p>{@order.shipping_address["line2"]}</p>
<% end %>
<p>
{@order.shipping_address["city"]}, {@order.shipping_address["postal_code"]}
</p>
<p>{@order.shipping_address["country"]}</p>
</div>
</.shop_card>
<% end %>
<div class="text-center">
<.shop_link_button href="/collections/all" class="px-8 py-3">
Continue shopping
</.shop_link_button>
</div>
<% else %>
<%!-- Payment pending or order not found --%>
<div class="text-center py-16">
<div
class="inline-flex items-center justify-center w-16 h-16 rounded-full mb-6 animate-pulse"
style="background-color: var(--t-surface-sunken);"
>
<span style="color: var(--t-text-secondary);">
<svg
class="w-8 h-8"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</span>
</div>
<h1
class="text-3xl font-bold mb-3"
style="font-family: var(--t-font-heading); color: var(--t-text-primary);"
>
Processing your payment
</h1>
<p class="text-lg mb-8" style="color: var(--t-text-secondary);">
Please wait while we confirm your payment. This usually takes a few seconds.
</p>
<p class="text-sm" style="color: var(--t-text-tertiary);">
If this page doesn't update, please <a
href="/contact"
class="underline"
style="color: var(--t-accent);"
>contact us</a>.
</p>
</div>
<% end %>
</main>
<.shop_footer theme_settings={@theme_settings} mode={@mode} />
<.cart_drawer
cart_items={@cart_items}
subtotal={@cart_subtotal}
cart_count={@cart_count}
mode={@mode}
open={assigns[:cart_drawer_open] || false}
cart_status={assigns[:cart_status]}
/>
<.search_modal hint_text={~s(Try a search e.g. "mountain" or "notebook")} />
<.mobile_bottom_nav active_page="checkout" mode={@mode} />
</div>

View File

@@ -1191,13 +1191,26 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<span>Subtotal</span>
<span>{@display_subtotal}</span>
</div>
<button
type="submit"
class="cart-drawer-checkout w-full mb-2"
style="width: 100%; padding: 0.75rem 1.5rem; font-weight: 600; transition: all 0.2s ease; background: var(--t-accent-button, hsl(var(--t-accent-h) var(--t-accent-s) 42%)); color: var(--t-text-inverse); border-radius: var(--t-radius-button); border: none; cursor: pointer; font-family: var(--t-font-body);"
>
Checkout
</button>
<%= if @mode == :preview do %>
<button
type="button"
class="cart-drawer-checkout w-full mb-2"
style="width: 100%; padding: 0.75rem 1.5rem; font-weight: 600; transition: all 0.2s ease; background: var(--t-accent-button, hsl(var(--t-accent-h) var(--t-accent-s) 42%)); color: var(--t-text-inverse); border-radius: var(--t-radius-button); border: none; cursor: pointer; font-family: var(--t-font-body);"
>
Checkout
</button>
<% else %>
<form action="/checkout" method="post">
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<button
type="submit"
class="cart-drawer-checkout w-full mb-2"
style="width: 100%; padding: 0.75rem 1.5rem; font-weight: 600; transition: all 0.2s ease; background: var(--t-accent-button, hsl(var(--t-accent-h) var(--t-accent-s) 42%)); color: var(--t-text-inverse); border-radius: var(--t-radius-button); border: none; cursor: pointer; font-family: var(--t-font-body);"
>
Checkout
</button>
</form>
<% end %>
<%= if @mode == :preview do %>
<a
href="#"
@@ -3089,22 +3102,16 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<.order_summary subtotal={3600} />
"""
attr :subtotal, :integer, required: true
attr :delivery, :integer, default: 800
attr :vat, :integer, default: 720
attr :mode, :atom, default: :live
def order_summary(assigns) do
total = assigns.subtotal + assigns.delivery + assigns.vat
assigns = assign(assigns, :total, total)
~H"""
<.shop_card class="p-6 sticky top-4">
<h2
class="text-xl font-bold mb-6"
style="font-family: var(--t-font-heading); color: var(--t-text-primary);"
>
Order Summary
Order summary
</h2>
<div class="flex flex-col gap-3 mb-6">
@@ -3116,42 +3123,43 @@ defmodule SimpleshopThemeWeb.ShopComponents do
</div>
<div class="flex justify-between">
<span style="color: var(--t-text-secondary);">Delivery</span>
<span style="color: var(--t-text-primary);">
{SimpleshopTheme.Cart.format_price(@delivery)}
<span class="text-sm" style="color: var(--t-text-secondary);">
Calculated at checkout
</span>
</div>
<div class="flex justify-between">
<span style="color: var(--t-text-secondary);">VAT (20%)</span>
<span style="color: var(--t-text-primary);">{SimpleshopTheme.Cart.format_price(@vat)}</span>
</div>
<div class="border-t pt-3" style="border-color: var(--t-border-default);">
<div class="flex justify-between text-lg">
<span class="font-semibold" style="color: var(--t-text-primary);">Total</span>
<span class="font-semibold" style="color: var(--t-text-primary);">Subtotal</span>
<span class="font-bold" style="color: var(--t-text-primary);">
{SimpleshopTheme.Cart.format_price(@total)}
{SimpleshopTheme.Cart.format_price(@subtotal)}
</span>
</div>
</div>
</div>
<.shop_button class="w-full px-6 py-3 font-semibold transition-all mb-3">
Checkout
</.shop_button>
<%= if @mode == :preview do %>
<.shop_button class="w-full px-6 py-3 font-semibold transition-all mb-3">
Checkout
</.shop_button>
<.shop_button_outline
phx-click="change_preview_page"
phx-value-page="collection"
class="w-full px-6 py-3 font-semibold transition-all"
>
Continue Shopping
Continue shopping
</.shop_button_outline>
<% else %>
<form action="/checkout" method="post" class="mb-3">
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<.shop_button type="submit" class="w-full px-6 py-3 font-semibold transition-all">
Checkout
</.shop_button>
</form>
<.shop_link_outline
href="/collections/all"
class="block w-full px-6 py-3 font-semibold transition-all text-center"
>
Continue Shopping
Continue shopping
</.shop_link_outline>
<% end %>
</.shop_card>
@@ -4298,6 +4306,80 @@ defmodule SimpleshopThemeWeb.ShopComponents do
"""
end
@doc """
Renders flash messages styled for the shop theme.
"""
attr :flash, :map, required: true
def shop_flash_group(assigns) do
~H"""
<div
id="shop-flash-group"
aria-live="polite"
class="fixed top-4 right-4 z-[200] flex flex-col gap-2"
>
<%= if msg = Phoenix.Flash.get(@flash, :info) do %>
<div
id="shop-flash-info"
class="flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg max-w-sm animate-in"
style="background-color: var(--t-surface-raised, #fff); color: var(--t-text-primary); border: 1px solid var(--t-border-default);"
role="alert"
phx-click={
Phoenix.LiveView.JS.push("lv:clear-flash", value: %{key: :info})
|> Phoenix.LiveView.JS.hide(
to: "#shop-flash-info",
transition: {"ease-out duration-200", "opacity-100", "opacity-0"}
)
}
>
<svg
class="w-5 h-5 shrink-0"
style="color: var(--t-accent);"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
<p class="text-sm">{msg}</p>
</div>
<% end %>
<%= if msg = Phoenix.Flash.get(@flash, :error) do %>
<div
id="shop-flash-error"
class="flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg max-w-sm animate-in"
style="background-color: var(--t-surface-raised, #fff); color: var(--t-text-primary); border: 1px solid hsl(0 70% 50% / 0.3);"
role="alert"
phx-click={
Phoenix.LiveView.JS.push("lv:clear-flash", value: %{key: :error})
|> Phoenix.LiveView.JS.hide(
to: "#shop-flash-error",
transition: {"ease-out duration-200", "opacity-100", "opacity-0"}
)
}
>
<svg
class="w-5 h-5 shrink-0"
style="color: hsl(0 70% 50%);"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
/>
</svg>
<p class="text-sm">{msg}</p>
</div>
<% end %>
</div>
"""
end
defp build_srcset(base, widths, format) do
# Database images end with / (e.g., /images/{id}/variant/)
# Mockups use - separator (e.g., /mockups/product-1)

View File

@@ -0,0 +1,94 @@
defmodule SimpleshopThemeWeb.CheckoutController do
use SimpleshopThemeWeb, :controller
alias SimpleshopTheme.Cart
alias SimpleshopTheme.Orders
require Logger
def create(conn, _params) do
cart_items = Cart.get_from_session(get_session(conn))
hydrated = Cart.hydrate(cart_items)
cond do
hydrated == [] ->
conn
|> put_flash(:error, "Your basket is empty")
|> redirect(to: ~p"/cart")
true ->
create_checkout(conn, hydrated)
end
end
defp create_checkout(conn, hydrated_items) do
# Create a pending order with price snapshots
case Orders.create_order(%{items: hydrated_items}) do
{:ok, order} ->
create_stripe_session(conn, order, hydrated_items)
{:error, _changeset} ->
Logger.error("Failed to create order")
conn
|> put_flash(:error, "Something went wrong. Please try again.")
|> redirect(to: ~p"/cart")
end
end
defp create_stripe_session(conn, order, hydrated_items) do
line_items =
Enum.map(hydrated_items, fn item ->
product_name =
if item.variant,
do: "#{item.name}#{item.variant}",
else: item.name
%{
price_data: %{
currency: "gbp",
unit_amount: item.price,
product_data: %{name: product_name}
},
quantity: item.quantity
}
end)
base_url = SimpleshopThemeWeb.Endpoint.url()
params = %{
mode: "payment",
line_items: line_items,
success_url: "#{base_url}/checkout/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "#{base_url}/cart",
metadata: %{"order_id" => order.id},
shipping_address_collection: %{
allowed_countries: ["GB", "US", "CA", "AU", "DE", "FR", "NL", "IE", "AT", "BE"]
}
}
case Stripe.Checkout.Session.create(params) do
{:ok, session} ->
{:ok, _order} = Orders.set_stripe_session(order, session.id)
conn
|> redirect(external: session.url)
{:error, %Stripe.Error{message: message}} ->
Logger.error("Stripe session creation failed: #{message}")
Orders.mark_failed(order)
conn
|> put_flash(:error, "Payment setup failed. Please try again.")
|> redirect(to: ~p"/cart")
{:error, reason} ->
Logger.error("Stripe session creation failed: #{inspect(reason)}")
Orders.mark_failed(order)
conn
|> put_flash(:error, "Payment setup failed. Please try again.")
|> redirect(to: ~p"/cart")
end
end
end

View File

@@ -0,0 +1,89 @@
defmodule SimpleshopThemeWeb.StripeWebhookController do
use SimpleshopThemeWeb, :controller
alias SimpleshopTheme.Orders
require Logger
def handle(conn, _params) do
raw_body = conn.assigns[:raw_body] || ""
signature = List.first(get_req_header(conn, "stripe-signature")) || ""
signing_secret = Application.get_env(:stripity_stripe, :signing_secret) || ""
case Stripe.Webhook.construct_event(raw_body, signature, signing_secret) do
{:ok, %Stripe.Event{} = event} ->
handle_event(event)
json(conn, %{received: true})
{:error, reason} ->
Logger.warning("Stripe webhook verification failed: #{inspect(reason)}")
conn
|> put_status(401)
|> json(%{error: "Invalid signature"})
end
end
defp handle_event(%Stripe.Event{type: "checkout.session.completed", data: %{object: session}}) do
order_id = get_in(session, [:metadata, "order_id"]) || session.metadata["order_id"]
case Orders.get_order(order_id) do
nil ->
Logger.warning("Stripe webhook: order not found for id=#{order_id}")
order ->
payment_intent_id = session.payment_intent
{:ok, order} = Orders.mark_paid(order, payment_intent_id)
# Update shipping address if collected by Stripe
if session.shipping_details do
update_shipping(order, session.shipping_details)
end
# Update customer email from Stripe session
if session.customer_details && session.customer_details.email do
Orders.update_order(order, %{customer_email: session.customer_details.email})
end
# Broadcast to success page via PubSub
Phoenix.PubSub.broadcast(
SimpleshopTheme.PubSub,
"order:#{order.id}:status",
{:order_paid, order}
)
Logger.info("Order #{order.order_number} marked as paid")
end
end
defp handle_event(%Stripe.Event{type: "checkout.session.expired", data: %{object: session}}) do
order_id = get_in(session, [:metadata, "order_id"]) || session.metadata["order_id"]
case Orders.get_order(order_id) do
nil -> :ok
order -> Orders.mark_failed(order)
end
Logger.info("Stripe checkout session expired for order #{order_id}")
end
defp handle_event(%Stripe.Event{type: type}) do
Logger.debug("Unhandled Stripe event: #{type}")
end
defp update_shipping(order, shipping_details) do
address = shipping_details.address || %{}
shipping_address = %{
"name" => shipping_details.name,
"line1" => address.line1,
"line2" => address.line2,
"city" => address.city,
"postal_code" => address.postal_code,
"state" => address.state,
"country" => address.country
}
Orders.update_order(order, %{shipping_address: shipping_address})
end
end

View File

@@ -0,0 +1,82 @@
defmodule SimpleshopThemeWeb.ShopLive.CheckoutSuccess do
use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.{Orders, Settings, Media}
alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator}
@impl true
def mount(%{"session_id" => session_id}, _session, socket) do
theme_settings = Settings.get_theme_settings()
generated_css =
case CSSCache.get() do
{:ok, css} ->
css
:miss ->
css = CSSGenerator.generate(theme_settings)
CSSCache.put(css)
css
end
logo_image = Media.get_logo()
header_image = Media.get_header()
order = Orders.get_order_by_stripe_session(session_id)
# Subscribe to order status updates (webhook may arrive after redirect)
if order && connected?(socket) do
Phoenix.PubSub.subscribe(SimpleshopTheme.PubSub, "order:#{order.id}:status")
end
# Clear the cart after successful checkout
socket =
if order && connected?(socket) do
empty_cart = []
socket
|> SimpleshopThemeWeb.CartHook.broadcast_and_update(empty_cart)
else
socket
end
socket =
socket
|> assign(:page_title, "Order confirmed")
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:logo_image, logo_image)
|> assign(:header_image, header_image)
|> assign(:mode, :shop)
|> assign(:order, order)
{:ok, socket}
end
def mount(_params, _session, socket) do
{:ok, redirect(socket, to: ~p"/")}
end
@impl true
def handle_info({:order_paid, order}, socket) do
{:noreply, assign(socket, :order, order)}
end
@impl true
def render(assigns) do
~H"""
<SimpleshopThemeWeb.PageTemplates.checkout_success
theme_settings={@theme_settings}
logo_image={@logo_image}
header_image={@header_image}
mode={@mode}
cart_items={@cart_items}
cart_count={@cart_count}
cart_subtotal={@cart_subtotal}
cart_drawer_open={@cart_drawer_open}
cart_status={@cart_status}
order={@order}
/>
"""
end
end

View File

@@ -39,7 +39,11 @@ defmodule SimpleshopThemeWeb.Router do
live "/collections/:slug", ShopLive.Collection, :show
live "/products/:id", ShopLive.ProductShow, :show
live "/cart", ShopLive.Cart, :index
live "/checkout/success", ShopLive.CheckoutSuccess, :show
end
# Checkout (POST — creates Stripe session and redirects)
post "/checkout", CheckoutController, :create
end
# Cart API (session persistence for LiveView)
@@ -66,6 +70,12 @@ defmodule SimpleshopThemeWeb.Router do
post "/printify", WebhookController, :printify
end
scope "/webhooks", SimpleshopThemeWeb do
pipe_through [:api]
post "/stripe", StripeWebhookController, :handle
end
# Enable LiveDashboard and Swoosh mailbox preview in development
if Application.compile_env(:simpleshop_theme, :dev_routes) do
# If you want to use the LiveDashboard in production, you should put