Skip to main content

lib/rapyd.ex

defmodule Rapyd do
  @moduledoc """
  Production-grade Elixir SDK for the [Rapyd](https://www.rapyd.net/)
  fintech-as-a-service platform.

  ## Quick Start

      client = Rapyd.new!(
        access_key: System.fetch_env!("RAPYD_ACCESS_KEY"),
        secret_key: System.fetch_env!("RAPYD_SECRET_KEY"),
        sandbox: true
      )

      {:ok, payment} = Rapyd.Services.Collect.create_payment(client, %{
        amount: 100.00,
        currency: "USD",
        payment_method: %{
          type: "us_visa_card",
          fields: %{
            number: "4111111111111111",
            expiration_month: "12",
            expiration_year: "2026",
            cvv: "123"
          }
        }
      })

  ## Services

  All API interaction is routed through domain-aligned service modules:

  | Service | Description |
  |---|---|
  | `Rapyd.Services.Collect` | Accept payments, refunds, subscriptions, customers |
  | `Rapyd.Services.Disburse` | Send payouts to beneficiaries worldwide |
  | `Rapyd.Services.Wallet` | Manage eWallets, contacts, virtual accounts, KYC |
  | `Rapyd.Services.Issuing` | Issue and manage physical and virtual cards |
  | `Rapyd.Services.Partner` | PayFac / KYB onboarding for sub-merchants |
  | `Rapyd.Services.Webhook` | Verify and route incoming Rapyd webhook events |
  | `Rapyd.Services.Resource` | Reference data: FX rates, countries, currencies |

  ## Error Handling

  All API calls return `{:ok, result}` or `{:error, %Rapyd.Error{}}`.

      case Rapyd.Services.Collect.create_payment(client, req) do
        {:ok, payment} ->
          IO.inspect(payment.id)

        {:error, %Rapyd.Error{type: :insufficient_funds}} ->
          # prompt customer for another payment method

        {:error, %Rapyd.Error{type: :rate_limit}} ->
          # back off and retry

        {:error, %Rapyd.Error{} = err} ->
          Logger.error("payment failed", code: err.error_code, op: err.operation_id)
      end

  ## Configuration

  Options accepted by `new/1` and `new!/1`:

  | Option | Type | Default | Description |
  |---|---|---|---|
  | `:access_key` | `String.t()` | required | Rapyd access key |
  | `:secret_key` | `String.t()` | required | Rapyd secret key |
  | `:sandbox` | `boolean()` | `true` | Use sandbox (`true`) or production (`false`) |
  | `:base_url` | `String.t()` | auto | Override the API base URL |
  | `:max_retries` | `non_neg_integer()` | `4` | Max request attempts (1 = no retries) |
  | `:timeout` | `non_neg_integer()` | `30_000` | Request timeout in milliseconds |
  | `:http_client` | module | `Rapyd.HTTP.Client` | Swappable HTTP client module |
  """

  alias Rapyd.Client

  @sandbox_url "https://sandboxapi.rapyd.net"
  @production_url "https://api.rapyd.net"

  @doc """
  Builds a new `Rapyd.Client` from the given options.

  Returns `{:ok, client}` or `{:error, reason}`.

  ## Examples

      {:ok, client} = Rapyd.new(
        access_key: "your_key",
        secret_key: "your_secret",
        sandbox: true
      )
  """
  @spec new(keyword()) :: {:ok, Client.t()} | {:error, String.t()}
  def new(opts) do
    with {:ok, access_key} <- require_opt(opts, :access_key),
         {:ok, secret_key} <- require_opt(opts, :secret_key) do
      {:ok, build_client(opts, access_key, secret_key)}
    end
  end

  @doc """
  Builds a new `Rapyd.Client` from the given options, raising on error.

  ## Examples

      client = Rapyd.new!(
        access_key: System.fetch_env!("RAPYD_ACCESS_KEY"),
        secret_key: System.fetch_env!("RAPYD_SECRET_KEY")
      )
  """
  @spec new!(keyword()) :: Client.t()
  def new!(opts) do
    case new(opts) do
      {:ok, client} -> client
      {:error, reason} -> raise ArgumentError, "Rapyd.new!/1 failed: #{reason}"
    end
  end

  @doc """
  Returns the SDK version string.
  """
  @spec version() :: String.t()
  def version, do: "1.0.0"

  # ---------------------------------------------------------------------------
  # Private helpers
  # ---------------------------------------------------------------------------

  defp build_client(opts, access_key, secret_key) do
    sandbox = Keyword.get(opts, :sandbox, true)

    %Client{
      access_key: access_key,
      secret_key: secret_key,
      sandbox: sandbox,
      base_url: resolve_base_url(opts, sandbox),
      max_retries: Keyword.get(opts, :max_retries, 4),
      timeout: Keyword.get(opts, :timeout, 30_000),
      http_client: Keyword.get(opts, :http_client, Rapyd.HTTP.Client)
    }
  end

  defp resolve_base_url(opts, sandbox) do
    case Keyword.get(opts, :base_url) do
      nil -> if sandbox, do: @sandbox_url, else: @production_url
      url -> url
    end
  end

  defp require_opt(opts, key) do
    case Keyword.get(opts, key) do
      nil -> {:error, "#{key} is required"}
      "" -> {:error, "#{key} must not be empty"}
      val -> {:ok, val}
    end
  end
end