# BankingCircle

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

A production-grade Elixir client for the [Banking Circle Connect
API](https://docs.bankingcircleconnect.com): cross-border payments (single
& bulk), accounts, and webhooks — with OAuth2/JWT auth (auto-refreshed,
single-flighted), client-side request validation, safe retries with
jittered backoff, idempotency support, and `:telemetry` instrumentation.

## Scope of this release

This package implements the full documented API surface: **Authentication**,
**Payments** (single & bulk, plus recalls and traces), **Accounts** (basic
operations, bookings/ledger, + AHV), **Virtual Accounts** (VIBANs —
listing, ordering, POBO/COBO customer/UBO details, closure lifecycle),
**Webhooks** (subscription management + AES-256-GCM payload verification),
**FX** (market-order trading, RFQ, indicative rates, held rates, trade
history/exposure, plus a WebSocket streaming client for live quotes and
Market Order execution), **Reporting** (both the async request → poll →
download flow, and the synchronous Reconciliation Report endpoint),
**Case Management** (RFI and Recall cases, including attachment upload
and answer submission), **Direct Debit Collections** (mandate-based
collection initiation, the one endpoint with documented idempotency-key
support), **Correspondent and Agency Banking** (the
`fi-to-fi-customer-credit-transfer-initiation` JSON endpoint), **ISO20022**
message transport (pain.001/pacs.008/camt.053 — see the scope note on
`BankingCircle.ISO20022` below), **Error handling**, **Idempotency**, and
**Rate limiting / retry**.

**Deliberately out of scope: Correspondent/Agency Banking via Swift.**
That integration path runs over the SWIFT FIN network itself (MT101/MT103
message exchange, BIC-to-BIC, via your Swift service bureau or direct
connection) — it isn't a Banking Circle Connect HTTP endpoint at all, so
there's nothing for an HTTP client library to wrap. If your integration
needs this, it's a separate SWIFT connectivity project, not something
`banking_circle` can extend into.

**Genuinely not implemented: Aliases (PayID, etc.).** Banking Circle's
docs describe this feature but I couldn't find its REST endpoint paths
and payload shapes documented anywhere I could search — rather than
invent plausible-looking endpoints for a payment-routing feature (where a
wrong guess is actively dangerous, not just inconvenient), I've left it
out. If you have API reference access to the Aliases endpoints, that's
a straightforward addition following the same pattern as every other
module here.

**Narrower scope notes, not full gaps:**
- `BankingCircle.VirtualAccounts.order/2`'s endpoint path is inferred
  from consistent terminology in the docs, not confirmed against the API
  reference directly — see its moduledoc before relying on it. Every
  other function in that module (list, close, close-status,
  add-customer-details, customer-details-status) is confirmed.
- `BankingCircle.ISO20022` is a transport layer (posts XML you supply to
  the right endpoint with the right content type) — it does not build or
  validate pain.001/pacs.008/camt.053 XML documents for you. See its
  moduledoc.
- `BankingCircle.FX.Stream` (WebSocket streaming quotes) is built on
  `WebSockex` rather than `Req`, has a genuinely different
  connection-lifecycle shape from the rest of this library, and — per its
  moduledoc — has had less real-world exercise than the REST modules.
  Verify reconnect/token-refresh behavior against your own sandbox before
  relying on it for production execution.

