SaltEdgeClient

Hex.pmDocsCILicense: MIT

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


Installation

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

Configuration

# 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:

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

Quick Start

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.Countrieslist/1
SaltEdgeClient.AIS.Providerslist/1, show/2, stream/1, all/1
SaltEdgeClient.AIS.Customerscreate/1, show/2, list/1, remove/2, stream/1, all/1
SaltEdgeClient.AIS.Connectionsconnect/1, reconnect/2, refresh/2, background_refresh/2, update/2, show/2, list/1, remove/2, stream/1
SaltEdgeClient.AIS.Consentslist/1, show/2, revoke/2, stream/1
SaltEdgeClient.AIS.Accountslist/1, stream/1, all/1
SaltEdgeClient.AIS.Transactionslist/1, update/2, stream/1, all/1
SaltEdgeClient.AIS.Rateslist/1, stream/1

PIS (Payment Initiation Service)

Module Operations
SaltEdgeClient.PIS.Customerscreate/1, show/2, list/1, remove/2, stream/1
SaltEdgeClient.PIS.Providerslist/1, show/2, stream/1
SaltEdgeClient.PIS.Paymentscreate/1, show/2, list/1, refresh/2, stream/1
SaltEdgeClient.PIS.PaymentTemplatesshow/2, list/1, stream/1
SaltEdgeClient.PIS.BulkPaymentscreate/1, show/2, list/1, refresh/2, stream/1

Data Enrichment Platform

Module Operations
SaltEdgeClient.Enrichment.Bucketscreate/1, show/2, remove/2
SaltEdgeClient.Enrichment.Accountsimport/1, list/1, remove/2, stream/1
SaltEdgeClient.Enrichment.Transactionsimport/1, categorize/1, categorized/1, categorized_stream/1
SaltEdgeClient.Enrichment.Merchantsshow/1
SaltEdgeClient.Enrichment.Categorieslist/1, list_by_type/2, learn/1
SaltEdgeClient.Enrichment.CustomerRuleslist/1, show/2, remove/2, stream/1
SaltEdgeClient.Enrichment.FinancialInsightscreate/1, show/2, list/1, remove/2, stream/1

Pagination

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

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{}}.

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

# 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

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

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:

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

# 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

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:


License

MIT