# Module 4 — Order Management (POS Core)

> **KB version:** 2.0 · **Code baseline:** `19ed3d9` · **Last reviewed:** 2026-11-03
> **Epic:** EP-TBD · **Status:** Pending PO review
> **Pending PO review:** kitchen `operation_status` code 3 renamed `pending` → `ready` internally, `pending` kept as an input alias (Draft · 2026-09-14/17); `order_items.used_delivery_app_price` flag added (2026-10-12); split-payment rejected when a delivery-app-price payment method is selected (2026-10-12); payment amounts must add up to the order's final price, single payments included (2026-10-26)
>   - order lines carry `type` (item/addon) and `parent_order_item_id`; add-ons sold with a meal are their own `addon` lines written right after the meal; standalone add-on-category (`ASP_CAT_1000`) items are `addon` lines with no parent
>   - request lines accept `ingredients[]` (recipe lines with status `default`, `replaced` or `removed`; `default` or left out = as in the recipe) and `addons[]`; validated against the item's recipe (removable flag, configured replacements) and the meal's catalog add-ons (`item_addons`)
>   - meal unit price includes the chosen replacements' `extra_charge`; add-on lines are priced on their own price; the `total_price` check includes extra charges and add-on lines; the payment-surcharge rate applies to both
>   - every line with a recipe gets a full recipe snapshot in `order_item_ingredients` (default/replaced/removed, names snapshotted)
>   - add-on lines attached to a meal are excluded from meals count / preparation time but counted in `count_of_items` when priced > 0; invoice prefixes them with "+ "
>   - order line output adds `order_item_id`, `type`, `parent_order_item_id`, `modifications[]`; lines ordered by line id
>   - recipe snapshot columns: `ingredient_id` = the recipe ingredient, `replaced_ingredient_id` = what was served; `modifications[]` uses the same keys
>   - an order that is `completed` with `operation_status` `ready` or `completed` is sent to CostControl (CC) by a queued after-commit job (3 tries, backoff 30/60/120 s, unique per order), checked after cashier and kitchen status changes; sent successfully at most once (a prior success row in `integration_logs` skips it)
>   - CC send is skipped (logged only) when the order is missing, not completed+ready, its branch has no `mapper_slug`, or no CC base URL is configured
>   - CC auth: login with configured username/password, bearer token cached (`token_ttl`, default 50 min); a 401 on order creation forces one re-login and resend
>   - CC payload: `order_id` = `order_number`, `branch_slug` = branch `mapper_slug`, `source`, `plan`, `total_price` = `final_price`, `items[]` = top-level lines with `ingredients[]` and `addons[]`; add-on lines with a parent go under their meal, parentless add-on lines are their own item
>   - CC `source`: external order → `branch-plan` when `external_orders_data.data.type` is `branch-plan`, else `standard`; POS order → `delivery-app` when any line has `used_delivery_app_price`, else `standard`; `plan` set only for `branch-plan`, as CC's lowercase underscore key (e.g. `maintain_weight`)
>   - CC add-on entries carry `ingredients: [{sku}]` — every line of the add-on's recipe snapshot as served (removed lines left out, a replaced line sends the replacing ingredient's sku)
> **Related flags:** TBD (requires As-Built Spec cross-reference, out of scope this pass) · **Open decisions:** TBD (requires As-Built Spec OD register, out of scope this pass)

---

## Module Overview
- **Purpose:** Create, price, pay, update, list, and print restaurant orders; manage financial status and kitchen preparation status.
- **Business Goal:** Accurate VAT-inclusive pricing with discounts, surcharges, split payments; a printable legal invoice; separation of cashier (money) and kitchen (preparation) concerns.
- **Main Actors:** Cashier (creates/updates orders, changes financial status, approves external orders), Chief (kitchen status), Platform (submits external orders — Module 5), Customer (implicit).
- **Dependencies:** Catalog (items/prices), Payment Types & Order Types, Promo Codes, Branch (taxability, invoice identity), Dashboard Settings (invoice header/footer, numbering), Noon integration (delivery), Storage/PDF (Snappy + wkhtmltopdf).
- **Entry Points (all POS, cashier/chief token):**
  - `GET /v1/pos/orders/list`, `GET /v1/pos/orders/kitchen-list`, `GET /v1/pos/orders/view/{id}`
  - `POST /v1/pos/orders/create`, `POST /v1/pos/orders/update`
  - `POST /v1/pos/orders/change-status` (cashier), `POST /v1/pos/orders/kitchen-change-status` (chief)
  - `POST /v1/pos/orders/print`
  - External-order endpoints — see Module 5.

