README.md

# SaltEdgeClient

[![Hex.pm](https://img.shields.io/hexpm/v/salt_edge_client.svg)](https://hex.pm/packages/salt_edge_client)
[![Docs](https://img.shields.io/badge/hex-docs-purple)](https://hexdocs.pm/salt_edge_client)
[![CI](https://github.com/iamkanishka/salt-edge-client-elixir/actions/workflows/ci.yml/badge.svg)](https://github.com/iamkanishka/salt-edge-client-elixir/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

Production-grade Elixir hex package for the **SaltEdge API v6**, covering all three product areas:

- **AIS** — Account Information Service
- **PIS** — Payment Initiation Service
- **Data Enrichment Platform** — Categorisation, Merchant ID & Financial Insights

---

## Installation

```elixir
# mix.exs
defp deps do
  [{:salt_edge_client, "~> 1.0.0"}]
end
```

---

## Configuration

```elixir
# config/config.exs
config :salt_edge_client,
  app_id:           System.get_env("SALTEDGE_APP_ID"),
  secret:           System.get_env("SALTEDGE_SECRET"),
  private_key:      System.get_env("SALTEDGE_PRIVATE_KEY"),    # optional: request signing
  webhook_secret:   System.get_env("SALTEDGE_WEBHOOK_SECRET"), # optional: webhook validation
  timeout:          30_000,                                     # ms
  max_retries:      3,
  retry_base_delay: 200,                                        # ms
  retry_max_delay:  30_000,                                     # ms
  debug:            false
```

All options can be overridden per-call via the `opts` keyword list:

```elixir
SaltEdgeClient.AIS.Customers.list(timeout: 5_000, max_retries: 1)
```

---

## Quick Start

```elixir
alias SaltEdgeClient.AIS.{Customers, Connections, Accounts, Transactions}
alias SaltEdgeClient.Paginator

# AIS — Create a customer and open a connect session
{:ok, customer} = Customers.create(identifier: "alice@example.com")

{:ok, session} = Connections.connect(
  customer_id: customer["id"],
  consent: %{scopes: ["accounts", "transactions"]},
  attempt: %{return_to: "https://yourapp.com/callback"}
)
IO.puts session["connect_url"]
# => "https://www.saltedge.com/connect?token=..."

# Stream all accounts lazily
Accounts.stream(connection_id: "conn-123")
|> Stream.filter(fn a -> a["currency_code"] == "EUR" end)
|> Enum.each(fn a -> IO.puts("#{a["name"]}: #{a["balance"]}") end)

# Collect all transactions across all pages
{:ok, txns} = Paginator.collect(
  &Transactions.list/1,
  connection_id: "conn-123",
  account_id:    "acc-456"
)
IO.puts("Total transactions: #{length(txns)}")
```

---

## API Coverage

### AIS (Account Information Service)

| Module | Operations |
|---|---|
| `SaltEdgeClient.AIS.Countries` | `list/1` |
| `SaltEdgeClient.AIS.Providers` | `list/1`, `show/2`, `stream/1`, `all/1` |
| `SaltEdgeClient.AIS.Customers` | `create/1`, `show/2`, `list/1`, `remove/2`, `stream/1`, `all/1` |
| `SaltEdgeClient.AIS.Connections` | `connect/1`, `reconnect/2`, `refresh/2`, `background_refresh/2`, `update/2`, `show/2`, `list/1`, `remove/2`, `stream/1` |
| `SaltEdgeClient.AIS.Consents` | `list/1`, `show/2`, `revoke/2`, `stream/1` |
| `SaltEdgeClient.AIS.Accounts` | `list/1`, `stream/1`, `all/1` |
| `SaltEdgeClient.AIS.Transactions` | `list/1`, `update/2`, `stream/1`, `all/1` |
| `SaltEdgeClient.AIS.Rates` | `list/1`, `stream/1` |

### PIS (Payment Initiation Service)

| Module | Operations |
|---|---|
| `SaltEdgeClient.PIS.Customers` | `create/1`, `show/2`, `list/1`, `remove/2`, `stream/1` |
| `SaltEdgeClient.PIS.Providers` | `list/1`, `show/2`, `stream/1` |
| `SaltEdgeClient.PIS.Payments` | `create/1`, `show/2`, `list/1`, `refresh/2`, `stream/1` |
| `SaltEdgeClient.PIS.PaymentTemplates` | `show/2`, `list/1`, `stream/1` |
| `SaltEdgeClient.PIS.BulkPayments` | `create/1`, `show/2`, `list/1`, `refresh/2`, `stream/1` |

### Data Enrichment Platform

| Module | Operations |
|---|---|
| `SaltEdgeClient.Enrichment.Buckets` | `create/1`, `show/2`, `remove/2` |
| `SaltEdgeClient.Enrichment.Accounts` | `import/1`, `list/1`, `remove/2`, `stream/1` |
| `SaltEdgeClient.Enrichment.Transactions` | `import/1`, `categorize/1`, `categorized/1`, `categorized_stream/1` |
| `SaltEdgeClient.Enrichment.Merchants` | `show/1` |
| `SaltEdgeClient.Enrichment.Categories` | `list/1`, `list_by_type/2`, `learn/1` |
| `SaltEdgeClient.Enrichment.CustomerRules` | `list/1`, `show/2`, `remove/2`, `stream/1` |
| `SaltEdgeClient.Enrichment.FinancialInsights` | `create/1`, `show/2`, `list/1`, `remove/2`, `stream/1` |

---

## Pagination

All list endpoints return `{:ok, %{data: [...], next_id: "..."}}`.
Three pagination patterns are available:

```elixir
alias SaltEdgeClient.{AIS.Transactions, Paginator}

# Option A: collect all into a list (eager)
{:ok, all_txns} = Paginator.collect(
  &Transactions.list/1,
  connection_id: "conn-123"
)

# Option B: lazy Stream (Enum / Stream compatible)
Paginator.stream(&Transactions.list/1, connection_id: "conn-123")
|> Stream.filter(fn t -> t["amount"] < 0 end)
|> Enum.take(10)

# Option C: page-by-page callback
Paginator.each_page(
  &Transactions.list/1,
  fn page -> IO.inspect(length(page), label: "page size") end,
  connection_id: "conn-123"
)

# Convenience wrappers on service modules
SaltEdgeClient.AIS.Accounts.stream(connection_id: "conn-123") |> Enum.to_list()
SaltEdgeClient.AIS.Accounts.all(connection_id: "conn-123")   # {:ok, [...]}
```

---

## Error Handling

Every function returns `{:ok, result}` or `{:error, %SaltEdgeClient.Error{}}`.

```elixir
alias SaltEdgeClient.Error

case SaltEdgeClient.AIS.Customers.show("missing-id") do
  {:ok, customer} ->
    customer

  {:error, %Error{status: 404, class: "CustomerNotFound"}} ->
    :not_found

  {:error, %Error{} = err} when Error.server_error?(err) ->
    :retry_later

  {:error, %Error{status: :network}} ->
    :connectivity_problem
end

# Helper predicates
Error.not_found?(err)                    # status == 404
Error.rate_limited?(err)                 # status == 429
Error.server_error?(err)                 # status >= 500
Error.retryable?(err)                    # network | 429 | 5xx
Error.has_class?(err, "CustomerNotFound")
```

---

## Webhooks

### Phoenix / Plug Router

```elixir
# router.ex
forward "/webhooks/saltedge", SaltEdgeClient.Webhook.Handler,
  validate_signature: true,
  handlers: %{
    ais_success:         &MyApp.Webhooks.handle_ais_success/1,
    ais_failure:         &MyApp.Webhooks.handle_ais_failure/1,
    ais_notify:          &MyApp.Webhooks.handle_ais_notify/1,
    ais_destroy:         &MyApp.Webhooks.handle_ais_destroy/1,
    pis_payment_success: &MyApp.Webhooks.handle_payment_success/1,
    pis_payment_failure: &MyApp.Webhooks.handle_payment_failure/1
  }
```

### Handler implementation

```elixir
defmodule MyApp.Webhooks do
  def handle_ais_success(%{"connection_id" => conn_id, "stage" => "finish"}) do
    MyApp.BankSync.run(conn_id)
  end
  def handle_ais_success(_data), do: :ok

  def handle_ais_failure(%{"error_class" => "InvalidCredentials"} = data) do
    MyApp.Notifications.notify_user(data["customer_id"], :reconnect_needed)
  end
  def handle_ais_failure(_data), do: :ok
end
```

### Standalone validation

```elixir
alias SaltEdgeClient.Webhook.Validator

body = conn.assigns[:raw_body]
sig  = get_req_header(conn, "signature") |> List.first()

case Validator.validate(body, sig) do
  :ok              -> process(conn)
  {:error, reason} -> send_resp(conn, 401, to_string(reason))
end
```

---

## Request Signing

When `:private_key` is configured, every outgoing request is signed with HMAC-SHA256:

```elixir
config :salt_edge_client, private_key: System.get_env("SALTEDGE_PRIVATE_KEY")
```

The `Expires-at` and `Signature` headers are added automatically by `SaltEdgeClient.Client`.

---

## Telemetry

```elixir
# Events emitted per request:
[:salt_edge_client, :request, :start]  # %{system_time: ...}, %{method:, path:}
[:salt_edge_client, :request, :stop]   # %{system_time: ...}, %{method:, path:, result:}

# Attach the built-in Logger handler in Application.start/2:
SaltEdgeClient.Telemetry.attach_default_logger()

# Or attach your own:
:telemetry.attach("my-handler", [:salt_edge_client, :request, :stop],
  fn _event, _measurements, meta, _config ->
    Logger.info("SaltEdge #{meta.method} #{meta.path} -> #{meta.result}")
  end, nil)
```

---

## Running Tests

```bash
mix deps.get
mix test                    # all tests
mix test --cover            # with ExCoveralls HTML report
mix credo --strict          # style analysis (0 issues expected)
mix dialyzer                # type checking
mix docs                    # generate ExDoc HTML
```

---

## Project Structure

```
lib/
├── salt_edge_client.ex                    # Entry point + module index
└── salt_edge_client/
    ├── application.ex                     # OTP Application
    ├── client.ex                          # HTTP executor (Req + retries + telemetry)
    ├── config.ex                          # Runtime configuration
    ├── error.ex                           # %Error{} struct + predicates
    ├── paginator.ex                       # stream/2, collect/2, each_page/3
    ├── signer.ex                          # HMAC-SHA256 signing + webhook verify
    ├── telemetry.ex                       # Telemetry events + default logger
    ├── ais/                               # Account Information Service
    │   ├── countries.ex
    │   ├── providers.ex
    │   ├── customers.ex
    │   ├── connections.ex
    │   ├── consents.ex
    │   ├── accounts.ex
    │   ├── transactions.ex
    │   └── rates.ex
    ├── pis/                               # Payment Initiation Service
    │   ├── customers.ex
    │   ├── providers.ex
    │   ├── payments.ex
    │   ├── payment_templates.ex
    │   └── bulk_payments.ex
    ├── enrichment/                        # Data Enrichment Platform
    │   ├── buckets.ex
    │   ├── accounts.ex
    │   ├── transactions.ex
    │   ├── merchants.ex
    │   ├── categories.ex
    │   ├── customer_rules.ex
    │   └── financial_insights.ex
    └── webhook/
        ├── handler.ex                     # Plug-based handler
        └── validator.ex                   # Signature validation

test/
├── salt_edge_client/
│   ├── ais/                               # AIS service tests
│   ├── pis/                               # PIS service tests
│   ├── enrichment/                        # Enrichment tests
│   ├── webhook/                           # Webhook tests
│   ├── error_test.exs
│   ├── signer_test.exs
│   ├── paginator_test.exs
│   └── config_test.exs
└── support/bypass_helpers.ex              # Shared Bypass test utilities
```

---

## Credo Clean

This package passes `mix credo --strict` with **zero issues**:

- All functions have `@spec` type annotations and `@doc` documentation
- No cyclomatic complexity violations (`Config.new/1` ≤ 3, `detect_event_type/1` ≤ 3 per predicate)
- All test modules use `alias` blocks — no unaliased nested module references
- `@moduledoc` present on every module

---

## License

[MIT](LICENSE)