**Note on network verification:** every module here has been written and
tested against a hand-built harness (Bypass fake servers standing in for
Banking Circle's endpoints) rather than against Banking Circle's actual
sandbox — some endpoint paths, request/response field names, and the
webhook checksum construction should be confirmed against your own
sandbox account before going to production. Wherever the docs left a
detail ambiguous, the code says so in a comment (see especially
`BankingCircle.Webhook.Verifier` and the mTLS `connect_options` in
`BankingCircle.Auth.TokenServer`).

## Installation

```elixir
def deps do
  [
    {:banking_circle, "~> 1.0.0"}
  ]
end
```

## Configuration

Banking Circle uses **entirely separate hosts, credentials, and client
certificates** for sandbox vs. production — mixing them up is the most
common integration mistake, so `BankingCircle.Config` validates all of
this at boot rather than at first request.

```elixir
# config/runtime.exs
import Config

config :banking_circle, BankingCircle,
  environment: :sandbox, # or :production
  username: {:system, "BC_USERNAME"},
  password: {:system, "BC_PASSWORD"},
  certificate_thumbprint: {:system, "BC_CERT_THUMBPRINT"},
  client_cert_path: {:system, "BC_CLIENT_CERT_PATH"},
  client_key_path: {:system, "BC_CLIENT_KEY_PATH"},
  request_timeout_ms: 15_000,
  max_retries: 3
```

With that in place, add nothing else — BankingCircle.Application starts
a supervised `TokenServer` for the `:default` client automatically.

### Multiple clients (e.g. one per legal entity)

```elixir
config :banking_circle, :entities,
  eu_entity: [
    environment: :production,
    username: {:system, "BC_EU_USERNAME"},
    password: {:system, "BC_EU_PASSWORD"},
    certificate_thumbprint: {:system, "BC_EU_CERT_THUMBPRINT"}
  ],
  uk_entity: [
    environment: :production,
    username: {:system, "BC_UK_USERNAME"},
    password: {:system, "BC_UK_PASSWORD"},
    certificate_thumbprint: {:system, "BC_UK_CERT_THUMBPRINT"}
  ]
```

```elixir
BankingCircle.Payments.create_single(attrs, :eu_entity)
```

### Runtime-only clients (multi-tenant)

```elixir
{:ok, _pid} =
  BankingCircle.start_client(:tenant_42,
    environment: :production,
    username: tenant.bc_username,
    password: tenant.bc_password,
    certificate_thumbprint: tenant.bc_cert_thumbprint
  )

BankingCircle.Payments.create_single(attrs, :tenant_42)

:ok = BankingCircle.stop_client(:tenant_42)
```

## Usage

### Single payments

```elixir
{:ok, payment} =
  BankingCircle.Payments.create_single(%{
    debtor_account_id: "acc_123",
    amount: Decimal.new("100.50"),
    currency: "EUR",
    creditor_name: "Jane Doe",
    creditor_iban: "DE89370400440532013000",
    transaction_reference: "INV-2026-001"
  })

{:ok, %{"status" => status}} = BankingCircle.Payments.get_status(payment["id"])
```

Invalid input is caught **before any network call**, via an
`Ecto.Changeset`:

```elixir
{:error, changeset} = BankingCircle.Payments.create_single(%{currency: "euros"})
Ecto.Changeset.traverse_errors(changeset, fn {msg, _} -> msg end)
#=> %{currency: ["must be a 3-letter ISO 4217 code"], ...}
```

### Bulk payments

```elixir
rows = [
  %{debtor_account_id: "acc_1", amount: Decimal.new("10.00"), currency: "EUR", ...},
  %{debtor_account_id: "acc_1", amount: Decimal.new("20.00"), currency: "EUR", ...}
]

{:ok, bulk} = BankingCircle.Payments.create_bulk(rows)
{:ok, stats} = BankingCircle.Payments.get_bulk(bulk["id"])
```

A row failing validation is reported with a 1-based index matching
Banking Circle's documented `elementIndex` semantics, so client-side and
server-side validation errors read the same way:

```elixir
{:error, [{2, changeset}]} = BankingCircle.Payments.create_bulk([good_row, bad_row])
```

### Webhooks

```elixir
{:ok, sub} =
  BankingCircle.Webhooks.create_subscription(%{
    url: "https://myapp.example.com/webhooks/banking_circle",
    encryption_key: my_32_char_key,
    event_types: ["OutgoingPaymentBooked", "IncomingPaymentProcessed"],
    active: false # verify your receiver before flipping this on
  })
```

Verifying an inbound payload (see `BankingCircle.Webhook.Verifier` for
the full Plug example and important caveats about the checksum field):

```elixir
{:ok, event} =
  BankingCircle.Webhook.Verifier.verify_and_decrypt(raw_body,
    checksum: checksum_header,
    tag: tag_header,
    nonce: nonce_header,
    key: my_32_char_key
  )
```

### FX

Market order (filled instantly at the prevailing rate):

```elixir
{:ok, trade} =
  BankingCircle.FX.trade(%{
    client_order_id: "YourUniqueReference",
    buy_currency: "EUR",
    sell_currency: "GBP",
    amount: 10_000,
    amount_currency: "EUR",
    tenor: :on
  })
```

RFQ (get a firm 30-second quote, then trade against it — no `tenor` when trading a quote):

```elixir
{:ok, [quote]} =
  BankingCircle.FX.request_quotes([
    %{
      quote_request_id: Ecto.UUID.generate(),
      customer_id: "000012356",
      currency_pair: "EURUSD",
      amount: 10_000,
      amount_currency: "EUR",
      tenor: :on,
      request_type: :rfq
    }
  ])

{:ok, trade} =
  BankingCircle.FX.trade(%{
    client_order_id: "YourUniqueReference",
    buy_currency: "EUR",
    sell_currency: "USD",
    amount: 10_000,
    amount_currency: "EUR",
    quote_id: quote["quoteId"]
  })
```

Held rate, locked for up to 24 hours and reusable within your daily limits:

```elixir
{:ok, held} = BankingCircle.FX.held_rate("EUR", "GBP", 1440)
```

### Reporting

```elixir
# Blocks until ready — fine for scripts/IEx/background workers.
{:ok, report} = BankingCircle.Reporting.fetch_report(:reconciliation, %{from: ~D[2026-07-01], to: ~D[2026-07-10]})

# Or drive it yourself for a web request handler / background job:
{:ok, request_id} = BankingCircle.Reporting.request_report(:account_activity, %{account_id: "acc_123"})
{:processing, retry_after_ms} = BankingCircle.Reporting.poll_status(request_id)
# ... schedule a follow-up poll after retry_after_ms ...
{:complete, report_id} = BankingCircle.Reporting.poll_status(request_id)
{:ok, report} = BankingCircle.Reporting.download(report_id)
```

### Case Management

```elixir
{:ok, %{"questions" => questions}} = BankingCircle.Cases.get_rfi_case(case_id)

{:ok, %{"attachmentId" => attachment_id}} =
  BankingCircle.Cases.upload_attachment(case_id, File.read!("passport.pdf"), "passport.pdf", "application/pdf")

answers = BankingCircle.Cases.RFI.build_answers(%{
  0 => "1994-06-01",
  1 => {:attachments, [attachment_id]}
})

{:ok, _} = BankingCircle.Cases.submit_rfi_answers(case_id, %{answers: answers})
```

### Direct Debit Collections

```elixir
{:ok, collection} =
  BankingCircle.DirectDebit.initiate(%{
    creditor_account_id: "acc_123",
    mandate_id: "your-stored-mandate-id",
    debtor_iban: "DE89370400440532013000",
    amount: Decimal.new("25.00"),
    currency: "EUR",
    end_to_end_id: "INV-2026-001"
  })
```

### Correspondent / Agency Banking

```elixir
{:ok, payment} =
  BankingCircle.Payments.create_fi_to_fi(%{
    debtor_account: %{account: "NL4089009999910133", financial_institution: "AAAANL2LXXX", country: "NL"},
    debtor_name: "Debtor Name",
    creditor_account: %{account: "CH2289000000021111123", country: "CH"},
    creditor_name: "Creditor Name",
    amount: %{currency: "EUR", amount: "1.00"},
    instr_id: "Agency banking payment 1"
  })
```

### Recalls and traces

```elixir
{:ok, recall} = BankingCircle.Payments.initiate_recall(payment_id, "AC03")
{:ok, trace} = BankingCircle.Payments.initiate_trace(payment_id)
```

## Architecture

```
BankingCircle              — client lifecycle (start_client/stop_client)
├── Config                 — validated per-client config (NimbleOptions)
├── Environment             — sandbox/production host resolution
├── Auth.Token              — JWT + expiry helpers
├── Auth.TokenServer        — cached, single-flight-refreshed token per client
├── HTTP.Client              — shared Req pipeline: auth injection, errors
├── HTTP.Middleware.Retry    — jittered backoff, Retry-After, safe-methods-only
├── HTTP.Middleware.Idempotency — UUIDv4 Idempotency-Key generation/attachment
├── Error                   — normalizes both documented error body shapes
├── Payments                 — single & bulk payment lifecycle, recalls, traces, fi-to-fi
├── Accounts                 — balances, bookings, AHV
├── VirtualAccounts             — VIBAN listing, ordering, UBO details, closure
├── FX                        — market order / RFQ / held rate trading, trade history
├── FX.Stream                  — WebSocket streaming quotes + Market Order execution
├── Reporting                  — async report request/poll/download flow
├── Cases / Cases.RFI          — RFI & Recall case management
├── DirectDebit                — mandate-based collection initiation (idempotency-keyed)
├── ISO20022                   — pain.001/pacs.008/camt.053 XML transport
├── Webhooks                  — subscription management
├── Webhook.Verifier          — AES-256-GCM payload decrypt/verify
├── Schemas.Payment            — Ecto-changeset request validation
└── Schemas.BulkPayment         — batch validation + CSV rendering
```

### Why these design choices

- **Req over Tesla/HTTPoison**: Req is the current Elixir ecosystem
  default for new HTTP clients (built on Finch/Mint, first-class
  streaming, sane defaults). We lean on its *built-in* retry mechanism
  rather than reimplementing request/response steps against internals.
- **Ecto embedded schemas without a database**: changesets are simply the
  best-in-class validation/casting primitive available in Elixir; using
  them here costs nothing (no Repo, no migrations) and gets you field-level
  error reporting for free.
- **One GenServer per client for token caching**: concurrent callers
  racing a near-simultaneous token expiry collapse into a single outbound
  auth request because the GenServer mailbox serializes it — no separate
  locking primitive needed.
- **Retry policy refuses to retry a bare POST**: Banking Circle's
  idempotency-key support is currently scoped to Direct Debit Collections
  endpoints. Blindly retrying an unacknowledged payment POST elsewhere
  risks a duplicate payment, so the retry middleware only retries
  GET/HEAD or requests explicitly carrying an `Idempotency-Key` header.

## Telemetry

```elixir
:telemetry.attach_many(
  "banking-circle-logger",
  [
    [:banking_circle, :request, :start],
    [:banking_circle, :request, :stop],
    [:banking_circle, :request, :exception]
  ],
  &MyApp.Telemetry.handle_event/4,
  nil
)
```

See `BankingCircle.Telemetry` for the full event/measurement/metadata table.

## Development

This package was authored without a local Elixir toolchain available in
its build environment, so while every module has been written and
reviewed carefully against the Elixir/OTP and Req/Ecto APIs it relies on,
**run the test suite yourself before depending on this in production**:

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

## License

MIT. See `LICENSE`.
