Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
194 lines
5.7 KiB
Elixir
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
|