Skip to main content

lib/paysafe/error.ex

defmodule Paysafe.Error do
  @moduledoc """
  Structured error type returned by all Paysafe API operations.

  All public functions in this library return `{:ok, result}` or
  `{:error, %Paysafe.Error{}}`.

  ## Error kinds

    * `:api_error` — Paysafe returned a non-2xx response with an error body.
    * `:http_error` — The HTTP request failed at the transport level.
    * `:rate_limited` — The rate limiter rejected the request before sending.
    * `:timeout` — The request timed out.
    * `:invalid_config` — Configuration validation failed.
    * `:invalid_params` — Request parameter validation failed.
    * `:webhook_signature_mismatch` — Webhook HMAC signature verification failed.
    * `:decode_error` — Response body could not be parsed.

  ## Paysafe API error codes

  The most common API error codes are documented here for reference:

    * `3001` — Invalid credentials.
    * `3002` — Invalid account.
    * `3004` — Invalid card number.
    * `3005` — Invalid card expiry.
    * `3007` — Invalid amount.
    * `3009` — Invalid payment type.
    * `3011` — Invalid payment handle token.
    * `3022` — Insufficient funds (trigggers PAS if enabled).
    * `3028` — External gateway system error (retryable).
    * `5000` — Internal server error (retryable).
    * `5068` — Either you submitted a request that requires an action not included
                in your merchant account, or the account is not configured.
    * `5107` — Payment Hub risk rule failed.

  """

  @type kind ::
          :api_error
          | :http_error
          | :rate_limited
          | :timeout
          | :invalid_config
          | :invalid_params
          | :webhook_signature_mismatch
          | :decode_error

  @type t :: %__MODULE__{
          kind: kind(),
          code: String.t() | nil,
          message: String.t(),
          http_status: non_neg_integer() | nil,
          field_errors: [field_error()] | [],
          details: map() | nil,
          retryable?: boolean()
        }

  @type field_error :: %{field: String.t(), error: String.t()}

  defstruct [
    :kind,
    :code,
    :message,
    :http_status,
    :details,
    field_errors: [],
    retryable?: false
  ]

  @retryable_codes ~w(3028 5000 5001 5002)
  @retryable_http_statuses [429, 500, 502, 503, 504]

  @doc """
  Build an error from a Paysafe API response body.
  """
  @spec from_response(map(), non_neg_integer()) :: t()
  def from_response(body, http_status) do
    error_map = body["error"] || %{}
    code = to_string(error_map["code"] || "")
    message = error_map["message"] || body["message"] || "Unknown API error"

    field_errors =
      (error_map["fieldErrors"] || [])
      |> Enum.map(fn fe ->
        %{field: fe["field"] || "", error: fe["error"] || ""}
      end)

    %__MODULE__{
      kind: :api_error,
      code: code,
      message: message,
      http_status: http_status,
      field_errors: field_errors,
      details: body,
      retryable?: code in @retryable_codes or http_status in @retryable_http_statuses
    }
  end

  @doc false
  @spec http_error(any()) :: t()
  def http_error(reason) do
    {kind, message, retryable?} =
      case reason do
        %{reason: :timeout} -> {:timeout, "Request timed out", true}
        %{reason: :closed} -> {:http_error, "Connection closed", true}
        other -> {:http_error, "HTTP error: #{inspect(other)}", false}
      end

    %__MODULE__{
      kind: kind,
      message: message,
      retryable?: retryable?
    }
  end

  @doc false
  @spec rate_limited() :: t()
  def rate_limited do
    %__MODULE__{
      kind: :rate_limited,
      message: "Rate limit exceeded. Reduce request frequency.",
      retryable?: true
    }
  end

  @doc false
  @spec invalid_params(String.t() | NimbleOptions.ValidationError.t()) :: t()
  def invalid_params(%NimbleOptions.ValidationError{} = err) do
    %__MODULE__{kind: :invalid_params, message: Exception.message(err), retryable?: false}
  end

  def invalid_params(message) when is_binary(message) do
    %__MODULE__{kind: :invalid_params, message: message, retryable?: false}
  end

  @doc false
  @spec webhook_mismatch() :: t()
  def webhook_mismatch do
    %__MODULE__{
      kind: :webhook_signature_mismatch,
      message: "Webhook HMAC-SHA256 signature does not match.",
      retryable?: false
    }
  end

  @doc false
  @spec decode_error(any()) :: t()
  def decode_error(reason) do
    %__MODULE__{
      kind: :decode_error,
      message: "Failed to decode response: #{inspect(reason)}",
      retryable?: false
    }
  end

  defimpl String.Chars do
    def to_string(%Paysafe.Error{kind: k, code: code, message: msg}) do
      code_part = if code && code != "", do: " [#{code}]", else: ""
      "Paysafe.Error(#{k})#{code_part}: #{msg}"
    end
  end
end