Skip to main content

README.md

# TreasuryPrime

An unofficial, complete Elixir client for the [Treasury Prime](https://www.treasuryprime.com)
banking API (the Ledger product): bank accounts, ACH, wires, book
transfers, FedNow, debit cards, account opening / KYC, check deposit
(RDC), check issuing, Green Dot cash loads, webhooks, and sandbox
simulations.

- **Zero required HTTP dependency.** Ships with a default transport built
  entirely on Erlang's own `:httpc`/`:ssl` — no Req, Finch, or Hackney
  required. Bring your own by implementing one callback
  (`TreasuryPrime.HTTPClient`) if you'd rather use what your app already
  depends on.
- **Every call comes in a raising and non-raising flavor**, e.g.
  `TreasuryPrime.Account.get/2` / `TreasuryPrime.Account.get!/2`.
- **Lazy pagination.** List endpoints return a `TreasuryPrime.Page`;
  `TreasuryPrime.Page.stream/1` gives you a lazy `Stream` across every
  page, fetching only what you actually consume.
- **~55 resource modules** covering the full Treasury Prime Ledger API
  surface — accounts, payments, cards, account opening, and utilities.
- Idempotency key helpers, automatic retry with backoff on `429`/`5xx`,
  `:telemetry` instrumentation, and webhook signature verification built in.

This is a community-built client and is not affiliated with or endorsed by
Treasury Prime, Inc. Always cross-check field names and behavior against
the [official API reference](https://docs.treasuryprime.com/reference/introduction)
for your integration — Treasury Prime's API surface evolves, and some
lesser-used endpoints in this library (noted in their `@moduledoc`s) were
implemented from documentation rather than against a live account.

## Installation

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

## Quick start

```elixir
client =
  TreasuryPrime.new(
    api_key_id: System.fetch_env!("TREASURY_PRIME_KEY_ID"),
    api_key_value: System.fetch_env!("TREASURY_PRIME_KEY_VALUE"),
    environment: :sandbox # or :production
  )

{:ok, page} = TreasuryPrime.Account.list(client)
Enum.each(page.data, &IO.inspect/1)

{:ok, account} = TreasuryPrime.Account.get(client, "acct_123456")

{:ok, ach} =
  TreasuryPrime.Ach.create(
    client,
    %{
      account_id: "acct_123456",
      counterparty_id: "cp_098765",
      amount: "100.00",
      direction: "credit",
      sec_code: "ccd"
    },
    idempotency_key: TreasuryPrime.Idempotency.generate_key()
  )
```

Prefer not to thread a `client` through every call? Configure one in
`config.exs` / `runtime.exs` and pull it from `TreasuryPrime.Config`
instead:

```elixir
# config/runtime.exs
config :treasury_prime,
  api_key_id: System.fetch_env!("TREASURY_PRIME_KEY_ID"),
  api_key_value: System.fetch_env!("TREASURY_PRIME_KEY_VALUE"),
  environment: :sandbox
```

```elixir
client = TreasuryPrime.Config.client()
TreasuryPrime.Account.list(client)
```

## Pagination

```elixir
{:ok, page} = TreasuryPrime.Ach.list(client, %{status: "pending"})
page.data #=> [%TreasuryPrime.Ach{}, ...]

# One page at a time:
{:ok, next_page} = TreasuryPrime.Page.next(page)

# Or lazily over everything:
client
|> TreasuryPrime.Ach.list!(%{status: "pending"})
|> TreasuryPrime.Page.stream()
|> Stream.filter(&(&1.amount == "100.00"))
|> Enum.take(10)
```

## Errors

Every function returns `{:ok, result} | {:error, %TreasuryPrime.Error{}}`,
or has a `!` variant that raises the same `TreasuryPrime.Error` struct
(which implements the `Exception` behaviour) instead.

```elixir
case TreasuryPrime.Ach.create(client, %{}) do
  {:ok, ach} ->
    ach

  {:error, %TreasuryPrime.Error{type: :api_error, status: 400, body: body}} ->
    Logger.error("ACH creation failed: #{inspect(body)}")

  {:error, %TreasuryPrime.Error{type: :network_error}} = error ->
    Logger.error(Exception.message(elem(error, 1)))
end
```

## Webhooks

```elixir
{:ok, webhook} =
  TreasuryPrime.Webhook.create(client, %{
    event: "ach.update",
    url: "https://example.application.com/notify",
    basic_user: "myapp",
    basic_secret: System.fetch_env!("TREASURY_PRIME_WEBHOOK_SECRET")
  })
```

In your webhook controller:

```elixir
def create(conn, params) do
  header = conn |> Plug.Conn.get_req_header("authorization") |> List.first()

  if TreasuryPrime.WebhookSignature.valid?(header, "myapp", webhook_secret()) do
    event = TreasuryPrime.WebhookEvent.parse!(params)
    {:ok, fresh_object} = TreasuryPrime.WebhookEvent.fetch(event, client)
    MyApp.Webhooks.handle(event.event, fresh_object)
    send_resp(conn, 200, "")
  else
    send_resp(conn, 401, "")
  end
end
```

## Sandbox testing

```elixir
{:ok, _} = TreasuryPrime.Testing.Simulation.ach_status(client, ach.id, "settled")
{:ok, _} = TreasuryPrime.Testing.Simulation.card_auth_request(client, card.id, %{amount: "25.00"})
```

## Using a different HTTP transport

```elixir
defmodule MyApp.ReqHTTPClient do
  @behaviour TreasuryPrime.HTTPClient

  @impl true
  def request(method, url, headers, body, opts) do
    case Req.request(method: method, url: url, headers: headers, body: body, retry: false) do
      {:ok, resp} -> {:ok, %{status: resp.status, headers: resp.headers, body: resp.body}}
      {:error, reason} -> {:error, reason}
    end
  end
end

client = TreasuryPrime.new(api_key_id: "...", api_key_value: "...", http_client: MyApp.ReqHTTPClient)
```

## Resource coverage

| Area | Modules |
|---|---|
| Account opening | `AccountApplication`, `BusinessApplication`, `PersonApplication`, `AdditionalPersonApplication`, `Deposit`, `Kyc`, `KycProduct`, `AccountProduct`, `AccountNumberReservation` |
| Accounts & parties | `Account`, `Business`, `Person`, `AccountLock`, `AverageBalance`, `DailyBalance`, `Transaction`, `ReserveAccount`, `StatementConfig`, `TaxDocument` |
| Payments | `Ach`, `Wire`, `Book`, `NetworkTransfer`, `FedNow`, `Check`, `CheckDeposit`, `Counterparty`, `IncomingAch`, `IncomingWire`, `InvoiceAccountNumber`, `ManualHold`, `Greendot`, `DepositSweep` |
| Cards | `Card`, `CardProduct`, `CardEvent`, `CardAuthLoopEndpoint`, `DigitalWalletToken`, `Marqeta.JS`, `Marqeta.UXToolkit` |
| Utilities | `Document`, `File`, `RoutingNumber`, `Webhook`, `WebhookEvent`, `WebhookSignature` |
| Testing | `Testing.Simulation` |

## Development

```sh
mix deps.get
mix test
mix format
mix credo
mix dialyzer
```

## License

MIT — see [LICENSE](LICENSE).