# loyverse

[![Hex.pm](https://img.shields.io/hexpm/v/loyverse.svg)](https://hex.pm/packages/loyverse)
[![Docs](https://img.shields.io/badge/hexdocs-docs-8e7ce6.svg)](https://hexdocs.pm/loyverse)
[![License](https://img.shields.io/hexpm/l/loyverse.svg)](LICENSE)

An Elixir client for the [Loyverse POS API](https://developer.loyverse.com/docs/).

Covers what the API is actually awkward about: cursor pagination, local business
days against a UTC-only API, and the handful of behaviours the docs don't
mention.

```elixir
client = Loyverse.client(System.fetch_env!("LOYVERSE_TOKEN"))

{from, to} = Loyverse.Time.utc_window(~D[2026-07-01], ~D[2026-07-31], -6)

client
|> Loyverse.receipts(
  created_at_min: DateTime.to_iso8601(from),
  created_at_max: DateTime.to_iso8601(to)
)
|> Enum.to_list()
```

## Install

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

## Try it

`explore.livemd` is a [Livebook](https://livebook.dev) notebook covering every
endpoint, grouped by resource:

[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fgithub.com%2FAAlvAAro%2Floyverse_ex%2Fblob%2Fmain%2Fexplore.livemd)

It needs a Livebook secret named `LOYVERSE_TOKEN`. Point it at a test account —
the notebook creates, updates and deletes real objects.

## Design

**Credentials are an argument, never global state.** `Loyverse.client/2` takes a
token, so one OS process can serve as many Loyverse accounts as it likes. That
is what a multi-tenant app needs, and retrofitting it later is painful.

**List endpoints return lazy streams.** `Enum.take(stream, 10)` costs one
request no matter how much history exists; `Enum.to_list/1` walks every page.

**Errors are matchable.** `get/3` returns `{:ok, body} | {:error, %Loyverse.Error{}}`,
so a rate limit is distinguishable from a bad token:

```elixir
case Loyverse.get(client, "receipts") do
  {:ok, body} -> body
  {:error, %Loyverse.Error{status: 429}} -> back_off()
  {:error, error} -> Logger.error(Exception.message(error))
end
```

`get!/3` and `stream!/3` raise instead — a stream has nowhere sensible to put an
error tuple.

Req retries 429 and 5xx with exponential backoff by default, so a
`%Loyverse.Error{status: 429}` means it retried and still failed.

## Resources

`receipts/2`, `items/2`, `categories/2`, `inventory/2`, `stores/2`,
`customers/2` — all lazy streams. Anything else the API exposes works through
`stream!/3` and `get/3` directly:

```elixir
Loyverse.stream!(client, "discounts")
Loyverse.get(client, "receipts/1-1234")
```

Adding a named function for another resource is one line.

## Writes

`post/3` is an upsert on every resource — include the object's `id` and it
updates, omit it and it creates. There is no PUT. `delete/2` soft-deletes and
returns `%{"deleted_object_ids" => [id]}`.

```elixir
Loyverse.post!(client, "items", %{item_name: "T-shirt", track_stock: true})

Loyverse.post!(client, "inventory", %{
  inventory_levels: [%{variant_id: v, store_id: s, stock_after: 40}]
})

Loyverse.delete(client, "items/#{item_id}")
```

`stock_after` sets the level rather than adjusting it, and stock only exists on
items with `track_stock: true` — setting that back to false zeroes every level
for the item at every store.

## Local business days

Loyverse timestamps are UTC. A naive midnight-to-midnight UTC window does not
line up with a local calendar day — at UTC-6 an 8pm sale is already tomorrow in
UTC, and reporting it on the wrong day is the easiest way to get a daily sales
figure quietly wrong.

```elixir
Loyverse.Time.utc_window(~D[2026-03-01], ~D[2026-03-01], -6)
#=> {~U[2026-03-01 06:00:00Z], ~U[2026-03-02 05:59:59.999Z]}

Loyverse.Time.local_date("2026-03-02T02:00:00.000Z", -6)
#=> ~D[2026-03-01]
```

The offset is a number of hours, not a named timezone: correct for a business in
one fixed-offset place, and it keeps this library free of a timezone database.
Somewhere with DST needs a real zone — convert with `tz` and pass the resulting
UTC datetimes yourself.

## API behaviours worth knowing

Learned from a working integration, not from the docs:

- **Never pass `order`.** Loyverse silently returns an empty `receipts` array
  whenever the parameter is present, whatever its value. Omitted, receipts come
  back newest-first anyway. `receipts/2` does not send it.
- **A cursor can terminate as `""`**, not only by being absent, and a page can
  come back empty with a cursor still set. `stream!/3` stops on both.
- **Rows live under a per-resource key** — `receipts`, `items`,
  `inventory_levels`. `stream!/3` takes the first list-valued key rather than
  keeping a lookup table in sync with the API.
- **`REFUND` receipts carry positive `total_money`.** Summing blindly overstates
  revenue by twice every refund. Cancelled receipts also come back, with
  `cancelled_at` set. This library hands you the raw rows — how you net them is
  yours.
- **`/inventory` is unreadable alone**: `variant_id` and a number, nothing
  human-readable. Join against `items/2`, whose `variants` carry `variant_id`
  and `sku`.
- **Rate limit is roughly 60 requests/min**, per account.

## Not included

Authentication beyond a personal access token. Loyverse also supports OAuth 2.0
(`cloud.loyverse.com/oauth/authorize`, scopes `RECEIPTS`/`ITEMS`/`MERCHANT`/`STORES`)
which is what an app serving other people's shops needs — customers approve
access rather than pasting a master token. Because credentials are already a
per-client argument, adding it is additive: a new `client/2` source, nothing
else changes.

Aggregation is out of scope, deliberately — netting refunds and picking day
boundaries are business decisions, and they belong to the app making them.
So are webhook subscriptions beyond the raw `webhooks/` endpoint, and the
multipart image upload on `items/{id}/image`.

## Test

```sh
mix test
```

No network and no token: `Req.Test` stubs everything.