## Business Workflow (order creation, step by step)
1. **POS boot:** cashier's app already knows the menu, order types with allowed payment methods, and the proposed next order number (Module 3 boot payload).
2. Cashier adds items (each line: item id, qty, optional `groupId` linking custom-meal components into one meal, optional `ingredients[]` listing recipe lines — `replaced` with a configured replacement, `removed`, or `default` (unchanged) — and optional `addons[]` `{id, qty}` sold with that meal).
3. Cashier sends `create` with: items, `total_price` (Σ (menu price + chosen replacements' extra charges) × qty, plus Σ add-on price × add-on qty — VAT-inclusive), `order_type` (id), `payments[]` (payment_type_id, amount, optional reference/transaction ids), optional `discount`, optional `note`, `order_number`, optional `is_paid`.
4. **Server re-derives the item total and rejects the request unless `total_price` matches exactly** (to the fill — `in:round(total,2)`). The client cannot invent prices.
5. **Surcharge ("additional fees"):** for each payment method flagged `has_additional_fee`, a surcharge of `additional_fee% × total_price` is added (e.g. card processing fee). An *effective rate* is derived and also baked into each order line's stored unit price so line items sum to the surcharged total.
6. **Tax math (branch is taxable):** `net = (total_price + fees) × 100/105` (strip the embedded 5% VAT from the VAT-inclusive items+surcharge), **then add the net delivery fee**: `net = (total_price + fees) × 100/105 + mobile_delivery_fee`. Non-taxable branch: `net = total_price + fees + mobile_delivery_fee`. *(Delivery fee is a VAT-exclusive amount and is NOT run through the 100/105 extraction.)*
7. **Discount** (optional): promo code takes precedence over a manual discount; `percentage` type → `net × pct/100`; `amount` type → flat value. Applied on the **net (pre-VAT)** figure — which now includes the delivery fee, so the discount reduces the fee too.
8. `after_discount = net − discount`; `vat = 5% × after_discount` if taxable else 0; `final_price = after_discount + vat`. *(The delivery fee is already inside `net`/`after_discount`, so VAT applies to it and it needs no separate term.)*
9. **Payment reconciliation:** the amounts on `payments[]` must add up to `final_price` within **0.01**, or the request is rejected before anything is written. The price is re-derived server-side from the same pricing pipeline, so the client cannot pay against a figure of its own.
10. **Order number assignment:** if the proposed number already exists among *open* orders (`day_closed=0`, `closed_at IS NULL`), the server instead assigns `max(open order_number) + 1`; otherwise the proposed number is used. When the day has no open orders, numbering restarts from setting `order_number_start + 1` (or 1).
11. **Status:** `is_approved = 0` (platform orders) → `inReview`/`inReview`; else `is_paid = 1` → `completed`, otherwise `active`; kitchen (`operation_status`) starts `inProgress` (or `inReview` for unapproved).
12. Order row is inserted; **payment rows** are written to `order_payments` (single payment → amount forced to `final_price`; split → amounts as provided); optional discount row; optional external-order data row; order lines written one by one (each meal line immediately followed by its add-on lines) with a **price snapshot** (menu price + replacement extra charges + per-line fee share) and a **recipe snapshot** per line.
13. A **thermal invoice PDF** (70×140 mm) is rendered (company header/logo/slogans from settings; branch display name, TRN, address, franchise name; cashier name; items; net/discount/subtotal/VAT/total; Dubai-time timestamp) and stored under `orders_pdf/{Y}/{m}/{d}/branch_{id}/…`; its URL is saved on the order and returned so the POS prints it.
14. Everything runs in one DB transaction; any failure rolls the whole order back.

**Order update workflow:** same pricing pipeline re-runs; existing payment rows are deleted and rewritten; all order lines are deleted (their add-on lines and recipe snapshots go with them by cascade) and rewritten; a **new** discount row is added; a fresh invoice PDF is generated; `payment_type_id` (legacy column) is set to the first payment's type. The payment amounts are reconciled against the recomputed final price the same way as on create.

**Status workflows:**
- Cashier `change-status` sets any of: `active, completed, cancelled, returned, inReview` (no transition restrictions).
- Chief `kitchen-change-status` sets any of: `inProgress, completed, ready, cancelled, inReview` (`pending` still accepted as an input alias for `ready`, 🚩 draft · 2026-09).
- If the order originated from a platform (`external_order_id` set), any status change triggers an after-commit **callback to the platform** (see Module 5).
- After every cashier or kitchen status change, if the order is now `completed` with kitchen status `ready` or `completed`, it is queued for sending to **CostControl** after commit (see CostControl rules below).

## Business Rules (exhaustive)
**Pricing / tax**
- VAT rate is a hardcoded **5%**; menu prices are always VAT-inclusive; VAT applies only if `branch.is_taxable`.
- Discounts are computed on the pre-VAT net → **VAT is charged on the discounted amount** (discount reduces tax).
- Payment surcharge is a percentage **of the whole order total**, per fee-bearing payment method; it increases the taxable base (fee is inside the 100/105 division → the surcharge itself is treated as VAT-inclusive).
- `mobile_delivery_fee` (platform orders only) is a **net/VAT-exclusive** amount added into the pre-discount net base (not divided by 105); the discount and the 5% VAT both apply on top of it (revised 2026-07-07 — see BL-11). It is therefore included in `price_before_discount`/`price_after_discount`, keeping gross ≥ net in reports.
- Rounding: monetary figures rounded to 2 dp for validation/printing; raw values stored unrounded (string columns).
- When the order's payment method has `enable_delivery_app_price` on and the item has a non-empty `delivery_app_price`, the line is charged at `delivery_app_price` instead of `price`; otherwise the normal `price` is used. Each order line records which source was actually used in `order_items.used_delivery_app_price` (boolean), independent of whether the payment method requested it — an item with no `delivery_app_price` set still records `false` even under a delivery-app-price-enabled payment method.

**Order lines (meals, replacements, add-ons)**
- A request line's `ingredients[]` lists recipe lines as `{ingredient_id, status: default|replaced|removed, replacement_ingredient_id}` (`replacement_ingredient_id` required when `replaced`). A `default` entry, or a recipe line left out, is served as in the recipe; every entry must still be a recipe line of the item and appear at most once per order line.
- A request line's `addons[]` lists add-ons sold with that meal as `{id, qty ≥ 1}`. The add-on qty is the absolute qty of the add-on line, not a per-meal multiplier.
- Meal unit price = item price (or `delivery_app_price` per the delivery-app-price rule) + the sum of the chosen replacements' `extra_charge`; the payment-surcharge fee rate is then applied to that unit price.
- Each add-on is written as its own order line, priced on the add-on item's own price (same delivery-app-price and fee-rate rules).
- Line `type`: an add-on sold with a meal is an `addon` line whose `parent_order_item_id` is the meal line, written immediately after it; a standalone line whose item is in the add-on menu category (`categories.code = ASP_CAT_1000` or below it) is an `addon` line with null parent; every other line is `item`.
- **Recipe snapshot:** every line whose item has a recipe gets one `order_item_ingredients` row per recipe line, even when nothing changed: `default` rows carry the original ingredient and recipe size; `replaced` rows carry the replacement ingredient, its size and its `extra_charge`; `removed` rows have a null `replaced_ingredient_id` and size 0. Ingredient names (`name_en`) are snapshotted, so later recipe or ingredient edits never change past orders.
- Order lines are returned ordered by line id, so add-on lines follow the meal they belong to.

**Validation (create/update)**
- `items` required, non-empty array; `items.*.id` must exist; `items.*.qty` required integer ≥ 1 (validated since 2026-07-07).
- `total_price` must equal the server-computed items total within ±0.01 (numeric tolerance since 2026-07-07; previously an exact string match that mis-rejected e.g. `100.50` vs `100.5`).
- `order_type` must be an existing order-type id.
- `payments` array required, min 1 entry; each needs an existing `payment_type_id` and `amount ≥ 0.01`; `reference_number`/`transaction_id` optional strings ≤ 255.
- The payment amounts must add up to the order's **final price** — the server-computed items total with surcharges and the delivery fee, less the discount, plus VAT — within ±0.01. Checked after every field rule passes, and reported as a validation error on the `payments` field: "The payment amounts (X) must add up to the order price (Y)." It applies to a single payment as much as to a split one, so an order can never be saved paid for less (or more) than it costs. The amounts are checked against the final price, **not** against `total_price` (the items total), which the separate `total_price` rule covers.
- Split payment (2+ entries in `payments`) is rejected on create/update when any of the selected payment types has `enable_delivery_app_price` on — a delivery-app-price payment method must be used alone, not combined with another payment method.
- Line customisation (checked only after all field rules pass): each changed ingredient must be in the item's recipe and changed at most once per line; `removed` only for recipe lines flagged `is_removable`; `replaced` only with one of that recipe line's configured replacements; each add-on must exist, be one the meal offers (`item_addons`), and not be listed twice on the same line.
- The `total_price` check includes replacement extra charges and add-on lines.
- Update also requires an existing order `id`.
- change-status: `id` exists + status within enum names.

**Numbering**
- Order numbers are **unique among open orders only**; they intentionally repeat across business days (receipt numbering resets at day close).
- The sequence base can be shifted via dashboard setting `order_number_start`.

**Payments**
- Split payment supported: N payment rows per order; each may carry gateway `reference_number`/`transaction_id`.
- Payment amounts are reconciled against the order's final price within ±0.01, on create and on update, whether the order is paid with one method or split across several. A mismatch is a validation error and the order is not written.
- A single payment is stored at exactly the final price, so that one payment row can never sit a cent away from `orders.final_price`. Split amounts are stored as collected.
- An internally built order request may leave a single payment's `amount` null, meaning "charge the whole order to this method"; the final price is filled in when the row is written. Platform/mobile orders are created this way (Module 5). A null amount is not a reconciliation mismatch; the POS endpoints require the amount.
- `orders.payment_type_id` is legacy: null on new orders, still populated on update (first payment type) for backward compatibility; the source of truth is `order_payments`.

**Listing**
- Cashier order list = open orders (`day_closed=0`, `closed_at IS NULL`) of the user's branch **paid with non-external payment types only** (platform orders are excluded and served by the external list).
- Kitchen list = open orders with `operation_status ∈ {inProgress, pending}` (two buckets), oldest-first.
- Order view/print by id (branch-scoped through global scope).

**Invoice content rules**
- `count_of_items` counts only lines with price > 0 (free custom components excluded); add-on lines count when priced > 0.
- Add-on lines are listed on the invoice prefixed "+ " (e.g. "+ Fries").
- Order number is printed only if setting `display_order_number` is on.
- Branch `display_name` overrides `name`; `trn`, `address`, `franchise_company_name` printed from the branch.
- Payment label: create → localized payment type names joined by " / "; update → "Name (amount)" joined by " + ".
- All invoice timestamps in Asia/Dubai formatted `d-m-Y h:i:s A`.

**CostControl (CC) hand-off** — CC consumes the stock of what was served.
- **Trigger:** an order is ready to send when its financial `status` is `completed` **and** its `operation_status` is `ready` or `completed`. The check runs after a cashier status change (`change-status`) and after a kitchen status change (`kitchen-change-status`), inside the status-change transaction; when it passes, a queued job is dispatched **after commit**.
- **Job:** 3 tries with backoff 30 s / 60 s / 120 s; unique per order id (only one send per order in flight, since `ready` and `completed` can both fire). A permanent failure is written to the `cost_control` log.
- **At most one successful send per order:** if `integration_logs` already holds a `success` row for the order's CC order-creation call, the job does nothing.
- **Skips (logged as a warning, nothing sent, no retry):** the order no longer exists; the order is not completed and ready when the job runs; the order's branch has no `mapper_slug`; no CC base URL is configured.
- **Auth:** `POST {base}/api/v1/platform/login` with the configured username/password returns a bearer token, cached for `token_ttl` minutes (default 50). If order creation answers **401**, the cached token is dropped, the service logs in again once and resends. Any other non-2xx response (or a failed login / missing token) throws, so the job retries.
- **Payload** (`POST {base}/api/v1/platform/orders/order-creation`):
  - `order_id` = the order's `order_number` (as a string); `branch_slug` = the branch's `mapper_slug`; `source`; `plan`; `total_price` = `final_price` rounded to 2 dp.
  - `items[]` = the order's top-level lines (no `parent_order_item_id`), in line-id order, each `{sku (item sku), qty, price (= order_items.item_price), ingredients[], addons[]}`.
  - `ingredients[]` = one entry per recipe-snapshot row: `{sku (recipe ingredient sku), replacement_sku (the served ingredient's sku only when replaced, else null), is_remove (true when removed)}`.
  - `addons[]` = the add-on lines whose parent is that meal: `{sku, qty, price (= item_price), ingredients[]}`, where `ingredients[]` = `{sku}` for every line of the add-on's recipe snapshot as served — `removed` lines are left out, a `replaced` line sends the replacing ingredient's sku, `default` lines send the recipe ingredient's sku; an add-on with no recipe sends an empty list.
  - An add-on line with no parent (standalone add-on) is sent as its own entry in `items[]`.
- **Source:**
  - External order (`external_order_id` set) → `branch-plan` when `external_orders_data.data.type` is `branch-plan`, otherwise `standard`.
  - POS order → `delivery-app` when any of its lines has `used_delivery_app_price = true`, otherwise `standard`.
- **Plan:** set only when the source is `branch-plan`, taken from `external_orders_data.data.plan` and sent as CC's plan key — lowercase words joined by underscores (`Helpers::planKey`; `Maintain Weight`, `maintain-weight`, `maintain weight` and `maintain_weight` all become `maintain_weight`); `null` otherwise. POS display output still shows the plan name in Title Case; only the CC payload uses the key.
- **Logging:** every CC request (login and order creation) is written to the `cost_control` daily log and as an `integration_logs` row of the order (provider `cost_control`, job attempt, url, headers, body, response status/body, `success`/`failed`/`error`); the password, bearer token and returned tokens are masked.
- CC base URL: production uses the CC UAT host, every other environment the CC QC host.

**🚩 SPRINT 1 · 09-08-2026 — Preparation time & measured timings (2026-08-07 → 08-11)**
- Every order carries a **kitchen preparation-time estimate**, rendered as `order_time` in `MM:00` form (20 minutes → `"20:00"`). It equals the **slowest menu item's `preparation_time` on the ticket**, scaled by how many meals the order holds (free/price-0 components still count toward the meal count; add-on lines attached to a meal neither count nor affect the slowest item; standalone lines count as usual): **1–3 meals ×1, 4–8 meals ×2, 9+ meals ×2.5**, rounded up. It is **recomputed on every edit** — editing an order silently replaces the estimate the kitchen already saw. Orders created before 2026-08-08 have no value and read `00:00`. It is an estimate only; nothing measures actual prep time against it.
- Built on the order-status activity log (below): **`inProgressTime`** is the wall-clock time the order last moved to kitchen status `inProgress` (null until the kitchen starts it), and **`actual_order_time`** is the duration of the latest `inProgress → pending` kitchen transition — i.e. how long the kitchen *actually* took, as opposed to the `order_time` estimate above. Both appear on the POS order list, the kitchen list, and the external-orders tab.

## Hidden Rules
- **A "completed" order is simply a paid order** — completion is financial, not operational; kitchen state lives in `operation_status`.
- **Any status can jump to any status** — nothing prevents `cancelled → completed` or reopening a returned order.
- Once a day is closed, orders vanish from every POS list and are **frozen (rule added 2026-07-07):** cashier status change, kitchen status change, and external approve/reject all reject day-closed orders with "This order belongs to a closed day and its status can no longer be changed." Live-day transitions remain unrestricted by explicit business decision (2026-07-07).
- If two fee-bearing payment methods are used in one split, **each adds its full percentage of the entire order total** (fees stack on the full amount, not on each method's share).
- The stored per-line `item_price` is the *fee-inflated* price — reports that multiply price × qty (DSR items report) implicitly include payment surcharges.
- `orderUpdate` preserves `mobile_delivery_fee` (fixed 2026-07-07): it reuses the request value or the order's existing fee and re-applies it to VAT and `final_price` exactly as create does. *(Previously it dropped the fee.)*
- `orderUpdate` **replaces** the discount on each edit (fixed 2026-07-07): the prior discount row is deleted before a new one is written, so the `hasOne` relation reflects the current discount and updating with no discount clears it. *(Previously it appended rows and showed the oldest.)*
- Update does **not** change `status`/`is_paid` — an active order stays active after editing.
- The old repository (`App\Http\Repositories\Pos\OrderRepository`) and `MobileOrderController` are **dead legacy code** — the bound implementation is `App\Services\Pos\OrderService` + `App\Repositories\Pos\OrderRepository`.
- An order's items are stored with price snapshots — later menu price changes never affect existing orders.
- Mobile/platform orders map their lines to plain `{id, qty}` lines, so they get all-`default` recipe snapshots, and any `ASP_CAT_1000` item in them becomes a standalone `addon` line.
- On update, make-your-own-meal names/prices are not taken from the request (create only); stored line prices always come from the catalog.
- Because order-number collision checking looks only at open orders, **numbers from a prior (closed) day are reused by design.**
- 🚩 **SPRINT 1 · 09-08-2026 (2026-07-13):** An `amount` discount larger than the order net now **floors the subtotal at 0** (0 subtotal ⇒ 0 VAT ⇒ 0 total) instead of producing a negative invoice. The entered discount value is still recorded as given, so **a reported discount can exceed the sales it was applied to** in DSR/reports.
- 🚩 **SPRINT 1 · 09-08-2026 (2026-07-27):** Every order status change is now audited. An `OrderObserver` writes to the new `active_order_status_activity_logs` table: two rows at order creation (one per status axis — financial `status` and `operation_status`), and one row per later change of either axis, recording `from_status`, `to_status`, **`time_between`** (how long the previous status lasted) and `updated_by`. This is the mechanism behind `inProgressTime`/`actual_order_time` above. **Limits:** `updated_by` is null for queue/console-driven writes, and `time_between` is a TIME column — a status lasting over 24 hours does not record truthfully.
- 🚩 **SPRINT 1 · 09-08-2026:** Order-number allocation is now locked against concurrent creates — the race that could previously produce duplicate open order numbers under load is closed.

## Decision Tree (order create — full)
```
IF any field rule fails → 422
IF customisation invalid (ingredient not in recipe / changed twice / not removable /
   replacement not offered / add-on not offered or listed twice) → 422
IF total_price != Σ((item.price + Σ replacement.extra_charge) × qty) + Σ(addon.price × addon.qty) → 422
IF any payment_type_id invalid → 422

fees := Σ over payments [pt.has_additional_fee ? pt.additional_fee% × total_price : 0]
feeRate := total_price > 0 ? fees/total_price×100 : 0

IF branch.is_taxable → net := (total_price + fees) × 100/105 + mobile_delivery_fee
ELSE               → net := total_price + fees + mobile_delivery_fee
   (mobile_delivery_fee is net/VAT-exclusive — added after the 100/105 extraction)

IF discount present:
    IF discount.promo_code_id present → type/amount from promo code
    ELSE                              → type/amount from request
    IF type == 'percentage' → discount_value := net × amount/100
    ELSE                    → discount_value := amount
ELSE discount_value := 0

after := net − discount_value          (net already includes mobile_delivery_fee)
vat := branch.is_taxable ? 5% × after : 0
final := after + vat

IF request.order_number exists among OPEN orders → order_number := next number
ELSE → order_number := request.order_number

IF is_approved == 0            → status := inReview,  operation := inReview
ELSE IF is_paid == 1           → status := completed, operation := inProgress
ELSE                           → status := active,    operation := inProgress

INSERT order; INSERT payments (single ⇒ amount := final)
IF external_data → INSERT external_orders_data
IF discount      → INSERT discount row
FOR each request line:
    unit := item.price + Σ chosen replacement.extra_charge
    INSERT order_items (type := addon IF item in ASP_CAT_1000 tree ELSE item, parent := null,
                        price := unit + feeRate share, qty, group_id)
    INSERT order_item_ingredients: one row per recipe line (default / replaced / removed)
    FOR each addon of the line:
        INSERT order_items (type := addon, parent := meal line,
                            price := addon.price + feeRate share, qty := addon.qty)
        INSERT its recipe snapshot (all default)
RENDER invoice PDF → save URL
COMMIT (any error ⇒ full rollback)
```

## Database Understanding
- `orders` — the financial document. UUID primary key. Carries frozen money figures (`price_before_discount` = net, `price_after_discount`, `vat`, `final_price`, `additional_fees`, `mobile_delivery_fee`), dual state (`status` money / `operation_status` kitchen, both stored as tinyint enum codes), day-closing bookkeeping (`day_closed`, `closed_at`, `related_date`), audit (`created_by`, `branch_id`), platform linkage (`external_order_id`), and `invoice_url`.
- `order_items` — line snapshot (one row per line, with its own id): `type` enum(`item`, `addon`, default `item`), `parent_order_item_id` (self-FK to the meal line, nullable, cascade delete — deleting a meal line deletes its add-on lines), index (`order_id`, `type`), qty, unit price at sale time (fee-inclusive), `group_id` to bundle custom-meal components into one visual meal, `used_delivery_app_price` (boolean) recording whether that line's price snapshot came from the item's `delivery_app_price` rather than its normal `price`.
- `order_item_ingredients` — the final recipe of each order line, one row per recipe line: `order_item_id` (cascade), `status` enum(`default`, `replaced`, `removed`), `ingredient_id` (the recipe ingredient), `replaced_ingredient_id` (what was served: the recipe ingredient when default, the replacement when replaced, null when removed), `size` (amount served, for kitchen and stock), `extra_charge` (replacement price snapshot), `original_name`, `ingredient_name`; unique (`order_item_id`, `ingredient_id`). Stores ids and names so past orders are immune to recipe edits.
- `order_payments` — normalized tender records (one per method used); replaces `orders.payment_type_id`; enables split payment and per-method settlement reporting.
- `discounts` — one row per applied discount (type, entered value, computed value, optional promo code). Exists apart from orders so promo usage can be analyzed.
- `integration_logs` — one row per outbound integration request of an order; CostControl rows use provider `cost_control` and carry the job `attempt`. A `success` row for the CC order-creation URL is what marks an order as already sent.
- `order_item_attributes` — per-line modifiers (only written by the dead legacy flow today).
- Why UUIDs on orders: order ids are printed/shared with external platforms; non-guessable ids avoid enumeration.
- 🚩 **SPRINT 1 · 09-08-2026:** `active_order_status_activity_logs` — one row per status transition (both `status` and `operation_status` axes), written by `OrderObserver`. Exists purely as an audit trail; it's what powers `inProgressTime`/`actual_order_time` and any future SLA reporting on kitchen speed.
- 🚩 **SPRINT 1 · 09-08-2026:** `order_items.custom_name` — display-name override for MYOM lines (see Module 3); when present it replaces the item name on every screen and document the line appears on.

## State Machine
**Financial status (`orders.status`; stored codes in parentheses):**
`active (1)`, `completed (2)`, `cancelled (3)`, `returned (4)`, `inReview (5)`

| From \ To | active | completed | cancelled | returned | inReview |
|---|---|---|---|---|---|
| *creation* | is_paid=0 | is_paid=1 | — | — | platform order (is_approved=0) |
| any status | ✅ | ✅ | ✅ | ✅ | ✅ |

- **Allowed:** every transition *while the order's day is open* — free transitions are accepted business policy (product-owner decision, 2026-07-07). Trigger: cashier with `order.change_status`; platform approval sets `completed`; rejection sets `cancelled`.
- **Forbidden (enforced since 2026-07-07):** any status change (cashier, kitchen, or approve/reject) on an order whose day is closed (`day_closed = 1` or `closed_at` set) — rejected with an explicit error.

**Kitchen status (`orders.operation_status`):**
`inProgress (1)`, `completed (2)`, `ready (3)`, `cancelled (4)`, `inReview (5)`
- Entry: `inProgress` (normal) or `inReview` (unapproved platform order). Any→any via chief. Kitchen board shows only `inProgress` + `ready`.
- 🚩 **Draft · 2026-09-14/17:** the internal name/value for code 3 was renamed from `pending` to `ready` (`OperationStatusEnum::PENDING` → `::READY`, stored value `'pending'` → `'ready'`). `kitchen-change-status` still **accepts `pending` as an input alias** and silently remaps it to `ready` before it reaches the service — so external API callers can keep sending `pending`, but everything internal (DB values, the kitchen-list query, the `inProgress→pending` activity-log lookup behind `actual_order_time` in A2) now reads `ready`.

**Day-close state:** `open (day_closed=0, closed_at NULL)` → `closed (day_closed=1, closed_at set, related_date set)`; one-way; triggered by cashier day-close (Module 6). Closed orders leave all POS lists.

## APIs
| Endpoint | Purpose | Input | Output | Validation | Errors | Side effects |
|---|---|---|---|---|---|---|
| `GET /pos/orders/list` | Open in-house orders | token | orders + lines (each with `order_item_id`, `type`, `parent_order_item_id`, `modifications[]`) + payments + discount | perm `order.read` | 500 | — |
| `GET /pos/orders/kitchen-list` | Kitchen board | token | inprogress + pending buckets | perm `order.read` | 500 | — |
| `GET /pos/orders/view/{id}` | Order detail | id | order | perm `order.read` | invalid-id msg | — |
| `POST /pos/orders/create` | New order | items (with optional `ingredients[]`, `addons[]`), total_price, order_type, payments[], discount?, note?, order_number, is_paid? | invoice PDF URL | see rules | 422; 500 rollback | order+payments+discount+lines+add-on lines+recipe snapshots+PDF |
| `POST /pos/orders/update` | Edit order | id + same as create | invoice PDF URL | + split sum == total ±0.01 | 422; 500 rollback | rewrites payments/lines/snapshots, new discount row, new PDF |
| `POST /pos/orders/change-status` | Cashier status | id, status name | success | status ∈ enum | 500 | platform callback if external; CostControl send if completed + ready/completed |
| `POST /pos/orders/kitchen-change-status` | Chief status | id, status name | success | status ∈ enum | 500 | platform callback if external; CostControl send if completed + ready/completed |
| `POST /pos/orders/print` | Reprint | id | invoice URL | id exists | 500 | — |

## Edge Cases
- Item deleted from catalog after being added client-side → `items.*.id exists` fails → order rejected.
- `total_price` float formatting: validation compares against `round(total,2)` as a string via `in:` — client must send the identically-rounded value (e.g. `100.5` vs `100.50` mismatch risk).
- Zero-priced order (all free items): `feeRate` guard avoids division by zero; VAT 0; final 0; single payment row of 0 recorded (despite `min:0.01` — the override bypasses the client-side minimum).
- ~~Concurrent creates can race on order-number checking (no lock) → duplicate open numbers possible under load.~~ **Fixed 🚩 SPRINT 1 · 09-08-2026** — allocation is now locked.
- Update of an order from a *different* branch: global BranchScope hides it → `Order::find` returns null → 500 on null property access.
- Order line output (`OrderItemResource`) carries `order_item_id`, `type`, `parent_order_item_id`, and `modifications[]` — only the `replaced`/`removed` snapshot rows (`status`, `ingredient_id`, `original_name`, `replaced_ingredient_id`, `ingredient_name`, `size`, `extra_charge`). Order list, external list, view and kitchen list load all snapshots in one query.
- A line whose item has no recipe gets no snapshot rows; its `modifications` is empty.
- PDF generation failure (wkhtmltopdf missing) rolls back the whole order — printing infrastructure is a hard dependency of *selling*.

## Assumptions
- One receipt printer paper: 70 mm wide.
- Exactly one guest per order (guest counts in DSR = order counts).
- Client always sends the correct current menu total (server verifies).
- Cashier device is trusted for qty values.

## TODOs / Potential bugs (business impact)
1. ~~**`OrderService::createOrder` line-fee bug:** `$additional_fees_per_item` used `$itemPrice` *before* it was assigned (previous loop iteration's value; first iteration → 0/undefined).~~ **Fixed 2026-07-07** — now computes from `$itemExist->price`, matching the update flow.
2. ~~**`update` response bug:** controller returned `['pdf' => $urls[0], 'image' => $urls[1]]` but the service returns only one URL → every successful update responded as a 500 exception after committing.~~ **Fixed 2026-07-07** — controller now returns `['pdf' => $urls[0]]` with an "updated successfully" message.
3. ~~**Duplicate discount rows** on every update.~~ **Fixed 2026-07-07** — discount is deleted then recreated (BL-1).
4. ~~**`mobile_delivery_fee` lost on update.**~~ **Fixed 2026-07-07** — carried through the update pricing pipeline (BL-2).
5. ~~No payment-amount sum validation at create.~~ **Fixed** — ±0.01 reconciliation of the payment amounts against the final price on create and update, single payments included (BL-3).
6. Status-change on day-closed orders is now blocked (2026-07-07); free live-day transitions remain accepted policy. **Order *edits* (`orders/update`) on a day-closed order are still not guarded.**
7. `is_subscription` on payment types exists in DB but nothing reads it — unfinished subscription-payment feature.
8. Legacy dead code: `oldstore`, old repositories, `MobileOrderController` — should be deleted to prevent confusion.
9. ~~Qty is unvalidated (negative/zero qty accepted).~~ **Fixed 2026-07-07** — `items.*.qty => required|integer|min:1` (BL-4).

## Open Questions
- Intended meaning of `returned` vs `cancelled` financially (refund handling is entirely out of system scope — no refund records exist).
- Should surcharges apply per payment-share for split payments?
- Replacement `extra_charge` raises the meal price — this reverses the October decision-log rule that replacements never change price. Needs PO confirmation.
- Add-on lines are validated against the meal's catalog add-ons (`item_addons`), which contradicts the decision that add-ons are linked in the cart only. Needs PO confirmation.
- Should reports/DSR split meals vs add-ons by `order_items.type`? Not implemented — reports treat all lines alike.
- CostControl receives `order_number` as `order_id`, but order numbers restart after day close, so the same `order_id` can reach CC for different orders. Is that unique enough on CC's side (e.g. together with `branch_slug` and date), or should the order UUID be sent?
- The shape of the CC login response is assumed: the token is read from `data.token`, `token`, `data.access_token` or `access_token`, whichever is present. Needs confirming against CC's API contract.
- Is the invoice a legal UAE tax invoice requirement (TRN display toggle suggests yes) — who owns compliance for the 100/105 rounding method?

## Evidence
- Order lines, customisation checks, pricing, recipe snapshot: `app/Services/Pos/OrderLineService.php`; called from `app/Services/Pos/OrderService.php` (`createOrder`, `orderUpdate`).
- Line validation and `total_price` check: `app/Http/Requests/Concerns/ValidatesOrderLines.php`, `app/Http/Requests/OrderStoreRequest.php`, `app/Http/Requests/OrderUpdateRequest.php`.
- Price computation (surcharges, delivery fee, discount, VAT, final price) and the payment-amount reconciliation: `app/Services/Pos/OrderPricingService.php` (`breakdown`, `finalPrice`, `paymentsTotal`, `paymentsCoverPrice`), used by `app/Http/Requests/Concerns/ValidatesOrderPayments.php` for validation and by `app/Services/Pos/OrderService.php` (`createOrder`, `orderUpdate`, `assertPaymentsMatchPrice`, `recordOrderPayments`) when the order is written. Unit coverage: `tests/Unit/OrderPricingServiceTest.php`.
- Schema: `database/migrations/2026_10_22_000005_add_recipe_snapshot_to_order_items.php`, `database/migrations/2026_10_25_000002_rename_ingredient_columns_on_order_item_ingredients.php`; models `app/Models/OrderItem.php`, `app/Models/OrderItemIngredient.php`, `app/Models/Order.php` (`orderItems` pivot, `loadLineIngredients`).
- Output and loading: `app/Http/Resources/OrderItemResource.php`, `app/Repositories/Pos/OrderRepository.php`.
- CostControl hand-off: `app/Services/Pos/CostControlService.php` (`isReadyToSend`, `sendOrder`, `buildPayload`, `source`, `token`), `app/Jobs/SendOrderToCostControlJob.php`, `app/Services/Pos/OrderService.php` (`sendToCostControlWhenReady`, called from `orderChangeStatus` and `orderKitchenChangeStatus`), `config/services.php` (`cost_control`), `config/logging.php` (`cost_control` channel), `database/migrations/2026_11_03_000001_add_attempt_to_integration_logs_table.php`.

---
