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