README.md

# RevolutClient

[![Hex.pm](https://img.shields.io/hexpm/v/revolut_client.svg)](https://hex.pm/packages/revolut_client)
[![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/revolut_client)
[![CI](https://github.com/iamkanishka/revolut_client/actions/workflows/ci.yml/badge.svg)](https://github.com/iamkanishka/revolut_client/actions)
[![Coverage Status](https://coveralls.io/repos/github/iamkanishka/revolut_client/badge.svg?branch=main)](https://coveralls.io/github/iamkanishka/revolut_client?branch=main)

Production-grade Elixir SDK for the complete **Revolut Developer API**.

| API             | Module                         | Description                                                   |
| --------------- | ------------------------------ | ------------------------------------------------------------- |
| Merchant API    | `RevolutClient.Merchant`       | Orders, customers, subscriptions, disputes, payouts, webhooks |
| Business API    | `RevolutClient.Business`       | Accounts, cards, payments, team members, webhooks v1/v2       |
| Open Banking    | `RevolutClient.OpenBanking`    | Full AISP + PISP (OB v3.1)                                    |
| Crypto Ramp     | `RevolutClient.CryptoRamp`     | Fiat ↔ crypto on-ramp partner API v2                          |
| Crypto Exchange | `RevolutClient.CryptoExchange` | Revolut X trading API                                         |
| Webhooks        | `RevolutClient.Webhook`        | HMAC verification + typed event dispatch                      |

---

## Features

- **Zero custom HTTP layer** — backed by [`Req`](https://hex.pm/packages/req)
- **Exponential backoff + jitter** on retryable errors (5xx, network failures, 429)
- **Token-bucket rate limiting** (optional, per-client)
- **[Telemetry](https://hex.pm/packages/telemetry)** events on every request (`:start`, `:stop`, `:exception`)
- **Type-safe error hierarchy** — pattern-match precisely on `RevolutClient.Error.*`
- **Webhook behaviour** — `use RevolutClient.Webhook` to build a verified event handler in seconds
- **HMAC-SHA256 signature verification** with constant-time comparison and replay-attack protection
- **Mockable HTTP adapter** — swap in `RevolutClient.MockHTTP` (Mox) for all tests
- **Full `@spec` and `@doc` coverage** — use `mix docs` to build HexDocs locally

---

## Installation

Add `revolut_client` to your `mix.exs`:

```elixir
def deps do
  [
    {:revolut_client, "~> 1.0"}
  ]
end
```

---

## Quickstart

```elixir
# Build a config
config = RevolutClient.Config.new!(
  api_key: System.fetch_env!("REVOLUT_MERCHANT_KEY"),
  environment: :sandbox
)

# Create a Merchant client
merchant = RevolutClient.merchant(config)

# Create an order
{:ok, order} = RevolutClient.Merchant.create_order(merchant, %{
  amount: 1000,   # minor units (pence)
  currency: "GBP",
  description: "Widget order"
})

# Capture it
{:ok, _} = RevolutClient.Merchant.capture_order(merchant, order["id"])
```

---

## Configuration

### Per-client

```elixir
config = RevolutClient.Config.new!(
  api_key: "sk_live_...",
  environment: :prod,
  timeout_ms: 15_000,
  max_attempts: 3,
  initial_delay_ms: 250,
  rate_limit: %{requests_per_second: 10, burst: 20}
)
```

### Application-level defaults (`config.exs`)

```elixir
config :revolut_client,
  environment: :sandbox,
  timeout_ms: 10_000,
  max_attempts: 2
```

Per-client options always override application-level defaults.

---

## Error handling

All functions return `{:ok, result}` or `{:error, RevolutClient.Error.t()}`:

```elixir
case RevolutClient.Merchant.get_order(client, order_id) do
  {:ok, order}                                     -> process(order)
  {:error, %RevolutClient.Error.API{status_code: 404}} -> {:reply, :not_found, state}
  {:error, %RevolutClient.Error.API{retryable?: true}}  -> retry_later()
  {:error, %RevolutClient.Error.Network{}}              -> retry_later()
  {:error, err}                                    -> reraise err, __STACKTRACE__
end
```

Error types:

| Type                                | When                                   |
| ----------------------------------- | -------------------------------------- |
| `RevolutClient.Error.API`           | Non-2xx HTTP response from Revolut     |
| `RevolutClient.Error.Network`       | Transport failure (timeout, DNS, TLS)  |
| `RevolutClient.Error.Validation`    | Bad argument caught before request     |
| `RevolutClient.Error.Configuration` | SDK misconfiguration                   |
| `RevolutClient.Error.Webhook`       | Invalid signature or malformed payload |
| `RevolutClient.Error.Serialization` | JSON encode/decode failure             |

---

## Webhooks

### Define a handler

```elixir
defmodule MyApp.RevolutWebhook do
  use RevolutClient.Webhook,
    secret: System.fetch_env!("REVOLUT_WEBHOOK_SECRET")

  @impl RevolutClient.Webhook
  def handle_event("ORDER_COMPLETED", payload, _meta) do
    MyApp.Orders.fulfill(payload["order_id"])
    :ok
  end

  @impl RevolutClient.Webhook
  def handle_event(_type, _payload, _meta), do: :ok
end
```

### Process in a Phoenix controller

```elixir
def webhook(conn, _params) do
  raw_body  = conn.assigns[:raw_body]
  signature = get_req_header(conn, "revolut-signature") |> List.first()

  case MyApp.RevolutWebhook.process(raw_body, signature) do
    {:ok, _}  -> send_resp(conn, 200, "")
    {:error, %RevolutClient.Error.Webhook{}} -> send_resp(conn, 401, "")
    {:error, _} -> send_resp(conn, 400, "")
  end
end
```

### Raw verification (no macro)

```elixir
RevolutClient.Webhook.verify(raw_body, signature_header, secret)
# => :ok | {:error, %RevolutClient.Error.Webhook{}}
```

---

## Telemetry

```elixir
:telemetry.attach(
  "my-revolut-logger",
  [:revolut_client, :request, :stop],
  fn _event, %{duration: dur}, %{status: status, url: url}, _cfg ->
    Logger.info("Revolut #{url} -> #{status} (#{System.convert_time_unit(dur, :native, :millisecond)}ms)")
  end,
  nil
)
```

Events emitted:

| Event               | Measurements  | Metadata                       |
| ------------------- | ------------- | ------------------------------ |
| `[..., :start]`     | `system_time` | `method, url, attempt`         |
| `[..., :stop]`      | `duration`    | `status, attempt, method, url` |
| `[..., :exception]` | `duration`    | `reason, attempt, retryable`   |

---

## Running tests

```bash
mix deps.get
mix test
mix test --cover   # with coverage report
mix credo --strict
mix dialyzer
```

---

## License

MIT — see [LICENSE](LICENSE).