README.md

# YapilyClient

[![Hex.pm](https://img.shields.io/hexpm/v/yapily_client.svg)](https://hex.pm/packages/yapily_client)
[![Hex Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/yapily_client)
[![API v12](https://img.shields.io/badge/Yapily%20API-v12.0.0-green)](https://docs.yapily.com)

Unofficial Elixir client for the [Yapily Open Banking API v12](https://docs.yapily.com).  
Connect to **2,000+ banks** across the UK and Europe.

## Installation

```elixir
# mix.exs
{:yapily_client, "~> 1.0.0"}
```

## Configuration

```elixir
# config/runtime.exs — read credentials at runtime, never hard-code
config :yapily_client,
  app_key:    System.fetch_env!("YAPILY_APP_KEY"),
  app_secret: System.fetch_env!("YAPILY_APP_SECRET")
```

Or build a config struct directly:

```elixir
config = YapilyClient.Config.new!(
  app_key:    System.fetch_env!("YAPILY_APP_KEY"),
  app_secret: System.fetch_env!("YAPILY_APP_SECRET")
)
```

## Quick start

```elixir
# 1. List supported banks
{:ok, institutions} = YapilyClient.Institutions.list(config)

# 2. Start an account authorisation (redirect flow)
{:ok, auth} = YapilyClient.Authorisations.create_account(config, %{
  institution_id:      "monzo",
  application_user_id: "your-internal-user-id",
  callback:            "https://yourapp.com/callback",
  feature_scope_list:  ["ACCOUNTS", "TRANSACTIONS"],
  one_time_token:      true
})
# Redirect the user to:
auth.authorisation_url

# 3. Exchange the token from your callback URL
{:ok, consent} = YapilyClient.Consents.exchange_one_time_token(config, token)

# 4. Read accounts
{:ok, accounts} = YapilyClient.Accounts.list(config, consent.id)

# 5. Stream transactions lazily
YapilyClient.Transactions.stream(config, consent.id, account_id)
|> Stream.filter(&(&1.currency == "GBP"))
|> Enum.take(100)
```

## Payments

### Domestic

```elixir
{:ok, payment} = YapilyClient.Payments.create(config, consent_token, %{
  type:                    YapilyClient.payment_type(:domestic),
  payment_idempotency_id:  YapilyClient.idempotency_key("invoice-001"),
  amount:                  100.00,
  currency:                "GBP",
  recipient: %{
    name: "Jane Smith",
    account_identifications: [
      %{type: "SORT_CODE",      identification: "200000"},
      %{type: "ACCOUNT_NUMBER", identification: "55779911"}
    ]
  },
  reference: "Invoice-001"
})
```

### International

```elixir
{:ok, _} = YapilyClient.Payments.create(config, consent_token, %{
  type:                   YapilyClient.payment_type(:international),
  payment_idempotency_id: YapilyClient.idempotency_key(),
  amount:    500.00,
  currency:  "GBP",
  recipient: %{
    name: "Maria Müller",
    address: %{country: "DE"},
    account_identifications: [
      %{type: "IBAN", identification: "DE89370400440532013000"},
      %{type: "BIC",  identification: "COBADEFFXXX"}
    ]
  },
  international_payment: %{
    currency_of_transfer: "EUR",
    charge_bearer: "DEBT",   # DEBT | CRED | SHAR | FOLLOWING
    priority:      "NORMAL", # NORMAL | URGENT
    purpose:       "GDDS"    # ISO 20022 purpose code
  }
})
```

### Periodic (standing order)

```elixir
{:ok, _} = YapilyClient.Payments.create(config, consent_token, %{
  type:                    YapilyClient.payment_type(:domestic_periodic),
  payment_idempotency_id:  YapilyClient.idempotency_key(),
  payment_date_time:       "2025-01-01T09:00:00Z",
  amount:   1_200.00,
  currency: "GBP",
  recipient: %{name: "Landlord", account_identifications: [...]},
  periodic_payment: %{
    frequency:         YapilyClient.frequency(:monthly),
    execution_day:     1,
    interval_month:    1,
    number_of_payments: 12          # or: final_payment_date_time: "2025-12-01T09:00:00Z"
  }
})
```

## Variable Recurring Payments (VRP)

```elixir
# 1. Authorise once
{:ok, vrp} = YapilyClient.VRP.create_sweeping_authorisation(config, %{
  institution_id:      "monzo",
  application_user_id: "user-id",
  callback:            "https://yourapp.com/vrp-callback",
  control_parameters: %{
    currency:                  "GBP",
    maximum_individual_amount: 500.00,
    periodic_limits: [
      %{maximum_amount: 2_000.00, currency: "GBP",
        period_type: "Month", period_alignment: "Calendar"}
    ]
  }
})
# Redirect user to: vrp.authorisation_url

# 2. Sweep repeatedly — no re-auth
{:ok, payment} = YapilyClient.VRP.create_payment(config, consent_token, vrp.id, %{
  amount:    250.00,
  currency:  "GBP",
  recipient: %{name: "Savings", account_identifications: [...]},
  reference: "Monthly Sweep"
})
```

## Error handling

```elixir
case YapilyClient.Accounts.list(config, consent_token) do
  {:ok, accounts} ->
    accounts

  {:error, err} when YapilyClient.Error.not_found?(err) ->
    handle_not_found()

  {:error, err} when YapilyClient.Error.unauthorized?(err) ->
    handle_unauthorized()

  {:error, err} when YapilyClient.Error.rate_limited?(err) ->
    handle_rate_limit()

  {:error, err} when YapilyClient.Error.vop_rejected?(err) ->
    handle_vop_failure()

  {:error, err} when YapilyClient.Error.insufficient_funds?(err) ->
    handle_insufficient_funds()

  {:error, err} when YapilyClient.Error.retryable?(err) ->
    retry_later()

  {:error, %YapilyClient.Error.APIError{status: s, code: c, trace_id: t}} ->
    Logger.error("API error #{s} #{c} (trace: #{t})")

  {:error, %YapilyClient.Error.EnhancedAPIError{issues: issues, tracing_id: t}} ->
    Enum.each(issues, &Logger.error("[#{&1.code}] #{&1.type}: #{&1.message}"))
    Logger.error("trace: #{t}")

  {:error, %YapilyClient.Error.ValidationError{field: f, message: m}} ->
    {:error, "#{f}: #{m}"}
end
```

## Consent polling (Fibonacci back-off)

```elixir
case YapilyClient.ConsentPoller.wait_for_authorisation(config, consent_id) do
  {:ok, %{status: "AUTHORIZED"} = consent} -> proceed(consent)
  {:ok, %{status: status}}                 -> handle_failure(status)
  {:error, :timed_out}                     -> show_timeout()
end
```

Delays: `1 s → 1 s → 2 s → 3 s → 5 s → 8 s → 13 s → 21 s → 34 s` (~88 s total).

## Webhook verification

```elixir
defmodule MyAppWeb.WebhookController do
  use MyAppWeb, :controller

  def handle(conn, _params) do
    raw_body  = conn.assigns.raw_body
    signature = get_req_header(conn, "x-yapily-signature") |> List.first()
    secret    = System.get_env("YAPILY_WEBHOOK_SECRET")

    case YapilyClient.Webhook.verify(raw_body, secret, signature) do
      :ok ->
        process_event(conn.body_params)
        send_resp(conn, 200, "ok")

      {:error, reason} ->
        send_resp(conn, 401, Atom.to_string(reason))
    end
  end
end
```

## Testing

```elixir
# config/test.exs
config :yapily_client, http_client: YapilyClient.HTTP.MockClient

# test/test_helper.exs
Mox.defmock(YapilyClient.HTTP.MockClient, for: YapilyClient.HTTP.Behaviour)

# In your test
import Mox

test "lists accounts" do
  config = YapilyClient.Config.new!(app_key: "k", app_secret: "s")

  expect(YapilyClient.HTTP.MockClient, :request, fn _config, :get, "/accounts", _opts ->
    {:ok, %{"data" => [%{"id" => "acc-1", "type" => "CURRENT", ...}]}}
  end)

  assert {:ok, [acc]} = YapilyClient.Accounts.list(config, "consent-token")
  assert acc.id == "acc-1"
end
```

## Service reference

| Module | Methods | Description |
|--------|---------|-------------|
| `YapilyClient.Institutions` | `list/1`, `get/2` | Supported banks |
| `YapilyClient.Accounts` | `list/2`, `get/3` | Account detail |
| `YapilyClient.Transactions` | `list/4`, `list_all/4`, `stream/4`, `list_real_time/3` | Transaction history |
| `YapilyClient.Payments` | `create/3`, `get/3` | All 6 payment types |
| `YapilyClient.BulkPayments` | `create/3`, `get_status/3` | Batch payments |
| `YapilyClient.Consents` | `list/2`, `get/2`, `delete/3`, `extend/3`, `exchange_oauth2_code/2`, `exchange_one_time_token/2` | Consent lifecycle |
| `YapilyClient.Authorisations` | 14 functions | All auth flows |
| `YapilyClient.FinancialData` | 10 functions | Balances, statements, identity |
| `YapilyClient.Users` | `list/2`, `create/2`, `get/2`, `delete/2`, `update/3` | PSU management |
| `YapilyClient.VRP` | 5 functions | Variable Recurring Payments |
| `YapilyClient.Notifications` | 4 functions | Event subscriptions |
| `YapilyClient.DataPlus` | 6 functions | Transaction enrichment |
| `YapilyClient.HostedPages` | 11 functions | Yapily-hosted UIs |
| `YapilyClient.Constraints` | 2 functions | Institution constraints |
| `YapilyClient.ApplicationManagement` | 8 functions | App management |
| `YapilyClient.Webhooks` | 5 functions | Webhook management |
| `YapilyClient.Beneficiaries` | 11 functions | VoP flows |
| `YapilyClient.Validate` | `get_identity/2`, `validate_ownership/4` | Ownership verification |
| `YapilyClient.ConsentPoller` | `wait_for_authorisation/2,3` | Fibonacci polling |
| `YapilyClient.Webhook` | `verify/3`, `valid?/3` | Signature verification |

## License

MIT