# Tipalti

A production-grade Elixir client for the full Tipalti API surface: the
modern OAuth2 REST API, the legacy HMAC-signed SOAP Payee/Payer API, and
the Procurement REST API.

Built deliberately with a **minimal dependency footprint** — the HTTP
transport runs on OTP's bundled `:httpc`/`:ssl`/`:xmerl` instead of pulling
in a full HTTP client stack, so this package brings in only two small,
dependency-free libraries (`jason`, `telemetry`).

## Why three APIs?

Tipalti's product grew over time, and it shows in the API surface:

| API | Style | Auth | Covered by |
|---|---|---|---|
| Modern REST | JSON over HTTPS | OAuth 2.0 client credentials | `Tipalti.Payees`, `Tipalti.Invoices`, `Tipalti.Payments` |
| Legacy SOAP | XML over HTTPS | HMAC-SHA256 signed requests | `Tipalti.SOAP.Payee`, `Tipalti.SOAP.Payer` |
| Procurement REST | JSON over HTTPS | Static `x-api-key` | `Tipalti.Procurement.PurchaseOrders`, `Tipalti.Procurement.Employees` |

This package speaks all three through one consistent interface, sharing a
single HTTP transport, retry policy, and telemetry layer underneath.

## Installation

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

## Quick start

```elixir
config = Tipalti.Config.new(
  mode: :sandbox,
  rest: [client_id: "...", client_secret: "..."],
  soap: [payer_name: "...", api_key: "..."],
  procurement: [api_key: "..."]
)

# Modern REST API
{:ok, page} = Tipalti.Payees.list(config)
for payee <- Tipalti.Payees.stream(config) do
  IO.inspect(payee)
end

# Legacy SOAP API
{:ok, result} =
  Tipalti.SOAP.Payer.process_payments(config, [
    %{idap: "vendor-123", amount: 100.00, currency: "USD", refCode: "pay-1"}
  ])

# Procurement REST API
{:ok, pos} = Tipalti.Procurement.PurchaseOrders.list(config)
```

Only populate the `Tipalti.Config` sections you actually use — a config
built with just `soap:` opts is fine as long as you only call
`Tipalti.SOAP.*` functions with it.

## Error handling

Every function has a non-bang variant returning `{:ok, result}` /
`{:error, exception}`, and a `!` variant that raises instead:

```elixir
case Tipalti.Payees.get(config, "p_123") do
  {:ok, payee} -> payee
  {:error, %Tipalti.AuthenticationError{}} -> :bad_credentials
  {:error, %Tipalti.RateLimitError{retry_after: ms}} -> :retry_later
  {:error, %Tipalti.ValidationError{errors: errors}} -> :bad_input
  {:error, %Tipalti.SOAPFaultError{fault_string: msg}} -> :soap_fault
  {:error, error} -> {:other_error, error}
end

payee = Tipalti.Payees.get!(config, "p_123")
```

See `Tipalti.Error` for the full exception hierarchy.

## Pagination

Every REST list endpoint has a `stream/2` variant that lazily walks every
page:

```elixir
Tipalti.Invoices.stream(config, status: "pending")
|> Stream.filter(&(&1.amount > 1000))
|> Enum.to_list()
```

## SOAP signing

The legacy API's HMAC-SHA256 request signing (`Tipalti.SOAP.Signature`) is
handled automatically — every `Tipalti.SOAP.Payee`/`Tipalti.SOAP.Payer`
function knows its operation's EAT (Encryption Additional Terms) parameter
and folds it into the signature for you.

## Procurement employee import

The three-step CSV upload flow (get a signed URL, upload the CSV, trigger
the import) is wrapped in one call:

```elixir
csv = File.read!("employees.csv")
{:ok, _result} = Tipalti.Procurement.Employees.import_employees(config, csv)
```

## IPN webhooks

```elixir
def handle_tipalti_webhook(conn) do
  {:ok, body, conn} = Plug.Conn.read_body(conn)

  case Tipalti.Webhook.parse(body) do
    {:ok, event} ->
      handle_event(Tipalti.Webhook.event_type(event), event)
      Plug.Conn.send_resp(conn, 200, "")

    {:error, _reason} ->
      Plug.Conn.send_resp(conn, 400, "")
  end
end
```

## Telemetry

Every request emits a `:telemetry.span/3` under `[:tipalti, :request]` by
default (configurable via `Tipalti.Config` `:telemetry_prefix`). See
`Tipalti.Telemetry` for the full event/metadata reference.

## Rate limiting

The Procurement API documents explicit rate limits. `Tipalti.HTTP` already
retries a `429` with backoff, but for high-throughput integrations
`Tipalti.RateLimiter` (an optional token-bucket limiter) lets you avoid
tripping the limit client-side in the first place.

## Design notes

- **HTTP transport**: `Tipalti.HTTP` is built on OTP's `:httpc`, with
  exponential backoff + full jitter on retries, TLS verification via
  `:public_key.cacerts_get/0` (OTP 25+), and connection pooling disabled
  (each request opens its own connection — simpler and more predictable
  across the range of environments this library runs in, e.g. serverless).
- **OAuth2 tokens**: `Tipalti.Auth.TokenServer` is a GenServer, one per
  distinct `client_id`, started on demand and supervised under
  Tipalti.Application. Tokens are cached and refreshed 60 seconds before
  expiry.
- **REST endpoint shapes** (`Tipalti.Payees`/`Invoices`/`Payments`) follow
  Tipalti's documented conventions for the modern REST API — see the
  moduledoc caveat in `Tipalti.Payees` if your instance's exact response
  envelope differs; the request/auth/pagination/error-handling machinery
  underneath is meant to be reused as-is.

## Quality

```
mix format --check-formatted   # clean
mix compile --warnings-as-errors  # clean
mix test                       # 43 tests, 0 failures
mix credo --strict             # 0 issues (312 functions)
mix dialyzer                   # 0 errors
```

`credo` and `dialyxir` are dev/test-only dependencies, pinned to git tags
alongside their own small transitive deps (`bunt`, `file_system`, `erlex`)
for environments where the hex.pm registry isn't reachable — see `mix.exs`.
The Dialyzer PLT takes a few minutes to build the first time
(`mix dialyzer --plt`); subsequent runs are fast.

## Testing this package

The test suite uses a small `:gen_tcp`-based mock HTTP server
(`test/support/mock_server.ex`) instead of Bypass/Plug, keeping the
dependency list minimal even for development/test.

```
mix test
```

## License

MIT
