# Open-balance check

The **open-balance check** tells you whether a guest owes an operator money, and how much, in each of
the operator's workspaces (buildings). Use it before you offer something that depends on the guest
being up to date, such as rebooker terms for returning residents.

Lavanda only reports the balance. Deciding who is eligible for what stays with you.

> **Note:** The check is being rolled out one client at a time. Ask your Lavanda representative to
> enable it for your API client before you build against it.

## Key concepts

- **Contact**: a guest or resident in an operator's CRM. Each contact has a Lavanda
  `uniqueReference`, and may also carry your own CRM id in `externalCrmId`.
- **CRM namespace**: the set of contacts that belongs to one operator. Access to the check is granted
  per CRM namespace. An operator can have more than one.
- **Balance**: what the contact owes in one workspace and one currency: issued invoices minus
  receipts and the credit notes raised against those invoices. A contact has one balance per
  workspace and currency they have been invoiced in.
- **Status**: `OWING` or `NOT_OWING`, worked out by Lavanda for each balance and for the contact
  overall.

## Look up a contact's balances

The check is a read-only field, `arBalances`, on the existing `CrmContact` type. There is no separate
query: you find the contact with a query that already exists, and select `arBalances` on it.

### By Lavanda contact reference

Use `crm.contact` when you hold the contact's Lavanda `uniqueReference`.

```graphql
query ContactArBalances($id: ID!) {
  crm {
    contact(id: $id) {
      id
      arBalances {
        status
        balances {
          workspace {
            id
            name
          }
          balance {
            amountMinor
            currency
          }
          status
          updatedAt
        }
      }
    }
  }
}
```

```json
{
  "id": "<contact-unique-reference>"
}
```

### By your own CRM id

If you store your own CRM id on the contact, search for it with `crm.contacts` and select the same
field on each match.

```graphql
query ContactArBalancesByCrmId($filters: ContactsFiltersInput) {
  crm {
    contacts(filters: $filters) {
      nodes {
        id
        arBalances {
          status
          balances {
            workspace {
              name
            }
            balance {
              amountMinor
              currency
            }
            status
            updatedAt
          }
        }
      }
    }
  }
}
```

```json
{
  "filters": {
    "externalCrmId": { "eq": "<your-crm-id>" }
  }
}
```

An empty `nodes` list means no contact you can see has that CRM id. Treat it as "not found".

## Check several contacts in one request

Use GraphQL aliases to check several contacts in one HTTP request. Each alias is answered on its own,
so an error on one alias does not stop the others. Within one alias, each contact is answered on its
own too: if a `crm.contacts` filter matches several contacts and the check fails for one of them,
only that contact's `arBalances` is null, with an error whose `path` points to it. The other contacts
in `nodes` keep their balances.

A request can carry at most 15 aliases, counted anywhere in the query, nested ones included. The router
rejects more, so split larger batches across requests.

This is also how you check a guest who appears in more than one of an operator's CRM namespaces.
Lavanda does not combine balances across namespaces for you. If you need "owes money in any
namespace", ask for each contact and treat the guest as owing when any of the results is `OWING`.

```graphql
query GuestArBalancesAcrossNamespaces(
  $firstNamespace: ContactsFiltersInput
  $secondNamespace: ContactsFiltersInput
) {
  crm {
    firstNamespace: contacts(filters: $firstNamespace) {
      nodes {
        id
        arBalances {
          status
          balances {
            workspace {
              name
            }
            balance {
              amountMinor
              currency
            }
            status
            updatedAt
          }
        }
      }
    }
    secondNamespace: contacts(filters: $secondNamespace) {
      nodes {
        id
        arBalances {
          status
          balances {
            workspace {
              name
            }
            balance {
              amountMinor
              currency
            }
            status
            updatedAt
          }
        }
      }
    }
  }
}
```

