Ctrl K
ComunidadGuías…

docs/architecture/order-lifecycle.md

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

Archivo original ↗Preguntar a KiBot
# Order lifecycle

An order is the server's record of a sale in progress. The renderer collects choices - cart lines, a
table, a discount, a tender - and submits them; the API resolves prices, tax, discounts, balances,
and settlement, and the renderer reads the result back. What a customer owes is never decided in the
renderer; the reasoning is in
[0002: backend-authoritative security and tax](../decisions/0002-backend-authoritative-security-and-tax.md).

```mermaid
flowchart LR
  Cart["Cart in the renderer"] -->|"POST /api/held-orders"| Held["held_orders row<br/>tables.status = held"]
  Cart -->|"POST /api/orders"| Order["orders row<br/>status = pending"]
  Held -->|"DELETE the hold, then restore cached cart"| Cart
  Order -->|"PATCH /discount, /items, /status"| Order
  Order -->|"POST /api/bills/generate"| Bill["bills row<br/>payment_status = unpaid"]
  Bill -->|"POST /api/bills/:id/payment(s)"| Settled["partial or paid"]
  Settled -->|"last unpaid bill settles; order is not terminal"| Done["orders.status = completed<br/>dine-in table released"]
  Settled -->|"POST /api/refunds"| Refund["refunds row<br/>bill partially_refunded or refunded"]
```

The endpoint tables, authorization gates, and rate limits are in the
[API reference](../reference/api.md); this page explains how the stages fit together and where each
boundary fails.

## Cart and held carts

A held cart is a snapshot of the renderer's cart, not an order. `POST /api/held-orders` stores it in
`held_orders` and marks the table `held`; nothing is priced, stock is not touched, and no kitchen
ticket exists. A table holds at most one cart: posting again for the same table replaces the stored
row, returning a new `ho-` id.

The POS resume path is [`frontend/src/store/held-orders.ts`](../../frontend/src/store/held-orders.ts)
plus the POS page at `frontend/src/app/(dashboard)/pos/page.tsx`: the store deletes the persisted
hold before returning its cached cart, which the page then loads into the renderer for checkout.
Order creation happens later. If checkout or order creation fails after the restore, the hold is
already gone and the cart remains only in renderer memory. The held-order API reference describes
the delete guard and table update.

Cart decisions travel with the hold as ids (`waivedChargeIds`, `optedInChargeIds`) and are handed to
the charges engine when the order is created, which stays authoritative about which of them apply.

## Creating the order

`POST /api/orders` in [`main/routes/orders.ts`](../../main/routes/orders.ts) is the pricing entry
point. It requires 1 to 200 items and a `type` of `dine_in`, `takeaway`, `delivery`, or `online`, and
accepts a table, customer, guest count, notes, manual charge amounts, cart charge choices, and the
delivery-only fields documented in the API reference.

Everything monetary is resolved server-side inside one transaction:

- The unit price comes from the catalog: `products.price`, or the variant price for an order with an
  `online_platform`. A client-sent price is never read.
- Quantity rules are checked per sale unit, including fractional and weight-precision products.
- Stock deduction and recipe depletion are written in the same transaction for immediate orders
  (scheduled orders defer stock and recipe depletion until kitchen release), so the order and its
  inventory effects commit together.
- Each line's tax is computed by the tax engine ([Taxation](taxation.md)) and stored on the line as
  `tax_amount`, `tax_breakdown`, `tax_snapshot`, and `tax_type`.
