defmodule BerrypodWeb.StripeWebhookController do use BerrypodWeb, :controller alias Berrypod.Orders alias Berrypod.Orders.{OrderNotifier, OrderSubmissionWorker} 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 = 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 cost from Stripe (if shipping options were presented) order = update_shipping_cost(order, session) # Update shipping address if collected by Stripe order = if session.shipping_details do {:ok, updated} = update_shipping(order, session.shipping_details) updated else order end # Update customer email from Stripe session order = if session.customer_details && session.customer_details.email do {:ok, updated} = Orders.update_order(order, %{customer_email: session.customer_details.email}) updated else order end # Reload items for the email (update_order doesn't preload) order = Orders.get_order(order.id) # Broadcast to success page via PubSub Phoenix.PubSub.broadcast( Berrypod.PubSub, "order:#{order.id}:status", {:order_paid, order} ) OrderNotifier.deliver_order_confirmation(order) # Submit to fulfilment provider if order.shipping_address && order.shipping_address != %{} do OrderSubmissionWorker.enqueue(order.id) else Logger.warning( "Order #{order.order_number} paid but no shipping address — manual submit needed" ) end 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 = session.metadata["order_id"] order = Orders.get_order(order_id) if order, do: Orders.mark_failed(order) Logger.info("Stripe checkout session expired for order #{order_id}") if Berrypod.Settings.abandoned_cart_recovery_enabled?() do maybe_create_abandoned_cart(session, order) end end defp handle_event(%Stripe.Event{type: type}) do Logger.debug("Unhandled Stripe event: #{type}") end defp maybe_create_abandoned_cart(_session, nil), do: :ok defp maybe_create_abandoned_cart(session, order) do email = session.customer_details && session.customer_details.email cond do is_nil(email) or email == "" -> Logger.debug("Cart recovery: no email captured for session #{session.id}") Orders.check_suppression(email) == :suppressed -> Logger.debug("Cart recovery: #{email} is suppressed") Orders.has_recent_paid_order?(email) -> Logger.debug("Cart recovery: #{email} has a recent paid order, skipping") not is_nil(Orders.get_abandoned_cart_by_session(session.id)) -> Logger.debug("Cart recovery: session #{session.id} already recorded") true -> create_cart_and_enqueue(session, order, email) end end defp create_cart_and_enqueue(session, order, email) do expired_at = DateTime.utc_now() |> DateTime.truncate(:second) attrs = %{ customer_email: email, order_id: order.id, stripe_session_id: session.id, cart_total: order.total, expired_at: expired_at } case Orders.create_abandoned_cart(attrs) do {:ok, cart} -> Berrypod.Orders.AbandonedCartEmailWorker.enqueue(cart.id) Logger.info("Abandoned cart recorded for #{email}, recovery email scheduled") {:error, reason} -> Logger.warning("Failed to create abandoned cart: #{inspect(reason)}") end 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 defp update_shipping_cost(order, session) do shipping_amount = session.shipping_cost && session.shipping_cost.amount_total if is_integer(shipping_amount) and shipping_amount > 0 do new_total = order.subtotal + shipping_amount case Orders.update_order(order, %{shipping_cost: shipping_amount, total: new_total}) do {:ok, updated} -> updated {:error, _} -> order end else order end end end