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

@@ -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