```json
{
  "firstNamespace": {
    "externalCrmId": { "eq": "<your-crm-id>" },
    "crmNamespace": { "id": { "eq": "<first-crm-namespace-id>" } }
  },
  "secondNamespace": {
    "externalCrmId": { "eq": "<your-crm-id>" },
    "crmNamespace": { "id": { "eq": "<second-crm-namespace-id>" } }
  }
}
```

The same works with `crm.contact`: alias it once per `uniqueReference` you want to check.

## Reading the response

| Field | Type | Meaning |
|-------|------|---------|
| `arBalances.status` | `AccountingArBalanceStatus!` | `OWING` when any balance is above zero. `NOT_OWING` otherwise, including when there are no balances. |
| `arBalances.balances` | `[AccountingArBalance!]!` | One entry per workspace and currency. Empty when the contact has no issued documents. |
| `balances.workspace` | `Workspace` | The workspace the balance belongs to. Can be null; see [Workspace details](#workspace-details-on-each-balance). |
| `balances.balance` | `AccountingMoney!` | `amountMinor` (in minor units, so `12500` is 125.00) and `currency`. Signed: negative means a credit. Never floored at zero. |
| `balances.status` | `AccountingArBalanceStatus!` | `OWING` when this balance is above zero. |
| `balances.updatedAt` | `DateTime!` | When this balance last changed. |

An example response, with made-up values:

```json
{
  "data": {
    "crm": {
      "contact": {
        "id": "<contact-id>",
        "arBalances": {
          "status": "OWING",
          "balances": [
            {
              "workspace": { "id": "<workspace-a-id>", "name": "Example House North" },
              "balance": { "amountMinor": 12500, "currency": "GBP" },
              "status": "OWING",
              "updatedAt": "2026-10-20T09:15:00Z"
            },
            {
              "workspace": { "id": "<workspace-b-id>", "name": "Example House South" },
              "balance": { "amountMinor": -3000, "currency": "GBP" },
              "status": "NOT_OWING",
              "updatedAt": "2026-10-02T14:40:00Z"
            }
          ]
        }
      }
    }
  }
}
```

How to read it:

- **A credit never offsets debt elsewhere.** The guest above is `OWING` because they owe 125.00 in one
  workspace, even though they are 30.00 in credit in another. Balances are not netted across
  workspaces or currencies.
- **Balances are signed and never floored.** A negative `amountMinor` is money the operator holds for
  the guest. Lavanda does not round it up to zero.
- **No balances means `NOT_OWING`.** A contact who has never been invoiced has an empty `balances`
  list and the status `NOT_OWING`.
- **`updatedAt` is when the balance last changed.** It does not tell you whether a very recent
  invoice has been counted yet. See [Known gaps](#known-gaps).

## Errors

Errors come back in `errors[]`, with the code in `extensions.code`. When the check fails for a
contact, you get no balances for it: `arBalances` is null, with an error whose `path` ends in
`arBalances`. The rest of the contact (`id`, name and so on) still resolves, and so do the other
contacts in the same list. A null `arBalances` means unknown, never not owing.

If Lavanda cannot answer at all (for example a timeout), every contact in the response can have
`arBalances: null` with a single error that does not point at any one contact. Treat a null `arBalances`
as unknown whether or not an error points at it.

If a contact comes back with `arBalances: null` and a `FIND_CONTACT_ERROR` error for it, your client cannot
read that contact. Treat it as not found.

| Code | Meaning | What to do |
|------|---------|------------|
| `AR_BALANCE_UNAVAILABLE` | Lavanda cannot answer for this contact right now. The check may be temporarily unavailable, or the contact is not ready to be checked yet. | Treat the guest as **unknown**. Never treat this as `NOT_OWING`. Retry later or fall back to a manual check. |
| `UNAUTHORIZED_ERROR` | Your client is not allowed to see this contact's balance. | Ask Lavanda for the grant on that CRM namespace. Treat the guest as unknown. |
| `AUTHORIZATION_UNAVAILABLE_ERROR` | Lavanda could not check your access. | Retry. Treat the guest as unknown until you get an answer. |
| `FIND_CONTACT_ERROR` | No contact you can read matches. From `crm.contact`, or from `crm.contacts` when a match exists but your client cannot read its profile. | Treat as not found. If you expected a match from `crm.contacts`, check your client has the `view_profile` grant. |

**Not found looks the same whether the contact is unknown or belongs to someone else.** A contact in
another operator's CRM namespace returns exactly the same response as a contact that does not exist:
`FIND_CONTACT_ERROR` from `crm.contact`, or an empty `nodes` list from `crm.contacts`. This stops
callers from discovering other operators' guests.

An error on a balance's `workspace` is different: see the next section. It never makes the balance
unknown.

The safe rule: only `NOT_OWING` means not owing. An error, a null, or a missing contact never does.

## Workspace details on each balance

Each balance carries the `Workspace` it belongs to, so you can select the workspace's current `name`
and other workspace fields.

Reading a workspace's details needs the `awc_workspaces_read` grant on that workspace. Without it, that
balance comes back with `workspace: null` and a `USER_NOT_ALLOWED_ERROR` error whose `path` points to it
(for example `["crm", "contact", "arBalances", "balances", 1, "workspace"]`). The `balance`, `status`
and `updatedAt` of that entry are still returned, and the contact's overall `status` still covers it.

So an error on `workspace` does not mean the balance is unknown. Read the amount and status as normal,
and ask Lavanda for `awc_workspaces_read` on the workspaces whose names you need.

## What the balance includes

- **Issued documents only.** Drafts do not count. The balance is issued invoices minus receipts and
  the credit notes raised against those invoices.
- **Payments count once their receipt is issued.** A payment moves the balance when Lavanda issues its
  receipt, not when the money is taken.

### Known gaps

- **Lag on busy billing days.** A new invoice or receipt can take a while to reach the balance, and
  during a large billing run it can be delayed by hours. Until it arrives, the balance and its
  `updatedAt` look exactly as they did before, so `updatedAt` cannot tell you that something is still
  on its way. If a decision depends on very recent invoices, check again later.
- **Group-invoiced residents.** When a resident is billed through a company, the debt sits on the
  company, not on the resident. The resident's check does not show it.
- **Not yet in the balance:** write-offs, standalone credit notes and refund reversals. Each will be
  added to the balance when it ships, and we will update this page.

## Access you need

Lavanda grants access to your API client per CRM namespace. For each operator namespace you check,
your client needs:

| Grant | Needed for |
|-------|------------|
| `view_ar_balance` | Reading `arBalances` on the namespace's contacts |
| `view_profile` | Reading the contact. Needed for both `crm.contact` and `crm.contacts` |
| `search_contacts` | Looking contacts up with `crm.contacts` (by your own CRM id) |
| `awc_workspaces_read` | Reading `workspace` details on each balance. Granted per workspace, not per namespace. |

You only need `search_contacts` if you look contacts up by your own CRM id. Ask your Lavanda
representative to set these up.

### Change to `crm.contact` errors for denied lookups

`crm.contact` used to tell you when a contact existed but your client could not see it. It now returns
the not-found error in that case. The same applies wherever a contact is resolved for another query,
for example when `crm.contacts` fetches a contact's profile fields.

| Case | Before | Now |
|------|--------|-----|
| Contact does not exist | `FIND_CONTACT_ERROR` "Failed to find contact" | No change |
| Contact exists but your client cannot see it | `USER_NOT_ALLOWED_ERROR` "Unable to show contact details" | `FIND_CONTACT_ERROR` "Failed to find contact" |

If your integration treats `USER_NOT_ALLOWED_ERROR` from `crm.contact` as "this contact exists",
update it: a missing grant now looks the same as an unknown contact. Queries for companies, leads and
contact relationships still return `USER_NOT_ALLOWED_ERROR` when access is denied.
