Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
commit
af6077b9c3
455 changed files with 78366 additions and 0 deletions
194
lib/tarakan_web/controllers/webhooks/stripe_controller.ex
Normal file
194
lib/tarakan_web/controllers/webhooks/stripe_controller.ex
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue