Skip to main content

README.md

# railsr

[![Hex Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/railsr)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

Production-grade Elixir client for the **[Railsr](https://www.railsr.com) Embedded Finance API**.

Complete coverage of every endpoint group: Endusers (v2), Ledgers, Transactions,
Beneficiaries, Cards, Direct Debit / Mandates, Compliance Firewall, Webhooks,
and Customer management.

---

## Features

| Feature                            | Detail                                                                            |
| ---------------------------------- | --------------------------------------------------------------------------------- |
| **Full API coverage**              | All Railsr v1/v2 endpoints across 10 resource modules                             |
| **OAuth 2.0 token management**     | ETS-backed cache with automatic pre-expiry refresh                                |
| **Retry with full-jitter backoff** | Configurable max retries, base backoff; respects 429                              |
| **Circuit breaker**                | Three-state (closed/open/half-open) protects against cascade failures             |
| **Client-side rate limiter**       | Token-bucket, ETS-backed, lock-free reads                                         |
| **Idempotency keys**               | Auto-generated on every POST/PUT/PATCH — override per-call                        |
| **Telemetry**                      | `[:railsr, :request, :start\|:stop\|:exception]``Telemetry.Metrics` compatible |
| **Typed returns**                  | `{:ok, struct}` or `{:error, %Railsr.Error{}}` everywhere                         |
| **Webhook verification**           | HMAC-SHA256 signature verification with constant-time compare                     |
| **Config validation**              | `NimbleOptions` validates all config keys at startup                              |
| **PLAY → LIVE**                    | Only the `:environment` config key changes                                        |
| **Zero business logic**            | Pure transport layer — no assumptions about your domain                           |

---

## Installation

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

---

## Configuration

```elixir
# config/config.exs (or runtime.exs for secrets)
config :railsr,
  client_id: System.get_env("RAILSR_CLIENT_ID"),
  client_secret: System.get_env("RAILSR_CLIENT_SECRET"),
  environment: :play,        # :play | :play_live | :live
  timeout: 30_000,           # HTTP timeout in ms (default: 30_000)
  max_retries: 3,            # Retries on 429 / 5xx (default: 3)
  base_backoff_ms: 200,      # Backoff base for retry jitter (default: 200)
  rate_limit_rps: 50,        # Client-side RPS cap (default: 50)
  pool_size: 10,             # HTTP connection pool size (default: 10)
  telemetry_prefix: [:railsr] # Telemetry event prefix (default: [:railsr])
```

> **Never hard-code credentials.** Use `System.get_env/1` or a secrets manager and
> load via `config/runtime.exs`.

---

## Quick Start

### 1. Onboard an enduser (person)

```elixir
alias Railsr.Resources.{Endusers, Ledgers, Transactions, Beneficiaries}

{:ok, enduser} = Endusers.create(%{
  person: %{
    name: %{family_name: "Smith", given_name: "Alice"},
    email: "alice@example.com",
    date_of_birth: "1990-01-15",
    nationality: "GB",
    country_of_residence: ["GB"],
    address: %{
      address_number: "14",
      address_street: "High Street",
      address_city: "London",
      address_postal_code: "EC1A 1BB",
      address_iso_country: "GB"
    }
  }
})
# => {:ok, %Railsr.Types.Enduser{enduser_id: "eu_xxx", status: "pending"}}
```

### 2. Trigger KYC

```elixir
{:ok, check} = Endusers.create_kyc_check(enduser.enduser_id)
# Listen for enduser-kyc-passed / enduser-kyc-failed webhooks
# Or poll:
{:ok, _} = Endusers.wait_for_status(enduser.enduser_id, ["active"], timeout_ms: 120_000)
```

### 3. Create a GBP ledger

```elixir
{:ok, ledger} = Ledgers.create(%{
  holder_id: enduser.enduser_id,
  holder_type: "enduser",
  ledger_type: "standard-gbp",
  asset_class: "currency",
  asset_type: "gbp"
})
# => {:ok, %Railsr.Types.Ledger{uk_sort_code: "040004", uk_account_number: "..."}}
```

### 4. Create a beneficiary and send money

```elixir
{:ok, ben} = Beneficiaries.create(%{
  name: "Bob Jones",
  uk_account_number: "87654321",
  uk_sort_code: "204514",
  currency: "GBP",
  enduser_id: enduser.enduser_id
})

# Run Confirmation of Payee (UK FPS recommended)
{:ok, ben} = Beneficiaries.verify(ben.beneficiary_id, "faster-payment")
IO.inspect(ben.cop_result)  # "matched" | "close_match" | "no_match"

# Send money
{:ok, tx} = Transactions.send_money(%{
  ledger_id: ledger.ledger_id,
  beneficiary_id: ben.beneficiary_id,
  amount: 1000,        # pence — £10.00
  currency: "GBP",
  payment_type: "faster-payment",
  reason: "Invoice #42"
})

# Poll for terminal status
{:ok, tx} = Transactions.wait_for_terminal(tx.transaction_id)
```

---

## Cards

```elixir
alias Railsr.Resources.Cards

# Issue a virtual card
{:ok, card} = Cards.create(%{
  ledger_id: ledger.ledger_id,
  card_type: "virtual",
  card_programme_id: "cp_xxx"
})

# Freeze / unfreeze
{:ok, _} = Cards.freeze(card.card_id)
{:ok, _} = Cards.unfreeze(card.card_id)

# Add a daily spend limit of £100
{:ok, rule} = Cards.create_rule(card.card_id, %{
  rule_type: "amount_limit",
  limit_amount: 10_000,
  limit_currency: "GBP",
  limit_interval: "daily"
})

# Block gambling MCCs
{:ok, _} = Cards.create_rule(card.card_id, %{
  rule_type: "mcc_block",
  mcc_list: ["7995", "7801", "7802"]
})

# Replace lost card
{:ok, new_card} = Cards.replace(card.card_id, %{replacement_reason: "lost"})
```

---

## Direct Debit

```elixir
alias Railsr.Resources.{Mandates, Payments}

# Create mandate (collect from enduser's external bank)
{:ok, mandate} = Mandates.create(%{
  enduser_id: enduser.enduser_id,
  ledger_id: ledger.ledger_id,
  account_number: "12345678",
  sort_code: "040004",
  account_holder_name: "Alice Smith",
  reference: "MYAPP-001"
})

# Wait for BACS activation (3-5 working days — use webhook in production)
{:ok, mandate} = Mandates.wait_for_active(mandate.mandate_id)

# Collect funds
{:ok, payment} = Payments.create(%{
  mandate_id: mandate.mandate_id,
  amount: 5_000,   # £50.00
  reason: "Wallet top-up"
})
```

---

## Compliance Firewall

```elixir
alias Railsr.Resources.{Firewall, Transactions}

# Set rules
{:ok, _} = Firewall.set_rules(%{
  rules: [
    %{
      name: "Quarantine large international payments",
      rule: """
      (and
        (> (transaction.amount) 500000)
        (not (= (beneficiary.country) "GB")))
      """,
      action: "quarantine",
      priority: 10
    }
  ]
})

# Upload a blocked BIC dataset
{:ok, _} = Firewall.create_dataset(%{
  name: "blocked_bics",
  columns: ["bic"],
  rows: [["CHASUS33"], ["DEUTDEDB"]]
})

# Resolve quarantined transactions
{:ok, quarantined} = Transactions.list_quarantined()

for tx <- quarantined do
  # Your compliance review logic here
  if approved?(tx) do
    Transactions.approve(tx.transaction_id)
  else
    Transactions.reject(tx.transaction_id, "Policy violation: high-risk jurisdiction")
  end
end
```

---

## Webhooks

### Configure endpoint

```elixir
Railsr.Resources.Webhooks.configure(%{
  url: "https://myapp.com/webhooks/railsr",
  secret: System.get_env("RAILSR_WEBHOOK_SECRET")
})
```

### Verify incoming payloads (Phoenix example)

```elixir
defmodule MyAppWeb.WebhookController do
  use MyAppWeb, :controller
  alias Railsr.Resources.Webhooks

  def railsr(conn, _params) do
    raw_body = conn.assigns[:raw_body]  # captured before JSON parsing
    signature = get_req_header(conn, "x-railsr-signature") |> List.first()
    secret = Application.get_env(:myapp, :railsr_webhook_secret)

    case Webhooks.verify_signature(raw_body, signature, secret) do
      :ok ->
        {:ok, event} = Webhooks.parse_event(Jason.decode!(raw_body))
        handle_event(event)
        send_resp(conn, 200, "ok")

      {:error, :invalid_signature} ->
        send_resp(conn, 401, "invalid signature")
    end
  end

  defp handle_event(%{type: "transaction-quarantined"} = event) do
    # Trigger your compliance review workflow
    MyApp.Compliance.review(event.data)
  end

  defp handle_event(%{type: "enduser-kyc-passed"} = event) do
    MyApp.Onboarding.kyc_passed(event.data["enduser_id"])
  end

  defp handle_event(_event), do: :ok
end
```

---

## Telemetry

```elixir
# Attach a development logger
Railsr.Telemetry.attach_logger(:debug)

# Or define proper metrics in your Telemetry supervisor:
import Telemetry.Metrics

def metrics do
  [
    counter("railsr.request.stop.count",
      tags: [:method, :path]
    ),
    summary("railsr.request.stop.duration",
      unit: {:native, :millisecond},
      tags: [:method, :path]
    ),
    counter("railsr.request.stop.error_count",
      keep: &match?(%{status: s} when s >= 400, &1),
      tags: [:method, :path]
    )
  ]
end
```

---

## Error Handling

All public functions return `{:ok, result}` or `{:error, %Railsr.Error{}}`.

```elixir
case Railsr.Resources.Ledgers.get("led_missing") do
  {:ok, ledger} ->
    IO.inspect(ledger)

  {:error, %Railsr.Error{type: :not_found}} ->
    Logger.warning("Ledger not found")

  {:error, %Railsr.Error{type: :rate_limited, retryable?: true}} ->
    # The SDK retries automatically, but you can handle residual rate limits here
    Process.sleep(1_000)
    retry()

  {:error, %Railsr.Error{type: :unauthorized}} ->
    # Token was revoked — SDK invalidates cache and retries once automatically
    Logger.error("Auth failure — check RAILSR_CLIENT_ID / RAILSR_CLIENT_SECRET")

  {:error, %Railsr.Error{type: t, message: msg, request_id: rid}} ->
    Logger.error("Railsr error type=#{t} message=#{msg} request_id=#{rid}")
end
```

### Error Types

| Type             | HTTP | Retryable?                           |
| ---------------- | ---- | ------------------------------------ |
| `:unauthorized`  | 401  | No (token cache auto-refreshed once) |
| `:forbidden`     | 403  | No                                   |
| `:not_found`     | 404  | No                                   |
| `:conflict`      | 409  | No                                   |
| `:unprocessable` | 422  | No                                   |
| `:rate_limited`  | 429  | Yes                                  |
| `:server_error`  | 5xx  | Yes                                  |
| `:circuit_open`  || Yes                                  |
| `:timeout`       || Yes                                  |
| `:network`       || Yes                                  |

---

## Environment Reference

| Config       | URL                              |
| ------------ | -------------------------------- |
| `:play`      | `https://play.railsbank.com`     |
| `:play_live` | `https://playlive.railsbank.com` |
| `:live`      | `https://live.railsbank.com`     |

To credit a ledger with test funds in PLAY:

```elixir
Railsr.Resources.Ledgers.dev_credit("led_xxx", 100_000, "GBP")
# Adds £1,000 of fake balance — PLAY environment only
```

---

## Testing

```bash
mix test
mix test --cover
mix coveralls.html   # opens HTML coverage report
mix credo --strict
mix dialyzer
mix ci               # runs all checks
```

---

## Contributing

1. Fork the repo
2. Create a feature branch: `git checkout -b feat/my-feature`
3. Write tests first
4. Run `mix ci` — all checks must pass
5. Open a PR against `main`

---

## License

MIT — see [LICENSE](LICENSE).