tarakan/lib/tarakan_web/controllers/webhooks/stripe_controller.ex
Maxfield Luke af6077b9c3
All checks were successful
CI and deploy / Test (push) Successful in 4m52s
CI and deploy / Deploy production (push) Successful in 23s
Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at
https://git.elektrine.com/elektrine/tarakan.
2026-07-29 04:43:40 -04:00

194 lines
5.7 KiB
Elixir

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