# 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)
]
:okAuthorize
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}
endRate 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}
])