Skip to main content

README.md

# Solaris

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

Production-grade Elixir client for the [Solaris Embedded Finance API](https://docs.solarisgroup.com/api-reference).

Covers the full API surface โ€” Onboarding, KYC, Digital Banking, SEPA Transfers, Cards, Lending, and Webhooks โ€” with idiomatic Elixir: typed errors, auto-paginating streams, telemetry, OAuth2 token management, retries, and rate limiting.

## Features

- โœ… **Full API coverage** โ€” Persons, Businesses, KYC, Accounts, SEPA, Cards, Loans, Trade Finance
- ๐Ÿ” **OAuth2 token management** โ€” Auto-refresh with proactive expiry buffer (via GenServer + `:persistent_term`)
- ๐Ÿ” **Retry with exponential backoff** โ€” Automatic retries on 429/5xx with jitter
- ๐Ÿ“Š **Telemetry** โ€” `:telemetry` events on every request and webhook; compatible with Prometheus/StatsD
- ๐ŸŒŠ **Streaming pagination** โ€” `stream/1` on all list endpoints via `Solaris.Pagination`
- ๐Ÿ”‘ **Idempotency keys** โ€” Auto-generated on POST/PUT/PATCH; override per-request
- ๐Ÿช **Webhook support** โ€” HMAC-SHA256 verification, event dispatch, Plug integration
- ๐Ÿงช **Sandbox helpers** โ€” Robot identification, card authorization simulation
- ๐Ÿ“ **Typed errors** โ€” `%Solaris.Error{}` with code, status, details, and request ID

## Installation

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

## Configuration

```elixir
# config/runtime.exs
config :solaris,
  client_id: System.fetch_env!("SOLARIS_CLIENT_ID"),
  client_secret: System.fetch_env!("SOLARIS_CLIENT_SECRET"),
  environment: :sandbox,           # :sandbox | :production
  timeout: 30_000,                 # HTTP timeout in ms
  max_retries: 3,                  # Retry count on transient errors
  webhook_secret: System.get_env("SOLARIS_WEBHOOK_SECRET")
```

## Quick Start

```elixir
# 1. Onboard a person
{:ok, person} = Solaris.Onboarding.Persons.create(%{
  first_name: "Jane",
  last_name: "Doe",
  email: "jane@example.com",
  birth_date: "1990-01-15",
  nationality: "DE",
  address: %{
    line_1: "Unter den Linden 1",
    postal_code: "10117",
    city: "Berlin",
    country: "DE"
  }
})

# 2. Register and verify mobile number (for 2FA)
{:ok, _} = Solaris.Onboarding.Persons.create_mobile_number(person["id"], "+491701234567")
{:ok, _} = Solaris.Onboarding.Persons.authorize_mobile_number(person["id"])
{:ok, _} = Solaris.Onboarding.Persons.confirm_mobile_number(person["id"], "123456")

# 3. Start KYC
{:ok, identification} = Solaris.Onboarding.KYC.create_person_identification(person["id"], %{
  method: "idnow"
})
# redirect customer to identification["url"]

# 4. After KYC โ€” check account
{:ok, accounts} = Solaris.Banking.Accounts.list_person_accounts(person["id"])
account = List.first(accounts)

# 5. Initiate a SEPA transfer (returns 202 + change_request_id for SCA)
{:ok, result} = Solaris.Banking.SEPA.create_person_credit_transfer(
  person["id"],
  account["id"],
  %{
    recipient_iban: "DE89370400440532013000",
    recipient_name: "Max Mustermann",
    amount: 10_000,
    currency: "EUR",
    reference: "Invoice #123"
  }
)

# 6. Complete the SCA change request via SMS OTP
change_request_id = result["change_request_id"]
{:ok, _} = Solaris.ChangeRequests.authorize_with_sms(change_request_id)
{:ok, _} = Solaris.ChangeRequests.confirm_with_otp(change_request_id, "483920")
```

## Error Handling

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

```elixir
case Solaris.Banking.Accounts.get_balance(account_id) do
  {:ok, %{"balance" => %{"amount" => amount, "currency" => currency}}} ->
    IO.puts("Balance: #{amount} #{currency}")

  {:error, %Solaris.Error{code: :not_found}} ->
    Logger.warning("Account not found")

  {:error, %Solaris.Error{code: :unauthorized, request_id: req_id}} ->
    Logger.error("Auth failed, request_id: #{req_id}")

  {:error, %Solaris.Error{code: :rate_limited}} ->
    # Automatic retries handle this, but you can also catch it
    :backoff
end
```

## Pagination & Streaming

```elixir
# Fetch one page
{:ok, page} = Solaris.Onboarding.Persons.list(per_page: 50)

# Stream ALL persons across all pages (lazy)
Solaris.Onboarding.Persons.stream()
|> Stream.filter(fn p -> p["status"] == "ACTIVE" end)
|> Stream.each(fn p -> process(p) end)
|> Stream.run()

# Fetch all into memory (use with care for large datasets)
{:ok, all_persons} = Solaris.Pagination.all(fn cursor ->
  Solaris.Onboarding.Persons.list(after: cursor)
  |> case do
    {:ok, page} -> {:ok, Solaris.Pagination.from_response(page)}
    err -> err
  end
end)
```

## Webhooks

### Phoenix Integration

```elixir
# endpoint.ex โ€” preserve raw body for signature verification
plug Plug.Parsers,
  parsers: [:json],
  json_decoder: Jason,
  body_reader: {Solaris.Webhooks.Plug.BodyReader, :read_body, []}

# router.ex
post "/webhooks/solaris", Solaris.Webhooks.Plug,
  handler: MyApp.SolarisHandler,
  secret: System.get_env("SOLARIS_WEBHOOK_SECRET")
```

```elixir
# my_app/solaris_handler.ex
defmodule MyApp.SolarisHandler do
  @behaviour Solaris.Webhooks.Handler

  @impl true
  def handle_event("BOOKING", %{"booking" => booking}, _event) do
    MyApp.Ledger.record(booking)
    :ok
  end

  def handle_event("IDENTIFICATION", payload, _event) do
    case payload["identification"]["status"] do
      "successful" -> MyApp.KYC.complete(payload["person_id"])
      "failed"     -> MyApp.KYC.reject(payload["person_id"])
      _ -> :ok
    end
  end

  # Always add a catch-all for forward compatibility
  def handle_event(_type, _payload, _event), do: :ok
end
```

### Manual Verification

```elixir
raw_body = get_raw_body(conn)
signature = get_req_header(conn, "solaris-webhook-signature")

case Solaris.Webhooks.verify_and_parse(raw_body, signature, webhook_secret) do
  {:ok, event} ->
    Solaris.Webhooks.dispatch(event, MyApp.SolarisHandler)
    send_resp(conn, 200, "ok")

  {:error, :invalid_signature} ->
    send_resp(conn, 401, "unauthorized")
end
```

## Telemetry

```elixir
# Attach the default logger (for development)
Solaris.Telemetry.attach_default_logger(:debug)

# Use with telemetry_metrics
defmodule MyApp.Metrics do
  def metrics do
    Solaris.Telemetry.metrics()
    # Returns distribution/counter metrics for all Solaris events
  end
end
```

Events emitted:

- `[:solaris, :request, :start]`
- `[:solaris, :request, :stop]` โ€” includes `method`, `path`, `status`, `duration`
- `[:solaris, :webhook, :received]` โ€” includes `event_type`, `delivery_id`
- `[:solaris, :rate_limit, :hit]`

## Consumer Loan Flow

```elixir
# 1. Create application
{:ok, app} = Solaris.Lending.ConsumerLoans.create_application(person_id, %{
  amount: 10_000_00, currency: "EUR", term_months: 36, purpose: "CONSUMER_GOODS"
})

# 2. Wait for CONSUMER_LOAN_APPLICATION webhook with status "OFFERED"

# 3. Download SECCI (legally required before signing)
{:ok, pdf} = Solaris.Lending.ConsumerLoans.get_secci(person_id, app["id"], offer_id)
present_to_customer(pdf)

# 4. Get final contract
{:ok, contract_pdf} = Solaris.Lending.ConsumerLoans.get_contract(person_id, app["id"], offer_id)

# 5. Create loan (after customer signs)
{:ok, loan} = Solaris.Lending.ConsumerLoans.create_loan(person_id, app["id"], %{
  offer_id: offer_id, signing_id: signing_id
})
```

## Sandbox Testing

```elixir
# Simulate card authorization (3DS flow)
Solaris.Cards.sandbox_simulate_authorization(card_id, %{amount: 5000, currency: "EUR"})

# Robot-based KYC identification
Solaris.Onboarding.KYC.sandbox_identify_with_robot("AUTOTEST-APPROVED")

# Simulate expired ID document
Solaris.Onboarding.Persons.simulate_id_document_expiry(person_id)

# Set cash operation status
Solaris.Banking.Transactions.sandbox_set_cash_operation_status(account_id, op_id, "PAID")
```

## Module Reference

| Module                          | Description                                      |
| ------------------------------- | ------------------------------------------------ |
| `Solaris.Onboarding.Persons`    | Person CRUD, mobile numbers, tax IDs, documents  |
| `Solaris.Onboarding.Businesses` | Business CRUD, legal reps, beneficial owners     |
| `Solaris.Onboarding.KYC`        | Identification sessions, signings, screener hits |
| `Solaris.Banking.Accounts`      | Accounts, balances, bookings, savings, IBANs     |
| `Solaris.Banking.SEPA`          | SCT, Instant SCT, SDD mandates                   |
| `Solaris.Banking.Transactions`  | Cash ops, top-ups, remittances, payouts          |
| `Solaris.Cards`                 | Issuance, lifecycle, tokenization, 3DS           |
| `Solaris.Lending.Loans`         | Loan servicing, repayment, dunning               |
| `Solaris.Lending.ConsumerLoans` | Loan applications, offers, SECCI                 |
| `Solaris.Lending.Overdraft`     | Overdraft facilities                             |
| `Solaris.Lending.Splitpay`      | BNPL / installment plans                         |
| `Solaris.Lending.TradeFinance`  | Business trade credit lines                      |
| `Solaris.ChangeRequests`        | SCA/2FA completion flow                          |
| `Solaris.Webhooks`              | Signature verification, dispatch                 |
| `Solaris.Webhooks.Plug`         | Phoenix/Plug webhook endpoint                    |
| `Solaris.Pagination`            | Cursor pagination, streaming                     |
| `Solaris.Telemetry`             | Telemetry events and metrics                     |

## License

MIT โ€” see [LICENSE](LICENSE).