defmodule TarakanWeb.Webhooks.StripeController do @moduledoc """ Stripe webhook receiver. Verifies the `Stripe-Signature` header against the raw request body (cached by `TarakanWeb.Endpoint.read_body/2`) and processes: * `checkout.session.completed` - mode `payment` funds the matching bounty; mode `subscription` activates the account's plan subscription * `customer.subscription.updated` / `customer.subscription.deleted` and `invoice.payment_failed` - sync the subscription lifecycle (`Tarakan.Billing`) Unknown events are acknowledged and ignored so Stripe does not retry them forever. """ use TarakanWeb, :controller alias Tarakan.Billing alias Tarakan.Market require Logger @signature_tolerance_seconds 300 def handle(conn, _params) do case verify_signature(conn) do :ok -> process_event(conn.params) json(conn, %{received: true}) {:error, reason} -> conn |> put_status(:bad_request) |> json(%{error: to_string(reason)}) end end # Checkout completion is shared by bounty funding (mode "payment") and # plan subscriptions (mode "subscription"). defp process_event(%{ "type" => "checkout.session.completed", "data" => %{"object" => %{"mode" => "subscription"} = session} }) do case Billing.handle_checkout_completed(session) do {:ok, _subscription} -> :ok {:error, reason} -> Logger.warning("stripe webhook: subscription checkout not applied: #{inspect(reason)}") end end # Completion is not payment. Delayed-notification methods complete the # session with `payment_status: "unpaid"` and settle later through # `checkout.session.async_payment_succeeded`, so the status is passed through # and the bounty stays pending until Stripe says the money arrived. defp process_event(%{ "type" => "checkout.session.completed", "data" => %{"object" => %{"id" => session_id} = session} }) do fund_bounty(session_id, Map.get(session, "payment_status")) end defp process_event(%{ "type" => "checkout.session.async_payment_succeeded", "data" => %{"object" => %{"id" => session_id}} }) do fund_bounty(session_id, "paid") end defp process_event(%{ "type" => "checkout.session.async_payment_failed", "data" => %{"object" => %{"id" => session_id}} }) do Logger.warning("stripe webhook: async payment failed for #{session_id}; bounty stays pending") :ok end defp process_event(%{ "type" => "customer.subscription.updated", "data" => %{"object" => object} }) do Billing.handle_subscription_updated(object) :ok end defp process_event(%{ "type" => "customer.subscription.deleted", "data" => %{"object" => object} }) do Billing.handle_subscription_deleted(object) :ok end defp process_event(%{ "type" => "invoice.payment_failed", "data" => %{"object" => object} }) do Billing.handle_invoice_payment_failed(object) :ok end defp process_event(_event), do: :ignored defp fund_bounty(session_id, payment_status) do case Market.mark_funded(session_id, payment_status) do {:ok, _bounty} -> :ok {:error, :payment_not_completed} -> Logger.info( "stripe webhook: session #{session_id} completed unpaid (#{inspect(payment_status)}); " <> "waiting for async payment confirmation" ) {:error, reason} -> Logger.warning( "stripe webhook: could not fund bounty for #{session_id}: #{inspect(reason)}" ) end end defp verify_signature(conn) do with {:ok, secret} <- webhook_secret(), {:ok, header} <- signature_header(conn), {:ok, raw_body} <- raw_body(conn), {:ok, timestamp, signatures} <- parse_signature_header(header), :ok <- check_timestamp(timestamp) do expected = sign(secret, timestamp, raw_body) if Enum.any?(signatures, &Plug.Crypto.secure_compare(expected, &1)) do :ok else {:error, :invalid_signature} end end end defp webhook_secret do case Application.get_env(:tarakan, :stripe_webhook_secret) do secret when is_binary(secret) and secret != "" -> {:ok, secret} _missing -> {:error, :webhook_not_configured} end end defp signature_header(conn) do case Plug.Conn.get_req_header(conn, "stripe-signature") do [header | _rest] -> {:ok, header} [] -> {:error, :missing_signature} end end defp raw_body(conn) do case conn.assigns[:raw_body] do body when is_binary(body) -> {:ok, body} _missing -> {:error, :missing_raw_body} end end # Header shape: t=1492774577,v1=5257a869e7ec...,v1=6ffbb59b... defp parse_signature_header(header) do pairs = for pair <- String.split(header, ","), [key, value] <- [String.split(pair, "=", parts: 2)], do: {key, value} timestamp = pairs |> Enum.find_value(fn {key, value} -> key == "t" && value end) signatures = for {"v1", value} <- pairs, do: value with timestamp when is_binary(timestamp) <- timestamp, {unix, ""} <- Integer.parse(timestamp), true <- signatures != [] do {:ok, unix, signatures} else _invalid -> {:error, :malformed_signature} end end defp check_timestamp(timestamp) do if abs(System.system_time(:second) - timestamp) <= @signature_tolerance_seconds do :ok else {:error, :timestamp_outside_tolerance} end end defp sign(secret, timestamp, raw_body) do :crypto.mac(:hmac, :sha256, secret, "#{timestamp}.#{raw_body}") |> Base.encode16(case: :lower) end end