Skip to main content

lib/codat/webhooks/handler.ex

defmodule Codat.Webhooks.Handler do
  @moduledoc """
  Behaviour for implementing typed Codat webhook event handlers.

  ## Example

      defmodule MyApp.CodatWebhookHandler do
        use Codat.Webhooks.Handler

        @impl true
        def handle_event("invoices.write.successful", payload, _metadata) do
          MyApp.InvoiceSync.handle_success(payload["companyId"], payload["data"])
          :ok
        end

        @impl true
        def handle_event(_event_type, _payload, _metadata), do: :ok
      end

  ## Return Values

  - `:ok` — processed; Plug responds `200 OK`
  - `{:error, reason}` — app error; Plug logs and responds `200 OK`
  - `{:retry, reason}` — Plug responds `500` to trigger Codat retry
  """

  @type event_type :: String.t()
  @type payload :: map()
  @type metadata :: %{
          required(:message_id) => String.t(),
          required(:timestamp) => integer(),
          required(:attempt_number) => non_neg_integer() | nil
        }
  @type result :: :ok | {:error, term()} | {:retry, term()}

  @callback handle_event(event_type(), payload(), metadata()) :: result()
  @callback on_exception(event_type(), payload(), Exception.t(), list()) :: :ok

  @optional_callbacks [on_exception: 4]

  defmacro __using__(_opts) do
    quote do
      @behaviour Codat.Webhooks.Handler

      require Logger

      @impl Codat.Webhooks.Handler
      def on_exception(event_type, _payload, exception, stacktrace) do
        Logger.error(
          "[Codat.Webhooks] Unhandled exception in #{inspect(__MODULE__)} " <>
            "for event #{event_type}: #{Exception.message(exception)}\n" <>
            Exception.format_stacktrace(stacktrace)
        )

        :ok
      end

      defoverridable on_exception: 4
    end
  end
end

defmodule Codat.Webhooks.NoOpHandler do
  @moduledoc "A no-op webhook handler for testing and development. Logs all events."

  use Codat.Webhooks.Handler

  require Logger

  @impl Codat.Webhooks.Handler
  def handle_event(event_type, payload, metadata) do
    Logger.debug(fn ->
      "[Codat.Webhooks.NoOpHandler] #{event_type} " <>
        "(msg_id: #{metadata.message_id}, company: #{payload["companyId"]})"
    end)

    :ok
  end
end

defmodule Codat.Webhooks.BroadcastHandler do
  @moduledoc """
  A webhook handler that broadcasts events to a `Phoenix.PubSub` topic.

  ## Usage

      defmodule MyApp.CodatBroadcaster do
        use Codat.Webhooks.BroadcastHandler,
          pubsub: MyApp.PubSub,
          topic: "codat_events"
      end
  """

  defmacro __using__(opts) do
    pubsub_name = Keyword.fetch!(opts, :pubsub)
    topic = Keyword.get(opts, :topic, "codat_events")

    quote do
      use Codat.Webhooks.Handler

      @impl Codat.Webhooks.Handler
      def handle_event(event_type, payload, _metadata) do
        Phoenix.PubSub.broadcast(
          unquote(pubsub_name),
          unquote(topic),
          {:codat_event, event_type, payload}
        )

        :ok
      end

      defoverridable handle_event: 3
    end
  end
end