- The charges engine decides which configured charges apply, honouring the cart's waived and
  opted-in ids. Standard ids are projected onto the order's `packaging_charge` and `service_charge`
  columns; non-standard charges are added to the total. The snapshot rules are in
  [product invariants](../reference/product-invariants.md#applied-charge-snapshots-stay-with-existing-orders).
- The total is the discounted subtotal plus exclusive tax plus delivery, packaging, service, and
  other charges, rounded to the currency's decimal places. A total whose minor-unit
  value would leave the safe-integer range is rejected instead of being stored lossily.

Order numbers are allocated inside the transaction. Request replay follows the
[order API idempotency contract](../reference/api.md#orders).
The order starts `pending` with its totals snapshotted, and a `dine_in` order with a table marks the
table `occupied`.

## Scheduled orders and kitchen release

Takeaway and delivery orders in the main POS (desktop and browser) can defer kitchen release by
supplying `scheduled_for` ("Send to kitchen at") when `scheduled_orders_enabled` is enabled (`false`
by default, configurable in Settings > POS; rejected with `409 SCHEDULED_ORDERS_DISABLED` when
disabled). In the POS interface, kitchen scheduling creation controls are shown when
`scheduled_orders_enabled` is enabled, while the scheduled orders queue remains visible whenever
`scheduled_orders_enabled` is enabled or active scheduled/blocked orders exist. Scheduling is
rejected (`400`) on `dine_in` and `online` orders.

Scheduling separates kitchen release from order status via four columns on `orders`:

- `scheduled_for`: canonical UTC ISO instant (`YYYY-MM-DDTHH:MM:SS.sssZ`), or `NULL` for immediate
  orders; see [scheduled kitchen release](business-time.md#scheduled-kitchen-release) for store
  timezone entry and daylight-saving rules.
- `release_status`: `'scheduled'`, `'blocked'`, or `'released'` (default `'released'` for immediate
  orders).
- `released_at`: timestamp recorded when the order is released to the kitchen.
- `release_blocked_reason`: actionable message recorded when a release attempt fails validation.

Lifecycle rules before and at release:

- **Creation without early side effects.** Creating an order with `scheduled_for` snapshots all
  quoted items, unit prices, discounts, charges, taxes, and totals immediately, sets
  `release_status = 'scheduled'`, leaves product, variant, add-on, and recipe supply stock
  untouched, suppresses KDS notifications and automatic KOT printing, and excludes the order from
  KDS endpoints, kitchen feeds, customer-display tokens, and `POST /api/printers/print-kot` (`409`).
- **Due sweep and manual release.** [`main/services/scheduled-orders.ts`](../../main/services/scheduled-orders.ts)
  runs a server-owned due sweep every 10 seconds, on server startup, and after system resume
  (`powerMonitor` `resume`). The sweep releases due orders where `release_status = 'scheduled'` and
  `scheduled_for <= now`. Orders in `release_status = 'blocked'` are skipped by automatic sweeps and
  must be retried explicitly via `POST /api/orders/:id/release` after resolving the stock or catalog
  problem. If all existing bills are refunded, release is blocked with reason `refunded`; cancel the
  order and create a replacement if needed. Unexpected release errors during a sweep are isolated
  and transition that order to `release_status = 'blocked'` with an actionable message so sweeps do not stall.
- **Shared transactional release (`releaseScheduledOrder`).** Both automatic sweeps and manual
  release execute the same SQLite transaction with an atomic compare-and-check on `release_status`
  so concurrent sweeps and retries never deduct stock twice, plus an optional `checkPreconditions`
  hook for gating release on payment stage. At release time, every non-terminal item re-validates
  required catalog entries (active product, variant, add-on group rules, and add-ons) and deducts
  product, variant, and add-on stock plus the recipe then in effect (preserving negative recipe
  supply stock) while saving actual `inventory_deducted_quantity` and `recipe_snapshot` values on
  each line. Saved prices, discounts, charges, taxes, and totals are never repriced. If any tracked
  product, variant, or add-on stock is insufficient or a catalog entry is unusable, an inner
  savepoint rolls back partial deductions and atomically sets `release_status = 'blocked'` with
  `release_blocked_reason` (`release_blocked` in `order_audit_log`). On release, `release_status`
  becomes `'released'`, `released_at` is set, KDS updates are broadcast, and configured backend
  automatic KOT printing runs; an already-settled non-refunded order keeps `status = 'pending'` when
  KDS is enabled and active unserved items exist, and becomes `status = 'completed'` (recording
  `completed_at`) immediately when KDS is disabled or no active unserved items remain.
- **Prepayment, edit lock, rescheduling, and cancellation.** Full or partial payment is allowed
  before release without triggering early release, without kitchen-delivery blocking, and without
  marking `orders.status = 'completed'`. Item additions, edits, removals, customer changes, manual
  price overrides, discounts, and bill charge mutations are permitted on a scheduled order only
  before the first payment is recorded (`409` with `SCHEDULED_ORDER_PAID` once paid). Timing-only
  changes (`PATCH /api/orders/:id/schedule` or `POST /api/orders/:id/release`) remain allowed until
  release even after payment and record `schedule_updated` or `order_released` in `order_audit_log`.
  Rescheduling resets `release_status` to `'scheduled'` and clears `release_blocked_reason` for
  orders in `'scheduled'`, while preserving `release_status = 'blocked'` and its reason for held
  orders so only explicit manual release retries release. Cancelling a scheduled order before
  release never restores inventory because none was deducted; cancelling a scheduled order with
  collected funds requires refunding the collected amount in full through `/api/refunds` first (`409`
  with `REFUND_REQUIRED_BEFORE_CANCEL`), preserving bills and audit history.

## Multi-counter ordering (create here, pay there)

FloCafe supports owner-configured multi-counter ordering (`multi_counter_enabled`, disabled by
default). In this workflow, separate physical terminals act as ordering stations, payment cashiers,
or combined stations:

- **Named counters and roles.** The store owner defines counters with roles: `ordering` (create order
  and send to payment), `payment` (manage awaiting payment queue and take tender), or `combined`
  (both standard checkout and send to payment). Each POS terminal selects its active counter,
  remembered locally on that device scoped to the active store.
- **Workflow guidance without ownership gating.** Counter roles guide UI defaults without restricting
  underlying staff permissions: ordering counters default to order building and 'Send to payment';
  payment counters automatically open the shared payment queue and orient the cart CTA toward the
  queue; combined counters retain standard direct checkout while allowing explicit 'Send to payment'.
  Staff can view and act on any order across counters; orders are never ownership-gated.
- **Real orders and bills.** "Send to payment" creates real `orders` and `bills` records, never
  transient held-order JSON. Orders record `dispatch_policy = 'on_payment'` and start in
  `checkout_stage = 'awaiting_payment'`.
- **Supported order types.** Multi-counter checkout is supported for **takeaway and dine-in** orders.
  Delivery and online orders use normal checkout on ordering and combined counters and cannot use
  "Send to payment" (`400`). Takeaway can combine multi-counter checkout and scheduled kitchen
  release.
- **Feature availability.** New deferred tickets require `multi_counter_enabled = 'true'`; otherwise
  creation returns `409` with `MULTI_COUNTER_DISABLED`. Replays of committed requests and actions on
  existing deferred tickets remain available after the feature is disabled.
- **Kitchen suppression prior to release.** Orders with `dispatch_policy = 'on_payment'` set
  `release_status = 'scheduled'` and defer stock deduction, KDS display, active-order heartbeats, and
  automatic KOT printing until full payment is collected.
- **Shared transactional release gate.** When full payment across all bills settles:
  - If unscheduled, the order is atomically released to the kitchen via `releaseScheduledOrder`.
  - If scheduled for a future instant, `checkout_stage` transitions to `'paid'`, while
    `release_status` stays `'scheduled'` until the scheduled instant arrives (or manual release is
    triggered).
  - Manual release (`POST /api/orders/:id/release`) on an unpaid `on_payment` order fails with `409`
    (`ORDER_NOT_PAID`).
  - Prepayment does not bypass kitchen preparation (orders with unserved kitchen items keep
    `orders.status = 'pending'`) and does not clear occupied dine-in tables.
- **Paid-but-blocked tickets.** If catalog preconditions or inventory levels fail at release time
  after payment, the transaction atomically transitions `release_status = 'blocked'` with
  `release_blocked_reason` without reverting the committed payment. The ticket remains clearly
  visible in the payment queue for explicit staff retry after restocking or refund/cancellation.
- **Shared LAN queue and QR ticket identification.** Terminals retrieve tickets via
  `GET /api/orders/awaiting-payment` (cursor-paginated with counts) or lookup by ticket/order number
  via `GET /api/orders/by-ticket/:ticket`. Tickets display a human-readable number and QR code
  (`FLOCAFE-TICKET:<order_number>`), which carries zero authentication or payment authority, and can
  be printed as a thermal ticket slip (`POST /api/printers/print-payment-ticket`).

## Discounts

Three surfaces apply discounts, all gated by the discount permission for their scope and by the same
settings:

| Surface    | Endpoint                                       | Notes                                                                                                                                                                                          |
| ---------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Order      | `PATCH /api/orders/:id/discount`               | Percentage or flat amount against the item subtotal; split or refunded bills or discounts below collected payments return `409`, and completed or cancelled orders return `400`.               |
| Order item | `PATCH /api/orders/:id/items/:itemId/discount` | One line; split or refunded bills or discounts below collected payments return `409`, and completed or cancelled orders or cancelled, voided, void-adjustment, or refunded items return `400`. |
| Bill       | `POST /api/bills/:id/applyDiscount`            | Recomputes the bill and order; paid bills return `400`, split or refunded bills, completed or cancelled orders, and discounts below collected payments return `409`.                           |

Common rules, enforced in the handlers:

- `discount_mode` selects what is allowed (`percentage` by default; `none` disables discounts, `flat`
  rejects percentage discounts and vice versa).
- `discount_max_percentage` (default 25) and `discount_max_amount` (`0` means no limit) cap the
  value. A discount above the cap is a `400`.
- `discount_requires_approval` requires a manager PIN, and the approving user must itself hold the
  discount permission. When an approver is explicitly supplied (`approver_id`, `manager_id`, or
  `user_id`) on an order, item, or bill discount, PIN verification is restricted to that active user
  without falling back to scanning other managers.
- A percentage discount applies to the fresh item subtotal, and a flat discount is capped at that
  subtotal. Re-applying a discount never compounds on the previous one. When a later item addition,
  edit, discount, price override, cancellation, or restoration, or a customer update changes the
  order subtotal, an existing percentage order discount scales to the new subtotal while a flat
  order discount stays at its fixed amount, capped at the new subtotal.
- Order and bill discounts scale item taxes by the discounted share of the subtotal while keeping
  stored `order_items` taxes un-order-discounted; see
  [Taxation](taxation.md#where-the-amount-came-from).
- Applying a discount to an order that has an unpaid bill re-syncs the bill's totals and balance.
  A zero `discount_value` clears an order discount, and a zero `value` clears a bill discount,
  subject to the bill endpoint's discount-mode checks. The item endpoint requires a positive
  `discount_value`.
- A discount cannot reduce a bill's total below collected payments (`below_collected_funds`,
  `409`). Handlers re-read current order, bill, item, and payment rows inside the database
  transaction lock so that concurrent payments, item edits, or bill splits revalidate against fresh
  state.
- Applying an order or bill discount records an `order_discount_applied` action in
  `order_audit_log` with the authenticated actor, discount details, and the approving manager ID
  when a manager PIN override was used (the raw PIN is never stored).

## Manual item price overrides

The owner (only) can sell one line at a price other than its menu price, including `0` for a
complimentary item. The permission `orders.item.price_override` is owner-only and not configurable.

- `PATCH /api/orders/:id/items/:itemId/price` takes `price` and a required `reason`. The same
  `price_override: { price, reason }` object is accepted on a line when an order is created or items
  are added, and a request from any other role is a `403`.
- The line's `unit_price` becomes the override, rounded half-up to the currency's decimal places. The
  first menu price stays in `original_unit_price` across repeated overrides, and `price_overridden`,
  `price_override_reason`, `price_overridden_by`, and `price_overridden_at` record the change.
  Receipts and bills read `unit_price`, so they show the selling price.
- The backend recomputes item tax, order totals, and an open bill. An existing item discount stays,
  capped at the new line total.
- Overrides are allowed once a line has been sent to the kitchen, because only the price changes.
  They are refused (`409`) once a bill has payments, a split, or a refund, and (`400`) on completed or
  cancelled orders and cancelled or voided lines.
- In the POS the owner sees a price control on each cart line (also when adding items to an open
  order). The normalized override is part of the line's cart identity, so it never merges into a
  menu-price line, survives holding and resuming, and is quoted by `POST /api/tax/preview` through an
  optional per-item `price_override: { price }`. The placed order re-checks the owner permission, so a
  held cart with an override placed by another role is refused with `403`.
- A quantity edit keeps the override flag and menu price on the replacement line; replacing the line
  with a different product or variant starts at that product's menu price.
- Each override records an `item_price_overridden` action in `order_audit_log`.
- `GET /api/reports/price-exceptions` lists overridden, non-cancelled lines whose override time falls
  in the selected store-timezone date range, or in one cash shift's window when `shift_id` is given.
  The dashboard shows them as the price exceptions card with a shift selector.

## Bills

`POST /api/bills/generate` creates the order's bill, or, when a bill already exists in `unpaid`,
`partial`, or `partially_paid` status, re-syncs its totals, discount, tax, and charges from the
order and recomputes `balance` against `paid_amount`. A `paid`, `partially_refunded`, or `refunded`
bill (including a zero-total `paid` bill) is never rewritten by bill generation or order-to-bill
synchronization (`syncUnpaidBillsForOrder`), except for a zero-total `paid` split sibling during
split-bill reallocation. Bills snapshot subtotal, discount, tax, charges, `total`, `paid_amount`,
`balance`, and `payment_status`; payable rounding may add a `round_off` adjustment for packs that
define one.

Order mutations that change the customer (`PATCH /api/orders/:id/customer`) or convert a dine-in
order to takeaway (`PATCH /api/orders/:id/convert-to-takeaway`) re-sync open bills under the same
rules:

- `PATCH /api/orders/:id/customer` requires an active order (`400` on `completed` or `cancelled`),
  refuses changes on a paid scheduled order (`409` with `SCHEDULED_ORDER_PAID`) or once any bill has
  payments, a split, or a refund (`409`), re-evaluates
  non-terminal item taxes for the new customer against each item's saved `tax_snapshot` category,
  behavior, and pack version when taxes are enabled and the customer's tax context (B2B status or
  state code) changes, recomputes order and open-bill totals, and records `customer_updated` in
  `order_audit_log`.
- `PATCH /api/orders/:id/convert-to-takeaway` requires an active `dine_in` order (`400`), refuses
  split, paid, or refunded bills (`409`), re-resolves charges for `takeaway`, rejects conversions
  whose payable total would fall below already-collected payments (`409` with
  `below_collected_funds`), re-syncs open bills, releases the table to `available`, and records
  `converted_to_takeaway` in `order_audit_log`.

A split order can have multiple bills, each with its own payments and balance. An order completes
only once every bill of the order is settled; the API reference documents split eligibility and
limits.

## Payments

`POST /api/bills/:id/payment` takes one payment line; `POST /api/bills/:id/payments` takes up to 100
lines and applies them in a single transaction. Both require the payment permission and read the
same fields:

- `method`: `cash`, `card`, `wallet`, or an active configured method, resolved by name or explicitly
  as `{ method: 'custom', payment_method_id }`. An inactive or unknown method is a `400`.
- `amount`: a number in major units with no more decimal places than the currency allows. A
  single-line payment may omit it to settle the remaining balance; a multi-line batch requires every
  amount.
- `transaction_id`: an external reference scoped to its payment method. Reusing it on another bill is
  a `409`; repeating it for the same method twice in a new multi-line batch is also a `409`, while
  reusing it across methods in that batch is a `400`. Replaying an identical committed payment
  returns the bill without writing again. When `card_transaction_id_required` is `true` (default
  `false`, toggled under Settings → Payment methods), a built-in `card` line without one is a `400`
  with display code `card_transaction_id_required`; custom methods are unaffected. The POS payment
  and prepaid checkout dialogs then ask for the card terminal's number on the card line.
- `notes` (up to 1024 UTF-16 code units); serialized payment-line JSON is limited to 8192 UTF-16
  code units.
- `customer_id`: if neither the bill nor order has a customer, a non-wallet payment may attach one;
  if either already has an associated customer, a different id is a `400`. Wallet payments require a
  customer already associated with the bill or order, with enough points.
- `override_pin`: the manager override described below.
- `approver_id`: when supplying `override_pin`, the selected manager ID (`manager_id` and `user_id`
  remain compatibility aliases).

Non-cash lines can never exceed the balance (`400`). Cash is applied up to the amount still owed and
the difference is recorded as change, so an over-tendered bill settles and its ledger line carries
`tendered_amount` and `change_amount`. When a bill's remaining balance is zero, any positive payment
tender (cash or non-cash) is rejected (`400` with `payment_exceeds_balance`), while an omitted-amount
single payment line or zero-amount batch payment completes zero-balance settlement. Single and batch
zero-balance settlements bind any newly selected `customer_id` to the bill and award eligible
loyalty cashback once when `bill.total > 0`.

The optional `Idempotency-Key` is actor-scoped: the same key with the same normalized request
returns the committed response, and the same key with a different request is a `409`. Concurrent
in-flight requests sharing the same key and actor await the active attempt. Replay detection runs
before gate checks and manager PIN verification, so replaying a committed payment with a missing,
changed, or invalid `override_pin` succeeds without re-evaluating the PIN or creating duplicate
payments.

Two gates sit between a valid request and the write:

- **Shift gate.** When `require_open_shift` is on, a cash tender (including a custom method that
  resolves to cash) needs an open shift for the actor (see [business time](business-time.md#cash-closures)),
  or the request is a `409`. The check runs after replay detection, so a committed payment can be
  re-read with the same key after its shift closed. A cash ledger line records the open
  `cash_session_id`.
- **Kitchen-delivery gate.** When `require_kitchen_delivered_before_settlement` is on, `kds_enabled`
  is not `false`, and `billing_type` is not `prepaid`, paying while kitchen items are undelivered
  is a `409` with code `KITCHEN_ITEMS_UNDELIVERED` unless a manager PIN is supplied as `override_pin`.
  When an approver is explicitly supplied (`approver_id`, `manager_id`, or `user_id`), PIN
  verification is restricted to that active manager without falling back to scanning other managers.
  When all kitchen items are delivered, `override_pin` verification is bypassed. The override is
  recorded in the order audit log.

A paid bill refuses positive payment (`400` with `bill_already_paid`) and a refunded bill refuses
payment with `409`. A zero-balance payment retry on an already-settled bill returns `200`
idempotently without rejecting. The response carries the updated bill plus the wallet and loyalty
effects; `bill.payment_details` is the
settlement ledger, one entry per line with the applied amount, the requested amount, cash tendered
and change, the `cash_session_id`, and the timestamp. While a `paid` bill's cash shift and business
day remain open, the tender method on a recorded non-wallet line can be corrected without changing
its amount via `PATCH /api/bills/:id/payments/:index/method` (see [the Bills API reference](../reference/api.md#bills)).

## Settlement and completion

A bill moves `unpaid` to `partial` to `paid`; refunds move it to `partially_refunded` or `refunded`.
When a payment settles the last unpaid bill of an order with immediate dispatch
(`dispatch_policy = 'immediate'`), an already-released order becomes `completed` (unless it was
already completed or cancelled) and its dine-in table is released to `available`. Prepayment under
multi-counter ordering (`dispatch_policy = 'on_payment'`) preserves `orders.status = 'pending'` and
keeps occupied dine-in tables occupied: completing payment transitions `checkout_stage = 'paid'` and
triggers release to the kitchen (if unscheduled or due), but the order completes only after food
service concludes. An unreleased scheduled or multi-counter order remains `pending` at payment time,
then completes on release when KDS is disabled (or no active unserved items exist) or when its last
active kitchen item transitions to `served` while KDS is enabled, provided the order is not a dine-in
order with an active table and remains settled and non-refunded. A payment never resurrects a terminal
order.

When a discount, an order mutation (`syncUnpaidBillsForOrder`), or a `POST /api/bills/generate`
re-sync reduces the remaining balance of a partially paid bill to exactly zero (where collected funds
equal the updated payable total), auto-settlement to `paid` is gated by kitchen delivery status. When
`require_kitchen_delivered_before_settlement` is enabled, `kds_enabled` is not `false`, and
`billing_type` is not `prepaid`:

- If kitchen items remain undelivered (`pending`, `preparing`, or `ready`), auto-settlement is held:
  the discount applies and reduces the balance to zero, but the bill remains in `partial` status
  (`paid_at` is unset), the order stays active, and the dine-in table remains occupied. Supplying a
  valid manager PIN as `override_pin` (or via an explicit approver) overrides the gate, recording
  `kitchen_delivery_override` in `order_audit_log` and allowing auto-settlement to proceed immediately.
- Once undelivered kitchen items are served (or when settled with an `override_pin`), a zero-balance
  payment retry settles the bill to `paid`.

When all kitchen items are delivered (or kitchen-delivery enforcement does not apply), the bill
automatically settles to `paid` with its `paid_at` timestamp recorded. If all sibling bills are
settled, the order transitions to `completed` and releases its dine-in table to `available`. Loyalty
cashback is credited on collected net funds, and no phantom payment lines are written to
`bill.payment_details`. A zero-balance settlement retry on an already settled bill returns `200`
idempotently without rejecting. Discounts that would reduce the bill total below already-collected
payments are rejected (`409` with diagnostic code `below_collected_funds`). Discounts on orders with
zero collected payments leave the bill `unpaid` until explicitly settled.

Wallet lines debit the customer's loyalty ledger. A paid bill credits cashback when loyalty is
enabled, excluding the share funded by wallet points.

## Order status

`PATCH /api/orders/:id/status` advances the order through preparation, service, and terminal
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 endpoints are documented under
[order-item status](../reference/api.md#order-item-status).

When KDS is enabled (`kds_enabled` not `false`), [`reconcileActiveOrderStatusFromItems`](../../main/services/orders.ts)
automatically reconciles active (`pending`, `preparing`, `ready`, `served`) parent `orders.status`
inside the same transaction across all order types (`dine_in`, `takeaway`, `delivery`, `online`)
whenever KDS item statuses change (`PATCH /api/order-items/:id/status`, embedded and standalone
`PATCH /api/kds/items/:id/status`, and the KDS WebSocket `status_update` handler) or whenever active
order items are appended, cancelled, edited, refunded, or restored:

- Live items are non-terminal (`cancelled`, `voided`, `void_adjustment`, and `refunded` excluded)
  positive-quantity lines across the full order, unfiltered by caller station or category scope
  ([`shared/order-item-status.ts`](../../shared/order-item-status.ts)).
- Starting kitchen work (`preparing`, `ready`, `served`, or `completed` on any live item while
  unfinished items remain) promotes `pending` to `preparing` and sets `cooking_started_at`.
- Once every live item is `ready` (or mixed `ready` with already `served` or `completed` lines), the
  order transitions to `ready`, records `ready_at`, and clears `served_at`.
- Once every live item is `served` or `completed`, the order promotes to `served` and records
  `served_at`, removing handed-over active orders from `/customer-display`.
- Appending, restoring, editing to add pending work, or requeuing items to `pending` or `preparing`
  on a `ready` or `served` order demotes it back to `preparing` and clears stale `ready_at` and
  `served_at` timestamps; `preparing` never regresses to `pending` when a started item is voided
  while pending items remain.
- Each resulting parent status change records an actor-attributed `status_changed` entry
  (`source: 'kds_reconciliation'`) in `order_audit_log`.

Terminal `completed` and `cancelled` orders never transition `orders.status` via item-status
reconciliation. When KDS is enabled, upfront-paid orders created within the rolling 24-hour
`orders.created_at` window that settle to `completed` before kitchen fulfillment finishes remain in
`GET /api/orders?view=active` and `GET /api/orders?status=active` while positive-quantity live items
stay in `pending`, `preparing`, or `ready` (non-`completed` active orders are not age-bounded), and
`reconcileActiveOrderStatusFromItems` mirrors active-order timestamp transitions on `completed`
orders without changing `orders.status` (stamping `cooking_started_at = COALESCE(cooking_started_at, now)`
and clearing `ready_at` and `served_at` on `preparing`, clearing stale `ready_at` and `served_at` on
`pending`, recording `ready_at` and clearing `served_at` on `ready`, and recording
`served_at = COALESCE(served_at, now)` on `served`). Both `/customer-display` surfaces (`GET /api/orders?board_status=...` and
`GET /api/customer-display/feed`) filter by configured `order_types` (`['takeaway']` by default) and
`statuses` before pagination, projecting an eligible KDS-unfinished `completed` order's effective
board status (`pending`, `preparing`, or `ready`) from live item statuses via
`projectBoardStatusFromItems` and omitting the order once no live item remains unfinished (for
example once every remaining live item is `served` or `completed`). When KDS is disabled, automatic
item-to-order reconciliation stands down and manual `PATCH /api/orders/:id/status` transitions
govern preparation and service.

## Refunds

`POST /api/refunds` reverses part or all of a paid bill. Cash refunds follow the same shift gate as
cash payments. Refund approval tiers, manager approval window settings, and store-credit
requirements are in
[product invariants](../reference/product-invariants.md#refunds-and-staff-approval-pins).

The refund modal loads eligible staff before enabling PIN entry and submission. It retains an
eligible approver and entered PIN while the modal remains open; when the time tier makes the
selected approver ineligible, it clears the PIN and selects an eligible default when one is
available. The backend rechecks the approver and approval tier when the refund is submitted.

## Failure behaviour

All of the stages above run against the local SQLite database, so the whole lifecycle works offline.
Duplicate submissions are guarded by the `Idempotency-Key` header (orders, payments, refunds),
`transaction_id` uniqueness, and the database's own constraints. Mutations that update order,
held-order, bill, payment, or refund state use `withTxn` for their database writes. Bill
print-status updates run outside this transaction. A failure in a wrapped mutation rolls back its
database writes, so it leaves no partial order, bill, or ledger line behind. A closed business day
does not block new orders or new payments; it blocks cash-drawer
movements, and refunds follow the approval tiers described above.

## Verification

```sh
npm run test:integration-order-hold-payment   # hold, discount, tax, installments, gates, replay
npm run test:financial-safety-remediation     # payment replay idempotency, PIN decoupling, discount revalidation
npm run test:issue-terminal-state-guards      # terminal bill/order guards, takeaway/customer sync, flat discount caps
npm run test:issue-exact-balance-settlement    # auto-settle bill and complete order on exact-balance discount
npm run test:integration-happy                # order to payment, discount recalculated
npm run test:integration-payments             # payment methods and split lines
npm run test:integration-discount             # discount edge cases
npm run test:integration-tax                  # tax extraction and rounding
npm run test:held-orders                      # held-cart API and store
npm run test:cash-sessions                    # shift gate
npm run test:refunds                          # refund window and approvals
npm run test:kds-contract                     # KDS item-to-order status reconciliation across write paths
npm run test:customer-display-feed            # pickup board projection and board_status pagination
npm run test:scheduled-orders                 # scheduled orders, DST, sweep, stock hold, prepay/refund rules
```