Ctrl K
ComunidadGuías…

docs/reference/api.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# API reference

This page is the endpoint and WebSocket contract for the three HTTP servers FloCafe runs. It
describes what each server exposes, which role each endpoint requires, and the parameters each
handler reads. It is the API surface only. Behaviour that belongs to another subject is linked
rather than restated, at the end of each section.

## How to read this page

Three servers run inside the POS process:

| Port   | Server     | What it serves                                                                         | Source                                           |
| ------ | ---------- | -------------------------------------------------------------------------------------- | ------------------------------------------------ |
| `3001` | Main API   | Every `/api/*` route, plus the static frontend export                                  | [`main/server.ts`](../../main/server.ts)         |
| `3002` | KDS        | The standalone kitchen display: a small REST surface plus the static KDS bundle        | [`main/kds-server.ts`](../../main/kds-server.ts) |
| `3003` | Server App | A filtered proxy onto a fixed list of `3001` routes, plus the static Server App bundle | [`main/server-app.ts`](../../main/server-app.ts) |

Each server walks its port upward from its base when the port is taken, so the bound port can
differ from the default. [System overview](../architecture/overview.md#processes-and-ports)
covers the retry ladder and how the running ports are reported to the UI.

The `/kds` WebSocket is upgraded on **both** `3001` and `3002`. A KDS display pointed at either
port gets the same frames from [`main/services/kds.ts`](../../main/services/kds.ts). Any other
upgrade path on either port is answered with `404`.

Base URLs:

- Main API: `http://flo.local:3001` or `http://<local-ip>:3001`
- KDS: `http://flo.local:3002`
- Server App: `http://flo.local:3003`

## Authentication

All three servers authenticate with a bearer JWT. Send it as `Authorization: Bearer <token>`. Each
server issues its own token through `POST /api/auth/login` and `POST /api/auth/pin-login` on its own port, using the same signing
secret, so a token from one server is accepted by the others. The scheme, token lifecycle, revocation, staleness handling, the
Master PIN second factor, and audit attribution are described in
[authentication and authorization](../architecture/authentication-and-authorization.md). That page
also explains why the three servers each write their own token-verification middleware and what
diverges between them.

Almost every protected endpoint is gated by `requirePermission(id)`, resolved live from SQLite —
see [Configurable permissions](../architecture/authentication-and-authorization.md#configurable-permissions).
For brevity, the **Authorization** column below names the endpoint's permission by its **shipped
default** role group instead of its permission id, using the same `ROLE_ACCESS` keys from
[`shared/role-permissions.ts`](../../shared/role-permissions.ts) that
[`shared/permissions.ts`](../../shared/permissions.ts) assigns as each permission's
`defaultRoles`:

| Group                 | Roles                                 |
| --------------------- | ------------------------------------- |
| `owner`               | owner                                 |
| `ownerManager`        | owner, manager                        |
| `ownerManagerCashier` | owner, manager, cashier               |
| `sales`               | owner, manager, cashier, server       |
| `cashierServer`       | cashier, server                       |
| `kitchen`             | owner, manager, chef                  |
| `orderStatus`         | owner, manager, cashier, server, chef |
| `allStaff`            | owner, manager, cashier, server, chef |
| `serverApp`           | server, manager, owner                |

**This is the default, not a fixed gate.** An owner can grant or deny any of these per role or per
user from **Staff > Role permissions**, without a code change, and the endpoint enforces whatever
is currently configured — not the table below. To find the exact permission id an endpoint checks,
read the route source or `shared/permissions.ts`; to see the current effective configuration for
an install, use `GET /api/authorization/roles`. `authorization.manage` and
`staff.privileged.manage` are the two permissions marked `configurable: false` in the catalog:
those two rows are not owner-configurable, unlike every other row in this reference.

The [roles and permissions](roles-and-permissions.md) page holds the in-app permission editor and
capability matrix — a UI view and its owner-only management API, not this backend gate list.

A row reading `any authenticated role` means the endpoint has no permission or role gate beyond
`requireAuth`. A row reading `+ KDS-enabled` also passes through `requireKdsEnabled`, which returns
`403` when `kds_enabled` is off. `+ Master PIN` adds `requireMasterPin`, which reads `master_pin`
from the body or the `x-master-pin` request header (never query parameters); see
[Master PIN as a second factor](../architecture/authentication-and-authorization.md#master-pin-as-a-second-factor)
for rate-limiting and storage semantics.

### Configurable authorization management

Router: `main/routes/authorization.ts`. Full path: `/api/authorization`. Every route below
requires `authorization.manage` — active owner only, protected, not configurable.

| Method   | Path                       | Parameters                                                 | Response                                                                                                                                                             |
| -------- | -------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET`    | `/catalog`                 | none                                                       | `{ permissions: PERMISSION_DEFINITIONS, roles: ROLE_KEYS }` — the stable permission catalog and role identities.                                                     |
| `GET`    | `/roles`                   | none                                                       | `{ roles: [{ role, revision, overrides, permissions }, ...] }` for every role.                                                                                       |
| `PUT`    | `/roles/:role`             | body: `revision`, `overrides: [{ permission_id, effect }]` | Atomically replaces that role's override set. `409` with the current role payload on a stale `revision`; `400` on an unknown, duplicate, or protected permission id. |
| `GET`    | `/users`                   | none                                                       | `{ users: [...] }` — the safe staff list this editor's per-user picker uses.                                                                                         |
| `GET`    | `/users/:userId`           | path: `userId`                                             | `{ user, revision, overrides, permissions }` — one user's effective values and exceptions. `404` if absent.                                                          |
| `PUT`    | `/users/:userId`           | body: `revision`, `overrides: [{ permission_id, effect }]` | Atomically replaces that user's override set. Same `409`/`400` behavior as the role route.                                                                           |
| `DELETE` | `/users/:userId/overrides` | body: `revision`                                           | Clears every override for that user, restoring inheritance. `409` on a stale `revision`.                                                                             |
| `GET`    | `/audit`                   | query: `?limit` (max 200), `?before_id`                    | `{ audit: [...] }`, newest first: actor, target type/id, permission id, previous/next effect, timestamp.                                                             |

Every write is one SQLite transaction and appends rows to `authorization_audit_log`, keyed under a
shared `batch_id` per save so a multi-permission change reads as one event.

## Rate limiting

`main/server.ts` applies a 100 requests per minute ceiling to all of `/api` on `3001`, skipping
loopback, RFC 1918, and Tailscale addresses. `3002` applies 100 per minute to its own `/api` through
the same LAN-exempt helper, and `3003` applies 150 per minute. `express-rate-limit` emits `RateLimit-*` headers; the in-repo
`rateLimit` helper in [`main/middleware/security.ts`](../../main/middleware/security.ts) does the
same.

`loginAuthRateLimit` and `authRateLimit` deliberately do **not** exempt private addresses, so
LAN clients are counted against authentication rate limits.

`loginAuthRateLimit` is the shared volume limiter across all three default login endpoints
(`POST /api/auth/login` on `3001`, `3002`, and `3003`). It reuses a single shared budget
(default 10 requests per 15 minutes, configurable via `FLO_AUTH_RATE_LIMIT_MAX`).
In addition, the three password login endpoints share an install-wide failed-login attempt lockout keyed to
canonical client IP (`normalizeIpForRateLimit`), and `POST /api/auth/pin-login` applies the same 5-attempt, 15-minute lockout per `(client IP, user ID)` pair (including loopback; see [PIN quick user switch and terminal lock](../architecture/authentication-and-authorization.md#pin-quick-user-switch-and-terminal-lock)). Successful login
resets the corresponding counter(s) (non-credential `403` responses preserve accumulated failures). See
[`docs/architecture/authentication-and-authorization.md`](../architecture/authentication-and-authorization.md#shared-login-attempt-rate-limiting-across-servers)
for architecture and role/permission handling details.

`authRateLimit` provides independent per-route volume limiters (also defaulting to 10 attempts
per 15 minutes, overridable with `FLO_AUTH_RATE_LIMIT_MAX`) for non-login credential and staff
routes on `3001` (and self-service credential routes on `3002` and `3003`):
`POST /api/auth/password/change`, `POST /api/auth/pin/change`, `POST /api/auth/recover-password`,
`/api/auth/recover-password/cloud/*`,
`POST /api/staff`, and `PUT /api/staff/:id`.

Per-resource limiters named in the tables below are `express-rate-limit` instances declared in
their own route file. Unless noted otherwise, they apply to every caller including LAN addresses.
In [`main/routes/orders.ts`](../../main/routes/orders.ts), `orderReadRateLimit` defaults to 120
requests per minute (configurable via `FLO_ORDER_READ_RATE_LIMIT_MAX` when set to a positive
integer); `orderWriteRateLimit` and
`orderItemCancelRateLimit` remain separate 60-requests-per-minute buckets (configurable via
`FLO_ORDER_WRITE_RATE_LIMIT_MAX` when set to a positive integer) and exempt authenticated callers
connecting from private, loopback, or Tailscale socket addresses (`isAllowedPrivateIp` on
`req.socket.remoteAddress`, ignoring caller-supplied forwarding headers), while still rate-limiting
public or unauthenticated traffic and returning `{ error: "Too many requests, please try again later." }`
on `429`.

## Main API (`:3001`)

`registerRoutes` in [`main/routes/index.ts`](../../main/routes/index.ts) mounts 38 routers under 39
paths. `staffRoutes` is the same router mounted at both `/api/staff` and `/api/users`, so the two
prefixes expose an identical surface. Seven further endpoints are registered inline on `app` in that
same file, outside any router, and are listed under
[Inline endpoints](#inline-endpoints-registered-outside-a-router). `GET /api/health` is registered
directly in `main/server.ts`.

A search for `router.` in the route files misses all eight of those, so a route inventory built on that
pattern alone is incomplete.

### Health

Router: `main/server.ts`.

| Method | Path          | Authorization | Parameters | Response                                                                                  |
| ------ | ------------- | ------------- | ---------- | ----------------------------------------------------------------------------------------- |
| `GET`  | `/api/health` | none          | none       | `{ status, db, service, version, timestamp }`. `503` when the database reports unhealthy. |

### Authentication

Router: `main/routes/auth.ts`. Full path: `/api/auth`.

| Method | Path                                   | Authorization                                                                           | Parameters                                                                                                                                                                                                                                                                                                                                                                                                                               | Response                                                                                                                                                                                                                                                                                         |
| ------ | -------------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `POST` | `/login`                               | none, `loginAuthRateLimit` + shared failed-login limiter                                | body: `email`, `password`, `rememberMe`                                                                                                                                                                                                                                                                                                                                                                                                  | `{ access_token, token_type: "bearer", expires_in, user: { id, name, email, role, has_pin, category_ids }, tenants: [ ... ] }`. `401` on bad credentials with `attempts_remaining`; `429` after 5 failed attempts across local login listeners.                                                           |
| `GET`  | `/pin-users`                           | none, auth limiter (600 per window)                                                     | query: optional `?scope` (`kds`)                                                                                                                                                                                                                                                                                                                                                                                                         | `{ users: [ { id, name, role } ] }` for active users with a PIN configured. When `scope=kds`, narrowed to accounts with `kitchen.use` and an active station when station assignments exist (`{ users: [] }` when KDS is disabled).                                                               |
| `POST` | `/pin-login`                           | none, auth limiter (600 failed per window) + per-(client IP, user) failed-login limiter | body: `user_id`, `pin`, `rememberMe`                                                                                                                                                                                                                                                                                                                                                                                                     | `{ access_token, token_type: "bearer", expires_in, user: { id, name, email, role, has_pin, category_ids }, tenant, tenants: [ ... ] }`. `400` when `user_id` or `pin` is missing; `401` on bad credentials with `attempts_remaining`; `429` after 5 failed attempts for that `(client IP, user_id)` pair. |
| `POST` | `/tenants/select`                      | any authenticated role                                                                  | none                                                                                                                                                                                                                                                                                                                                                                                                                                     | `{ access_token, token_type: "bearer", tenant }`.                                                                                                                                                                                                                                                |
| `POST` | `/logout`                              | any authenticated role                                                                  | none                                                                                                                                                                                                                                                                                                                                                                                                                                     | `{ message }`. Revokes the presented token.                                                                                                                                                                                                                                                      |
| `POST` | `/refresh`                             | any authenticated role                                                                  | none                                                                                                                                                                                                                                                                                                                                                                                                                                     | `{ access_token, token_type: "bearer", expires_in }`.                                                                                                                                                                                                                                            |
| `GET`  | `/me`                                  | any authenticated role                                                                  | none                                                                                                                                                                                                                                                                                                                                                                                                                                     | `{ user: { id, name, email, role, has_pin }, tenants: [ ... ] }`.                                                                                                                                                                                                                                |
| `POST` | `/password/change`                     | any authenticated role + auth limiter                                                   | body: `current_password`, `password`                                                                                                                                                                                                                                                                                                                                                                                                     | `{ message, token }`. Advances `tokens_valid_after` to revoke earlier sessions and returns a fresh token for the caller. A wrong current password returns `400` with `attempts_remaining`; the fifth consecutive failure locks that user out for 5 minutes, and attempts during the lockout return `429`. The new password must satisfy `validatePassword`. |
| `POST` | `/pin/change`                          | any authenticated role + auth limiter                                                   | body: `pin` (4-6 digits), plus `current_pin` when a PIN is configured or `current_password` on first-time setup                                                                                                                                                                                                                                                                                                                          | `{ message, has_pin: true, token }`. Sets or updates the caller's PIN, advances `tokens_valid_after`, and returns a fresh token for the caller. A wrong current PIN or password returns `400` with `attempts_remaining`; the fifth consecutive failure locks that user out for 5 minutes, and attempts during the lockout return `429`. |
| `POST` | `/recover-password`                    | any authenticated role + auth limiter                                                   | body: `email`, `master_pin`, `new_password`                                                                                                                                                                                                                                                                                                                                                                                              | Local-setup recovery gated on `email`, `master_pin`, `new_password`. `409` before first-run setup finishes.                                                                                                                                                                                      |
| `GET`  | `/recover-password/cloud/verification` | none, loopback only, auth limiter (600 per window)                                      | none                                                                                                                                                                                                                                                                                                                                                                                                                                     | `{ verified }`.                                                                                                                                                                                                                                                                                  |
| `POST` | `/recover-password/cloud/request`      | none, loopback only, auth limiter                                                       | none; sends the verification email first when the owner email is unverified, otherwise asks FloAdmin to email the owner a reset link                                                                                                                                                                                                                                                                                                     | `202` `{ step: "verify_email", email_hint }` or `{ step: "reset", reset_id, expires_at, email_hint }`. `409` when email recovery is unavailable, `429` on FloAdmin rate limit, `502` when FloAdmin is unreachable.                                                                               |
| `GET`  | `/recover-password/cloud/status`       | none, loopback only, auth limiter (600 per window)                                      | query: `reset_id`; returns the FloAdmin approval status                                                                                                                                                                                                                                                                                                                                                                                  | `{ status }` where status is `pending`, `approved`, `expired`, or `consumed`.                                                                                                                                                                                                                    |
| `POST` | `/recover-password/cloud/complete`     | none, loopback only, auth limiter                                                       | body: `reset_id`, `new_password`; redeems the approved reset, then sets the password                                                                                                                                                                                                                                                                                                                                                     | `{ message }`. `404` when no active owner matches the registered email, `409` when the reset is not approved or has expired. The grant is redeemed only after the owner is resolved.                                                                                                             |
| `GET`  | `/setup/status`                        | any authenticated role                                                                  | none                                                                                                                                                                                                                                                                                                                                                                                                                                     | `{ needsSetup, userCount, initialRole, schemaVersion, masterPinAvailable, cloudRecoveryAvailable, currencyReset }`.                                                                                                                                                                              |
| `POST` | `/setup/initialize`                    | any authenticated role                                                                  | body: `name`, `password`, `business_type`, `setup_profile`, `service_model`, `language`, `business_name`, `country`, `currency`, `currency_symbol`, `timezone`, `business_address`, `business_phone`, `tax_registration_number`, `state_code`, `tax_registered`, `billing_type`, `terms_accepted`, `master_pin`, `owner_approval_pin`, `owner_approval_pin_confirmation`, `cloud_server_url`, `email_product_updates`, `email_marketing` | Creates the first owner. Refuses once any user exists.                                                                                                                                                                                                                                           |
| `POST` | `/setup/seed`                          | any authenticated role                                                                  | none                                                                                                                                                                                                                                                                                                                                                                                                                                     | Always `410`; directs the caller to `/api/auth/setup/initialize`.                                                                                                                                                                                                                                |

### Categories

Router: `main/routes/categories.ts`. Full path: `/api/categories`.

Categories support at most two levels: root categories (`parent_id IS NULL`) and one subcategory level (`parent.parent_id IS NULL`). Sibling names are unique after trimming, case-insensitively, under the same parent (`parent_id IS NULL` for roots or matching `parent_id` for subcategories), while identical child names are allowed under different root parents. Categories do not inherit taxes or permissions from their parent; subcategory kitchen routing and KDS scope inheritance are covered in [Kitchen printing](../printers.md#kitchen-printing) and [KDS category and station scoping](#kds-category-and-station-scoping).

| Method   | Path       | Authorization                                       | Parameters                                                                                                          | Response                                                                                                                                                                                                                                                                                                                                                                                                                      |
| -------- | ---------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET`    | `/`        | any authenticated role                              | query: `?active`, `?root`, `?parent_id`                                                                             | Category list, soft-deleted rows excluded, ordered depth-first with root siblings sorted by `sort_order, name` and each parent's subcategories grouped immediately after in sibling `sort_order, name` order. `?root=true` and `?parent_id` narrow to a tree level. `?active=true` (or `1`) restricts returned categories and nested `children` to active categories whose ancestors, if any, are all active and non-deleted. |
| `GET`    | `/:id`     | any authenticated role                              | path: `id`                                                                                                          | Single category; `404` when absent or soft-deleted.                                                                                                                                                                                                                                                                                                                                                                           |
| `POST`   | `/`        | `ROLE_ACCESS.ownerManager` + category write limiter | body: `name`, `description`, `parent_id`, `sort_order`, `is_active`, `color`, `icon`, `addon_group_ids`             | `201` with created category. `400` on blank name, Unicode case-insensitive duplicate sibling name, missing/inactive parent, or grandchild depth (`parent.parent_id !== null`).                                                                                                                                                                                                                                                |
| `POST`   | `/reorder` | `ROLE_ACCESS.ownerManager` + category write limiter | body: `parent_id` (nullable for roots), `category_ids`                                                              | `200` with `{ categories }` ordered by updated `sort_order, name`. Validates that `category_ids` is a duplicate-free list matching the complete non-deleted sibling set for `parent_id` before updating `sort_order` in one SQLite transaction (`400` with zero partial updates on mismatch).                                                                                                                                 |
| `PUT`    | `/:id`     | `ROLE_ACCESS.ownerManager` + category write limiter | path: `id`; body: `name`, `description`, `parent_id`, `sort_order`, `is_active`, `color`, `icon`, `addon_group_ids` | `400` on blank name, cycle, grandchild depth, re-parenting a category that already has subcategories, assigning a new or changed inactive parent, changing normalized `name` or `parent_id` into a duplicate sibling name, or deactivating (`is_active: false`) an active category that has non-deleted subcategories.                                                                                                        |
| `DELETE` | `/:id`     | `ROLE_ACCESS.ownerManager` + category write limiter | path: `id`; query: `?action`, `?reassign_to`                                                                        | `400` when the category has non-deleted subcategories (subcategories are never silently re-parented or flattened), or when active products exist without a valid `reassign` or `delete_all` action.                                                                                                                                                                                                                           |

### Addon groups

Router: `main/routes/addon-groups.ts`. Full path: `/api/addon-groups`.

| Method   | Path                        | Authorization                                          | Parameters                                                                                                                                                 | Response                                           |
| -------- | --------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `GET`    | `/`                         | any authenticated role + addon-group read limiter      | none                                                                                                                                                       | `{ addon_groups: [ ... ] }` with add-ons attached. |
| `GET`    | `/:id`                      | any authenticated role + addon-group read limiter      | path: `id`                                                                                                                                                 | Single group with add-ons.                         |
| `POST`   | `/`                         | `ROLE_ACCESS.ownerManager` + addon-group write limiter | body: `name`, `description`, `is_required`, `min_selection`, `max_selection`, `allow_multiple_quantities`, `sort_order`, `is_active`, `addons`             | `201` with the created group.                      |
| `PUT`    | `/:id`                      | `ROLE_ACCESS.ownerManager` + addon-group write limiter | path: `id`; body: `name`, `description`, `is_required`, `min_selection`, `max_selection`, `allow_multiple_quantities`, `sort_order`, `is_active`, `addons` | -                                                  |
| `DELETE` | `/:id`                      | `ROLE_ACCESS.ownerManager` + addon-group write limiter | path: `id`                                                                                                                                                 | -                                                  |
| `POST`   | `/:groupId/addons`          | `ROLE_ACCESS.ownerManager` + addon-group write limiter | path: `groupId`; body: `name`, `price`, `tax_category_id`, `tax_behavior`, `inherit_parent_tax_category`, `is_active`, `sort_order`                        | `201` with the created add-on.                     |
| `PUT`    | `/:groupId/addons/:addonId` | `ROLE_ACCESS.ownerManager` + addon-group write limiter | path: `addonId`, `groupId`; body: `name`, `price`, `tax_category_id`, `tax_behavior`, `inherit_parent_tax_category`, `is_active`, `sort_order`             | -                                                  |
| `DELETE` | `/:groupId/addons/:addonId` | `ROLE_ACCESS.ownerManager` + addon-group write limiter | path: `groupId`, `addonId`                                                                                                                                 | -                                                  |

### Products

Router: `main/routes/products.ts`. Full path: `/api/products`.

| Method   | Path                              | Authorization              | Parameters                                                                                                                                                                                                                                                                                                                                                                                                                  | Response                                                                                                                                                                                                                                                                                                                                  |
| -------- | --------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET`    | `/`                               | any authenticated role     | query: `?category_id`, `?active`, `?search`, `?barcode`, `?low_stock`                                                                                                                                                                                                                                                                                                                                                       | Menu list. Variant objects include `recipe_multiplier`. `?category_id` matches direct product assignment. `?active=true` (or `1`) requires an active product that is uncategorized or in an active, non-deleted category chain. `?low_stock` returns only items at or below their threshold.                                              |
| `GET`    | `/:id/image`                      | none                       | path: `id`                                                                                                                                                                                                                                                                                                                                                                                                                  | Product image bytes. Unauthenticated by design so `<img>` tags work; serves only stored `data:image/(webp\|png\|jpeg\|jpg);base64,...` URIs within `MAX_IMAGE_DATA_URI_LENGTH` (50,000 chars) and `MAX_IMAGE_BYTES` (37,500 bytes), checking `If-None-Match` against the SHA-256 `ETag` (`304`) before decoding Base64 (`404` otherwise). |
| `GET`    | `/:id`                            | any authenticated role     | path: `id`                                                                                                                                                                                                                                                                                                                                                                                                                  | Single product; variant objects include `recipe_multiplier`.                                                                                                                                                                                                                                                                              |
| `POST`   | `/fetch-url`                      | `ROLE_ACCESS.ownerManager` | body: `url`                                                                                                                                                                                                                                                                                                                                                                                                                 | Body `url` is fetched server-side and stored as the product image. Subject to the SSRF blocklist.                                                                                                                                                                                                                                         |
| `POST`   | `/`                               | `ROLE_ACCESS.ownerManager` | body: `category_id`, `name`, `sku`, `barcode`, `description`, `price`, `cost_price`, `sale_unit`, `allow_fractional_quantity`, `weight_precision`, `inventory_product_id`, `inventory_deduction_quantity`, `tax_category_id`, `tax_behavior`, `track_inventory`, `stock_quantity`, `low_stock_threshold`, `is_active`, `image_url`, `sort_order`, `cb_percent`, `tags`, `addon_group_ids`, `variants`, `reason`             | `400` if `category_id` names a missing, soft-deleted, or inactive category or ancestor.                                                                                                                                                                                                                                                   |
| `PUT`    | `/:id`                            | `ROLE_ACCESS.ownerManager` | path: `id`; body: `category_id`, `name`, `sku`, `barcode`, `description`, `price`, `cost_price`, `sale_unit`, `allow_fractional_quantity`, `weight_precision`, `inventory_product_id`, `inventory_deduction_quantity`, `tax_category_id`, `tax_behavior`, `track_inventory`, `stock_quantity`, `low_stock_threshold`, `is_active`, `image_url`, `sort_order`, `cb_percent`, `tags`, `addon_group_ids`, `variants`, `reason` | `400` if a new or changed `category_id` names a missing, soft-deleted, or inactive category or ancestor; retaining the product's current non-deleted `category_id` is allowed.                                                                                                                                                            |
| `DELETE` | `/:id`                            | `ROLE_ACCESS.ownerManager` | path: `id`                                                                                                                                                                                                                                                                                                                                                                                                                  | -                                                                                                                                                                                                                                                                                                                                         |
| `POST`   | `/:id/stock`                      | `ROLE_ACCESS.ownerManager` | path: `id`; body: `action`, `quantity`, `reason`                                                                                                                                                                                                                                                                                                                                                                            | -                                                                                                                                                                                                                                                                                                                                         |
| `GET`    | `/loyalty/global-rate-candidates` | `ROLE_ACCESS.ownerManager` | none                                                                                                                                                                                                                                                                                                                                                                                                                        | -                                                                                                                                                                                                                                                                                                                                         |
| `POST`   | `/loyalty/apply-global-rate`      | `ROLE_ACCESS.ownerManager` | none                                                                                                                                                                                                                                                                                                                                                                                                                        | Applies one cashback rate across products.                                                                                                                                                                                                                                                                                                |

In product create/update requests, each `variants` entry may include a positive finite
`recipe_multiplier`. If omitted, a new variant uses 1 and an existing variant keeps its stored
value. Product reads return the field on each variant; see [product invariants](product-invariants.md#supplies-stock-and-recipe-depletion)
for its recipe-depletion behavior.

### Recipes

Router: `main/routes/recipes.ts`. Full path: `/api/recipes`.

| Method   | Path                  | Authorization              | Parameters                                                      | Response                         |
| -------- | --------------------- | -------------------------- | --------------------------------------------------------------- | -------------------------------- |
| `GET`    | `/`                   | `ROLE_ACCESS.ownerManager` | none                                                            | `{ recipes: [ ... ] }`.          |
| `GET`    | `/product/:productId` | `ROLE_ACCESS.ownerManager` | path: `productId`                                               | Recipe snapshot for one product. |
| `PUT`    | `/product/:productId` | `ROLE_ACCESS.ownerManager` | path: `productId`; body: `yield_quantity`, `is_active`, `items` | -                                |
| `DELETE` | `/product/:productId` | `ROLE_ACCESS.ownerManager` | path: `productId`                                               | -                                |

### Supplies

Router: `main/routes/supplies.ts`. Full path: `/api/supplies`.

| Method   | Path             | Authorization              | Parameters                                                                                                     | Response                                                                                                                                         |
| -------- | ---------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET`    | `/`              | `ROLE_ACCESS.ownerManager` | query: `?include_inactive`, `?low_stock`, `?search`                                                            | Supply list. `?low_stock` and `?include_inactive` narrow it.                                                                                     |
| `POST`   | `/`              | `ROLE_ACCESS.ownerManager` | body: `name`, `base_unit`, `stock_quantity`, `low_stock_threshold`, `unit_cost`, `unit_cost_unit`, `is_active` | -                                                                                                                                                |
| `GET`    | `/movements`     | `ROLE_ACCESS.ownerManager` | query: `?supply_id`, `?movement_type`, `?search`, `?before_id`, `?per_page`                                    | Supply movement ledger, newest first; `?search` matches the supply name, reason, or actor across the whole ledger; `?before_id` pages backwards. |
| `GET`    | `/:id`           | `ROLE_ACCESS.ownerManager` | path: `id`                                                                                                     | -                                                                                                                                                |
| `PUT`    | `/:id`           | `ROLE_ACCESS.ownerManager` | path: `id`; body: `name`, `is_active`, `low_stock_threshold`, `unit_cost`, `unit_cost_unit`                    | -                                                                                                                                                |
| `DELETE` | `/:id`           | `ROLE_ACCESS.ownerManager` | path: `id`                                                                                                     | -                                                                                                                                                |
| `POST`   | `/:id/movements` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `movement_type`, `quantity`, `unit`, `unit_cost`, `total_cost`, `reason`                     | Body `movement_type`, `quantity`, `unit`, `reason`, plus optional `unit_cost` or `total_cost` on `receive` movements.                            |

### Inventory movements

Router: `main/routes/inventory.ts`. Full path: `/api/inventory`.

| Method | Path         | Authorization              | Parameters                                                                                            | Response |
| ------ | ------------ | -------------------------- | ----------------------------------------------------------------------------------------------------- | -------- |
| `GET`  | `/movements` | `ROLE_ACCESS.ownerManager` | query: `?product_id`, `?reference_type`, `?reference_id`, `?movement_type`, `?before_id`, `?per_page` | -        |

### Tables

Router: `main/routes/tables.ts`. Full path: `/api/tables`.

| Method   | Path              | Authorization              | Parameters                                                                                                           | Response                                                                                                                                                                                                                                                           |
| -------- | ----------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET`    | `/`               | any authenticated role     | query: `?status`, `?floor`, `?section`, `?kitchen_station_id`, `?active`                                             | Table list; `?status`, `?floor`, `?section`, `?kitchen_station_id`, `?active`.                                                                                                                                                                                     |
| `GET`    | `/:id`            | any authenticated role     | path: `id`                                                                                                           | Single table.                                                                                                                                                                                                                                                      |
| `PATCH`  | `/floors/:name`   | `ROLE_ACCESS.ownerManager` | path: `name`; body: `newName`                                                                                        | -                                                                                                                                                                                                                                                                  |
| `DELETE` | `/floors/:name`   | `ROLE_ACCESS.ownerManager` | path: `name`                                                                                                         | -                                                                                                                                                                                                                                                                  |
| `POST`   | `/`               | `ROLE_ACCESS.ownerManager` | body: `number`, `name`, `capacity`, `floor`, `section`, `position_x`, `position_y`, `kitchen_station_id`             | -                                                                                                                                                                                                                                                                  |
| `PATCH`  | `/positions`      | `ROLE_ACCESS.ownerManager` | body: `positions`                                                                                                    | Bulk floor-plan position update.                                                                                                                                                                                                                                   |
| `PUT`    | `/:id`            | `ROLE_ACCESS.ownerManager` | path: `id`; body: `number`, `name`, `capacity`, `floor`, `section`, `position_x`, `position_y`, `kitchen_station_id` | -                                                                                                                                                                                                                                                                  |
| `POST`   | `/:id/deactivate` | `ROLE_ACCESS.ownerManager` | path: `id`                                                                                                           | Soft-deactivates the table rather than deleting it.                                                                                                                                                                                                                |
| `POST`   | `/:id/reactivate` | `ROLE_ACCESS.ownerManager` | path: `id`                                                                                                           | -                                                                                                                                                                                                                                                                  |
| `POST`   | `/:id/move-order` | `ROLE_ACCESS.sales`        | path: `id`; body: `target_table_id`, `order_id`                                                                      | `{ order, sourceTable, targetTable }`. `400` when `target_table_id` is missing, matches `id`, or names an inactive table; `404` when either table or the active order is not found; `409` when the destination table has an active or held order or is `reserved`. |
| `PATCH`  | `/:id/status`     | `ROLE_ACCESS.ownerManager` | path: `id`; body: `status`, `reservation_customer_id`                                                                | Body `status`, plus `reservation_customer_id` for reservation state.                                                                                                                                                                                               |

### Kitchen stations

Router: `main/routes/kitchen-stations.ts`. Full path: `/api/kitchen-stations`.

| Method   | Path         | Authorization              | Parameters                                                                                                                                     | Response                                               |
| -------- | ------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `GET`    | `/`          | any authenticated role     | none                                                                                                                                           | `{ kitchen_stations: [ ... ] }`, active stations only. |
| `GET`    | `/:id`       | any authenticated role     | path: `id`                                                                                                                                     | Single station.                                        |
| `POST`   | `/`          | `ROLE_ACCESS.ownerManager` | body: `name`, `description`, `category_ids`, `printer_id`, `printer_ip`, `printer_port`, `printer_name`, `sort_order`                          | -                                                      |
| `PUT`    | `/:id`       | `ROLE_ACCESS.ownerManager` | path: `id`; body: `name`, `description`, `category_ids`, `printer_id`, `printer_ip`, `printer_port`, `printer_name`, `sort_order`, `is_active` | -                                                      |
| `PUT`    | `/:id/users` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `user_ids`                                                                                                                   | Replaces the station's assigned `user_ids`.            |
| `DELETE` | `/:id`       | `ROLE_ACCESS.ownerManager` | path: `id`                                                                                                                                     | -                                                      |

### Counters

Router: `main/routes/counters.ts`. Full path: `/api/counters`.

| Method   | Path   | Authorization                                                                                                    | Parameters                                                                                                                                                                   | Response                                                                                                                            |
| -------- | ------ | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `GET`    | `/`    | any of `pos.use`, `orders.read`, `orders.create`, `payments.take`, `settings.view`, `settings.manage` (or owner) | optional query: `?include_inactive` (or `?all`)                                                                                                                              | `{ counters: [ ... ] }`, ordered by `sort_order ASC, name ASC`. Returns active counters by default, or all counters when requested. |
| `GET`    | `/:id` | any of `pos.use`, `orders.read`, `orders.create`, `payments.take`, `settings.view`, `settings.manage` (or owner) | path: `id`                                                                                                                                                                   | `{ counter }`. `404` when counter is not found.                                                                                     |
| `POST`   | `/`    | active owner with `settings.manage`                                                                              | body: `name` (required, trimmed), `role` (`ordering` \| `payment` \| `combined`, default `combined`), `sort_order` (integer, default 0), `is_active` (boolean, default true) | `201` with `{ counter }`. `400` on missing name or invalid role; `403` for non-owner.                                               |
| `PUT`    | `/:id` | active owner with `settings.manage`                                                                              | path: `id`; body: `name`, `role`, `sort_order`, `is_active`                                                                                                                  | `{ counter }`. `400` on invalid payload; `403` for non-owner; `404` when counter is not found.                                      |
| `DELETE` | `/:id` | active owner with `settings.manage`                                                                              | path: `id`                                                                                                                                                                   | `{ success: true }`. `403` for non-owner; `404` when counter is not found.                                                          |

### Orders

Router: `main/routes/orders.ts`. Full path: `/api/orders`.

The cart-to-payment path these endpoints drive - held carts, the pricing pipeline, discounts, bills,
settlement, and KDS-driven order-status reconciliation - is in
[Order lifecycle](../architecture/order-lifecycle.md).

| Method  | Path                              | Authorization                                                                                  | Parameters                                                                                                                                                                                                                                                                                                                                                                                   | Response                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| ------- | --------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET`   | `/`                               | `orders.read` (default roles: `ROLE_ACCESS.sales`) + order read limiter                        | query: `?view`, `?status`, `?board_status`, `?type`, `?today`, `?start_date`, `?end_date`, `?table_id`, `?before_id`, `?per_page`, `?search`                                                                                                                                                                                                                                                 | `{ orders, nextCursor? }`, newest page first. `view` accepts `all`, `active`, or `unpaid` and defaults to `all`; `active` (and `?status=active`) includes `pending`, `preparing`, `ready`, and `served` orders plus eligible KDS-unfinished `<24h` `completed` orders (see [Order lifecycle](../architecture/order-lifecycle.md#order-status)); `unpaid` includes unbilled orders and orders with an unpaid or partial bill with a positive balance, excluding cancelled orders. `?board_status` accepts a comma-separated non-empty subset of `pending`, `preparing`, and `ready` (`400` otherwise) and narrows before pagination to released (`release_status = 'released'`) active orders in those statuses plus eligible released KDS-unfinished `<24h` `completed` orders whose projected board status matches, preserving stored `orders.status` on returned rows. `?type` accepts a single order type or comma-separated subset of `takeaway`, `dine_in`, `online`, and `delivery` (`400` when invalid with `?board_status`). Status, board status, type, table, view, date, and search filters apply before pagination. `?before_id` pages backwards using the numeric id cursor and stable `created_at`, `id` ordering. `?search` matches order number, customer name, or phone. The Orders screen requests 15 matches per page; the API default remains 50.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `GET`   | `/scheduled`                      | `orders.read` (default roles: `ROLE_ACCESS.sales`) + scheduled order read limiter              | optional query: `cursor`, `limit` (max 200)                                                                                                                                                                                                                                                                                                                                                  | `{ orders, next_cursor, scheduled_count, blocked_count }`. Returns active unreleased (`release_status IN ('scheduled', 'blocked')`, non-cancelled, non-completed) orders with a non-null `scheduled_for`, ordered with `blocked` first and `scheduled_for ASC`, hydrated with items, bills, and customer details for the POS scheduled queue. `next_cursor` is returned when additional pages exist; `scheduled_count` and `blocked_count` report total active unreleased counts across all pages.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `GET`   | `/awaiting-payment`               | `orders.read` (default roles: `ROLE_ACCESS.sales`) + order read limiter                        | optional query: `cursor`, `limit` (max 200), `search`                                                                                                                                                                                                                                                                                                                                        | `{ orders, tickets, next_cursor, has_more, awaiting_count, blocked_count, total_count, counts }`. Returns active unreleased tickets in `checkout_stage = 'awaiting_payment'` or blocked for `on_payment` dispatch, ordered with blocked tickets first, then oldest `created_at ASC, id ASC`. Each ticket is a flat row containing `id`, `order_number`, `type`, `table_id`, `table_name`, `customer_id`, `customer_name`, `customer_phone`, `scheduled_for`, `total`, `total_amount`, `round_off`, `checkout_stage`, `dispatch_policy`, `release_status`, `release_blocked_reason`, `created_at`, `updated_at`, `bill_id`, `bill_number`, `paid_amount`, `balance`, `bill_payment_status`, `ticket_balance`, and `ticket_paid_amount`. Ticket balances and paid amounts are aggregated across all bills with refund-aware payment totals; `bill_id`, `balance`, and `paid_amount` continue to identify the selected payable bill. Supports cursor pagination and text/ticket search.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `GET`   | `/by-ticket/:ticket`              | `orders.read` (default roles: `ROLE_ACCESS.sales`) + order read limiter                        | path: `ticket`; optional query: `by` (`id`)                                                                                                                                                                                                                                                                                                                                                  | `{ order, bill, bills }`. Hydrated order and associated bills. `ticket` accepts the raw order number or scanned QR payload (`FLOCAFE-TICKET:<order_number>`), or numeric order id when `?by=id` is supplied. `404` when the ticket is not found or already paid.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `GET`   | `/:id`                            | `orders.read` (default roles: `ROLE_ACCESS.sales`) + order read limiter                        | path: `id`                                                                                                                                                                                                                                                                                                                                                                                   | `{ ...order, items, table }`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `POST`  | `/`                               | `ROLE_ACCESS.sales` + order write limiter                                                      | body: `items`, `table_id`, `customer_id`, `type`, `guest_count`, `special_instructions`, `scheduled_for`, `dispatch_policy`, `checkout_stage`, `packaging_charge`, `delivery_charge`, `service_charge`, `online_platform`, `external_order_id`, `delivery_address`, `expected_payment_method`, `expected_payment_method_id`, `delivery_note`; header: `Idempotency-Key`                      | `201` with the created order. Repeating the same request body with an `Idempotency-Key` returns its stored response; reusing the key with a different body returns `409`. Optional `scheduled_for` defers kitchen release on `takeaway` and `delivery` orders (`400` on `dine_in`/`online`, past times, or skipped/repeated DST times in the store timezone), saving quoted prices and totals with `release_status = 'scheduled'` without touching stock, KDS, or KOT; see [Scheduled orders and kitchen release](../architecture/order-lifecycle.md#scheduled-orders-and-kitchen-release). Optional `dispatch_policy` (`immediate` or `on_payment`, default `immediate`) supports multi-counter ordering for `takeaway` and `dine_in` (`400` on other types); new `on_payment` orders require `multi_counter_enabled = 'true'` (`409` with `MULTI_COUNTER_DISABLED` otherwise), while committed idempotent replays remain available after disabling. When `on_payment`, creates the order with `checkout_stage = 'awaiting_payment'`, `release_status = 'scheduled'`, defers kitchen stock deduction, creates an initial bill, and returns `qr_payload` (`FLOCAFE-TICKET:<order_number>`) and `qr_data_url` for ticket scanning; see [Multi-counter ordering](../architecture/order-lifecycle.md#multi-counter-ordering-create-here-pay-there). If supplied, optional `checkout_stage` must match the dispatch policy: `'awaiting_payment'` for `on_payment`, or `'paid'` for `immediate` (`400` otherwise). `expected_payment_method` and `delivery_note` are stored only for `type: 'delivery'`: without an id, the method keeps its legacy string/sentinel resolution (`unknown` by default, `pending`, `cash`, `card`, or an active custom method name), and is never recorded as a payment; `400` for any other method or a note over 200 characters. A custom method whose name matches a built-in or sentinel needs its id to be identified as custom. An optional `expected_payment_method_id` must be a positive safe integer naming an **active** configured method; the server stores its canonical name and requires any supplied non-empty name to match case-insensitively. Invalid, unknown, or inactive ids return `400`. The id is a historical marker, not a foreign key, so renaming or deleting the method later never rewrites an order; only `orders.expected_payment_method_id` decides that the stored name prints literally on a delivery slip. Non-delivery orders persist both expected-method fields as `null`. An item's `variant_id` is required when the product has active variants, and `400` when it names an unknown, foreign, or inactive variant; `unit_price` is never taken from the client. |
| `PATCH` | `/:id/schedule`                   | `orders.create` or `orders.status.update` + order write limiter                                | path: `id`; body: `scheduled_for`                                                                                                                                                                                                                                                                                                                                                            | `{ order }`. Updates `scheduled_for` (validated in the store timezone). For unreleased orders in `release_status = 'scheduled'`, resets `release_status` to `'scheduled'` and clears `release_blocked_reason`; for held orders in `release_status = 'blocked'`, preserves `release_status = 'blocked'` and `release_blocked_reason` so only explicit `POST /:id/release` retries release. Records `schedule_updated` in `order_audit_log`. Allowed before release even after payment; `409` once released, completed, or cancelled.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `POST`  | `/:id/release`                    | `orders.create` or `orders.status.update` + order write limiter                                | path: `id`                                                                                                                                                                                                                                                                                                                                                                                   | `{ order, already_released, kot_auto_printed }` on release (`order_released` in `order_audit_log`), or `409` with `code: 'RELEASE_BLOCKED'` and `{ order }` when tracked product/variant/add-on stock, catalog validation, or fully refunded bills hold the whole order in `release_status = 'blocked'`. A full refund sets `release_blocked_reason` to `refunded`; cancellation is the recovery path. Returns `409` (`ORDER_NOT_PAID`) when an `on_payment` multi-counter order is not fully paid and does not meet the full-refund hold condition.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `POST`  | `/:id/items`                      | `ROLE_ACCESS.sales` + order write limiter                                                      | path: `id`; body: `items`, `special_instructions`; header: `Idempotency-Key`                                                                                                                                                                                                                                                                                                                 | Appends items and returns `{ order: { ...order, items }, added_item_ids, added_items }` (where `added_item_ids` and `added_items` list only the newly inserted lines). Honours `Idempotency-Key`. Items resolve a `variant_id` under the same rules as order creation, priced with `variant.online_price` when the order carries an `online_platform`. Returns `409` (`SCHEDULED_ORDER_PAID`) on a scheduled order once a payment has been recorded.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `PATCH` | `/:id/status`                     | `orders.status.update` (default roles: `ROLE_ACCESS.orderStatus`) + order write limiter        | path: `id`; body: `status`, `reason`, `override_pin`, `free_table`, `approver_id`, `manager_id`, `user_id`                                                                                                                                                                                                                                                                                   | Order-level transition. Allowed targets: `pending` to `preparing`, `ready`, `served`, `completed`, `cancelled`; `preparing` to `ready`, `served`, `completed`, `cancelled`; `ready` to `served`, `completed`, `cancelled`; `served` to `completed`, `cancelled`. `completed` and `cancelled` are terminal. Repeating the current status is a no-op. See the status safeguard rules below.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `PATCH` | `/:id/customer`                   | `ROLE_ACCESS.ownerManager` + order write limiter                                               | path: `id`; body: `customer_id`                                                                                                                                                                                                                                                                                                                                                              | `{ order: { ...order, customer } }`. Returns `409` (`SCHEDULED_ORDER_PAID`) on a paid scheduled order. See [Order lifecycle](../architecture/order-lifecycle.md#bills) for active-order, bill-state, tax-recompute, and audit rules.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `PATCH` | `/:id/convert-to-takeaway`        | `ROLE_ACCESS.sales` + order write limiter                                                      | path: `id`                                                                                                                                                                                                                                                                                                                                                                                   | `{ order: { ...order, items, table: null } }`. See [Order lifecycle](../architecture/order-lifecycle.md#bills) for active dine-in, bill-state, charge-recompute, and audit rules.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `PATCH` | `/:id/discount`                   | `ROLE_ACCESS.ownerManagerCashier` + order write limiter                                        | path: `id`; body: `discount_type`, `discount_value`, `discount_reason`, `override_pin`, `approver_id`, `manager_id`, `user_id`                                                                                                                                                                                                                                                               | `400` when the order is completed or cancelled (`order_not_discountable`). `409` on a paid scheduled order (`SCHEDULED_ORDER_PAID`), on a split or refunded bill, or when the discount would reduce a partially paid bill's total below collected payments (`below_collected_funds`). Auto-settles the bill to `paid`, completes the order, and frees the table when remaining balance reaches zero on a partially paid bill (gated by kitchen delivery; see [Settlement and completion](../architecture/order-lifecycle.md#settlement-and-completion)). Audited as `order_discount_applied`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `PATCH` | `/:id/items/:itemId/discount`     | `ROLE_ACCESS.ownerManagerCashier` + order write limiter                                        | path: `id`, `itemId`; body: `discount_type`, `discount_value`, `override_pin`, `approver_id`, `manager_id`, `user_id`                                                                                                                                                                                                                                                                        | Returns `{ item }` with updated `edit_version`. `400` on a completed or cancelled order, or a cancelled, voided, void-adjustment, or refunded item. `409` on a paid scheduled order (`SCHEDULED_ORDER_PAID`), on a split or refunded bill, or when the discount would reduce a partially paid bill's total below collected payments (`below_collected_funds`). Auto-settles the bill to `paid`, completes the order, and frees the table when remaining balance reaches zero on a partially paid bill (gated by kitchen delivery; see [Settlement and completion](../architecture/order-lifecycle.md#settlement-and-completion)).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `PATCH` | `/:id/items/:itemId/price`        | `orders.item.price_override` (owner only, not configurable) + order write limiter              | path: `id`, `itemId`; body: `price` (non-negative, `0` allowed), `reason` (required, at most 200 characters)                                                                                                                                                                                                                                                                                 | `{ item }`. Replaces the line's unit price, keeps the first menu price in `original_unit_price`, flags `price_overridden`, records the actor and time, and recomputes item tax, order totals, and any open bill on the backend. An existing item discount stays, capped at the new line total. `403` for any other role, `400` on a cancelled or completed order or item, `409` on a paid scheduled order (`SCHEDULED_ORDER_PAID`) or once a bill has payments, a split, or a refund. Audited as `item_price_overridden`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `PATCH` | `/:orderId/items/:itemId/cancel`  | any authenticated role + item cancel limiter, then an in-handler permission check              | path: `orderId`, `itemId`; body: `override_pin`, `reason`, `approver_id`, `manager_id`, `user_id`                                                                                                                                                                                                                                                                                            | See the note below. Returns `{ order: { ...order, items } }`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `PATCH` | `/:orderId/items/:itemId/edit`    | `orders.item.edit` (shipped to `ROLE_ACCESS.owner`) + order write limiter                      | path: `orderId`, `itemId`; header: `Idempotency-Key`; body: `expected_edit_version` (non-negative integer matching the item's `edit_version`, derived from the latest `order_audit_log.id` for the item or `0`), and exactly one of `quantity` or `replacement` (`product_id`, `quantity`, optional `variant_id`, `modifier_selection`, `addons`, `special_instructions`), optional `reason` | Returns `{ order: { ...order, items } }` where hydrated items carry `edit_version` and, on `cancelled` or `voided` rows marked `superseded: true` in `item_edited`, `superseded: true`. Honours `Idempotency-Key` (replaying a matching key + request before stale-version or terminal-item checks, `409` on key reuse with a different request) and rejects a stale `expected_edit_version` with `409`. On a `pending` line, restores recorded product/add-on/recipe stock once, cancels the old line as superseded (`superseded: true`), and inserts and deducts the new `pending` line; the new line keeps the stored variant and add-on selections even if the catalog has since disabled them (reductions preserve stored selections and scale historical recipe snapshots and inventory deductions onto the new pending line; replacements and increases re-validate against the current catalog, and the carried `variant_selection` snapshot is persisted verbatim). On a line the kitchen already started (`preparing`, `ready`, `served`), a quantity increase keeps the original line and inserts/deducts only the extra units as a new `pending` line (`superseded: false`); a quantity decrease updates the existing line's billable quantity in place without restoring or re-deducting stock, scaling its held `inventory_deducted_quantity`, add-on deductions, and `recipe_snapshot` to the remaining quantity so wasted units stay out of available stock, while preserving its kitchen status and stored unit/add-on prices and recording `wasted_quantity` in `item_edited`; product replacement is disallowed once kitchen preparation has started (`400`). Re-syncs unpaid and partially paid bills. `409` on a paid scheduled order (`SCHEDULED_ORDER_PAID`), on a split, paid, or refunded order, or when the new total falls below the amount already collected. Audited as `item_edited`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `PATCH` | `/:orderId/items/:itemId/restore` | in-handler `orders.item.restore` (shipped to `ROLE_ACCESS.ownerManager`) + order write limiter | path: `orderId`, `itemId`                                                                                                                                                                                                                                                                                                                                                                    | Returns `{ order: { ...order, items } }`. `400` on a completed or cancelled order, `409` on a paid scheduled order (`SCHEDULED_ORDER_PAID`), when a bill is paid, partially paid, or refunded, or when the cancelled line was superseded by an item edit. Re-deducts inventory and recipe components, and recomputes any order-level discount (scaling a percentage discount or capping a flat discount at the new subtotal).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |

Order creation, item append, and order discount accept an optional version-4 UUID `X-Operation-ID`
correlation header. An error response with a registered failure point in
[`main/lib/operation-failure.ts`](../../main/lib/operation-failure.ts) includes `reason_code`,
`operation`, `stage`, `classification`, `outcome`, and `correlation_id` alongside `error`;
validation rejections may include `display_code`, and order-creation persistence failures
(`persistence.failed`) include `facts: { failure_class }` (`PERSISTENCE_FAILURE_CLASSES` in
`main/lib/operation-failure.ts`). A valid supplied UUID is returned as the correlation id,
otherwise the server generates one. Failures without a registered tuple may retain the `{ error }`
response.

`PATCH /:id/status` evaluates order, bill/payment, and kitchen state inside the transaction:

- Every status transition enforces the caller's
  [KDS category and station scope](#kds-category-and-station-scoping) inside the transaction (`403`
  `Insufficient permissions` when configured station assignments resolve to no active station, when
  the order's table station is outside `stationIds`, when any non-terminal positive-quantity item on
  the order is outside the caller's category or station scope, or when the order has no non-terminal
  positive-quantity items and the caller is category-scoped or not covered by an unrestricted
  station).
- Ordinary `completed` transitions require every payable bill for the order to be settled (`paid` with
  zero unpaid balance) and billed totals (accounting for `round_off`) to cover `order.total`. An
  unbilled positive-total order is not considered paid. When
  `require_kitchen_delivered_before_settlement` is enabled (`kds_enabled` not `false` and
  `billing_type` not `prepaid`), any item still in `pending`, `preparing`, or `ready` also blocks
  ordinary completion.
- When unsettled payment or the applicable kitchen-delivery requirement blocks `completed`, an
  explicit `override_pin` from an active `owner` or `manager` holding `orders.status.update` (plus
  `payments.take` when overriding unsettled payment) is required (`400` when missing, `403` when
  invalid or unauthorized, `429` after 5 failed attempts). Being logged in as an owner or manager
  does not bypass `override_pin`. Overriding completion never marks bills paid, erases balances, or
  creates payment records.
- `cancelled` transitions require an explicit `override_pin` from an active `owner` or `manager`
  holding `orders.status.update` and `orders.item.cancel` whenever the order has advanced past
  `pending`, any item is in `preparing`, `ready`, `served`, or `completed`, or any bill holds
  recorded collected funds. Cancelling a scheduled order that holds net collected funds returns
  `409` (`REFUND_REQUIRED_BEFORE_CANCEL`) until refunded in full. Cancellation preserves bill
  payment and refund records (refunds remain a separate operation via `/api/refunds`) and restores
  product, variant, add-on, and recipe stock only for items still in `pending` status that actually
  deducted inventory (unreleased scheduled orders deduct no stock on creation, so none is restored).
- When `approver_id` (or `manager_id`, `user_id`) is supplied on an overridden transition, PIN verification is
  restricted to that active user without falling back to scanning other managers. When omitted, candidate managers
  are evaluated, skipping users who lack the required role or permissions (including `payments.take` when completion
  is blocked by unsettled payment). Every overridden `status_changed` audit log entry records
  `actor_user_id`, `approved_by`, `overridden_safeguards`, `bill_ids`, `outstanding_amount`,
  `outstanding_amounts`, and `collected_amount` (plus `undelivered_items` when overriding kitchen
  delivery), and never records the PIN.

`PATCH .../cancel` and `PATCH .../restore` resolve the caller's effective permissions from the
database inside a `withTxn` (deferred, not immediate) transaction rather than from the JWT claim,
and re-run every policy check there. Each opens the single transaction scope it runs in; the route
does not wrap the handler again.

`PATCH .../cancel` is a policy switch, not a plain status write:

- `403` without `orders.item.void` (shipped to `ROLE_ACCESS.sales`) when the item is already in
  `preparing` or `ready`, and `403` without it again for an item that is already `voided`,
  `void_adjustment` or `refunded`. Every other status, including `pending`, `served` and an item
  already `cancelled`, needs `orders.item.cancel` (shipped to `ROLE_ACCESS.ownerManager`). Voiding
  an in-progress item additionally requires `override_pin`. When an approver is explicitly supplied
  (`approver_id`, `manager_id`, or `user_id`), PIN verification is restricted to that active user
  without falling back to scanning other managers. A caller who already holds the required
  permission can repeat the call on a terminal item without a PIN, in which case it is a no-op
  that returns the current state.
- `409` on a paid scheduled order (`SCHEDULED_ORDER_PAID`) or when any bill for the order is paid, partially paid, or carries payment details.
- `400` when the order is `completed` or `cancelled`.
- Voiding an item in preparation writes a mirrored negative `order_items` row with status
  `void_adjustment` and marks the original `voided`, so the bill total adjusts while the original
  line stays visible. Inventory is not restored on the void path. A plain cancel sets the item to
  `cancelled` and restores the recorded inventory deduction and recipe components.
- Cancelling the last active item cancels the order and frees its table.
- `override_pin` attempts are rate limited per canonical client IP, not per item, to slow brute force (counting only failed attempts; 429 after 5 failed attempts within 15 minutes; serialized to prevent concurrent brute-force races).

### Order item status

Router: `main/routes/order-items.ts`. Full path: `/api/order-items`.

| Method  | Path          | Authorization                                                                | Parameters                                    | Response                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| ------- | ------------- | ---------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PATCH` | `/:id/status` | `kitchen.status.update` (default roles: `ROLE_ACCESS.kitchen`) + KDS-enabled | path: `id`; body: `status`, `expected_status` | `{ order }`. Body `status` in `pending`, `preparing`, `ready`, `served`, plus optional `expected_status`. Re-checks `kitchen.status.update` and station/category scope inside the write transaction and reconciles active parent `orders.status` (see [Order lifecycle](../architecture/order-lifecycle.md#order-status)). Returns `409` (`Scheduled order has not been released to the kitchen yet`) when the item belongs to an unreleased scheduled order. |

### Held orders

Router: `main/routes/held-orders.ts`. Full path: `/api/held-orders`.

Held-cart behavior and the POS resume sequence are described in
[Order lifecycle](../architecture/order-lifecycle.md#cart-and-held-carts).

| Method   | Path        | Authorization                                  | Parameters                                                                 | Response                                                                                                                                                                                                                                                                                                                                                           |
| -------- | ----------- | ---------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET`    | `/`         | `ROLE_ACCESS.sales` + held-order read limiter  | none                                                                       | Held carts in `updated_at` order, each with `waivedChargeIds` and `optedInChargeIds`; a hold saved without them reads back as an empty list. A row whose stored selection JSON is damaged is still returned with empty selections.                                                                                                                                 |
| `POST`   | `/`         | `ROLE_ACCESS.sales` + held-order write limiter | body: held-cart fields plus optional `waivedChargeIds`, `optedInChargeIds` | `{ success, id }`. Selections are bounded charge ids (`CHARGE_ID_PATTERN`, `MAX_CHARGE_ID_LENGTH`) and are deduplicated; an omitted, null, or empty list means no choices, and a malformed list is a `400` that leaves the stored cart untouched. Unknown but well-formed ids persist, because the charges engine decides applicability when the order is created. |
| `DELETE` | `/:tableId` | `ROLE_ACCESS.sales` + held-order write limiter | path: `tableId`; query: `?heldOrderId`                                     | `heldOrderId` must match the table's current hold; a missing or stale id returns `{ success: true, deleted: false }`. A successful deletion sets the table to `available` only if it is still `held`.                                                                                                                                                              |

### Bills

Router: `main/routes/bills.ts`. Full path: `/api/bills`.

Bill generation, settlement, and completion are described end to end in
[Order lifecycle](../architecture/order-lifecycle.md#bills).

| Method  | Path                          | Authorization                                                                                 | Parameters                                                                                                                                                                       | Response                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| ------- | ----------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET`   | `/`                           | `ROLE_ACCESS.ownerManagerCashier`                                                             | query: `?status`, `?order_id`, `?customer_id`, `?today`, `?per_page`, `?limit`, `?offset`                                                                                        | `{ bills, pagination }`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `GET`   | `/:id`                        | `ROLE_ACCESS.ownerManagerCashier`                                                             | path: `id`                                                                                                                                                                       | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `GET`   | `/order/:orderId`             | `ROLE_ACCESS.ownerManagerCashier`                                                             | path: `orderId`                                                                                                                                                                  | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `POST`  | `/generate`                   | `ROLE_ACCESS.ownerManagerCashier`                                                             | body: `order_id`                                                                                                                                                                 | See [Order lifecycle](../architecture/order-lifecycle.md#bills) for bill creation and re-sync behavior.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `POST`  | `/:id/split-check`            | `ROLE_ACCESS.ownerManagerCashier`                                                             | body: `checks`                                                                                                                                                                   | Body `checks` must hold between 2 and 20 entries and the resulting split group is capped at 20 checks. An unpaid untouched dine-in check is divided by allocating every active order item quantity; a split-group child may be divided again while it stays unpaid with no payment recorded, keeping its bill id, bill number and group, while paid siblings keep their own rows and payments. `400` when a request does not allocate exactly the source's own quantities. `403` when `split_checks_enabled` is off. `409` when the source has a recorded payment, a legacy already-split bill is re-split, the source has no divisible allocation, or the resulting split group would exceed 20 checks.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `POST`  | `/:id/payment`                | `ROLE_ACCESS.ownerManagerCashier`                                                             | path: `id`; body: payment line object (accepts optional `override_pin`, `approver_id`, `manager_id`, `user_id`), `customer_id`; header: `Idempotency-Key`                        | See [Order lifecycle](../architecture/order-lifecycle.md#payments) for input, replay, gate, and settlement behavior.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `POST`  | `/:id/payments`               | `ROLE_ACCESS.ownerManagerCashier`                                                             | path: `id`; body: `payments`, `customer_id`, optional `override_pin`, `approver_id`, `manager_id`, `user_id`; header: `Idempotency-Key`                                          | See [Order lifecycle](../architecture/order-lifecycle.md#payments) for input, replay, gate, and settlement behavior.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `PATCH` | `/:id/payments/:index/method` | `ROLE_ACCESS.ownerManager` + `payments.take` + active `owner` or `manager` Staff Approval PIN | path: `id`, `index`; body: `method`, `override_pin`, optional `payment_method_id`, `transaction_id`, `reason`, `approver_id`, `manager_id`, `user_id`; header: `Idempotency-Key` | `{ bill, audit }` (with `idempotentReplay: true` on `Idempotency-Key` or natural replay). Corrects the tender method on payment line `:index` of a `paid` bill without changing the amount (`400` `amount_immutable` if a differing `amount` is supplied, `400` `wallet_method_immutable` if changing to `wallet`, `400` `card_transaction_id_required` when required for `card`, `400` `payment_method_unavailable` for unknown methods). Caller must be an authenticated active `owner` or `manager` holding `payments.take` (`403` `insufficient_role_permissions` for `cashier`, `server`, or `chef` callers, or when `payments.take` is revoked) and must provide an active `owner` or `manager` Staff Approval PIN (`403` when missing, invalid, or belonging to a `cashier`, `server`, or `chef`; `429` when rate-limited). Returns `404` when the bill or payment line is not found, and `409` when the bill is not `paid` (`bill_not_paid`), the existing line is `wallet` (`wallet_method_immutable`), the payment's cash shift is closed, missing/unverifiable, or was recorded without an open shift (`shift_closed`), the store business day is closed (`day_closed`), the bill, line, or a split sibling has been refunded (`bill_refunded`, `payment_line_refunded`, `split_sibling_refunded`) or settled in a closed or different shift (`split_sibling_shift_closed`), the target custom payment method is inactive (`payment_method_inactive`), `transaction_id` is already used (`transaction_reference_used`, `transaction_reference_reused`), or `Idempotency-Key` conflicts (`idempotency_key_conflict`). Audited in `order_audit_log` as `bill.payment_method.corrected`. |
| `POST`  | `/:id/applyDiscount`          | `ROLE_ACCESS.ownerManager`                                                                    | path: `id`; body: `type`, `value`, `reason`, `override_pin`, `approver_id`, `manager_id`, `user_id`                                                                              | `400` when the bill is paid (`paid`). `409` on a paid scheduled order (`SCHEDULED_ORDER_PAID`), when the order is completed or cancelled (`order_not_discountable`), the bill is split (`split_check`) or refunded (`refunded`), or the discount would reduce the bill total below collected payments (`below_collected_funds`). Auto-settles the bill to `paid`, completes the order, and frees the table when remaining balance reaches zero on a partially paid bill (gated by kitchen delivery; see [Settlement and completion](../architecture/order-lifecycle.md#settlement-and-completion)). Audited as `order_discount_applied`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `PATCH` | `/:id/charges`                | `bills.discount.apply`                                                                        | path: `id`; body: `charge_id`, `waived` or `applied`, optional `override_pin`, `approver_id`, `manager_id`, `user_id`                                                            | `{ bill }` with updated charge breakdown and totals. Only for unpaid, unsplit bills with no payments; the charge must be active and apply to the order type. Waiving a charge when the caller is outside `ROLE_ACCESS.ownerManager` (or when `override_pin` is supplied) requires an active `owner` or `manager` Staff Approval PIN holding `bills.discount.apply` (`403` with `requiresApproval: true` when missing, `403` on invalid PIN, `429` when rate-limited, `400` on conflicting approver ids). `409` on a paid scheduled order (`SCHEDULED_ORDER_PAID`) or when the order is cancelled.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `POST`  | `/:id/markPrinted`            | `bills.print`                                                                                 | path: `id`                                                                                                                                                                       | Stamps `bills.printed_at`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `POST`  | `/:id/print`                  | `ROLE_ACCESS.ownerManagerCashier`                                                             | path: `id`; body: `print_type`                                                                                                                                                   | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `GET`   | `/:id/print-history`          | `ROLE_ACCESS.ownerManagerCashier`                                                             | path: `id`                                                                                                                                                                       | Print jobs recorded for the bill, newest first.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |

Bill detail reads and bills embedded in hydrated orders include computed `payment_method_correction` metadata. Its `correctable` flag is true when at least one payment line is eligible; `reason` is null in that case, otherwise it is the first applicable correction failure code. Each entry in `lines` carries its original payment `index`, `correctable` flag, and nullable `reason`. This metadata describes financial eligibility only and is advisory: the correction request still checks current eligibility, caller permissions, and approval inside the write flow.

`POST /generate`, `POST /:id/payment`, `POST /:id/payments`, `POST /:id/applyDiscount`, and `PATCH /:id/charges` follow the same `X-Operation-ID` and structured failure contract as [Orders](#orders), including `facts: { failure_class }` on `POST /generate` persistence failures.

### Refunds

Router: `main/routes/refunds.ts`. Full path: `/api/refunds`.

| Method | Path | Authorization                     | Parameters                                                                                                                                         | Response                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| ------ | ---- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST` | `/`  | `ROLE_ACCESS.ownerManager`        | body: `bill_id`, `order_item_id`, `amount`, `reason`, `approver_id`, `manager_id`, `method`, `shift_id`, `override_pin`; header: `Idempotency-Key` | Creates a refund against `bill_id`, or a single `order_item_id`. Approval tiers and manager window rules are documented in [product invariants](product-invariants.md#refunds-and-staff-approval-pins). Honours `Idempotency-Key` (replaying a committed refund returns its stored `201` response without re-verifying PINs or incrementing rate limits, including for queued retries; reusing the key with a different request returns `409 idempotency_conflict`). |
| `GET`  | `/`  | `ROLE_ACCESS.ownerManagerCashier` | query: `?bill_id`, `?limit`, `?offset`                                                                                                             | Refund rows, newest first.                                                                                                                                                                                                                                                                                                                                                                                                                                           |

### Expenses

Router: `main/routes/expenses.ts`. Full path: `/api/expenses`. Reads need `expenses.view`. Expense
creation, voiding, and replacement need `expenses.manage` **and** `expenses.view`, while payment and
reversal mutations need `expenses.pay` and `expenses.reverse` (alongside `expenses.view`, plus
`cash.movements.manage` for cash methods) so an actor can always read back the row they touched.
Amounts are positive safe-integer minor units in the expense's own `currency_code`; no endpoint
converts currencies or assumes a minor-unit factor. Active incurred totals per currency (after
subtracting the replaced source on replacement) and returned aggregate totals must fit within
`Number.MAX_SAFE_INTEGER`, or the operation fails with `422 amount_range_exceeded`; legacy rows with
an out-of-range stored amount project `amount_minor` and `due_minor` as `null` so list, detail, and
recovery mutations stay usable without rounding.

| Method  | Path                               | Authorization                                                                                                                   | Parameters                                                                                                                   | Response                                                                                                                                                                                                                                                                                                                                                                               |
| ------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET`   | `/context`                         | `expenses.view`                                                                                                                 | none                                                                                                                         | `{ currency_code, business_date, cash_session_open }`: store currency, store business date, and whether a shift is open for the caller.                                                                                                                                                                                                                                                |
| `GET`   | `/categories`                      | `expenses.view`                                                                                                                 | query: `?include_inactive`                                                                                                   | `{ categories, truncated }`. Deactivated categories stay readable for history and never accept a new expense.                                                                                                                                                                                                                                                                          |
| `POST`  | `/categories`                      | `expenses.manage` + `expenses.view`; header `Idempotency-Key`                                                                   | body: `name`, optional `is_active` (defaults to `true`)                                                                      | `201 { category }`. Names are unique after trimming, case-insensitively.                                                                                                                                                                                                                                                                                                               |
| `PATCH` | `/categories/:id`                  | `expenses.manage` + `expenses.view`; header `Idempotency-Key`                                                                   | path: `id`; body: `name` and/or `is_active`                                                                                  | `{ category }`. Explicit values only - there is no toggle - and renaming never rewrites the category label snapshotted on existing expenses.                                                                                                                                                                                                                                           |
| `GET`   | `/`                                | `expenses.view`                                                                                                                 | query: `?from`, `?to`, `?category_id`, `?status`, `?currency`, `?limit`, `?cursor`                                           | `{ expenses, limit, maxLimit, nextCursor? }`, newest `incurred_on` first with a stable id tiebreak. `status` is `active` (default), `voided`, `replaced`, or `all`; `limit` defaults to 50 and caps at 100.                                                                                                                                                                            |
| `GET`   | `/summary`                         | `expenses.view`                                                                                                                 | query: `?from`, `?to`, `?category_id`, `?currency`                                                                           | `{ basis, filters, groups, totals }`; SQL aggregation over every matching expense, not just the fetched page.                                                                                                                                                                                                                                                                          |
| `GET`   | `/:id`                             | `expenses.view`                                                                                                                 | path: `id`; query: `?payments_limit`, `?payments_cursor`                                                                     | `{ expense, payments, paymentsNextCursor? }`; payments are newest first, with reversals kept as their own entries.                                                                                                                                                                                                                                                                     |
| `POST`  | `/`                                | `expenses.manage` + `expenses.view`; header `Idempotency-Key`                                                                   | body: `category_id`, `description`, `amount_minor`, `incurred_on`, optional `currency_code`, `payee`, `notes`                | `201 { expense }`. A new expense never starts paid, `replaces_expense_id` cannot be set here, and a supplied `currency_code` must match the store currency.                                                                                                                                                                                                                            |
| `POST`  | `/:id/void`                        | `expenses.manage` + `expenses.view`; header `Idempotency-Key`                                                                   | path: `id`; body: `reason`                                                                                                   | `{ expense }`. Requires net paid of zero, preserves the original expense fields and `created_at`, and adds void metadata.                                                                                                                                                                                                                                                              |
| `POST`  | `/:id/replace`                     | `expenses.manage` + `expenses.view`; header `Idempotency-Key`                                                                   | path: `id`; body: replacement fields plus `reason`                                                                           | `201 { expense, replaced_expense_id }`. Voids the source and creates the linked replacement in one transaction; a source can be replaced once.                                                                                                                                                                                                                                         |
| `POST`  | `/:id/payments`                    | `expenses.view` + `expenses.pay`, plus `cash.movements.manage` when `method` is `cash`; header `Idempotency-Key`                | path: `id`; body: `amount_minor`, `method` (`cash`, `card`, `bank_transfer`, `other`), optional `reference`, `currency_code` | `201 { expense, payment }`. Amounts are positive minor units that may not exceed the current due; a supplied `currency_code` must match the expense. New payments require the expense currency to match the current store currency or return `409 currency_mismatch`. A cash payment needs a real open shift regardless of `require_open_shift` and writes exactly one linked Pay Out. |
| `POST`  | `/:id/payments/:paymentId/reverse` | `expenses.view` + `expenses.reverse`, plus `cash.movements.manage` when the reversed payment was cash; header `Idempotency-Key` | path: `id`, `paymentId`; body: `reason`                                                                                      | `201 { expense, payment }`. Copies the committed payment's amount and method, requires the expense currency to match the current store currency (`409 currency_mismatch` otherwise), is allowed once per payment, and leaves the original payment and its drawer movement untouched; a cash reversal needs today's open shift and writes one linked Pay In.                            |

Mutation bodies are hashed into an immutable fingerprint under the authenticated actor and the
`Idempotency-Key`: replaying the same key with the same normalized request returns the committed
result (with an `Idempotent-Replay: true` header) without writing again, and reusing a key for a
different operation, resource, or payload is a `409`. Replays resolve before current balance,
category, or currency checks, while current access is still enforced, and only committed mutations
are recorded. Omitted currency on expense creation or replacement resolves against the committed
expense snapshot during replay, so it remains equivalent to that explicit currency after regional
settings change.

`incurred_on` is a validated Gregorian `YYYY-MM-DD` business-date label: it may precede today (a
closed day is fine) and may not be in the future, so audit timestamps record the real creation
time rather than a midnight back-dated one. The summary covers active expenses incurred inside the inclusive range and reports current net paid
and due against them, grouped by currency and category, so a payment recorded after the range still
counts as paid to date. Those are not payments-made-in-period figures, they are never subtracted
from sales reports, and voided or replaced expenses stay out of the headline totals while remaining
readable through the `status` history filter. Payments settle against the immutable ledger and are
never edited in place: a reversal is a separate reasoned row that copies the original amount and
method, and cash is always corrected by that reversal: `POST /api/cash-closures/movements/:id/void`
refuses a drawer movement that backs an expense payment or reversal, so the original Pay Out is
never voided out from under the ledger.

### Payment methods

Router: `main/routes/payment-methods.ts`. Full path: `/api/payment-methods`.

| Method   | Path             | Authorization              | Parameters                                          | Response                                                |
| -------- | ---------------- | -------------------------- | --------------------------------------------------- | ------------------------------------------------------- |
| `GET`    | `/merge-history` | `ROLE_ACCESS.ownerManager` | none                                                | Record of method merges.                                |
| `GET`    | `/`              | `ROLE_ACCESS.allStaff`     | query: `?include_inactive`                          | `{ payment_methods: [ ... ] }`.                         |
| `POST`   | `/`              | `ROLE_ACCESS.ownerManager` | body: `name`                                        | -                                                       |
| `PUT`    | `/:id`           | `ROLE_ACCESS.ownerManager` | path: `id`; body: `name`, `is_active`, `sort_order` | -                                                       |
| `DELETE` | `/:id`           | `ROLE_ACCESS.ownerManager` | path: `id`                                          | -                                                       |
| `POST`   | `/:id/merge`     | `ROLE_ACCESS.ownerManager` | path: `id`; body: `target_type`, `target_id`        | Folds a method into another; paid history is rewritten. |

### Cash sessions

Router: `main/routes/cash-sessions.ts`. Full path: `/api/cash-sessions`.

See [business time](../architecture/business-time.md#cash-closures) for single-shift and per-staff shift (`one_shift_per_staff`) lifecycle and shared-drawer rules.

| Method | Path                     | Authorization                     | Parameters                                               | Response                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| ------ | ------------------------ | --------------------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST` | `/open`                  | `ROLE_ACCESS.ownerManagerCashier` | body: `opening_float_cents`                              | Body `opening_float_cents` (integer minor units, defaults to 0). `409` (`session_already_open`) when a shift is already open (`one_shift_per_staff` off) or the caller already has an open shift (`one_shift_per_staff` on), or `409` (`day_already_closed`) when the current store business day is already closed.                                                                                                                                                                             |
| `GET`  | `/open`                  | `ROLE_ACCESS.ownerManagerCashier` | none                                                     | `{ sessions, one_shift_per_staff }`: open shifts visible to the caller (all open shifts for `owner`/`manager`, own open shift for other roles) with live `expected_cash_cents`.                                                                                                                                                                                                                                                                                                                 |
| `GET`  | `/current`               | `ROLE_ACCESS.ownerManagerCashier` | query: optional `?session_id`, `?user_id`                | The open shift (for the caller by default, or the specified `session_id`/`user_id` for `owner`/`manager`; `400 session_id_invalid` for a non-positive integer `session_id`; `403 session_owner_required` if a non-`owner`/`manager` requests another user's shift), or `null` when none is open.                                                                                                                                                                                                |
| `GET`  | `/current/close-preview` | `ROLE_ACCESS.ownerManagerCashier` | query: optional `?session_id`                            | Informational carry-over preview of open/unpaid bills created during the shift window (`unpaid_in_shift` with `count`, `bill_count`, `pending_cents`, `sample`, and `would_block_close: false`). Defaults to the caller's open shift; `owner`/`manager` may pass `?session_id` (`400 session_id_invalid` for a non-positive integer `session_id`; `404 session_unavailable` when no open shift matches; `403 session_owner_required` if a non-`owner`/`manager` previews another user's shift). |
| `POST` | `/:id/close`             | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: `counted_cash_cents`, optional `notes` | Closes the shift; returns `{ closure_id, variance_cents, expected_cash_cents, counted_cash_cents }`. Open/unpaid orders from the shift remain open and carry over to the next shift while `expected_cash_cents` reconciles only cash collected during the shift. `409` when the shift is already closed (`session_already_closed`). `403` when a non-`owner`/`manager` attempts to close another user's shift (`session_owner_required`).                                                       |

### Cash closures

Router: `main/routes/cash-closures.ts`. Full path: `/api/cash-closures`.

| Method | Path                  | Authorization                     | Parameters                                                                                   | Response                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| ------ | --------------------- | --------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET`  | `/movements`          | `ROLE_ACCESS.ownerManagerCashier` | query: `?business_date`                                                                      | Append-only cash-drawer history, newest first, including soft-voided rows.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `POST` | `/movements`          | `ROLE_ACCESS.ownerManagerCashier` | body: `business_date`, `movement_type`, `amount_cents`, `reason`, optional `cash_session_id` | Attaches the movement to the caller's open shift, or to `cash_session_id` when specified (`400` if not a positive integer, `404 cash_session_not_found` if not found, `409 session_already_closed` if already closed, `403 session_owner_required` if a non-`owner`/`manager` targets another user's shift).                                                                                                                                                                                                                                                                               |
| `POST` | `/movements/:id/void` | `ROLE_ACCESS.ownerManager`        | path: `id`; body: `reason`                                                                   | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `POST` | `/`                   | `ROLE_ACCESS.owner`               | body: `business_date`, `opening_float_cents`, `counted_cash_cents`, `notes`                  | Closes a business date. `counted_cash_cents` is the physical count; the response carries the variance and the next `z_number`. `409` with `open_shift_blocks_day_close` until all shifts opened on or before that business date are closed (response includes `blocking_shift` and `blocking_shifts`). Claims any configured `z_report_day_close` attempt in a savepoint during the close transaction and dispatches it asynchronously after commit without affecting the close result (see [WhatsApp receipt delivery](../architecture/cloud-integrations.md#whatsapp-receipt-delivery)). |
| `POST` | `/:id/print`          | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: `isReprint`                                                                | Prints the close receipt. Day-close rows require `owner` (`403`); shift-session rows (`scope = 'session'`) require the shift owner (`opened_by` on the linked shift session, or `closed_by` when no linked session exists), `manager`, or `owner` (`403`).                                                                                                                                                                                                                                                                                                                                 |

### Customers

Router: `main/routes/customers.ts`. Full path: `/api/customers`.

| Method   | Path                   | Authorization                                              | Parameters                                                                     | Response                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| -------- | ---------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DELETE` | `/admin/cleanup`       | `ROLE_ACCESS.owner` + customer write limiter               | none                                                                           | Owner-only customer cleanup.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `POST`   | `/admin/repair-phones` | `ROLE_ACCESS.ownerManager` + customer write limiter        | none                                                                           | Rewrites stored phone values into canonical E.164 form.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `GET`    | `/alerts`              | `ROLE_ACCESS.sales` + customer read limiter                | none                                                                           | Customers with phone or data-quality problems.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `GET`    | `/`                    | `ROLE_ACCESS.sales` + customer read limiter                | query: `?search`, `?filter`, `?sort`, `?order`, `?per_page`                    | Paginated list; `?search`, `?filter`, `?sort`, `?order`, `?per_page`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `GET`    | `/:id`                 | `ROLE_ACCESS.sales` + customer read limiter                | path: `id`                                                                     | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `GET`    | `/:id/wallet`          | `ROLE_ACCESS.sales` + customer read limiter                | path: `id`                                                                     | Wallet balance and loyalty state for the customer.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `GET`    | `/:id/top-items`       | `ROLE_ACCESS.sales` + customer read limiter                | path: `id`                                                                     | `{ items }`: up to 5 `{ product_id, product_name, total_quantity, order_count, available }`, the products the customer bought most on `completed` orders. Variants and modifiers count under their product. Cancelled, voided, refunded, and refund-adjustment lines are excluded, as is an order whose every bill is `refunded`. Ranked by total quantity, then order count, then name (case-insensitive), then product id. `available` is false for a deleted or inactive product or one whose category or any ancestor category is deleted or inactive; such products stay listed. `404` for an unknown customer. |
| `POST`   | `/`                    | `ROLE_ACCESS.sales` + customer write limiter               | body: `phone`, `name`, `email`, `address`, `notes`, `country_code`             | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `PUT`    | `/:id`                 | `ROLE_ACCESS.ownerManagerCashier` + customer write limiter | path: `id`; body: `phone`, `name`, `email`, `address`, `notes`, `country_code` | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |

### Staff and users

Router: `main/routes/staff.ts`. Full path: `/api/staff`, also mounted at `/api/users`.
Mounted at both `/api/staff` and `/api/users` from one router, so a request to either prefix reaches the same handlers.

| Method | Path              | Authorization                             | Parameters                                                                | Response                    |
| ------ | ----------------- | ----------------------------------------- | ------------------------------------------------------------------------- | --------------------------- |
| `GET`  | `/`               | `ROLE_ACCESS.ownerManager`                | query: `?role`, `?active`                                                 | `{ users: [ ... ] }`.       |
| `GET`  | `/:id`            | `ROLE_ACCESS.ownerManager`                | path: `id`                                                                | -                           |
| `POST` | `/`               | `ROLE_ACCESS.ownerManager` + auth limiter | body: `name`, `email`, `password`, `role`, `pin`, `station_ids`           | `201` with the new user id. |
| `PUT`  | `/:id`            | `ROLE_ACCESS.ownerManager` (`staff.operational.manage` / `staff.privileged.manage`, plus `staff.credentials.reset` when `password` is non-empty or `pin` is defined) + auth limiter | path: `id`; body: `name`, `email`, `password`, `role`, `pin`, `is_active` | Overwriting or clearing `password` or `pin` without current credentials requires `staff.credentials.reset` (`owner` by default, configurable for operational targets). |
| `POST` | `/:id/deactivate` | `ROLE_ACCESS.ownerManager`                | path: `id`                                                                | -                           |
| `POST` | `/:id/reactivate` | `ROLE_ACCESS.ownerManager`                | path: `id`                                                                | -                           |

### Printers

Router: `main/routes/printers.ts`. Full path: `/api/printers`.

| Method   | Path                              | Authorization                           | Parameters                                                                                                                                                             | Response                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| -------- | --------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET`    | `/`                               | `printers.manage` or `printing.execute` | none                                                                                                                                                                   | `{ printers: [ ... ] }`; `printers.manage` receives the full list, while `printing.execute` receives only the default printer's `id`, `name`, `connection_type`, `is_default`, `text_size`, and `readiness_state`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `GET`    | `/detect`                         | any authenticated role                  | none                                                                                                                                                                   | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `GET`    | `/supported`                      | any authenticated role                  | none                                                                                                                                                                   | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `GET`    | `/:id`                            | any authenticated role                  | path: `id`                                                                                                                                                             | Single printer.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `POST`   | `/`                               | `ROLE_ACCESS.ownerManager`              | body: `connection_type`, `ip_address`, `port`, `paper_width`, `text_size`, `profile_id`, `is_default`, `cash_drawer_pulse_enabled`, `name`                             | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `PUT`    | `/:id`                            | `ROLE_ACCESS.ownerManager`              | path: `id`; body: `connection_type`, `ip_address`, `port`, `paper_width`, `text_size`, `profile_id`, `is_default`, `cash_drawer_pulse_enabled`, `name`                 | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `DELETE` | `/:id`                            | `ROLE_ACCESS.ownerManager`              | path: `id`                                                                                                                                                             | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `POST`   | `/:id/set-default`                | `ROLE_ACCESS.ownerManager`              | path: `id`                                                                                                                                                             | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `POST`   | `/:id/test`                       | `ROLE_ACCESS.ownerManager`              | path: `id`; body: `rasterProbe`                                                                                                                                        | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `POST`   | `/kick-drawer`                    | `cash.movements.manage`                 | none                                                                                                                                                                   | Requires an open cash shift for the caller (or any open shift for `owner`/`manager`); returns `409` with `{ error: "open_cash_shift_required" }` otherwise. Success is `{ success: true }` for USB/network printers or includes `webusb: true`, `bytes`, and `printerName` for browser delivery. `404` if no printer is configured, `400` if the selected connection is unsupported or lacks an IP address, and `502` if the pulse fails.                                                                                                                                                                                                                                                                         |
| `POST`   | `/test-active`                    | `printing.execute`                      | none                                                                                                                                                                   | Sends a standard test page to the default printer; returns `{ success: true }` or includes `webusb: true`, `bytes`, and `printerName` for browser delivery. `404` if no default printer is configured, `400` if the selected connection is unsupported or lacks an IP address, `409` if regional settings are missing, and `502` if printing fails.                                                                                                                                                                                                                                                                                                                                                               |
| `POST`   | `/print-menu`                     | `catalog.view` and `printing.execute`   | body: boolean `includeInactive`, `includeOutOfStock`, `includeHidden`, `includeDescriptions`, `includeModifiers`; optional `paperWidth` (`58` or `80`) and `printerId` | `200` physical-print success, or `webusb: true` with byte array for browser delivery; `400` invalid input or `printer_not_configured`, `404` unknown `printerId` (`printer_not_found`), `409` missing regional configuration, `422` empty selection, `502` print failure. Prints to `printerId` when given, otherwise to the configured default, across connection types.                                                                                                                                                                                                                                                                                                                                         |
| `POST`   | `/print-bill`                     | `printing.execute`                      | body: `billId`, `orderId`, `isReprint`, `preview`, `useUnicode`, `arabicShaping`                                                                                       | Body `billId` or `orderId`, plus `isReprint`, `preview`, `useUnicode`, `arabicShaping`. `preview` returns the rendered payload without sending it to the device. Returns `403` (`BILL_PRINTING_DISABLED`) for the `server` role when `server_app_bill_printing_enabled` is off, and `502` on print failure with structured `failure_class`, `connection_type`, and `correlation_id`.                                                                                                                                                                                                                                                                                                                        |
| `POST`   | `/print-kot`                      | `printing.execute`                      | body: `orderId`, `stationName`, `items`, `useUnicode`, `arabicShaping`                                                                                                 | Body `orderId`, optional `stationName` and `items`, plus `useUnicode` and `arabicShaping`. Returns `403` (`KOT_PRINTING_DISABLED`) when `kot_printing_enabled` is off, `409` when `orderId` is an unreleased scheduled order, and `502` on print failure with structured `failure_class`, `connection_type`, and `correlation_id`.                                                                                                                                                                                                                                                                                                                                                      |
| `POST`   | `/print-payment-ticket`           | `printing.execute`                      | body: `orderId` (or `order_id`), optional `printerId` (or `printer_id`), `useUnicode`, `preview`                                                                       | Prints a payment ticket slip with order number, non-terminal items, totals, and (when `raster.enabled` is true) a QR code to the default or specified printer, or returns `{ webusb: true, bytes, printerName }` for browser delivery, or `{ preview: true }`. `400` on missing order id or when no printer is configured, `404` when order is not found, `409` if regional settings are missing, and `502` on print failure.                                                                                                                                                                                                                                                                                     |
| `GET`    | `/delivery-slip-payment/:orderId` | `printing.execute`                      | path: `orderId`                                                                                                                                                        | `200` with optional `payment` fields `status`, `amount`, `amountDue`, `formattedAmount`, `formattedAmountDue`, `method`, `methods`, and `expectedMethod` (the order's expected collection method, absent when unknown); `404` if the order does not exist. Uses the order total when no bill exists, otherwise the latest bill or all same-order bills in that bill's split group and their stored balances.                                                                                                                                                                                                                                                                                                      |
| `POST`   | `/print-delivery-slip`            | `printing.execute`                      | body: `orderId`, `useUnicode`, `arabicShaping`                                                                                                                         | Body `orderId` plus optional `useUnicode` and `arabicShaping`. Includes the full customer number when either `bill_delivery_show_customer_phone_always` or `bill_show_customer_phone` is on, and omits it only when both are off. Prints the delivery address, delivery note, order note, items, and payment status; an unpaid balance names the expected collection method. No bill is required: without a bill the slip shows the order total; otherwise it uses the selected bill balance(s), including any positive balance on refund-marked bills, as the amount due. Paid bills show the captured method(s) and total; refunds retain their refund status; see [product invariants](product-invariants.md). |

### Merchant print templates

Router: `main/routes/print-templates.ts`. Full path: `/api/print-templates`.

| Method | Path            | Authorization                                | Parameters                                       | Response |
| ------ | --------------- | -------------------------------------------- | ------------------------------------------------ | -------- |
| `GET`  | `/`             | `ROLE_ACCESS.ownerManager`                   | none                                             | -        |
| `POST` | `/`             | `ROLE_ACCESS.owner` + template write limiter | body: `name`, `payload`, `origin`, `derivedFrom` | -        |
| `PUT`  | `/:id`          | `ROLE_ACCESS.owner` + template write limiter | path: `id`; body: `name`, `payload`              | -        |
| `POST` | `/:id/activate` | `ROLE_ACCESS.owner` + template write limiter | path: `id`                                       | -        |
| `POST` | `/:id/archive`  | `ROLE_ACCESS.owner` + template write limiter | path: `id`                                       | -        |
| `POST` | `/:id/rollback` | `ROLE_ACCESS.owner` + template write limiter | path: `id`                                       | -        |
| `GET`  | `/:id/payload`  | `ROLE_ACCESS.ownerManager`                   | path: `id`                                       | -        |
| `GET`  | `/:id/export`   | `ROLE_ACCESS.owner` + template write limiter | path: `id`                                       | -        |
| `POST` | `/import`       | `ROLE_ACCESS.owner` + template write limiter | body: `file`                                     | -        |

### Menu CSV

Router: `main/routes/menu-csv.ts`. Full path: `/api/menu-csv`.

Category, product, and add-on exports include `flo_csv_encoding=quote-v1` for reversible formula protection; see [product invariants](product-invariants.md#catalog-csv-encoding-and-legacy-apostrophe-ambiguity) for marked, unmarked legacy, and unsupported encoding rules.

| Method | Path                 | Authorization           | Parameters   | Response                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| ------ | -------------------- | ----------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET`  | `/template/:type`    | `catalog.import-export` | path: `type` | CSV header template for `categories` (includes `parent`), `products` (includes `category_parent`), or `addons`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `GET`  | `/export/categories` | `catalog.import-export` | none         | `text/csv` attachment `categories-export.csv` with `parent` (live parent category name, empty for roots or when a legacy parent is missing/soft-deleted) and `flo_csv_encoding=quote-v1`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `GET`  | `/export/products`   | `catalog.import-export` | none         | `text/csv` attachment `products-export.csv` with `category_parent` (live immediate parent name when `category` has a live parent, empty when root or orphaned) and `flo_csv_encoding=quote-v1`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `GET`  | `/export/addons`     | `catalog.import-export` | none         | `text/csv` attachment `addons-export.csv` with `flo_csv_encoding=quote-v1`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `POST` | `/import/categories` | `catalog.import-export` | body: `csv`  | Body `csv` is the raw file text. Additive import (`create` or `skip`): resolves `parent` independently of row order, treats a legacy dangling `parent_id` pointing to a missing or soft-deleted category as an effective root during CSV lookup without modifying stored `parent_id`, accepts flat CSVs without a `parent` column when category lookup is unambiguous, and rejects cycles, grandchildren, missing/inactive/ambiguous parents, ambiguous lookups, and duplicate sibling names. Decodes `quote-v1` rows, preserves unmarked category names, and returns `created`, `updated`, `reactivated`, `skipped`, and `failed` counts plus per-row `errors`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `POST` | `/import/products`   | `catalog.import-export` | body: `csv`  | Body `csv` is the raw file text. Resolves `(category_parent, category)` against root parents first, falling back to an existing live non-root `parent -> child` pair only when no root parent matches and the pair uniquely identifies one live category; when the `category_parent` column is present and blank, matches an effective root category (including a legacy category whose parent is missing/soft-deleted) first or falls back to an unambiguous subcategory; when `category_parent` is omitted (flat CSVs), resolves `category` if unambiguous across all live categories and rejects ambiguous category names explicitly. New product rows and category-changing updates require an active category with an active, non-deleted ancestor chain, whereas an existing product identified by `id` may retain its unchanged non-deleted `category_id`. A supplied `id` matching a non-deleted product updates it; a missing or unrecognized `id` creates a product with a generated id unless the name and category already exist, in which case the row is skipped. Decodes `quote-v1` rows and ID-proven legacy product fields. Returns `created`, `updated`, `reactivated`, `skipped`, and `failed` counts plus per-row `errors`. |
| `POST` | `/import/addons`     | `catalog.import-export` | body: `csv`  | Body `csv` is the raw file text. Decodes `quote-v1` rows and ID-proven legacy add-on fields, and reports counts plus per-row `errors`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |

### Settings

Router: `main/routes/settings.ts`. Full path: `/api/settings`.

| Method   | Path                                | Authorization                                                                                  | Parameters                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | Response                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| -------- | ----------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET`    | `/offline-mode`                     | `settings.view`                                                                                | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | `{ offline_mode }`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `PUT`    | `/offline-mode`                     | `offline-mode.manage` (owner only, not configurable)                                           | body: `offline_mode` (boolean)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | `{ offline_mode }`. `400` for a non-boolean value.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `GET`    | `/charges`                          | `settings.view`, `orders.create`, or `bills.discount.apply`                                    | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | `{ charges }`; fee definitions only.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `PUT`    | `/charges`                          | `settings.manage`                                                                              | body: `charges` or a charge-definition array                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | `{ charges }`; replaces the validated fee definitions. Fee amounts remain configured in Settings.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `GET`    | `/business`                         | `settings.view`, `bills.read`, `bills.print`, or `bills.generate`                              | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `PUT`    | `/business`                         | `ROLE_ACCESS.ownerManager`                                                                     | body: `business_name`, `timezone`, `business_day_start_time`, `currency`, `country`, `language`, `tax_registration_number`, `state_code`, `business_address`, `business_phone`, `instagram_handle`, `billing_type`, `tables_required`, `tax_registered`, `bill_show_name`, `bill_show_address`, `bill_show_phone`, `bill_show_tax_id`, `bill_show_tax_breakdown`, `bill_show_customer_name`, `bill_show_customer_phone`, `bill_show_table_number`, `bill_show_large_order_number`, `bill_show_logo`, `receipt_logo`, `receipt_logo_raster`, `currency_display`, `number_digits`, `calendar`, `country_selected` | A `currency` that differs from the stored value returns `409` with `error: "currency_change_requires_reset"` plus `current_currency` and `requested_currency`. Currency changes go through the reset flow, not this route.                                                                                                                                                                                                                                                                                                                                                                                         |
| `GET`    | `/tax`                              | `ROLE_ACCESS.allStaff`                                                                         | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `PUT`    | `/tax`                              | `ROLE_ACCESS.ownerManager`                                                                     | body: `tax_registered`, `tax_registration_number`, `state_code`, `tax_scheme`, `country`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `GET`    | `/loyalty`                          | `ROLE_ACCESS.allStaff`                                                                         | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `PUT`    | `/loyalty`                          | `ROLE_ACCESS.ownerManager`                                                                     | body: `loyalty_enabled`, `global_cashback_percent`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `GET`    | `/discount`                         | `ROLE_ACCESS.allStaff`                                                                         | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `PUT`    | `/discount`                         | `ROLE_ACCESS.ownerManager`                                                                     | body: `discount_max_percentage`, `discount_max_amount`, `discount_mode`, `discount_requires_approval`                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `GET`    | `/kds`                              | `settings.view` or `kitchen.use`                                                               | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | KDS settings (`{ kds_default_view }`) for the dashboard settings and KDS display surfaces; defaults to `'kanban'` when unset.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `PUT`    | `/kds`                              | `settings.manage` or `kitchen.use`                                                             | body: `kds_default_view`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | Updates `kds_default_view` (`'tabs'` or `'kanban'`) and returns `{ kds_default_view }`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `GET`    | `/product-analytics`                | `settings.manage`                                                                              | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | `{ level, choice_pending, email, diagnostics_enabled }`: the PostHog sharing level (`full`, `anonymous` or `off`), whether the one-time question is due, the email `full` would link, and diagnostics transmission.                                                                                                                                                                                                                                                                                                                                                                                                |
| `PUT`    | `/product-analytics`                | `settings.manage`                                                                              | body: `level`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | Stores the level and writes `telemetry_enabled` (`false` for `off`); `400` for any other value. Same response as `GET`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `GET`    | `/pos-sort`                         | `settings.view`, `orders.read`, or `orders.create` + settings read limiter                     | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | `{ category_sort, item_sort }` for POS catalog display ordering.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `GET`    | `/customer-display`                 | `settings.view`, `orders.read`, or `orders.create` + settings read limiter                     | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | `{ statuses, order_types, refresh_seconds, show_updated }` for the order-status board; defaults when unset (`statuses: ['pending', 'preparing', 'ready']`, `order_types: ['takeaway']`, `refresh_seconds: 3`, `show_updated: true`).                                                                                                                                                                                                                                                                                                                                                                               |
| `PUT`    | `/customer-display`                 | `settings.manage` + settings write limiter                                                     | body: `statuses` (non-empty subset of `pending`, `preparing`, `ready`), optional `order_types` (non-empty subset of `takeaway`, `dine_in`, `online`, `delivery`; defaults to `['takeaway']` when omitted), `refresh_seconds` (integer 2-60), `show_updated` (boolean)                                                                                                                                                                                                                                                                                                           | The stored config; `400` for any other shape.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `GET`    | `/customer-display/tokens`          | `settings.manage` + settings read limiter                                                      | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | `{ tokens }`: `{ id, label, created_at, last_seen_at }` for each active paired display. Never includes the token or its hash.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `POST`   | `/customer-display/tokens`          | `settings.manage` + settings write limiter                                                     | body: `label` (1-60 characters)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | `201` with `{ id, label, created_at, last_seen_at, token }`. The plaintext `token` appears only in this response. `400` for a bad label, `409 customer_display_token_limit` at 20 active tokens.                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `DELETE` | `/customer-display/tokens/:id`      | `settings.manage` + settings write limiter                                                     | path: `id`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | `{ success: true }`; `404` when the token is unknown or already revoked. Takes effect on the next feed request.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `GET`    | `/order-numbering`                  | `ROLE_ACCESS.allStaff`                                                                         | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `PUT`    | `/order-numbering`                  | `ROLE_ACCESS.ownerManager`                                                                     | body: `order_number_prefix`, `order_number_include_date`, `order_number_reset_daily`, `invoice_number_prefix`, `invoice_number_include_period`, `invoice_number_reset_period`, `invoice_financial_year_start_month`, `invoice_financial_year_start_day`                                                                                                                                                                                                                                                                                                                         | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `GET`    | `/cloud`                            | `ROLE_ACCESS.ownerManager`                                                                     | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `PUT`    | `/cloud`                            | `ROLE_ACCESS.ownerManager` (`owner` for `cloud_server_url`, `cloud_api_key`, `cloud_store_id`) | body: `cloud_server_url`, `cloud_api_key`, `cloud_store_id`, `cloud_sync_enabled`, `cloud_orders_enabled`, `cloud_reports_enabled`, `cloud_command_polling_enabled`                                                                                                                                                                                                                                                                                                                                                                                                             | `403` when a non-owner supplies `cloud_server_url`, an unmasked `cloud_api_key`, or `cloud_store_id`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `POST`   | `/cloud/register`                   | `ROLE_ACCESS.ownerManager` (`owner` for `cloud_server_url`)                                    | body: `cloud_server_url`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | `403` when a non-owner supplies `cloud_server_url`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `POST`   | `/cloud/test`                       | `ROLE_ACCESS.ownerManager`                                                                     | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `GET`    | `/cloud/account`                    | `ROLE_ACCESS.owner`                                                                            | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `PUT`    | `/cloud/account/preferences`        | `ROLE_ACCESS.owner`                                                                            | body: `product_updates`, `email_marketing`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | Body `product_updates` and `email_marketing`. `409` when no cloud account is linked.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `POST`   | `/cloud/account/verification`       | `ROLE_ACCESS.owner`                                                                            | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `GET`    | `/cloud/delete-data/status`         | `ROLE_ACCESS.owner`                                                                            | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `POST`   | `/cloud/stop-all`                   | `ROLE_ACCESS.owner`                                                                            | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `POST`   | `/cloud/delete-data`                | `ROLE_ACCESS.owner` + Master PIN                                                               | body: `confirmation`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | Body `confirmation` must equal `DELETE CLOUD DATA`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `POST`   | `/cloud/delete-data/cancel`         | `ROLE_ACCESS.owner` + Master PIN                                                               | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `GET`    | `/google-drive`                     | `ROLE_ACCESS.owner`                                                                            | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `PUT`    | `/google-drive`                     | `ROLE_ACCESS.owner`                                                                            | body: `frequency`, `retention_count`, `destination_folder_id`, `warning_acknowledged`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `GET`    | `/google-drive/destinations`        | `ROLE_ACCESS.owner`                                                                            | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `POST`   | `/google-drive/destinations`        | `ROLE_ACCESS.owner`                                                                            | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `POST`   | `/google-drive/connect`             | `ROLE_ACCESS.owner`                                                                            | body: `allow_switch`, `warning_acknowledged`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `POST`   | `/google-drive/disconnect`          | `ROLE_ACCESS.owner`                                                                            | none                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `POST`   | `/google-drive/backup-now`          | `ROLE_ACCESS.owner`                                                                            | body: `warning_acknowledged`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `GET`    | `/google-drive/jobs/:jobId`         | `ROLE_ACCESS.owner`                                                                            | path: `jobId`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `POST`   | `/google-drive/jobs/:jobId/cancel`  | `ROLE_ACCESS.owner`                                                                            | path: `jobId`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | -                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `GET`    | `/google-drive/backups`             | `ROLE_ACCESS.owner`                                                                            | query: `?limit`, `?cursor`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | `{ backups, nextCursor, hasMore }`, newest first by `created_at` with an `id` tiebreak. `limit` accepts 1-100; omitting pagination returns all remote backups with `nextCursor: null` and `hasMore: false`. Invalid pagination parameters return `400`.                                                                                                                                                                                                                                                                                                                                                            |
| `POST`   | `/google-drive/restore`             | `ROLE_ACCESS.owner` + Master PIN                                                               | body: `confirmation`, `file_id`, `expected_sha256`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | Body `confirmation` must equal the drive restore confirmation phrase, plus `file_id` and optional `expected_sha256`. `202` with a job handle.                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `GET`    | `/google-drive/qr-menu`             | `catalog.manage` or `google-drive.manage`                                                      | none