# 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://: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 `. 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 `` 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:`), 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:`) 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 | QR menu connection, publication, sharing, file, link, QR images, and saved-generator status. | | `PUT` | `/google-drive/qr-menu/preferences` | `catalog.manage` or `google-drive.manage` | body: template, page size, description/modifier/photo toggles, and catalog visibility filters | Validated saved generator preferences and current QR menu status. | | `POST` | `/google-drive/qr-menu/generate` | `catalog.manage` or `google-drive.manage` | body: `html`, `pageSize`, `preferences` | Renders the bounded HTML in an isolated offscreen window, publishes the PDF, and returns current QR menu status. | | `POST` | `/google-drive/qr-menu/upload` | `catalog.manage` or `google-drive.manage` | body: `.pdf` `fileName`, `mimeType: "application/pdf"`, base64 `data` | Validates the 15 MB limit and `%PDF-` header, replaces the stable Drive file, and returns current status. | | `PUT` | `/google-drive/qr-menu/sharing` | `catalog.manage` or `google-drive.manage` | body: `enabled` (boolean) | Adds or removes the file-only public reader permission without changing the file ID. | | `GET` | `/auto_lock_minutes` | any authenticated role + settings read limiter | none | `{ setting: { key: "auto_lock_minutes", value, updated_at } }` (defaults to `"0"` when unset). Readable by any authenticated staff member independently of feature permissions. | | `GET` | `/refunds` | any authenticated role + settings read limiter | none | `{ refund_manager_enabled, refund_manager_window }` (defaults to `true` and `"1h"` when unset). Readable by any authenticated staff member. | | `PUT` | `/refunds` | `settings.manage` (`owner` role required) + settings write limiter | body: `refund_manager_enabled` (boolean or `'true'`/`'false'`), `refund_manager_window` (`30m`, `1h`, `2h`, `4h`, or `same_business_day`) | `{ refund_manager_enabled, refund_manager_window }`. `403` when the caller role is not `owner`, `400` when neither field is supplied or either value is invalid. | | `GET` | `/` | `ROLE_ACCESS.allStaff` | none | All settings. | | `GET` | `/bill-templates` | `ROLE_ACCESS.ownerManager` | none | - | | `GET` | `/printing` | `printers.manage` or `settings.view` + settings read limiter | none | Scoped read of the receipt/printer batch settings (`auto_print_kot`, `printer_use_unicode`, `printer_arabic_shaping`, `printer_trim_decimals`, language policies, `bill_show_*` flags, `cash_drawer_pulse_*`) plus read-only `kot_printing_enabled` and `kds_enabled` flags. An unset `z_report_language_policy` or `cash_drawer_pulse_enabled` returns `null`. Invalid stored language policies return the default policy. | | `PUT` | `/printing` | `printers.manage` + settings write limiter | none | Body is the whole printing settings object; rejected with `400` when it is not an object. | | `GET` | `/:key` | `ROLE_ACCESS.allStaff` + settings read limiter | path: `key` | One setting. Registered after every named route, so a named key resolves to its named handler. | | `PUT` | `/:key` | `ROLE_ACCESS.ownerManager` + settings write limiter | path: `key`; body: `value` | Body `value`. Only the allow-listed non-sensitive keys are accepted (including `pos_category_sort`, `pos_item_sort`, `require_open_shift`, `one_shift_per_staff`, `scheduled_orders_enabled`, `auto_lock_minutes` in `0`, `1`, `5`, `15`, or `30`, and `multi_counter_enabled`, `refund_manager_enabled`, and `refund_manager_window` which require the owner role); sensitive keys need their named route. `key: one_shift_per_staff` with `value: false` returns `409 multiple_shifts_open` while more than one shift is open. `key: currency` returns `409 currency_change_requires_reset` on any value change. | `diagnostics_consent` and `diagnostics_transmission_enabled` are owner/manager-writable Boolean settings. Their delivery gates and consent synchronization are described in [store-attributed diagnostics](../architecture/cloud-integrations.md#store-attributed-diagnostics), including how `telemetry_enabled` affects anonymous diagnostic delivery. PostHog usage follows the sharing level from `/product-analytics`, and the installation email is sent only at `full`; PostHog exceptions follow diagnostics transmission at every level. See [PostHog](../architecture/cloud-integrations.md#posthog). ### Tax packs Router: `main/routes/tax-packs.ts`. Full path: `/api/tax-packs`. | Method | Path | Authorization | Parameters | Response | | -------- | ---------------------------------------- | -------------------------- | --------------------------------------------------------------------- | ----------------------------------------------- | | `GET` | `/` | `ROLE_ACCESS.ownerManager` | none | Installed tax packs. | | `GET` | `/audit` | `ROLE_ACCESS.ownerManager` | query: `?limit` | Tax pack audit trail; `?limit` bounds the page. | | `GET` | `/catalog` | `ROLE_ACCESS.ownerManager` | none | Available packs and versions. | | `GET` | `/updates` | `ROLE_ACCESS.ownerManager` | none | Installed packs with newer upstream versions. | | `POST` | `/ensure-country` | `ROLE_ACCESS.ownerManager` | body: `acknowledge_community_disclaimer` | Resolves the pack for the configured country. | | `POST` | `/manual-config` | `ROLE_ACCESS.owner` | body: `override` | Owner-only manual tax configuration. | | `POST` | `/catalog/install` | `ROLE_ACCESS.owner` | body: `pack_id`, `version` | - | | `POST` | `/test-calculation` | `ROLE_ACCESS.ownerManager` | body: `category_id`, `amount`, `tax_behavior` | - | | `POST` | `/overrides` | `ROLE_ACCESS.owner` | body: `entity_type`, `entity_id`, `category_id` | - | | `PUT` | `/overrides/:overrideId` | `ROLE_ACCESS.owner` | path: `overrideId`; body: `entity_type`, `entity_id`, `category_id` | - | | `DELETE` | `/overrides/:overrideId` | `ROLE_ACCESS.owner` | path: `overrideId` | - | | `POST` | `/:packId/versions/:versionId/activate` | `ROLE_ACCESS.owner` | path: `packId`, `versionId`; body: `acknowledge_community_disclaimer` | - | | `POST` | `/:packId/versions/:versionId/reinstall` | `ROLE_ACCESS.owner` | path: `packId`, `versionId` | - | | `POST` | `/:packId/rollback` | `ROLE_ACCESS.owner` | path: `packId` | Rolls a pack back to an earlier version. | | `GET` | `/:packId` | `ROLE_ACCESS.ownerManager` | path: `packId` | - | ### KDS Router: `main/routes/kds.ts`. Full path: `/api/kds`. The role gate is a `router.use` layer below the `/pairing` mount, so it also applies to the `/pairing` reads. | Method | Path | Authorization | Parameters | Response | | ------- | ------------------- | ----------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PUT` | `/view` | `ROLE_ACCESS.kitchen` + KDS-enabled | body: `kds_default_view` or `view` | Updates `kds_default_view` (`'tabs'` or `'kanban'`) and returns `{ kds_default_view }`. | | `GET` | `/orders` | `ROLE_ACCESS.kitchen` + KDS-enabled | query: `?station_id` | `{ orders, counts }`, same projection as the WebSocket `initial_data` frame. | | `GET` | `/pairing` | `ROLE_ACCESS.kitchen` + KDS-enabled (404) | none | Paired display state for the caller's stations. | | `POST` | `/pairing` | `ROLE_ACCESS.ownerManager` | body: `station_id` | Registers a display against `station_id`. | | `GET` | `/display` | `ROLE_ACCESS.kitchen` + KDS-enabled | query: `?station_id` | Display-mode view of the same snapshot. | | `PATCH` | `/items/:id/status` | `ROLE_ACCESS.kitchen` + KDS-enabled | path: `id`; body: `status`, `expected_status` | Body `status` in `pending`, `preparing`, `ready`, `served`, plus optional `expected_status` for a compare-and-set. Station and category scope are re-checked inside the write transaction, and active parent `orders.status` is reconciled (see [Order lifecycle](../architecture/order-lifecycle.md#order-status)). | ### Kitchen Router: `main/routes/kitchen.ts`. Full path: `/api/kitchen`. | Method | Path | Authorization | Parameters | Response | | ------ | --------- | ----------------------------------- | ---------- | -------------------------------------------------------------------------------- | | `GET` | `/orders` | `ROLE_ACCESS.kitchen` + KDS-enabled | none | `{ orders, counts }`. The `:3001` REST equivalent of the KDS WebSocket snapshot. | ### KDS info Router: `main/routes/kds-info.ts`. Full path: `/api/kds-info`. | Method | Path | Authorization | Parameters | Response | | ------ | ---- | ---------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/` | any authenticated role | none | `{ mdns_url, ip_url, qr_url, qr_data_url, ips_data }`. URLs point at the KDS port with **no path suffix**. `ips_data` is one `{ ip, url, qr_data }` per local address. | ### Customer display feed Router: `main/routes/customer-display-feed.ts`. Full path: `/api/customer-display`. | Method | Path | Authorization | Parameters | Response | | ------ | ------- | ------------------------------------------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/feed` | a customer display token (`Authorization: Bearer cdt_...`); exempt from the JWT middleware by exact path and method | query: ?cursor | `{ business_name, config, orders, next_cursor, has_more }` where `orders` is `{ id, number, status }` for up to 100 eligible orders per page (released active orders and eligible KDS-unfinished `<24h` `completed` orders with projected board status matching the configured `order_types` and `statuses`; see [Order lifecycle](../architecture/order-lifecycle.md#order-status)), sorted oldest first within the page, with `next_cursor` paging older eligible orders. Carries no customer, staff, or item data. `400` for an invalid cursor; `401` for an unauthenticated token. Rate limited to 120 requests per minute. | A display token is not a JWT, so it cannot authorize any other endpoint. Only its SHA-256 hash is stored in `customer_display_tokens`; the table is excluded from database export and cleared on restore. ### POS info Router: `main/routes/pos-info.ts`. Full path: `/api/pos-info`. | Method | Path | Authorization | Parameters | Response | | ------ | ---- | ---------------------- | ---------- | ---------------------------------------------------------------------------- | | `GET` | `/` | any authenticated role | none | `{ mdns_url, ip_url, qr_url, qr_data_url, ips_data }` for the main API port. | ### Server App info Router: `main/routes/server-app-info.ts`. Full path: `/api/server-app-info`. | Method | Path | Authorization | Parameters | Response | | ------ | ---- | ---------------------- | ---------- | -------- | | `GET` | `/` | any authenticated role | none | - | ### More apps Router: `main/routes/more-apps.ts`. Full path: `/api/more-apps`. | Method | Path | Authorization | Parameters | Response | | ------ | --------- | ---------------------- | ---------- | -------- | | `GET` | `/` | any authenticated role | none | - | | `GET` | `/revflo` | any authenticated role | none | - | ### WhatsApp Router: `main/routes/whatsapp.ts`. Full path: `/api/whatsapp`. | Method | Path | Authorization | Parameters | Response | | -------- | ---------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/status` | `ROLE_ACCESS.ownerManagerCashier` | none | Connection state. Deliberately omits the raw QR string; `/qr` renders it. | | `POST` | `/settings` | `ROLE_ACCESS.ownerManager` | none | - | | `GET` | `/qr` | `ROLE_ACCESS.ownerManager` | none | Rendered QR image for pairing. | | `GET` | `/pairing-code` | `ROLE_ACCESS.ownerManager` | none | - | | `POST` | `/enable` | `ROLE_ACCESS.ownerManager` | none | - | | `POST` | `/disable` | `ROLE_ACCESS.ownerManager` | none | - | | `POST` | `/connect` | `ROLE_ACCESS.ownerManager` | body: `method`, `phone` | - | | `POST` | `/cancel-pairing` | `ROLE_ACCESS.ownerManager` | none | Cancels an in-progress QR or pairing-code attempt without logging out or wiping existing registered credentials. | | `POST` | `/disconnect` | `ROLE_ACCESS.ownerManager` | none | - | | `POST` | `/send` | `whatsapp.use` (+ `reports.view` on report kinds; + `reports.daily-sales.export` on `daily_sales_report`) | body: `bill_id`, `phone_e164`, `body`, `kind` | Body `bill_id` or `phone_e164`, `body`, `kind` (`bill_receipt`, `manual_reply`, `auto_followup`, `z_report`, or `daily_sales_report`). | | `GET` | `/automation` | `whatsapp.use` | none | `{ config, placeholders }` for message templates, trigger rules, and safety settings. | | `PUT` | `/automation` | `whatsapp.manage` (modifying `z_report_day_close` requires role `owner` and `reports.view`) | body: `templates`, `rules`, `safety` | `{ ok: true, config }` after merging omitted sections and rules with current config, validating any `z_report_day_close` recipient in E.164 format (`400` `invalid_phone` or `phone_required`; `403` `owner_required` or `PERMISSION_DENIED` when a non-owner or a user without `reports.view` modifies `z_report_day_close`, while saving other automation settings preserves the stored `z_report_day_close` rule), normalizing, and saving `whatsapp_automation_config`. | | `POST` | `/automation/broadcast` | `ROLE_ACCESS.ownerManager` | body: `template_id`, `min_visits`, `inactive_days`, `min_points`, `limit` | `{ ok: true, matched, attempted, sent, skipped, errors }` for up to `limit` (1-50, default 25) matching active customers; `503` when disconnected. | | `POST` | `/automation/run-sweep` | `ROLE_ACCESS.ownerManager` | none | `{ ok: true, feedbackSent, winbackSent }` from an immediate scheduled-rule sweep. | | `GET` | `/messages` | `whatsapp.use` | query: `?limit`, `?offset`, `?direction`, `?status`, `?phone`, `?bill_id` | Message history with `trigger_rule` attribution; `kind: "z_report"` entries are omitted unless the caller also has `reports.view`, and `kind: "daily_sales_report"` entries are omitted unless the caller also has both `reports.view` and `reports.daily-sales.export`. These permission filters are applied before pagination. | | `POST` | `/messages/:messageId/retry` | `whatsapp.use` | path: `messageId` | `{ ok: true, messageId }` after retrying a `failed` outbound message in place (`403` unless the caller also has `reports.view` on report kinds, plus `reports.daily-sales.export` when `kind: "daily_sales_report"`); `503` when disconnected, in cooldown, or in Offline mode; `404` when not found; `400` when invalid, not `failed`, or blocked by safety checks. | | `GET` | `/inbox` | `ROLE_ACCESS.ownerManager` | query: `?limit`, `?offset` | - | | `POST` | `/inbox/:messageId/reply` | `ROLE_ACCESS.ownerManager` | path: `messageId`; body: `body` | Replies to an inbound message. | | `GET` | `/blocklist` | `ROLE_ACCESS.ownerManager` | none | - | | `POST` | `/blocklist` | `ROLE_ACCESS.ownerManager` | body: `phone_e164`, `reason` | Body `phone_e164` and `reason`. | | `DELETE` | `/blocklist/:phone` | `ROLE_ACCESS.ownerManager` | path: `phone` | Removes a blocked number. | Template rendering, automated trigger rules, and anti-ban safety controls are described in [WhatsApp receipt delivery](../architecture/cloud-integrations.md#whatsapp-receipt-delivery). ### Support tickets Router: `main/routes/support-ticket.ts`. Full path: `/api/support-ticket`. | Method | Path | Authorization | Parameters | Response | | ------ | ----------------------------------- | ------------------------------------------ | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/profile` | `ROLE_ACCESS.allStaff` | none | - | | `GET` | `/diagnostics-preview` | `ROLE_ACCESS.allStaff` | query: `?category` | - | | `GET` | `/:clientTicketId/status` | `ROLE_ACCESS.allStaff` | path: `clientTicketId` | - | | `POST` | `/` | `ROLE_ACCESS.allStaff` | body: support-ticket fields; optional `operation_failure` | Queues the ticket; the structured failure context is validated and normalized as described in [cloud integrations](../architecture/cloud-integrations.md#linking-a-ticket-to-a-known-operation-failure). | | `GET` | `/pre-login/profile` | any authenticated role + pre-login limiter | none | Unauthenticated tenant profile for the login-screen ticket form. Body limit 300kb on this subtree. | | `GET` | `/pre-login/:clientTicketId/status` | any authenticated role + pre-login limiter | path: `clientTicketId` | Unauthenticated ticket status lookup; `clientTicketId` must be a UUID. | | `POST` | `/pre-login` | any authenticated role + pre-login limiter | none | Unauthenticated ticket creation from the login screen. Body limit 300kb on this subtree. | ### Diagnostics Router: `main/routes/diagnostics.ts`. Full path: `/api/diagnostics`. | Method | Path | Authorization | Parameters | Response | | -------- | ----------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `POST` | `/event` | `support.use` + diagnostics write limiter | body: legacy event fields | Local-only legacy event intake; free-form messages are never automatically transmitted. | | `POST` | `/capture` | authenticated API caller + diagnostics write limiter | body: `capture_point`, `operation_id`, `outcome`, optional allowlisted `facts` | `202` with the server-registered reason, operation, stage, classification, outcome, and correlation ID; rejects unregistered points or invalid fields. | | `GET` | `/health` | `support.use` | none | Safe consent, transport, queue, rejection, and delivery-gap state without report bytes or error text. | | `GET` | `/recent` | `support.use` | query: `?limit` (clamped to 200) | `{ failures: [ ... ] }`, locally captured failures, newest first. | | `DELETE` | `/recent` | `settings.manage` | none | `{ removed }`, the number of local failures dropped. This clears local evidence only. | | `GET` | `/support-bundle` | `support.use` | query: `?limit` (clamped to 200), `?category` | `{ bundle: { system, recent_failures } }` for copy-for-support. Never carries the raw log tail. | ### Database Router: `main/routes/database.ts`. Full path: `/api/db`. | Method | Path | Authorization | Parameters | Response | | ------ | ----------- | ------------------------------------------------------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/export` | `ROLE_ACCESS.owner` | none | Full database snapshot as a downloadable JSON attachment (`{ version, app, exported_at, schema_version, redacted_fields, data }`). Excludes virtual tables, FTS shadow tables, virtual generated columns, `EXPORT_EXCLUDE_TABLES` (`cloud_sync_outbox`, `support_ticket_outbox`, `store_diagnostics_outbox`, `diagnostics_hourly_aggregates`, `diagnostics_delivery_gaps`, `local_diagnostics`, `kds_pairing_tokens`, `customer_display_tokens`, `revoked_tokens`), user credential columns (`password`, `pin`, `pin_hash`), and redacts secret `settings` values (`jwt_secret`, `auth_clock_high_water_ms`, `cloud_api_key`, `cloud_device_secret`, `cloud_pos_hash`, `mobile_pairing_code`, `mobile_pairing_code_expires_at`, `cloud_deletion_status_token`, `cloud_last_error`, and `GOOGLE_DRIVE_PRIVATE_SETTING_KEYS`) to `[REDACTED]`. | | `POST` | `/import` | `ROLE_ACCESS.owner` + Master PIN when `overwrite` or a schema-version mismatch | body: `data`, `overwrite` | Body `data` plus `overwrite`. Master PIN is required when `overwrite` is set or the snapshot's `schema_version` (or `schemaVersion`) differs from the local one. Preserves existing `users` rows in place, never imports supplied user credentials (`password`, `pin`, `pin_hash`), activation state, or privileged roles (`owner`, `manager`), creates inactive placeholders with unusable credentials (and operational roles only: `server`, `chef`, or `cashier`, clamping any other value to `cashier`) for missing imported staff IDs, and skips `EXPORT_EXCLUDE_TABLES` and `IMPORT_BLOCKED_SECURITY_TABLES` (`role_permission_overrides`, `user_permission_overrides`, `authorization_audit_log`, `auth_audit_log`) on both additive and overwrite imports. Omitted `addons.stock_quantity` values default to `0` before inventory-ledger validation, and `product_variants`, `addons`, and `order_item_addons` insert only defined per-row columns so omitted legacy stock fields fall back to their SQLite defaults. Re-aligns sequence counters, marks sequence repair complete, and rebuilds search indexes before commit. Non-application/virtual tables and virtual generated columns in the import payload are ignored, and inventory movement rows are applied in durable append order (`id` ASC). | | `POST` | `/backup` | `ROLE_ACCESS.owner` + Master PIN | body: `master_pin` | - | | `GET` | `/download` | `ROLE_ACCESS.owner` + Master PIN | header: `x-master-pin` | Streams a checkpointed `.db` backup snapshot. Accepts `x-master-pin` header (or `master_pin` body); query-string credentials are rejected. | | `GET` | `/tables` | `ROLE_ACCESS.owner` | none | `{ tables: [ ... ] }`, every non-internal SQLite table name. | | `POST` | `/restore` | `ROLE_ACCESS.owner` (`database.manage`) | body: `confirmation`, `selection_token` | Restores a backup file the operator picked in the native dialog. `confirmation` must equal `RESTORE BACKUP`, and `selection_token` must be the single-use token the main process issued for that dialog; a path with no outstanding selection is refused with `400`, so the route never accepts a path the renderer invented. The token is validated before checking that the file exists (`404` when missing) and carries schema-version metadata (`400` when missing), and consumed only once those preflight checks pass. Replaces the live database with the selected backup and migrates supported older backups forward to the current schema. Returns `422` with the reason when the mechanism refuses the file. The pre-restore data is retained as a listed `flo-backup-*-pre-restore-*.db` copy. | ### Database tools Router: `main/routes/database-tools.ts`. Full path: `/api/db-tools`. | Method | Path | Authorization | Parameters | Response | | ------ | --------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/health-check` | `ROLE_ACCESS.owner` | none | - | | `POST` | `/apply-safe-fixes` | `ROLE_ACCESS.owner` | none | - | | `GET` | `/backups` | `ROLE_ACCESS.owner` | query: `?limit`, `?cursor` | `{ backups, nextCursor, hasMore }`, newest first by `createdAt` with a `fileName` tiebreak. `limit` accepts 1-100; omitting pagination returns all managed backups with `nextCursor: null` and `hasMore: false`. Invalid pagination parameters return `400`. | | `POST` | `/backups/:fileName/delete` | `ROLE_ACCESS.owner` + Master PIN | path: `fileName` | - | | `GET` | `/local-backup` | `ROLE_ACCESS.owner` | none | `{ enabled, directory, retention, lastBackupAt }` for the daily local backup. | | `PUT` | `/local-backup` | `ROLE_ACCESS.owner` | body: `enabled` (boolean) | Turns the daily local backup on or off and returns the same status. | | `GET` | `/master-pin/status` | `ROLE_ACCESS.owner` | none | - | | `POST` | `/master-pin/reset` | `ROLE_ACCESS.owner` (`database.manage`) + current Master PIN when one is configured | body: `pin`, `confirm_pin`, and `master_pin` when a PIN is already configured | First-time setup requires no `master_pin`. | | `GET` | `/currency-reset-impact` | `ROLE_ACCESS.owner` | none | Active currency plus the invoice, order, refund, customer, product, and add-on counts the destructive warning uses. | | `POST` | `/currency-reset` | `ROLE_ACCESS.owner` + Master PIN | body: `currency` | Also needs `current_currency` and `confirmation_phrase` of the form `CHANGE TO `. Creates a recovery backup, recreates the local database, preserves `local_backup_enabled` and the sanitized menu catalog with monetary fields zeroed, and returns `{ success, currency, backupPath, cleanupPending, localBackupFailed }`; `localBackupFailed` is `true` when the daily local backup is on but the pre-reset copy could not be kept there. The active session becomes invalid. | | `POST` | `/initialize` | `ROLE_ACCESS.owner` + Master PIN | body: `confirmation_phrase` | A stale Drive restore boundary classified as ambiguous by recovery may be discarded only by this owner-authorized reset with the exact `INITIALIZE` phrase (stored Drive credentials are dropped); an active in-process reset still conflicts. Returns `{ success, backupPath, cleanupPending, localBackupFailed }`; `localBackupFailed` is `true` when the daily local backup is on but the pre-reset copy could not be kept there, which never fails the committed reset. | ### Reports Router: `main/routes/reports.ts`. Full path: `/api/reports`. Report date parameters are tenant business dates. Each `YYYY-MM-DD` value is interpreted in the store's configured timezone and business-day start time, and omitted dates default to the tenant's current business date. See [business time](../architecture/business-time.md) for the storage and day-boundary rules. | Method | Path | Authorization | Parameters | Response | | ------ | --------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `GET` | `/daily-stats` | `ROLE_ACCESS.ownerManager` | none | Today only; no date parameter. | | `GET` | `/summary` | `ROLE_ACCESS.ownerManager` | query: `?date` | Summary for one business date. Order, bill, collection, and payment-method totals exclude cancelled orders; `ordersByStatus` counts all statuses. | | `GET` | `/financial-summary` | `ROLE_ACCESS.owner` | query: `?start_date`, `?end_date` | Owner-only collection summary, refund audit, and period profitability summary (`profitability`) for a range. Refunds are attributed to the original bill payment date, so gross, refund, net, and payment-method totals reconcile over the selected period. | | `GET` | `/tax-components` | `ROLE_ACCESS.ownerManager` | query: `?start_date`, `?end_date` | - | | `GET` | `/price-exceptions` | `reports.view` | query: `?start_date`, `?end_date`, `?shift_id` | `{ priceExceptions: { startDate, endDate, shiftId, shifts, count, complimentaryCount, totalDifference, rows } }`. Each row is a non-cancelled line with a manual price override whose override time falls in the range: order, item, menu price, billed price, difference, reason, who changed it, and when. `shift_id` narrows the list to that cash shift's open-to-close window (404 when unknown); `shifts` lists up to 20 shifts overlapping the range. | | `GET` | `/sales` | `ROLE_ACCESS.ownerManager` | query: `?start_date`, `?end_date` | Daily order and sales series, paid payment-method totals, and order-type totals over a business-date range, excluding cancelled orders. | | `GET` | `/topProducts` | `ROLE_ACCESS.ownerManager` | query: `?start_date`, `?end_date`, `?limit` | Top products by quantity over a business-date range, excluding cancelled orders and cancelled, voided, void-adjustment, or refunded items. When the caller also holds `reports.financial.view`, each product row includes ex-tax profitability metrics and the response includes period `profitability`. | | `GET` | `/recentOrders` | `ROLE_ACCESS.ownerManager` | query: `?limit`, `?date`, `?start_date`, `?end_date` | - | | `GET` | `/tables` | `ROLE_ACCESS.ownerManager` | none | Table-level utilisation and non-cancelled order totals for the current business date; no date parameter. | | `GET` | `/insights` | `ROLE_ACCESS.ownerManager` | query: `?days`, `?start_date`, `?end_date` | - | | `GET` | `/x-report` | `ROLE_ACCESS.ownerManager` | query: `?date`, optional `?session_id` | Live day or shift report. Recomputed on every read. When `?session_id` is omitted, aggregates the full business date across all shifts and includes `openShifts` and `blockingOpenShift` (`session: null`). When `?session_id` is provided, scopes the X report (`grossCollected`, `refunded`, `netCollected`, `paymentMethods`, `staffSales`, `taxComponents`, `cashMovements`, `openingFloatCents`, `expectedCashCents`, `alreadyClosed`, `zNumber`, `openShifts`, `blockingOpenShift`, and `session`) to that shift and its business date (`400 session_id_invalid` for a non-positive integer; `404 session_unavailable` when not found; `403 shift_access_forbidden` when a non-owner/non-manager requests another staff member's shift). `openingFloatCents` is reported separately and excluded from `expectedCashCents`; the stored Z adds the opening float at close. | | `GET` | `/z-report` | `ROLE_ACCESS.ownerManager` | query: `?date` | The immutable close snapshot for one business date. `404` with `{ error: "Day not closed", alreadyClosed: false, businessDate }` when no close row exists. The `zReport` object carries integer-minor-unit `*_cents` fields, `payment_methods`, `staff_sales`, `tax_components`, and `cash_movements` arrays, plus `z_number`, `closed_by`, `closed_by_name`, `notes`, `created_at`, `business_date`, `period_start`, and `period_end`. `variance_cents` is `counted_cash_cents - expected_cash_cents` and may be negative. | | `GET` | `/x-report/export` | `reports.view` (`ROLE_ACCESS.ownerManager` by default) | query: `?date`, `?format` (required: `csv`, `xlsx`, or `html`), optional `?session_id` | Exports a live X-report snapshot for one business date or, when `?session_id` is provided, for that shift (`Shift # - ` in the `Summary` rows and `format=html` header, with the same `400 session_id_invalid`, `404 session_unavailable`, and `403 shift_access_forbidden` checks as `GET /x-report`; print-styled HTML for PDF/browser print when `format=html`). Returns `400` with `{ error: "format must be xlsx, csv, or html" }` when `format` is missing or invalid. Sets `Content-Disposition` to `attachment; filename="x-report-."` (or `attachment; filename="x-report--shift-."` when `?session_id` is provided). | | `GET` | `/z-report/export` | `reports.view` (`ROLE_ACCESS.ownerManager` by default) | query: `?date`, `?format` (required: `csv`, `xlsx`, or `html`) | Exports the stored Z-report snapshot for a closed business date (or print-styled HTML for PDF/browser print when `format=html`). Returns `400` with `{ error: "format must be xlsx, csv, or html" }` when `format` is missing or invalid, and `404` with `{ error: "Day not closed" }` when the day has not been closed. Sets `Content-Disposition` to `attachment; filename="z-report-Z-."`. | | `POST` | `/cash-closes/:id/whatsapp` | `reports.view` + `whatsapp.use` | path: `id`; body: `phone_e164`, `body` (non-empty, max 4096 chars) | Sends the submitted report body and returns `{ success: true, messageId }`; when WhatsApp is disconnected, returns `{ fallback: true, reason: "not_connected" }` so the client can open WhatsApp for sharing. Invalid input returns `400`; an unknown day close returns `404`. | | `POST` | `/daily-sales/whatsapp` | `reports.view` + `reports.daily-sales.export` + `whatsapp.use` | body: `phone_e164`, optional `date` or `from` + `to` | Formats a localized plain-text daily sales summary (up to 93 business days, bounded to 4096 characters while preserving all financial totals) from `buildDailySalesExportDataset` and sends it with `kind: "daily_sales_report"`. Returns `{ success: true, deliveryStatus, messageId, body }` where `deliveryStatus` is `"sent"` when acknowledged by WhatsApp within the brief ACK wait window or `"submitted"` when handed to the socket and still awaiting server ACK; when WhatsApp is disconnected, returns `{ fallback: true, reason: "not_connected", body }` for manual sharing. Returns `400` on invalid phone, invalid range, disabled WhatsApp, Offline mode, blocklist, content, or send rejection, `409` when regional settings are not configured, and `429` when rate-limited or in cooldown. | | `GET` | `/daily-sales/export` | `reports.daily-sales.export` (owner by default) | query: `?date`, `?from`, `?to`, `?format`, `?part` | See [GET `/api/reports/daily-sales/export`](#get-apireportsdaily-salesexport). | | `GET` | `/daily-invoices/export` | `reports.daily-sales.export` (owner by default) | query: `?date`, `?from`, `?to`, `?format` | See [GET `/api/reports/daily-invoices/export`](#get-apireportsdaily-invoicesexport). | | `GET` | `/customer-sales/export` | `reports.customer-sales.export` (owner by default) | query: `?start_date`, `?end_date`, `?format` | One row per item sold with customer details, for a business-date range of at most 366 days. `format` is `xlsx` or `csv`; invalid format, reversed range, or oversized range returns `400`. See [customer sales export](daily-sales-export.md#customer-sales-export). | ### GET `/api/reports/daily-sales/export` Owner-only daily sales export for one tenant business date (`?date=YYYY-MM-DD`, defaults to the current business date) or an inclusive business-date range (`?from=YYYY-MM-DD&to=YYYY-MM-DD`, up to 93 calendar days). Returns an XLSX workbook (`Summary`, `Daily`, `Taxes`, `Items`, and `Payments` sheets), one part of a five-file CSV set (`summary`, `daily`, `taxes`, `items`, `payments`), or a print-styled HTML document (`format=html`) used for desktop PDF export and browser print. Accounting and row grouping live entirely in `buildDailySalesExportDataset`; the endpoint only validates, serializes, and sets headers. See [daily sales export](daily-sales-export.md) for the full reconciliation contract, the summary metric field order, and the worksheet/CSV columns. | Param | Values | Notes | | -------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `date` | `YYYY-MM-DD` | Single tenant business date; defaults to the current business date when `from`/`to` are omitted. | | `from` | `YYYY-MM-DD` | Inclusive start business date (must be paired with `to`, cannot be combined with `date`). | | `to` | `YYYY-MM-DD` | Inclusive end business date (`from <= to`, at most 93 calendar days inclusive). | | `format` | `xlsx`, `csv`, or `html` | Required. Any other value returns `400`. | | `part` | `summary`, `daily`, `taxes`, `items`, or `payments` | Required when `format=csv`; ignored for `xlsx` and `html`. Missing or invalid on CSV returns `400`. | - `format=xlsx` returns `200` with `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` and `Content-Disposition: attachment; filename="daily-sales-YYYY-MM-DD.xlsx"` (or `daily-sales-YYYY-MM-DD_to_YYYY-MM-DD.xlsx` for multi-day ranges). - `format=csv` returns `200` with `Content-Type: text/csv; charset=utf-8` and `Content-Disposition: attachment; filename="daily-sales--.csv"` for the requested `part` (`summary` has header `metric,value`; `daily`, `taxes`, `items`, and `payments` use the column headers and encoding rules in [daily sales export](daily-sales-export.md#file-formats)). - `format=html` returns `200` with `Content-Type: text/html; charset=utf-8` and `Content-Disposition: attachment; filename="daily-sales-YYYY-MM-DD.html"` (or `daily-sales-YYYY-MM-DD_to_YYYY-MM-DD.html`); see [daily sales export](daily-sales-export.md#file-formats). Errors: `400` for an invalid `format`, a CSV without `part`, mixed `date` and `from`/`to` arguments, an incomplete range, an invalid calendar date, `to < from`, a range exceeding 93 days, or an `html` export exceeding the 15 MB PDF size limit; `401` unauthenticated; `403` non-owner; `409` for a concurrent-write conflict; `500` otherwise. ### GET `/api/reports/daily-invoices/export` Compiled daily invoices export (`reports.daily-sales.export`, owner by default) for one tenant business date (`?date=YYYY-MM-DD`, defaults to the current business date) or an inclusive business-date range (`?from=YYYY-MM-DD&to=YYYY-MM-DD`, up to 366 calendar days). Returns a print-styled A4 portrait HTML document (`format=html`, default when omitted) formatted with the store's regional locale and preferences (`resolveRegionalSnapshot`), used by `exportReportAsPdf` for desktop PDF export (`invoices-YYYY-MM-DD.pdf` or `invoices-YYYY-MM-DD_to_YYYY-MM-DD.pdf`) and browser print fallback. Page 1 renders a bounded fiscal summary cover page (store legal name, tax registration ID with the active country tax pack's `registrationNumberLabel` or country profile `taxIdLabel`, address, business date/range, timezone, business-day start, currency, total invoices count, gross sales, net base, tax collected, in-period refunds, net revenue, the VAT/tax summary table by rate, refund scope reconciliation across in-period, prior-bill, outside-period, and lifetime totals, and summary reconciliation metrics) reused from `buildDailySalesExportDataset` (see [daily sales export](daily-sales-export.md)). When the window has issued invoices, page 2+ renders all bills paid in the business-day window (`payment_status IN ('paid', 'partially_refunded', 'refunded')`) ordered by ascending business date and numeric `bill_number` sequence (`paid_at`, `id`, and `bill_number` tie-breakers), with `page-break-inside: avoid; break-inside: avoid;` on each `.invoice-card`, `[PAID]`, `[PARTIALLY REFUNDED]`, and `[REFUNDED]` status badges, non-excluded line items (unit price, pre-tax subtotal, and after-tax total), itemised charges (including custom fees from `charges_breakdown` alongside standard charges, with legacy charge fallbacks), tax components, payment details, and `[In Period]` / `[Outside Period]` refund details. A trailing page-broken appendix renders the per-day tax breakdown table and any prior-period invoices refunded during the selected period. - Returns `200` with `Content-Type: text/html; charset=utf-8` and `Content-Disposition: attachment; filename="invoices-YYYY-MM-DD.html"` (or `invoices-YYYY-MM-DD_to_YYYY-MM-DD.html`). - Errors: `400` for an unsupported `format`, mixed `date` and `from`/`to` arguments, an incomplete or reversed range, an invalid calendar date, a range exceeding 366 days, more than 5,000 matching invoices (`MAX_DAILY_INVOICES_COUNT`), or an HTML export exceeding the 15 MB PDF size limit; `401` unauthenticated; `403` when lacking `reports.daily-sales.export`; `409` for unconfigured regional settings or a concurrent-write conflict; `500` otherwise. ### Inline endpoints (registered outside a router) These seven are declared on `app` inside `registerRoutes` in `main/routes/index.ts`, not on a router. Every path below is absolute; there is no mount prefix. Router: declared inline in `main/routes/index.ts`. | Method | Path | Authorization | Parameters | Response | | ------ | -------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `POST` | `/api/tax/preview` | `pos.use`, `orders.create`, or `kitchen.use` | body: `items` (each may carry `price_override: { price }` to quote an owner-set price), `customer_id`, `packaging_charge`, `delivery_charge`, `service_charge`, `discount_type`, `discount_value`, `online_platform`; optional new-order context: `order_type` (required when any context field is supplied), `waived_charge_ids`, `opted_in_charge_ids` | `400` when `items` is missing or empty, the supplied order type is missing or invalid, either supplied charge-ID value is neither `null` nor an array of strings, or an item names a variant that is unknown, foreign, or inactive (including a product with active variants that names none). Returns the tax rollup for a hypothetical basket without persisting anything. An item's `variant_id` follows the order rules and is priced from the catalog, so the quote matches the order it becomes. With the optional context, the server resolves configured charges for that order type and returns them as `summary.charges_breakdown` in the payable total; without it, the legacy manual charge inputs are unchanged. Any one of the three sale permissions admits the caller, and the POS checkout modal calls this on every cart change. | | `GET` | `/api/tax/categories` | `ROLE_ACCESS.ownerManager` | none | `{ pack_id, country, categories, default_category_id, configuration_ready, unclassified_category_id }`. `categories` is empty until the pack's configuration is complete. | | `GET` | `/api/mobile/pairing-code` | `ROLE_ACCESS.owner` | none | `{ pairing_code, expires_at, qr_data_url }`. Returns the cached code when one is live, otherwise generates one. `409` when the store is not yet claimed in FloAdmin; `502` for any other cloud failure. | | `POST` | `/api/mobile/rotate-code` | `ROLE_ACCESS.owner` | none | Same shape as the read, and every already paired RevFlo device is disconnected. | | `GET` | `/api/mobile/devices` | `ROLE_ACCESS.owner` | none | `{ devices: [ ... ] }`. `502` when FloAdmin is unreachable. | | `GET` | `/api/customers-search` | `customers.view` (`ROLE_ACCESS.sales` by default; grants are configurable) | query: `q` | Flat array of at most 20 active customers, each with a `wallet_balance`. `q` shorter than 2 characters returns `[]`. | | `GET` | `/api/crm/lookup` | `ROLE_ACCESS.sales` | query: `phone`, `country_code` | `{ found, customer }`. `400` when `phone` is missing. The number is normalized to E.164 against the tenant country before lookup. | ## KDS server (`:3002`) The KDS server reads the same database as the main API. Its whole surface: | Method | Path | Authorization | Parameters | Response | | ------- | --------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `GET` | `/api/health` | none | none | `{ status, service, version, timestamp }` | | `GET` | `/api/kds/info` | none | none | `{ language, country, kds_default_view, theme_mode }` for the pre-login display (`kds_default_view` defaults to `'kanban'`; `theme_mode` is `'light'`, `'dark'`, `'system'`, or `null`). `404` when KDS is disabled, so the surface is not advertised to the LAN. | | `PUT` | `/api/kds/view` | `ROLE_ACCESS.kitchen` | body: `kds_default_view` or `view` | Updates `kds_default_view` (`'tabs'` or `'kanban'`) and returns `{ kds_default_view }`. `403` when KDS is disabled. | | `POST` | `/api/auth/login` | none, `loginAuthRateLimit` + shared failed-login limiter | body: `email`, `password` | `{ access_token, user: { id, name, email, role, has_pin, category_ids, station_ids, station_assignments_configured } }`. 24-hour token. `401` on bad credentials with `attempts_remaining`; `429` after 5 failed attempts across local login listeners; `403` for a role outside `kitchen`. | | `GET` | `/api/auth/pin-users` | none, auth limiter (600 per window) | none | `{ users: [ { id, name, role } ] }` for active users with a PIN configured who hold `kitchen.use` and have an active station when station assignments exist. `404` when KDS is disabled. | | `POST` | `/api/auth/pin-login` | none, auth limiter (600 failed per window) + per-(client IP, user) failed-login limiter | body: `user_id`, `pin`, `rememberMe` (or `remember_me`) | `{ access_token, user: { id, name, email, role, has_pin, category_ids, station_ids, station_assignments_configured, permission_ids, authorization_revision } }`. 24 hours, or 10 days with remember. `404` when KDS is disabled; `400` when `user_id` or `pin` is missing; `401` on bad credentials with `attempts_remaining`; `429` after 5 failed attempts; `403` without `kitchen.use` or an active station. | | `POST` | `/api/auth/logout` | bearer token, not role-gated | none | `{ message }`. Revokes the token. | | `GET` | `/api/auth/me` | `ROLE_ACCESS.kitchen` | none | `{ user: { id, name, email, role, has_pin, permission_ids, authorization_revision } }` | | `POST` | `/api/auth/password/change` | `ROLE_ACCESS.kitchen` + auth limiter | body: `current_password`, `password` | `{ message, token }`; self-service password change with the same validation and 5-attempt lockout as `POST /api/auth/password/change` on `3001`, returning a fresh KDS token (`403` when KDS is disabled). | | `POST` | `/api/auth/pin/change` | `ROLE_ACCESS.kitchen` + auth limiter | body: `pin`, plus `current_pin` or `current_password` | `{ message, has_pin: true, token }`; self-service PIN setup or update with the same validation and 5-attempt lockout as `POST /api/auth/pin/change` on `3001`, returning a fresh KDS token (`403` when KDS is disabled). | | `GET` | `/api/kds/orders` | `ROLE_ACCESS.kitchen` | none | `{ orders, counts }`, the same projection and the same category and station narrowing as the WebSocket `initial_data` frame. `403` when KDS is disabled or the caller has no active station. | | `PATCH` | `/api/kds/items/:id/status` | `ROLE_ACCESS.kitchen` | path: `id`; body: `status`, `expected_status` | `{ success: true }`. Reconciles active parent `orders.status` in the same transaction (see [Order lifecycle](../architecture/order-lifecycle.md#order-status)). `400` for a terminal or voided item, `403` for a station or category the caller does not cover, `409` when the item belongs to an unreleased scheduled order or the row changed before the write. | | `GET` | `/api/categories` | `ROLE_ACCESS.kitchen` | none | `{ categories: [ ... ] }` narrowed to the caller's station and category scope. | Plus three static handlers: `express.static` over the frontend export, `GET /` redirecting to `/kds-standalone`, and a `GET /*splat` fallback that serves the standalone KDS page. `PATCH /api/kds/items/:id/status` re-verifies the caller's active status, token revocation, and token staleness **inside** its `IMMEDIATE` transaction, so a session revoked between the middleware and the write cannot land a status change. The KDS server's `requireAuth` reads the user row on every request and does not cache it, unlike the main API's 30-second cache. See [three separate token-verification middlewares](../architecture/authentication-and-authorization.md#three-separate-token-verification-middlewares). ## Server App (`:3003`) The Server App implements no business logic. It authenticates the caller, checks the role against `ROLE_ACCESS.serverApp` (server, manager, owner), and forwards an explicit allowlist to `http://127.0.0.1:
/api/...`. [System overview](../architecture/overview.md#the-server-app-is-a-filtered-proxy-not-a-second-api) owns that boundary model; this section is the allowlist. Twenty-five routes in total, thirteen of which forward: | Method | Path | Forwards to | | ------ | ---------------------------- | --------------------------------------------------------------------------------------------------------------- | | `GET` | `/api/categories` | `GET /api/categories` | | `GET` | `/api/products` | `GET /api/products` | | `GET` | `/api/products/:id/image` | `GET /api/products/:id/image` | | `GET` | `/api/tables` | `GET /api/tables` | | `POST` | `/api/tables/:id/move-order` | `POST /api/tables/:id/move-order` | | `GET` | `/api/orders` | `GET /api/orders` | | `POST` | `/api/orders` | `POST /api/orders` | | `POST` | `/api/orders/:id/items` | `POST /api/orders/:id/items` | | `GET` | `/api/customers-search` | `GET /api/customers-search` | | `GET` | `/api/crm/lookup` | `GET /api/crm/lookup` | | `POST` | `/api/customers` | `POST /api/customers` | | `POST` | `/api/printers/print-kot` | `POST /api/printers/print-kot` | | `POST` | `/api/printers/print-bill` | `POST /api/printers/print-bill` (`403` `BILL_PRINTING_DISABLED` when `server_app_bill_printing_enabled` is off) | Adding a route to `3001` does not expose it on `3003`. A path becomes reachable from the Server App only when a forwarding route is added to `main/server-app.ts`. The eleven routes that are not forwards: | Method | Path | Authorization | Response | | ------ | --------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/api/health` | none | `{ status, service, version, timestamp }` | | `GET` | `/api/server-app/info` | none | `{ language, country, currency, currency_symbol, currency_position, currency_fraction_digits, kds_enabled, category_sort, item_sort }`. `404` when the feature is disabled; `409` with `error: "regional_not_configured"` when the regional snapshot cannot be resolved. | | `POST` | `/api/auth/login` | none, `loginAuthRateLimit` + shared failed-login limiter | Body `email`, `password`, `remember_me`. `{ access_token, user: { id, name, email, role, has_pin, permission_ids, authorization_revision } }`; 24 hours, or 10 days with `remember_me`. `401` on bad credentials with `attempts_remaining`; `429` after 5 failed attempts across local login listeners; `403` for a role outside `ROLE_ACCESS.serverApp`. | | `GET` | `/api/auth/pin-users` | none, auth limiter (600 per window) | `{ users: [ { id, name, role } ] }` for active users with a PIN configured who hold `server-app.use`. `404` when the feature is disabled. | | `POST` | `/api/auth/pin-login` | none, auth limiter (600 failed per window) + per-(client IP, user) failed-login limiter | Body `user_id`, `pin`, `remember_me` (or `rememberMe`). `{ access_token, user: { id, name, email, role, has_pin, permission_ids, authorization_revision } }`; 24 hours, or 10 days with remember. `404` when disabled; `400` when `user_id` or `pin` is missing; `401` on bad credentials with `attempts_remaining`; `429` after 5 failed attempts; `403` without `server-app.use`. | | `GET` | `/api/auth/me` | `ROLE_ACCESS.serverApp` | `{ user: { id, name, email, role, has_pin, permission_ids, authorization_revision } }` | | `POST` | `/api/auth/password/change` | `ROLE_ACCESS.serverApp` + auth limiter | Body `current_password`, `password`. `{ message, token }`; self-service password change with the same validation and 5-attempt lockout as `POST /api/auth/password/change` on `3001`, returning a fresh Server App token (`404` when the feature is disabled). | | `POST` | `/api/auth/pin/change` | `ROLE_ACCESS.serverApp` + auth limiter | Body `pin`, plus `current_pin` or `current_password`. `{ message, has_pin: true, token }`; self-service PIN setup or update with the same validation and 5-attempt lockout as `POST /api/auth/pin/change` on `3001`, returning a fresh Server App token (`404` when the feature is disabled). | | `POST` | `/api/auth/logout` | `ROLE_ACCESS.serverApp` | `{ success: true }`, revoking the token | | `GET` | `/` | none | Redirect to `/server-standalone` | | `GET` | `/*splat` | none | Static bundle, falling back to the `/server-standalone` page | When the frontend export is missing, the `GET /` redirect is replaced by a placeholder page instead, so `GET /` is two mutually exclusive registrations rather than two routes. `requireServerAppAuth` is applied to every route except `/api/health`, `/api/server-app/info`, and `/api/products/:id/image`, and it runs the feature check **before** the token check. Behaviour by condition: - Feature disabled: `404` with `{ error: "Not found" }`, so the surface is not advertised. - Main API unreachable: `502` with `{ error: "Could not reach the local POS API" }` from the forwarding layer. - Request aborted by shutdown: `503` with an empty body. The forwarder relays the upstream status, content type, and body verbatim, and passes the method, query string, `Authorization` header, `Idempotency-Key`, and JSON body upstream. Three limiters on `3003` run **before** authentication: 150 requests per minute across `/api`, 150 per minute on `POST /api/customers`, and 30 per minute shared by the two print forwarders. Each returns `429` when exceeded. ## KDS WebSocket (`/kds`) One connection contract, implemented once in [`main/services/kds.ts`](../../main/services/kds.ts) and served on both `:3001` and `:3002`. ### Connecting Open `ws://:/kds` on `3001` or `3002`. The upgrade handler answers before the upgrade completes: | Condition | Response | | ----------------------------------------- | ----------------------------------------------------- | | Path is not `/kds` | `404`, socket destroyed | | Database maintenance in progress | `503`, socket destroyed | | KDS disabled (`kds_enabled` off) | `404`, socket destroyed | | Disallowed `Origin` header | `403`, socket destroyed | | Total or pending connection limit reached | `429`, socket destroyed | | Otherwise | upgraded, and a `connected` frame is sent immediately | ### Handshake 1. On connect the server sends `connected`. 2. The client sends `auth` with a bearer JWT. A display must authenticate within `KDS_AUTH_TIMEOUT_MS`, 5 seconds; otherwise the server sends `auth_error` and closes with code `1008`. 3. On success the server sends `auth_success`, then sends `initial_data` as a **separate frame**. `auth_success` carries a `user` object and nothing else: ```json { "type": "auth_success", "user": { "id": "chef-1", "name": "Chef One", "role": "chef", "categoryIds": ["cat-1", "cat-2"], "stationIds": ["station-1"] } } ``` It does not carry `orders` or `counts`. A client that waits for the order list on this frame waits forever. ### Frames the server sends | Type | Carries | When | | ---------------- | ------------------------- | -------------------------------------------------------------- | | `connected` | `message`, `timestamp` | Immediately on connect | | `auth_success` | `user` (see above) | After a successful `auth` | | `initial_data` | `orders`, `counts` | After `auth_success`, and on every subsequent broadcast | | `status_updated` | `order_item_id`, `status` | After the requesting client's own `status_update` succeeds | | `pong` | `timestamp` | In reply to a `ping` | | `error` | `message` | A rejected client message or a rejected status update | | `auth_error` | `message` | Before the server closes an unauthenticated or revoked session | ### Frames the server accepts The message switch in `handleMessage` accepts exactly three types. Anything else gets `{ "type": "error", "message": "Unknown message type" }`. | Type | Fields | Notes | | --------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `auth` | `token` | Only accepted before authentication. Any other type before auth closes the connection. | | `status_update` | `order_item_id`, `status`, optional `expected_status` | `status` must be one of `pending`, `preparing`, `ready`, `served`; reconciles active parent `orders.status` in the same transaction (see [Order lifecycle](../architecture/order-lifecycle.md#order-status)). | | `ping` | none | Answered with `pong`. | `expected_status` turns the write into a compare-and-set. Without it the update excludes the terminal statuses in its `WHERE` clause. With it, a row whose status has changed returns `Item status changed; refresh and try again`. ### Every update is a full snapshot There is no incremental order event. `notifyKdsUpdate` and `broadcastOrderUpdate` both call `sendActiveOrders`, which recomputes the caller's visible orders and counts and sends the result as a complete `initial_data`. A client must replace its board wholesale on every `initial_data` frame. The `status_updated` frame is an acknowledgement for the requesting client only; other displays learn about the change through their own `initial_data`. Broadcasts are coalesced: a burst of `notifyKdsUpdate` calls within one microtask produces one snapshot per client. ### Errors a display must handle | `error` message | Cause | | ---------------------------------------------------------- | ------------------------------------------------- | | `Unknown message type` | A client frame the switch does not accept | | `order_item_id and status required` | A `status_update` missing either field | | `Invalid status. Use: pending, preparing, ready, served` | A `status_update` outside the allowed set | | `Invalid expected status. Use: ...` | A malformed `expected_status` | | `Item not found` | Unknown `order_item_id` | | `This item has been voided and can no longer be updated` | The item was voided | | `This bill adjustment cannot be updated from KDS` | The item is a `void_adjustment` row | | `This terminal item cannot be updated from KDS` | `completed`, `cancelled`, or `refunded` | | `Not authorized to update this station` | Outside the caller's station scope | | `Not authorized to update this item` | Outside the caller's category scope | | `Item status changed; refresh and try again` | The compare-and-set lost | | `Scheduled order has not been released to the kitchen yet` | The item belongs to an unreleased scheduled order | | `Could not update item status` | The write threw | `auth_error` precedes a close on: `Authentication required` (no `auth` in time, or any non-`auth` frame before authenticating), `Invalid or revoked token`, `User not found`, `Only kitchen staff can access KDS`, `No active kitchen station is assigned to this user`, and `Could not load station permissions`. The 30-second heartbeat also closes a session with `Session expired or revoked` when the token or role stops being valid, and with `KDS is disabled` or `Database maintenance in progress` when the store setting changes under a live connection. ### Connection limits - 100 connected clients (`MAX_KDS_CLIENTS`), at most 25 pending (unauthenticated) clients globally (`MAX_UNAUTHENTICATED_KDS_CLIENTS`), and at most 5 pending clients per normalized IP (`MAX_UNAUTHENTICATED_KDS_CLIENTS_PER_IP`). Once a socket authenticates, it stops consuming the pending budget; closing and closed sockets are pruned before each admission check. Requests with no `Origin` header or a local/LAN/Tailscale `Origin` (`isAllowedOrigin`) are admitted; a direct `connection` event that fails admission closes with `1008` (disallowed `Origin`) or `1013` (capacity reached). - A client whose `bufferedAmount` exceeds 1 MB at send time is closed with `1013` as too slow. - The server sends a WebSocket ping every 30 seconds and terminates a client that misses one. - A client is re-sent a fresh `initial_data` without any change of its own when its category or station assignment changes, or when the voided-item expiry marker moves. ## KDS category and station scoping Two independent narrowing axes decide what a chef sees. Both are resolved at connect time and re-resolved on every broadcast, and both are re-checked inside the write transaction for a `status_update`. **Categories.** `categoryIds` is the empty list for `owner` and `manager`, which means unrestricted, and the parsed `users.category_ids` expanded with non-deleted child subcategories (`expandCategoryIdsWithSubcategories`) for everyone else. When the list is non-empty, an item is visible only if its product's category is in the expanded list. **Stations.** `stationIds` comes from `getUserKdsStationIds`. When a user has station assignments configured but none active, authentication is refused with `No active kitchen station is assigned to this user` rather than silently showing an empty board. `getKdsStationCategoryIds` and `getKdsStationRoutingScope` resolve each assigned station's `category_ids` (expanded with non-deleted child subcategories except child subcategories explicitly assigned to another active kitchen station) into a set of categories, plus a separate set for orders on tables with no `kitchen_station_id`, and report whether any assigned station is unrestricted (empty `category_ids`). An order is in scope when its table's `kitchen_station_id` is one of the caller's stations, or when the table has no station and the order contains an item in a tableless routing category, or when the caller has an unrestricted station. An item is then in scope when `isKdsStationItemAllowed` passes for that station and category set, and the category filter above also passes. The `counts` map is computed with the same scope, so it always matches the orders it accompanies. Three statuses are hidden from every KDS surface: `void_adjustment` rows (the mirrored line a void writes, which is a bill adjustment rather than a kitchen item), and `completed`, `cancelled`, and `refunded` items. A `voided` item stays visible for `KDS_VOIDED_ITEM_VISIBILITY_MS` after its `voided_at` timestamp and then ages off the board; the 30-second heartbeat notices the change and pushes a fresh snapshot. `projectKdsOrder` and `projectKdsItem` receive a `restrictedPayload` flag, true for `chef` and for anyone with a non-empty category or station scope, false for an unrestricted `owner` or `manager`. Unrestricted sessions receive the fuller row. The same two axes are applied by `GET /api/kds/orders`, `GET /api/kitchen/orders`, `PATCH /api/order-items/:id/status`, `PATCH /api/orders/:id/status`, and `PATCH /api/kds/items/:id/status` on `:3001` and `:3002`. See [KDS station and category narrowing](../architecture/authentication-and-authorization.md#kds-station-and-category-narrowing) for the authorization framing. ## Refunds Refund eligibility, approval tiers, the Staff Approval PIN rules, and how refund amounts are stored belong to [product invariants](product-invariants.md#refunds-and-staff-approval-pins). This page documents only the two routes. ## Cross-references | Subject | Page | | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | Which processes run, on which ports, and the port retry ladder | [System overview](../architecture/overview.md) | | Token issuance, verification, revocation, staleness, Master PIN, audit attribution | [Authentication and authorization](../architecture/authentication-and-authorization.md) | | What each role sees in the interface | [Roles and permissions](roles-and-permissions.md) | | Refund approval rules and refund amount storage | [Product invariants](product-invariants.md) | | Daily sales export accounting and reconciliation contract | [Daily sales export](daily-sales-export.md) | | Merchant print template payload and offline transfer format | [Merchant print templates](merchant-print-templates.md) | | Business dates, day boundaries, and timestamp storage | [Business time](../architecture/business-time.md) | | Printer drivers, ESC/POS, and print routing | [Printing architecture](../architecture/printing.md) | | Device Master PIN second factor | [`main/middleware/master-pin.ts`](../../main/middleware/master-pin.ts) | ## Coverage This page lists 323 HTTP route registrations across 322 distinct rows, and one WebSocket contract: - `285` on the main API: 277 router registrations, 7 inline registrations in `main/routes/index.ts`, and `GET /api/health` in `main/server.ts`. - `13` REST routes on the KDS server, plus 3 static handlers. - `25` route registrations on the Server App, of which 13 forward to the main API. Two of those 25 are mutually exclusive `GET /` handlers under one path, which is why 25 registrations render as 24 rows. Every one of the 322 rows is listed with its method, path, authorization gate, and the parameters its handler reads. The **Response** column carries a note for 187 of them: 150 main-API endpoints, all 13 KDS-server routes, and the 24 Server App routes. The remaining 135 main-API endpoints are listed with method, path, gate, and parameters only, and this page asserts nothing about what they return. Those rows are complete as a route inventory and silent as a contract; the handler in `main/routes/` is authoritative for them. A `-` in the Response column means that, not that the endpoint does nothing. The route inventory is maintained by hand against the route files, not generated. The role groups, parameters, and rate limiters in the tables were read out of the route registrations themselves, but a route added after this page was last checked will not appear until someone adds it here.