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:
94
lib/simpleshop_theme_web/controllers/checkout_controller.ex
Normal file
94
lib/simpleshop_theme_web/controllers/checkout_controller.ex
Normal 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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user