Packages

Client for the Exact Online REST API

Current section

Files

Jump to
exact_online README.md
Raw

README.md

# exact_online

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

An Elixir client for the [Exact Online](https://www.exact.com) REST API, built
on [Req](https://hex.pm/packages/req).

It handles the parts of the API that are easy to get wrong: OAuth2 with a
rotating refresh token, the OData `d` envelope, cursor pagination, and the
rate limits Exact Online reports per division.

## Installation

```elixir
def deps do
  [{:exact_online, "~> 0.1"}]
end
```

## Quickstart

Register an app in the [Exact App Center](https://apps.exactonline.com) to get
a client id, a client secret and a redirect URI.

```elixir
credentials = [
  client_id: System.fetch_env!("EXACT_CLIENT_ID"),
  client_secret: System.fetch_env!("EXACT_CLIENT_SECRET"),
  redirect_uri: "https://example.com/oauth/callback",
  region: :nl
]

# 1. Send the user to this URL.
Exact.OAuth.authorize_url(credentials)

# 2. Exchange the `code` from the callback and store the token.
{:ok, token} = Exact.OAuth.exchange_code(code, credentials)
:ok = Exact.TokenStore.ETS.put(user_id, token)

# 3. Build a client that refreshes and re-stores the token for you.
client =
  Exact.new(
    token_store: Exact.TokenStore.ETS,
    token_key: user_id,
    credentials: credentials
  )

# 4. Every other endpoint needs a division.
{:ok, division} = Exact.System.Me.division(client)
client = Exact.Client.put_division(client, division)

{:ok, page} = Exact.CRM.Account.list(client, select: ["ID", "Name"], top: 25)
page.results
```

There is a runnable walkthrough in
[`notebooks/exact_online.livemd`](notebooks/exact_online.livemd).

## OAuth2

Exact Online access tokens are valid for ten minutes and refresh tokens for
thirty days. The refresh token **rotates on every refresh**: each refresh
returns a new one and invalidates the old one, so it has to be persisted or the
grant is lost and the user has to authorize again.

That is what `Exact.TokenStore` is for. `Exact.TokenStore.ETS` keeps tokens in
memory, which is fine for scripts, Livebook and tests. In an application,
implement the behaviour against your database:

```elixir
defmodule MyApp.ExactTokens do
  @behaviour Exact.TokenStore

  @impl true
  def fetch(user_id) do
    case MyApp.Repo.get(MyApp.ExactToken, user_id) do
      nil -> :error
      record -> {:ok, MyApp.ExactToken.to_token(record)}
    end
  end

  @impl true
  def put(user_id, token) do
    MyApp.ExactToken.upsert!(user_id, token)
    :ok
  end
end
```

Given a store, the client refreshes the token when it is within a minute of
expiring, and once more if the API answers with a 401. Concurrent requests
that find an expired token take a lock, so the grant is not spent twice.

You can also skip all of this and pass `access_token:` directly, in which case
nothing is refreshed.

## Regions

Exact Online runs one installation per country. An account created in the Dutch
installation is not reachable through the German host.

| Region | Host |
| --- | --- |
| `:nl` (default) | `start.exactonline.nl` |
| `:be` | `start.exactonline.be` |
| `:de` | `start.exactonline.de` |
| `:uk` | `start.exactonline.co.uk` |
| `:fr` | `start.exactonline.fr` |
| `:es` | `start.exactonline.es` |
| `:us` | `start.exactonline.com` |

Pass `base_url:` for a host that is not listed.

## Divisions

A division is one administration inside an account, and every endpoint except
`Exact.System.Me` is scoped to one. Set it once with `division:` on
`Exact.new/1`, or rescope an existing client with
`Exact.Client.put_division/2`. Relative paths get the division prefix; a path
starting with `/api` is used as-is.

## Querying

Query options map onto the OData parameters:

```elixir
Exact.CRM.Account.list(client,
  select: ["ID", "Name", "Email"],
  filter: "Status eq " <> Exact.Query.string("C"),
  orderby: ["Name asc"],
  top: 50,
  inlinecount: "allpages"
)
```

`$filter` is picky about literals, so use `Exact.Query.string/1`,
`Exact.Query.guid/1` and `Exact.Query.datetime/1` rather than interpolating.

## Pagination

Exact Online returns 60 records per page (1000 for the bulk and sync
endpoints) and pages with a cursor, not an offset. `list/2` gives you one page
and its `next` cursor; `stream/2` follows the cursor lazily:

```elixir
client
|> Exact.CRM.Account.stream(select: ["ID", "Name"])
|> Stream.map(& &1["Name"])
|> Enum.take(500)
```

Every page is a request, so narrow the stream with `:select` and `:filter`.
`stream/2` raises `Exact.Error` on failure, because a stream has nowhere to put
an error tuple.

## Errors

Everything returns `{:ok, result}` or `{:error, %Exact.Error{}}`; the bang
variants raise. Match on `:reason` rather than `:status`, so transport
failures are covered too:

```elixir
case Exact.CRM.Account.get(client, id) do
  {:ok, account} -> account
  {:error, %Exact.Error{reason: :not_found}} -> nil
  {:error, %Exact.Error{reason: :rate_limited, retry_after: seconds}} -> {:retry_in, seconds}
  {:error, error} -> {:error, error}
end
```

Transient failures (429 and 5xx on safe methods) are retried up to three times,
honoring `Retry-After`. Tune it through `req_options: [retry: ..., max_retries: ...]`.

## Rate limits

Exact Online enforces a daily and a minutely limit per division and reports the
state on every response. The ceilings depend on your agreement, so the client
reports what the API says instead of assuming a number.

```elixir
{:ok, page} = Exact.CRM.Account.list(client, top: 1)
page.rate_limit.minutely_remaining
```

`Exact.Error` carries the same struct, so a rate limited caller can back off.

## Resources

Exact Online exposes around 600 resources. This library ships modules for the
ones most integrations start with:

`Exact.System.Me`, `Exact.System.Division`, `Exact.CRM.Account`,
`Exact.CRM.Contact`, `Exact.Sales.SalesInvoice`, `Exact.Financial.GLAccount`.

For the rest, either call the path directly:

```elixir
Exact.Client.list(client, "logistics/Items", select: ["ID", "Code"])
Exact.Client.stream(client, "bulk/CRM/Accounts")
```

Or generate a module, which is what the shipped ones do:

```elixir
defmodule MyApp.Exact.Item do
  @moduledoc "See [Items](https://start.exactonline.nl/docs/HlpRestAPIResourcesDetails.aspx?name=LogisticsItems)."
  use Exact.Resource, service: "logistics", resource: "Items"
end

MyApp.Exact.Item.list(client, select: ["ID", "Code", "Description"], top: 25)
```

That gives you `list/2`, `stream/2`, `get/3`, `create/2`, `update/3` and
`delete/2`. See `Exact.Resource` for the options, including read-only resources
and non-GUID primary keys.

Records are plain maps keyed by the field names Exact Online uses. Nothing is
renamed, so the
[reference documentation](https://start.exactonline.nl/docs/HlpRestAPIResources.aspx)
applies as written.

## Not covered

The XML API and webhook signature verification are not wrapped. The bulk and
sync endpoints are reachable as ordinary paths but have no dedicated helpers.

## Testing your own code

The client takes `req_options:`, so point it at a
[`Req.Test`](https://hexdocs.pm/req/Req.Test.html) stub:

```elixir
client = Exact.new(division: 123_456, access_token: "test", req_options: [plug: {Req.Test, MyApp.Exact}])

Req.Test.stub(MyApp.Exact, fn conn ->
  Req.Test.json(conn, %{"d" => %{"results" => [%{"ID" => "abc", "Name" => "Paradiso"}]}})
end)
```

## Development

```
mix deps.get
mix test
mix check   # format, credo, test, dialyzer
mix docs
```

## License

MIT. See the `LICENSE` file.