# Before the first release, point this at your checkout instead:
# {:exact_online, path: "/path/to/exact_online"}
Mix.install([
  {:exact_online, "~> 0.1"},
  {:kino, "~> 0.14"}
])

What you need

An app registered in the Exact App Center. It gives you a client id, a client secret and the redirect URI you registered. The redirect URI has to match exactly, including the scheme and any trailing slash.

For this notebook the redirect URI does not need to be a working web server: you will copy the code out of the browser address bar by hand.

client_id_input = Kino.Input.password("Client ID")
client_secret_input = Kino.Input.password("Client secret")
redirect_uri_input = Kino.Input.text("Redirect URI", default: "https://example.com/callback")

region_input =
  Kino.Input.select(
    "Region",
    Enum.map(Exact.Region.regions(), &{&1, to_string(&1)})
  )

Kino.Layout.grid([client_id_input, client_secret_input, redirect_uri_input, region_input],
  columns: 1
)
credentials = [
  client_id: Kino.Input.read(client_id_input),
  client_secret: Kino.Input.read(client_secret_input),
  redirect_uri: Kino.Input.read(redirect_uri_input),
  region: Kino.Input.read(region_input)
]

:ok

Authorize

Open the URL below, log in, and approve the app. Exact Online then redirects to your redirect URI with a code parameter. Copy that value into the input in the next cell.

The code is single use and expires within minutes, so do this in one go.

url = Exact.OAuth.authorize_url(credentials)

Kino.Markdown.new("""
[Authorize this app](#{url})
""")
code_input = Kino.Input.password("Authorization code")
{:ok, token} = Exact.OAuth.exchange_code(Kino.Input.read(code_input), credentials)

# Store the token so the client can refresh it. Exact Online rotates the
# refresh token on every refresh, and the store is where the new one lands.
# In an application you would implement Exact.TokenStore against your database
# instead: the ETS store lives and dies with this notebook.
:ok = Exact.TokenStore.ETS.put(:notebook, token)

%{
  expires_at: token.expires_at,
  division: token.division
}

Build a client

The client refreshes the access token when it is about to expire and once more if the API answers with a 401, writing the rotated token back to the store each time. You never call refresh yourself.

client =
  Exact.new(
    region: Keyword.fetch!(credentials, :region),
    token_store: Exact.TokenStore.ETS,
    token_key: :notebook,
    credentials: credentials
  )

Who am I, and in which division?

Almost every endpoint is scoped to a division, which is one administration inside an Exact Online account. Exact.System.Me is the exception, and it is how you learn which divisions are in reach.

{:ok, me} = Exact.System.Me.get(client)

Kino.DataTable.new([
  %{field: "FullName", value: me["FullName"]},
  %{field: "Email", value: me["Email"]},
  %{field: "CurrentDivision", value: me["CurrentDivision"]},
  %{field: "ThemeCode", value: me["ThemeCode"]}
])
client = Exact.Client.put_division(client, me["CurrentDivision"])

{:ok, divisions} =
  Exact.System.Division.list(client, select: ["Code", "Description", "Currency"])

Kino.DataTable.new(divisions.results)

Pick the division you want to work in. It defaults to the one you are logged in to.

division_input =
  Kino.Input.select(
    "Division",
    Enum.map(divisions.results, &{&1["Code"], "#{&1["Code"]} - #{&1["Description"]}"}),
    default: me["CurrentDivision"]
  )
client = Exact.Client.put_division(client, Kino.Input.read(division_input))

Read a collection

list/2 returns one page. Exact Online sends 60 records per page by default and pages with a cursor rather than an offset, so page.next holds the URL of the following page.

{:ok, page} =
  Exact.CRM.Account.list(client,
    select: ["ID", "Code", "Name", "Email", "City"],
    filter: "Status eq " <> Exact.Query.string("C"),
    orderby: ["Name asc"],
    top: 25
  )

Kino.DataTable.new(page.results)
%{next_page?: page.next != nil, records: length(page.results)}

Stream a whole collection

stream/2 follows the cursor for you and stays lazy, so Stream.take/2 only fetches the pages it needs. Every page is a request, so a large collection is also a large chunk of your rate limit. Narrow it with :select and :filter.

client
|> Exact.Financial.GLAccount.stream(select: ["Code", "Description", "BalanceType"])
|> Stream.take(200)
|> Enum.to_list()
|> Kino.DataTable.new()

Any other resource

The library ships modules for a handful of resources. For the rest, either call the path directly:

{:ok, items} = Exact.Client.list(client, "logistics/Items", select: ["ID", "Code"], top: 10)
Kino.DataTable.new(items.results)

Or generate a module, which is the same five lines the shipped ones use:

defmodule Item do
  use Exact.Resource, service: "logistics", resource: "Items"
end

{:ok, page} = Item.list(client, select: ["ID", "Code", "Description"], top: 10)
Kino.DataTable.new(page.results)

Errors

Nothing raises unless you use a bang function. Match on :reason rather than on the status, so transport failures are covered too.

case Exact.CRM.Account.get(client, "00000000-0000-0000-0000-000000000000") do
  {:ok, account} -> account
  {:error, %Exact.Error{reason: :not_found}} -> "no such account"
  {:error, %Exact.Error{reason: reason, message: message}} -> {reason, message}
end

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.

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

Kino.DataTable.new([
  %{window: "day", limit: page.rate_limit.limit, remaining: page.rate_limit.remaining,
    resets_at: page.rate_limit.reset},
  %{window: "minute", limit: page.rate_limit.minutely_limit,
    remaining: page.rate_limit.minutely_remaining, resets_at: page.rate_limit.minutely_reset}
])