# Ramp

[![Hex.pm](https://img.shields.io/hexpm/v/ramp.svg)](https://hex.pm/packages/ramp)
[![Docs](https://img.shields.io/badge/docs-hexdocs-purple)](https://hexdocs.pm/ramp)

A production-grade, dependency-light Elixir client for the
[Ramp Developer API](https://docs.ramp.com/developer-api/v1/overview/introduction) (v1).

Ramp covers the full Ramp API surface -- cards, users, transactions,
spend limits, bills, vendors, merchants, entities, departments, locations,
statements, cashbacks, spend programs, reimbursements, audit logs,
accounting sync, and webhooks -- with OAuth2 token management, automatic
retries, cursor-based pagination as lazy `Stream`s, deferred-task polling,
and webhook signature verification all built in.

## Why Ramp

- **Genuinely standalone.** HTTP transport runs on Erlang/OTP's built-in
  `:httpc`/`:ssl` and JSON on `Jason` -- the only two dependencies. No
  `Req`/`Finch`/`Mint`/`Hackney` chain to pull in.
- **Idiomatic Elixir, not a Go port with the serial numbers filed off.**
  List endpoints are lazy `Stream`s. Errors are one normalized exception
  struct you pattern-match on. Token management is a supervised `GenServer`
  with correct single-flight refresh semantics for free.
- **Handles the annoying parts for you:** exponential backoff with jitter,
  honoring `Retry-After`, one-shot 401 token refresh, idempotency keys,
  deferred-task polling, and constant-time webhook signature verification
  with replay protection.

## Installation

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

## Quick start

```elixir
client =
  Ramp.Client.new(
    client_id: System.fetch_env!("RAMP_CLIENT_ID"),
    client_secret: System.fetch_env!("RAMP_CLIENT_SECRET"),
    scopes: ~w(transactions:read cards:read cards:write users:read)
  )

{:ok, card} = Ramp.Cards.get(client, "crd_1a2b3c")

client
|> Ramp.Transactions.list(state: "CLEARED")
|> Stream.take(50)
|> Enum.each(&IO.inspect/1)
```

## Authentication

Three ways to authenticate, all via `Ramp.Client.new/1`:

```elixir
# 1. client_credentials (recommended) -- starts and manages its own
#    unsupervised TokenManager, linked to the calling process.
Ramp.Client.new(
  client_id: "...",
  client_secret: "...",
  scopes: ~w(transactions:read cards:read)
)

# 2. A pre-obtained access token (e.g. you manage refresh yourself).
Ramp.Client.new(access_token: "ramp_at_...", expires_in: 600)

# 3. An externally supervised TokenManager -- the recommended setup for
#    long-running production apps. Add it to your own supervision tree:
children = [
  {Ramp.TokenManager,
   name: MyApp.RampTokenManager,
   client_id: System.fetch_env!("RAMP_CLIENT_ID"),
   client_secret: System.fetch_env!("RAMP_CLIENT_SECRET"),
   scopes: ~w(transactions:read cards:read)}
]
Supervisor.start_link(children, strategy: :one_for_one)

client = Ramp.Client.new(token_manager: MyApp.RampTokenManager)
```

Use `sandbox: true` (or `base_url:` for a custom endpoint) to hit Ramp's
sandbox environment instead of production.

## Pagination

Every `list/2` returns a lazily auto-paginating `Stream` -- pages are
fetched only as you consume them:

```elixir
# Fetches only as many pages as needed.
client
|> Ramp.Transactions.list(state: "CLEARED")
|> Enum.find(&(&1.amount > 10_000))

# Fully materializes -- fetches every page.
all_cards = client |> Ramp.Cards.list() |> Enum.to_list()
```

Prefer manual control over pagination? Every resource also exposes
`list_page/2`, returning one `Ramp.Page{data: [...], next_cursor: ...}`
at a time.

## Errors

Every operation returns `{:ok, result}` or `{:error, %Ramp.Error{}}`.
`Ramp.Error` normalizes every failure mode -- HTTP error responses,
network failures, timeouts, deferred-task failures -- into one struct:

```elixir
case Ramp.Cards.get(client, "nonexistent") do
  {:ok, card} -> card
  {:error, error} ->
    cond do
      Ramp.Error.not_found?(error) -> :not_found
      Ramp.Error.rate_limited?(error) -> :try_again_later
      true -> raise error
    end
end
```

`Ramp.HTTP` already retries retryable errors (`429`/`5xx`/network
failures) automatically with exponential backoff + jitter, honoring
`Retry-After` -- you're only ever handed an error after retries are
exhausted.

## Async writes

Card issuance, user invites, and limit creation are asynchronous on Ramp's
side. The corresponding `create/*` functions poll to completion by
default:

```elixir
{:ok, card} = Ramp.Cards.create(client, "usr_123", "Marketing Team Card")
```

Pass `poll: false` to instead get a `Ramp.DeferredTaskRef` back
immediately and poll it yourself (e.g. from another process):

```elixir
{:ok, task_ref} = Ramp.Cards.create(client, "usr_123", "Ops Card", poll: false)
{:ok, card_json} = Ramp.Poller.poll(client, task_ref.id, timeout_ms: 30_000)
```

## Webhooks

Verify and decode inbound deliveries:

```elixir
def webhook(conn, _params) do
  {:ok, raw_body, conn} = Plug.Conn.read_body(conn)
  signature = conn |> Plug.Conn.get_req_header("x-webhook-signature") |> List.first()
  timestamp = conn |> Plug.Conn.get_req_header("x-webhook-timestamp") |> List.first()

  case Ramp.Webhooks.construct_event(raw_body, signature, timestamp, @webhook_secret) do
    {:ok, event} ->
      MyApp.WebhookProcessor.handle(event)
      send_resp(conn, 200, "ok")

    {:error, %Ramp.Error{}} ->
      send_resp(conn, 400, "invalid signature")
  end
end
```

Or use `Ramp.Webhooks.Handler` for declarative per-event-type dispatch:

```elixir
handler =
  Ramp.Webhooks.Handler.new(webhook_secret: @webhook_secret)
  |> Ramp.Webhooks.Handler.on("card.created", &MyApp.Cards.on_created/1)
  |> Ramp.Webhooks.Handler.on("card.terminated", &MyApp.Cards.on_terminated/1)

Ramp.Webhooks.Handler.handle(handler, raw_body, signature, timestamp)
```

Manage *which* events get delivered *where* with `Ramp.WebhookSubscriptions`.

## Resource modules

Every resource follows the same shape: `list/2` (a `Stream`), `get/2`, and
whatever writes it supports.

| Module | Ramp resource |
|---|---|
| `Ramp.Accounting` | ERP connections, GL accounts, custom fields, sync |
| `Ramp.AuditLogs` | Audit event log |
| `Ramp.Bills` | Accounts payable |
| `Ramp.Business` | The business account (singleton) |
| `Ramp.Cards` | Card issuance and lifecycle |
| `Ramp.Cashbacks` | Cashback rewards |
| `Ramp.Departments` | Org departments |
| `Ramp.Entities` | Legal entities |
| `Ramp.Limits` | Spend controls |
| `Ramp.Locations` | Office locations |
| `Ramp.Merchants` | Card-network merchants |
| `Ramp.Reimbursements` | Out-of-pocket expense reimbursements |
| `Ramp.SpendPrograms` | Spend program templates |
| `Ramp.Statements` | Billing statements |
| `Ramp.Transactions` | Card transactions |
| `Ramp.Users` | Platform users |
| `Ramp.Vendors` | AP vendors |
| `Ramp.WebhookSubscriptions` | Webhook endpoint registrations |

## Telemetry

`Ramp.HTTP` emits `:telemetry.span/3` events under
`[:ramp, :http, :request]` (`:start`, `:stop`, `:exception`) with
`%{method:, path:, attempt:}` metadata -- attach a handler for logging,
metrics, or tracing.

## Testing your integration

Point a client at Ramp's sandbox with `sandbox: true`. The test suite in
this repo also includes `test/support/mock_server.ex`, a small
dependency-free `:gen_tcp`-based HTTP mock server, if you'd like a
lightweight pattern for testing your own code that calls into Ramp.

## Development

```sh
mix deps.get
mix test
mix credo --strict
mix dialyzer
mix docs
```

## License

MIT. See [LICENSE](LICENSE).

This is an independent, community-maintained client and is not officially
affiliated with or endorsed by Ramp.
