Skip to content
Merged
20 changes: 10 additions & 10 deletions docs/architecture/authentication-and-authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ list. `requireRole` (in `main/middleware/security.ts`) still exists, but a stati
(`tests/authorization-static-audit.test.ts`) fails the build if a new call site under
`main/routes/` uses it; the few remaining direct role checks are reviewed context policy, covered
in their own sections below (KDS station/category scope, refund approver identity, the
override-PIN holder for item cancel/void, and the last-active-owner/staff-target rule).
override-PIN holder for order-status and item cancel/void overrides, and the
last-active-owner/staff-target rule).

**Resolution.** For every permission id, `resolveEffectivePermissions(userId)` evaluates, in
order: the protected-owner rule (for `authorization.manage` and `staff.privileged.manage` only,
Expand Down Expand Up @@ -126,15 +127,14 @@ a router, in [`main/routes/index.ts`](../../main/routes/index.ts). A static sear

**Gates inside transaction bodies.** The most important case. Some endpoints perform the
permission check inside the `withTxn` callback, after the transaction opens, using
`hasPermission(actorId, id)` against state read in the same transaction. `PATCH
/api/orders/:orderId/items/:itemId/cancel` and the matching `.../restore` endpoint in
`main/routes/orders.ts` do this, and
the override-PIN path additionally requires the presenting user to satisfy `hasRole(role,
ROLE_ACCESS.ownerManager)` — a deliberate context-policy role check, not a permission, because the
override PIN belongs to a person acting in an owner/manager capacity regardless of what their
`orders.item.void`/`orders.item.restore` grant says. The check cannot be hoisted into middleware
without changing the read the decision is based on, and it is invisible to anything that inspects
the route's middleware chain.
`hasPermission(actorId, id)` against state read in the same transaction.
`PATCH /api/orders/:id/status`, `PATCH /api/orders/:orderId/items/:itemId/cancel`, and the matching
`.../restore` endpoint in `main/routes/orders.ts` do this, and their override-PIN paths
additionally require the presenting user to satisfy `hasRole(role, ROLE_ACCESS.ownerManager)` - a
deliberate context-policy role check, not a permission, because the override PIN belongs to a
person acting in an owner/manager capacity regardless of what their individual permission grants
say. The check cannot be hoisted into middleware without changing the read the decision is based
on, and it is invisible to anything that inspects the route's middleware chain.

**KDS WebSocket authorization.** The KDS socket authenticates with the same bearer token but
authorizes per message. See below.
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture/order-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ enabled, excluding the share funded by wallet points.
## Order status

`PATCH /api/orders/:id/status` advances the order through preparation, service, and terminal
statuses. Its allowed transitions, no-op rule, and cancellation approval requirements are in the
statuses inside a single transaction. Its allowed transitions, terminal completion and cancellation
safeguards, `override_pin` approval rules, pending-only restock rule, and audit fields are in the
[API reference](../reference/api.md#orders). Kitchen item status is a separate surface
(`/api/order-items`); see [order-item status](../reference/api.md#order-item-status).

Expand Down
28 changes: 27 additions & 1 deletion docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ and settlement - is in [Order lifecycle](../architecture/order-lifecycle.md).
| `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`, `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`. `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. |
| `POST` | `/:id/items` | `ROLE_ACCESS.sales` + order write limiter | path: `id`; body: `items`, `special_instructions`; header: `Idempotency-Key` | Appends items and returns the recomputed order. 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`. |
| `PATCH` | `/:id/status` | `ROLE_ACCESS.orderStatus` + order write limiter | path: `id`; body: `status`, `reason`, `override_pin`, `free_table` | 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. |
| `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` | 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` | - |
| `PATCH` | `/:id/convert-to-takeaway` | `ROLE_ACCESS.sales` + order write limiter | path: `id` | - |
| `PATCH` | `/:id/discount` | `ROLE_ACCESS.ownerManagerCashier` + order write limiter | path: `id`; body: `discount_type`, `discount_value`, `discount_reason`, `override_pin` | - |
Expand All @@ -296,6 +296,32 @@ header. A validation rejection with a registered failure point includes `reason_
present. 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:

- 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. 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.
- When `approver_id` (or `manager_id`) is supplied on an overridden transition, PIN verification is
restricted to that active user. 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.

Both item endpoints 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
Expand Down
13 changes: 6 additions & 7 deletions docs/reference/product-invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,8 @@ npm run test:authorization-permissions
```

A static audit test in the same suite rejects any newly introduced `requireRole(...)` runtime gate
under `main/routes/`; the remaining direct role checks in the codebase are reviewed context policy
(refund approver identity, the override-PIN holder for item cancel/void, the last-active-owner and
staff-target rule, and KDS station/category scope), each documented where it lives.
under `main/routes/`; the remaining direct role checks in the codebase are reviewed context policy,
documented in [authentication and authorization](../architecture/authentication-and-authorization.md#configurable-permissions).

**Change policy:** the two protected permissions, and the precedence order overrides resolve in,
are structural — changing either needs an explicit product decision, because either one can create
Expand Down Expand Up @@ -304,9 +303,9 @@ tables (`supplies`, `supply_movements`) with a signed ledger, separate from prod
sales without a variant use a portion of 1, and the portion is scaled from the resolved catalog
variant, never from the order payload.
3. **Restoration is keyed off the snapshot, never the current recipe or portion.** Cancelling a
pending item or an order restores exactly what the snapshot recorded, even if the recipe or the
variant's portion was edited since. Cancelling a half-portion sale therefore returns half of
every ingredient even after the variant was switched to double.
pending item (directly or by cancelling the order) restores exactly what the snapshot recorded,
even if the recipe or the variant's portion was edited since. Cancelling a half-portion sale
therefore returns half of every ingredient even after the variant was switched to double.
4. **Void after preparation is physical waste.** Items voided while in progress, ready, or
completed restore neither supplies nor product stock. Only a `pending` cancel restores.
5. **Refunds never restore supplies**, matching product inventory behaviour.
Expand All @@ -328,7 +327,7 @@ later. Snapshots keep historical cancellations correct under recipe edits, mirro

**Enforced by:** `applySupplyStockChange()` in `main/services/supplies.ts`, which does not clamp;
`buildRecipeSnapshot()` and `applyRecipeSnapshot()` in `main/services/recipes.ts`; the order
creation, item append, item cancel and item restore paths in `main/routes/orders.ts`. Restore acts
creation, item append, order and item cancel, and item restore paths in `main/routes/orders.ts`. Restore acts
only on a `cancelled` item, re-deducts its inventory and recipe components, and returns it to
`pending`; a `voided` item is never restored. The portion comes from
`resolveOrderItemVariant()` in `main/services/product-variants.ts`, the same server-side resolution
Expand Down
14 changes: 10 additions & 4 deletions docs/reference/roles-and-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,16 @@ session, and the closing actor is recorded on the closure row. The route is
where the stored session and the actor are read together. This is a context policy, not a
permission — see [the authentication and authorization page](../architecture/authentication-and-authorization.md).

**Order cancellation.** Cashiers can cancel a whole order while it is pending. If the order or any
item has advanced to `preparing` or later, an owner or manager approval PIN is required. The rule
turns on stored order and item status, not on whether a KDS screen is open; printing a kitchen
ticket does not advance the status.
**Order cancellation and completion overrides.** Staff holding `orders.status.update` can cancel a
whole order without a PIN only while the order and all of its items are `pending` and no bill holds
collected funds. If the order or any item has advanced to `preparing` or later, or if any bill holds
recorded collected payment, an active owner or manager approval PIN (`override_pin`, with
`orders.status.update` and `orders.item.cancel`) is required. Completing an order blocked by
unsettled payable bills, an unbilled positive total, or undelivered kitchen items (when
`require_kitchen_delivered_before_settlement` applies) likewise requires an explicit active owner or
manager PIN (with `payments.take` when overriding unsettled payment), even when the logged-in user
is already an owner or manager. Full safeguard, restock, and audit rules are in
[the Orders API reference](api.md#orders).

**Owner visibility.** The permission editor itself is rendered only for an authenticated user
holding `authorization.manage` — an active owner, always. The API enforces this independently;
Expand Down
Loading
Loading