<!-- GENERATED by scripts/build-kb.sh from docs/kb/ — DO NOT EDIT BY HAND -->

# Dinex — Business Knowledge Base (combined)

> Generated from the per-module files listed in docs/kb/README.md. Edit those files, not this one.

---

# System Context

> **KB version:** 2.0 · **Code baseline:** `b71c156` · **Last reviewed:** 2026-10-26
> **Epic:** EP-TBD · **Status:** Pending PO review
> **Pending PO review:**
> - Order lines can be customised before being added (ingredient replacement with optional extra charge, ingredient removal, add-ons sold as their own order lines).
> - External platforms push catalog data (ingredients, items with recipes, replacements, add-ons) into Dinex, so the menu catalog is partly owned outside Dinex.
> **Related flags:** TBD (requires As-Built Spec cross-reference, out of scope this pass) · **Open decisions:** none

---

# Dinex — Business Knowledge Base

> **Purpose of this document:** Single source of truth for the business logic of the Dinex restaurant POS platform, reverse-engineered from the codebase (Laravel backend, API-only).
> It is written for Product Owners, Business Analysts, and new engineers joining after the original developers left.
> Every rule below was inferred from actual code (controllers, services, repositories, validations, migrations, seeders, config). Nothing is invented; anything uncertain is listed under *Open Questions*.

> **Provenance:** The whole knowledge base is reverse-engineered from the Dinex codebase. Each module file's own header carries the commit it was read against (`Code baseline`) and the date it was last reviewed; dated history of what changed and why lives in `docs/KB_CHANGELOG.md`.

---

## System Context (read this first)

**Dinex is a multi-branch restaurant Point-of-Sale (POS) and back-office system for a UAE restaurant business** (the seeded brand is "Asparagus"). It consists of:

| Surface | Consumers | Route file |
|---|---|---|
| **POS API** (`/api/v1/pos/...`) | Cashier & Kitchen (chef) touch-screen apps in branches | `routes/api/v1/pos.php` |
| **Dashboard API** (`/api/v1/dashboard/...`) | Head-office back office (admins) | `routes/api/v1/dashboard.php` |
| **Auth API** (`/api/v1/auth/...`, `/api/v1/platform/...`) | All clients + external platforms | `routes/api/v1/auth.php` |
| **Marketplace webhooks** (`/api/v1/webhooks/noon/...`) | Noon delivery fleet | `routes/marketPlaceWebhooks.php` |

**Core business flows:**
1. Cashier builds an order from a menu (categories/items), applies discounts/promo codes, takes payment (possibly split across payment methods), and prints a thermal invoice (PDF). Each menu line can be customised before it is added — a recipe ingredient can be swapped for a configured alternative (which may carry an extra charge) or removed where the recipe allows it, and add-ons can be sold alongside a meal as order lines of their own (see Modules 3 and 4).
2. A kitchen screen ("chief" role) tracks preparation status separately from the financial status.
3. At end of business day, the cashier "closes the day": a Daily Sales Report (DSR) is generated, the order-number sequence resets, and orders are frozen for reporting.
4. External orders arrive from the **Asparagus Mobile App** (a "Platform") via an authenticated platform API; the cashier approves or rejects them; approved *delivery* orders are handed to **Noon** (third-party delivery fleet) and tracked via webhooks.
5. Head office monitors sales, discounts, tax, payments, and branch performance via reports and charts.
6. Authenticated external platforms also push **catalog** data into Dinex — ingredients, and items with their recipes, ingredient replacements and add-ons — so the menu catalog is only partly owned inside Dinex; CostControl and the mobile app are upstream owners of parts of it (see Modules 3 and 5).

**Money model:** All amounts are implicitly **AED** (UAE). VAT is **5%**, and menu prices are always **VAT-inclusive**. Time is business-relevant in **Asia/Dubai** timezone.

**A "business day" is not a calendar day.** It runs from the first open order until the cashier closes the day (see Module 6). All reporting pivots on `related_date` — the date the business day *opened*.

---


---

# Module 1 — Authentication, Roles & Permissions

> **KB version:** 2.0 · **Code baseline:** `b71c156` · **Last reviewed:** 2026-10-26
> **Epic:** EP-TBD · **Status:** Pending PO review
> **Pending PO review:**
> - Platform-token-only item push routes: the four `items/mobile/*` and `items/cc/*` push/simulate endpoints carry no role or permission middleware, so any valid platform token can write the whole catalog.
> - Ingredient-category CRUD gate: all five endpoints (read included) demand the full `ingredients.read` + `create` + `update` + `delete` set, so a read-only ingredient role is locked out of even listing categories.
> **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:** Control who can access the Dashboard, the POS, and the platform (machine-to-machine) API.
- **Business Goal:** Segregate head-office staff, branch staff, kitchen staff, and external platforms; enforce single-session tokens per user.
- **Main Actors:** `superadmin`, `admin` (head office); `cashier`, `chief` (branch); `Platform` (external system, e.g. Asparagus Mobile App); Guest (unauthenticated POS client before login).
- **Dependencies:** Spatie roles/permissions package, custom token tables (`oauth_user_tokens`, `platform_tokens`), custom middleware (`auth_api`, `guest_api`, `api.role`, `api.permission`, `auth.platform`).
- **Entry Points:**
  - `POST /api/v1/auth/dashboard/login`
  - `POST /api/v1/auth/pos/login`
  - `POST /api/v1/auth/logout`
  - `POST /api/v1/platform/login`, `POST /api/v1/platform/refresh`, `GET /api/v1/platform/me`

## Business Workflow
1. A user submits email + password to the dashboard or POS login endpoint.
2. The system checks the user exists and the password is correct.
3. The user's **first assigned role** is checked against the allowed roles for that surface:
   - Dashboard login allows only `superadmin` or `admin`.
   - POS login allows only `cashier` or `chief`.
4. If the role does not match, the login is rejected with HTTP 403 ("You are not authorized to login here") — even with correct credentials.
5. On success, **all previous tokens of that user are deleted** (single active session), and a new opaque 80-character token is issued, stored as a SHA-256 hash, **valid for exactly 1 day**.
6. Every subsequent request carries `Authorization: Bearer <token>`. Middleware resolves the token → user → **the user's branch is attached to the request** (this drives branch scoping everywhere else).
7. Role middleware (`api.role:cashier|chief`) and permission middleware (`api.permission:order.create`) gate each route. Permission checks use **all permissions of the user's first role**.
8. Logout deletes all the user's tokens and invalidates the session.

**Platform (machine) authentication:**
1. Platform submits `username` + `password` to `/platform/login`.
2. On success all still-active platform tokens are **revoked** (not deleted) and a new 80-char token is issued (SHA-256 hashed, 1-day expiry).
3. `/platform/refresh` rotates the token: the presented valid token is revoked and a fresh one is returned. A **legacy fallback** exists: if the presented value is an old-style encrypted token ID, it is accepted once, deleted, and replaced with a new-style token.
4. On platform-authenticated requests, `Auth` is set to the platform's **related user** (a seeded cashier account, e.g. "Asparagus Mobile Cashier"), so platform orders are attributed to that cashier and that cashier's branch rules apply.

**Guest access:** The pre-login POS screen fetches the branch/user list via `guest_api` middleware — a static shared key (`app.guest_key`, hardcoded in `config/app.php`) that the frontend sends encrypted; the backend decrypts and compares.

## Business Rules
- Dashboard is restricted to roles `superadmin`, `admin`.
- POS is restricted to roles `cashier`, `chief`.
- A user has effectively **one role** (only `roles[0]` is ever checked).
- Tokens expire after **1 day** (users and platforms alike).
- Logging in **kills all other sessions** of the same user (tokens deleted). Platform login kills other platform sessions (tokens revoked).
- Password minimum length: 8 characters (login validation and user create/update).
- Dashboard login requires the email to already exist in `users` (validation `exists:users,email`); POS login does not pre-check existence.
- Permission matrix (from `config/permission.php`):
  - **superadmin / admin:** full create/read/update/delete on user, item, payment_types, category, order (incl. change_status), promo_code, dsr, reports, ingredients, settings, order_types.
  - **cashier:** read-only on payment_types, promo_code, item, category, order_types; full CRUD + change_status on order; full CRUD on dsr.
  - **chief:** read-only on payment_types, promo_code, item, category, order_types; full CRUD + change_status on order. **No DSR access.**
- Permission names are `<model>.<action>` (e.g. `order.change_status`); actions are `create, read, update, delete, change_status`.
- The two gates read their lists differently: `api.role:a|b` passes when the user's first role is **any one** of the listed roles, while `api.permission:x|y` passes only when the user's role holds **every** permission in the list (the middleware loops the list and 403s on the first one missing).
- Route-level gate for ingredient categories: all five endpoints — `GET dashboard/ingredient-categories/list`, `GET .../view/{id}`, `POST .../create`, `PUT .../update`, `POST .../delete` — share one controller-wide gate of `api.role:admin|superadmin` plus `api.permission:ingredients.read|ingredients.create|ingredients.update|ingredients.delete`. There is no per-verb split: reading a category demands the same four permissions as deleting one. The ingredient endpoints themselves (`dashboard/ingredients/*`) carry the identical combined gate.
- The platform item-push routes are gated by a valid platform token alone. Each of `POST /v1/pos/items/mobile/check-mapper-slugs`, `POST /v1/pos/items/mobile/items-creation`, `POST /v1/pos/items/cc/item-creation` and `POST /v1/pos/items/cc/item-creation-simulate` sits in a route group whose only middleware is `cors` + `auth.platform`, and the backing controller actions (`CheckMapperSlugs`, `ItemCreationPlatform`, `ItemCreationSimulateCC`) are explicitly excluded from the controller's `api.role:cashier|chief` + `api.permission:item.read` middleware. No role and no permission is checked on them, and the platform's identity is not checked either: every authenticated platform may push or simulate items across the whole catalog, whichever platform it is. The acting user recorded for `created_by` is still the platform's `related_user`.
- Failed token lookup, malformed header, or missing header always answers HTTP 401 with code `auth-001`.
- Platform request identification: the `me` endpoint returns the platform record (password hidden).

## Hidden Rules
- **If a user is given two roles, only the first is honored** — the second role's permissions are invisible to the system.
- **Correct credentials + wrong surface = hard 403**, and (side effect) the just-created session is immediately logged out server-side.
- Because login deletes prior tokens, **two devices cannot share one cashier account simultaneously**; the second login silently kicks the first device.
- The guest key is a **hardcoded constant in source control** — anyone with repo access can mint guest tokens.
- Platform tokens are *revoked* (audit trail retained); user tokens are *deleted* (no audit trail).
- `AuthApiMiddleware` sets `request->branch` from the user's branch — **all branch-scoped behavior downstream implicitly trusts the user record's `branch_id`**, never a client-provided branch.
- User "delete" is a **hard delete** (the `SoftDeletes` trait is imported into the model but never used).
- Superadmin users are invisible in the dashboard user list and user detail endpoints (filtered out).
- New users are always created `is_active = 1`; there is **no code path that deactivates a user or checks `is_active`**.
- `CustomRoleMiddleware`/`CustomPermissionMiddleware` resolve the acting user via `$request->user()` (Laravel's real authenticated-user accessor), so `api.role`/`api.permission` do enforce on platform-authenticated routes as well — the platform's `related_user` is the identity they check. That enforcement only reaches routes that actually carry the middleware, and the item-push routes do not: `items/mobile/check-mapper-slugs`, `items/mobile/items-creation`, `items/cc/item-creation` and `items/cc/item-creation-simulate` are excluded from it, so no role or permission gate applies to them.
- **Any platform credential is effectively a catalog-write credential.** Because the item-push routes check nothing beyond `auth.platform`, a leaked or over-shared platform username/password — or a platform provisioned for an unrelated integration — can create, overwrite and simulate menu items for every branch's catalog. The blast radius of a platform token is therefore much wider than the "one platform impersonates one cashier" model suggests.
- The combined ingredient-category gate is an **all-four-permissions** check, not an any-one check: a role holding only `ingredients.read` is refused on *every* ingredient-category endpoint, listing and viewing included. Today only `admin` and `superadmin` reach these routes and `config/permission.php` grants both the full `create,read,update,delete` set, so the gate passes in practice — but introducing any read-only ingredient role would lock it out of reading categories rather than merely out of writing them.

## Decision Tree
```
LOGIN (dashboard or POS):
IF email not found (dashboard: fails validation 400) → reject
ELSE IF password wrong → 400/401 "Invalid credential"
ELSE IF user.roles[0].name NOT IN allowed-roles-for-surface
     → Auth::logout(); 403 "You are not authorized to login here"
ELSE
     → delete all existing tokens of user
     → create token (expiry = now + 1 day)
     → return token + user

AUTHENTICATED REQUEST:
IF Authorization header missing/malformed → 401
ELSE IF token hash not found OR revoked OR expired → 401
ELSE IF user of token no longer exists → 401
ELSE attach user + user.branch to request; continue
   IF route has api.role and user.roles[0].name not in role list → 403
   ELSE IF route has api.permission and any required permission missing from role → 403
   ELSE proceed to controller

PLATFORM REFRESH:
IF presented token valid & unrevoked & unexpired
     → revoke it, issue new token
ELSE IF value decrypts to a legacy token ID that exists
     → delete legacy token, issue new token
ELSE → 401 "Invalid or expired token"
```

## Database Understanding
- `users` — staff members; `branch_id` ties each staff member to exactly one branch. This link is *the* mechanism that scopes POS activity to a branch.
- `oauth_user_tokens` (`Token` model) — opaque bearer tokens for staff; exists so tokens can be expired/limited per-user independent of Laravel Sanctum (Sanctum is installed but effectively unused for these APIs).
- `platforms` — registered external systems (name, username, password, slug, related_user). The **slug** binds a platform to config in `config/services.php` (e.g. `asparagus_mobile_app`), used for callbacks.
- `platform_tokens` — bearer tokens for platforms, with `revoked_at` audit.
- Spatie tables (`roles`, `permissions`, `role_has_permissions`, `model_has_roles`) — role/permission matrix seeded from `config/permission.php`.
- `platforms.related_user` → `users.id`: **every platform impersonates a staff user**, so downstream logic (branch scoping, created_by) works without special-casing platforms.

## State Machine
Token lifecycle: `issued → (expired after 1 day | deleted-on-new-login | deleted-on-logout)`. Platform tokens: `issued → revoked (on new login/refresh) | expired`.
No other states in this module.

## APIs
| Endpoint | Purpose | Input | Output | Validation | Errors | Side effects |
|---|---|---|---|---|---|---|
| `POST /v1/auth/dashboard/login` | Head-office login | email, password | token + user | email required & exists; password ≥ 8 | 400 invalid; 403 wrong role | Deletes prior tokens |
| `POST /v1/auth/pos/login` | Branch staff login | email, password | token + user | email required; password ≥ 8 | 401 invalid; 403 wrong role | Deletes prior tokens |
| `POST /v1/auth/logout` | End session | token (header) | success msg | must be authenticated | 500 on error | Deletes tokens, invalidates session |
| `POST /v1/platform/login` | Platform login | username, password | token + platform | username exists in platforms | 400 validation; 401 invalid | Revokes prior active tokens |
| `POST /v1/platform/refresh` | Rotate platform token | Authorization header | new token | — | 400 no token; 401 invalid | Revokes/deletes presented token |
| `GET /v1/platform/me` | Identify platform | Authorization header | platform record | auth.platform | 401 | none |
| `GET /v1/pos/branches/listWithUsers` | Pre-login branch/user picker | guest key | branches (id, name, display_name) + users | guest_api key check | 401 | none |

## Edge Cases
- Login while another device is active: the other device's next request gets 401 with no explanation.
- Token presented exactly at expiry instant: `expire_date > now()` — an expired-by-a-second token fails.
- Platform legacy token replay: works exactly once (token row deleted after first refresh).
- A user whose role was removed after login: role middleware will throw (`roles[0]` on empty collection → exception → likely 500), effectively locking them out.
- `AuthApiMiddleware` wraps everything in try/catch returning 401 — any internal error during auth **masquerades as "Not authorized"**.

## Assumptions
- One user = one branch = one role.
- Platforms are few and provisioned manually via seeder (`PlatformSeeder`), not via any admin UI/API.
- The frontend knows how to encrypt the guest key with the app key (shared Laravel APP_KEY).

## TODOs / Technical Debt (business impact)
- `is_active` on users is written but never enforced → no way to suspend staff without deleting them.
- `ResponseMessageEnum::list()` references a misspelled undefined constant (`UNAUTHRAIZED`) → calling it fatals; localized message lists are effectively broken.
- Guest key hardcoded in the repo → rotate & move to env.
- Sanctum installed and `auth:sanctum` route exists (`GET /api/user`) but is orphaned from the real token system.
- Commented-out `last_used_at` updates → no session-activity telemetry.

## Open Questions
- Is single-session-per-user intentional policy or a side effect? (It prevents a cashier and a manager sharing an account — likely intentional but undocumented.)
- Should `chief` really have `order.create/update/delete` (config grants it) when routes only expose kitchen status change to chiefs?
- Is the ingredient-category gate meant to be per-verb (read for list/view, create/update/delete for the writes) rather than one all-four check on every endpoint? Equivalently: should a read-only ingredient permission grant — or at least not block — ingredient-category reads?
- Are the platform item-push endpoints intended to be open to every registered platform, or should they be restricted to the specific platform(s) that own the catalog source (by slug, role, or an `item.create`-style permission on the platform's `related_user`)?

---


---

# Module 2 — Branches & Branch Settings

> **KB version:** 2.0 · **Code baseline:** `b71c156` · **Last reviewed:** 2026-10-26
> **Epic:** EP-TBD · **Status:** Reviewed
> **Pending PO review:** none
> **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:** Model physical restaurant locations and their device/receipt configuration.
- **Business Goal:** Multi-branch operation with per-branch taxation, invoicing identity, order scoping, day-closing, and delivery-outlet mapping.
- **Main Actors:** Admins (see branch list), cashiers (operate within their branch), platforms (target a branch by slug).
- **Dependencies:** Users (`users.branch_id`), Orders (global `BranchScope`), Noon outlets, Branch settings.
- **Entry Points:**
  - `GET /v1/dashboard/branches/list` (⚠ unauthenticated — outside the auth group)
  - `GET /v1/pos/branches/listWithUsers` (guest key)
  - `GET /v1/pos/branch-settings/` and `PUT /v1/pos/branch-settings/update` (cashier/chief)

## Business Workflow
1. Branches are provisioned via seeders/DB (no admin CRUD API exists).
2. A staff user is assigned to a branch; on login, the branch rides along on every request.
3. A **global query scope** (`BranchScope`) automatically filters `Order` and `DailySalesReport` queries to the requesting user's branch — a cashier can never see another branch's orders.
4. Each branch stores its own receipt hardware config (printer IP, scanner threshold, receipt language) via the POS branch-settings screen, which also returns the global invoice-header settings so the POS can render receipts.

## Business Rules
- Branch fields and their business meaning:
  - `name` — internal name; `display_name` — the name printed on invoices (falls back to `name` if null).
  - `trn` — UAE Tax Registration Number printed on invoices.
  - `is_taxable` (default **1**) — master switch for the 5% VAT math on all orders of the branch (see Module 4).
  - `franchise_company_name` — legal entity name printed on invoices (nullable).
  - `address` — printed on invoices.
  - `mapper_slug` — the identifier external platforms use to target the branch when submitting orders.
  - `sort_order` — display order of branches in dashboard lists.
  - `noon_outlet_id` — links a branch to exactly one Noon outlet (unique, nullable). **A branch without this link cannot dispatch delivery orders to Noon.**
- Branch settings are one row per branch, upserted (`updateOrCreate`) — updating always overwrites all three fields (`printer_ip`, `scanner_threshold`, `receipt_lang`), even with nulls.
- The branch-settings "show" bundles global dashboard settings (`logo`, `company_name`, `invoice_header_slogan`, `invoice_footer_slogan`, `display_trn`) + the branch + its settings — this is the POS boot payload for receipt rendering.

## Hidden Rules
- **Branch scoping only applies when the request carries a branch** (i.e. authenticated staff requests). Console jobs, webhooks, and the unauthenticated test endpoints see ALL branches' orders.
- `UpdateBranchSetingRequest` has its validation rules commented out → **branch settings accept any input, including nulls that erase existing values.**
- The dashboard branch list endpoint was outside the auth middleware group (anyone could enumerate branches) — **fixed 2026-07-07**: it now sits inside the admin/superadmin auth group.
- A branch's "taxability" is fixed per branch, not per order or per item.
- 🚩 **SPRINT 1 · 09-08-2026 (2026-08-10):** Mobile orders are now pinned to the branch named by the request's `branch_slug`, not the branch of the platform's related user — `PlatformAuthMiddleware` resolves that branch, merges it into the request, and re-registers `BranchScope` pinned to it. **This fixed a real bug:** order numbering for mobile orders previously drew from the related user's branch (or from *every* branch at once when that user had none), producing wrong or duplicated order numbers on mobile orders (see Module 5, A8).
- 🚩 **SPRINT 1 · 09-08-2026:** `BranchScope` now treats `admin`/`superadmin` as branch-unscoped (head office is cross-branch by design — this was implicit before but is now an explicit rule in the scope itself), and an explicitly-passed branch id overrides the scope for any user. The POS pre-login picker (`listWithUsers`) now lists **branch staff only** — admins/superadmins are excluded from that list.

## Decision Tree
```
ON ANY Order/DSR QUERY:
IF request has 'branch' (set by auth middleware)
THEN add WHERE branch_id = user's branch
ELSE no branch filter (all branches visible)

ON BRANCH SETTINGS UPDATE:
Always upsert row for user's branch with the 3 provided values (no validation)
```

## Database Understanding
- `branches` — the physical locations; the anchor for tax policy, invoice identity, order numbering scope-of-day, and Noon dispatch.
- `branch_settings` — 1:1 device/receipt config; exists separately so operational hardware config doesn't pollute the branch's business identity.
- `noon_outlets` — Noon's own outlet registry mirrored locally (codes, bilingual names, geo, serviceability flags); a branch points at one outlet (`branches.noon_outlet_id`).

## State Machine
None (branches have no status field). "Branch open/closed" is *derived* in dashboard charts from the latest order's `closed_at` (see Module 7).

## APIs
| Endpoint | Purpose | Input | Output | Validation | Errors | Side effects |
|---|---|---|---|---|---|---|
| `GET /v1/dashboard/branches/list` | Branch dropdowns in dashboard | token | id + name, ordered by `sort_order` | admin/superadmin auth (since 2026-07-07) | 401/403/500 | none |
| `GET /v1/pos/branches/listWithUsers` | POS login screen | guest key | branches with their users | guest key | 401 | none |
| `GET /v1/pos/branch-settings/` | POS boot config | token | header settings + branch + settings | role cashier/chief | 401/403 | none |
| `PUT /v1/pos/branch-settings/update` | Save device config | printer_ip, scanner_threshold, receipt_lang | saved row | **none** | 500 | upsert row |

## Edge Cases
- Branch with `display_name = null` → invoice shows internal `name`.
- Deleting a `noon_outlet` sets `branches.noon_outlet_id` handling via FK `set null` on the *old* (pre-refactor) column; the current FK is declared on `branches` side without cascade rules — orphan risk.
- `BranchSettingController::update` error path calls `BranchSettingResource::collection($e->getMessage())` — passing a string to a resource collection → a *second* exception masks the first (error responses from this endpoint are unreliable).

## Assumptions
- Branch provisioning is an engineering task (seeder/SQL), intentionally not exposed to admins.
- Exactly one Noon outlet per branch and vice versa.

## TODOs
- ~~Add auth to the dashboard branch list~~ — done (2026-07-07).
- Restore validation on branch settings update.
- Provide admin CRUD for branches (currently impossible without DB access).

## Open Questions
- Is `scanner_threshold` a barcode scanner sensitivity? (Name only; nothing consumes it server-side.)
- Should `is_taxable=0` branches print TRN? (Currently they can, since TRN display is a global setting.)

---


---

# Module 3 — Menu Catalog (Categories, Items, Attributes, Ingredients)

> **KB version:** 2.0 · **Code baseline:** `19ed3d9` · **Last reviewed:** 2026-11-03
> **Epic:** EP-TBD · **Status:** Pending PO review
> **Pending PO review:**
> - Ingredient categories (`ingredient_categories`, type > category > sub category, leaf-only ingredients) and their dashboard CRUD with its tree/deletion guards
> - Ingredient `sku` unique (nullable) + `ingredient_category_id` must be a leaf category; category locked while the ingredient takes part in a replacement; ingredient delete blocked while used in a recipe or as a replacement
> - Recipe line columns on `item_ingredients`: decimal `size`, `is_removable`, `sort_order`, unique (item, ingredient)
> - Per-recipe-line replacements (`item_ingredient_replacements`) restricted to the original ingredient's leaf category, with own `size` and `extra_charge`
> - Add-ons = items of menu category `ASP_CAT_1000` (or below), linked per meal via `item_addons`; auto-removed when an item leaves that category; item delete blocked while it is an add-on
> - `items.has_replacement` / `items.has_addon` derived flags recomputed on every dashboard save
> - New item response shape (recipe lines with replacements, item-level `addons[]`), `GET /pos/items/view/{id}`, and the two lookups (`replacement-candidates`, `add-ons`)
> - `categories.code` platform lookup key
> - Ingredient `kg` unit
> - `POST /pos/ingredient/cc/ingredient-creation` endpoint, incl. unvalidated unit-code mapping risk
> - Platform item push (`POST /pos/items/mobile/items-creation`): open to any `auth.platform` platform, optional `slug` (written to `mapper_slug`), field validation, partial updates keyed on sku, recipe/add-ons by sku with dashboard composition rules, `created_by` = platform user
> - `items.created_from` = calling platform's slug (push) or `dashboard` (dashboard create), set on create, never changed on update
> - `POST /pos/items/cc/item-creation-simulate` dry run of the sized CostControl payload as the calling platform (validates and saves as the sized push, then rolls back — add-ons included; returns action, validated payload and item preview)
> - `GET /dashboard/items/add-ons` optional `category_id` filter (must be the add-on category or a category below it)
> - Recipe-line replacements are stored but not returned by the item resources (`ItemResource`, `DBItemResource`, and so every item list/view and the simulate preview)
> - `POST /pos/items/cc/item-creation`: sized CostControl item push — only the `ready_meals` size is stored, add-ons created or updated by sku from their full definition, `price` also written as `delivery_app_price`
> - Sized push add-on recipe: `addons[].sizes[].ingredients[] {sku, size > 0, removable?, sort_order?}` — all lines of the add-on's `ready_meals` size stored (not removable unless sent, sort_order defaults to list position, no replacements); empty list clears, no `ready_meals` size keeps
> **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:** Define what can be sold: the category tree, sellable items, item attributes, recipe ingredients and their classification, replacements and add-ons.
- **Business Goal:** Let head office manage a hierarchical menu including "make your own meal" (custom meal builder) flows and customisable meals (removable ingredients, replacements, add-ons), and give the POS fast structured access to it.
- **Main Actors:** Admin/superadmin (CRUD), cashier/chief (read), platforms (reference items by `mapper_slug`).
- **Dependencies:** Attachments (item images), Ingredients, Ingredient categories, Orders (items are attached to orders with a price snapshot).
- **Entry Points:**
  - Dashboard: `/v1/dashboard/categories/*`, `/v1/dashboard/items/*`, `/v1/dashboard/ingredients/*`, `/v1/dashboard/ingredient-categories/*`
  - POS: `/v1/pos/categories/*`, `/v1/pos/items/search`, `/v1/pos/items/view/{id}`

## Business Workflow
1. Admin creates categories, optionally linking each category to one or more **parent categories** (a many-to-many hierarchy via `parent_categories`, each link carrying a `parent_level`).
2. Admin maintains the ingredient category tree (type > category > sub category) and files each ingredient under a leaf category.
3. Admin creates items with price, image, categories (each link carries `category_level` and `is_custom_category`), an optional recipe (ingredient lines with size, removable flag and per-line replacements), optional add-ons, optional attributes, optional recipe text.
4. The POS home screen loads **main categories** (`is_not_main = 0`, level 0 or 2) together with order types + allowed payment types + the **next order number** (one round-trip boot).
5. Cashier drills into subcategories or item lists; for "make your own meal", the POS calls the multi-category item resolver to assemble component choices. To customise a meal, the POS loads `GET /pos/items/view/{id}` (recipe lines, replacements, add-ons).
6. Items chosen become order lines (Module 4).

## Business Rules
- **Category fields:**
  - `name` unique; `slug` auto-derived (`lowercase, spaces→dashes`).
  - `code` (string, unique, nullable) — a platform-facing category identifier, distinct from `slug`; the platform item push targets categories by this field (`category_code`).
  - `sort_order` **required and unique** across all categories (global manual ordering).
  - `level` — "0", "1", "2" hierarchy depth markers (stored as strings).
  - `has_sub` — category contains subcategories; `has_items` — category directly contains items.
  - `is_not_main` — hides the category from the POS main screen; **forced to 0 when level = 0** (root categories are always main).
  - A category may have multiple parents (used by the custom-meal structure).
- **Item fields:**
  - `name` required, `price` required numeric (**0 is allowed** — used for free components), at least one category required.
  - `slug` auto from name; `mapper_slug` unique nullable — the external-platform item identifier.
  - 🚩 **SPRINT 1 · 09-08-2026:** `sku` (unique, nullable, added 2026-07-29) is now **the identifier the mobile app orders by** — `mapper_slug` survives only as the key used to *match* an existing POS item during SKU sync, and for MYOM placeholders. `POST /pos/items/mobile/check-mapper-slugs` reconciles the two catalogs: for every platform item whose `mapper_slug` matches a POS item, it writes the platform's SKU onto that POS item and reports back what the POS holds.
  - 🚩 **SPRINT 1 · 09-08-2026 (2026-08-08):** `preparation_time` in minutes (default **10** — `Item::DEFAULT_PREPARATION_TIME`, dashboard create/update validation `1..1440`) — the kitchen-time input consumed by Module 4's per-order `order_time` calculation.
  - `is_custom` = 1 marks an item as a **custom-meal component** (excluded from normal category item listings).
  - Image optional (`png,jpg,svg,jpeg`); if none, default image `items/default-item.png` is assigned; "remove image" resets to the default.
  - Ingredients attach as recipe lines (`item_ingredients`) carrying `size` (amount of the ingredient in the item), `is_removable` and `sort_order` — see *Recipe lines* below.
  - Attributes: definitions live in `attributes` (`model_type = items`, type `single` or `multiple`); item values in `item_attributes`. Multiple-type attributes fan an item out into variants (e.g. sizes) when queried via `valueOfAttribute`.
- **Ingredient fields:** `name_en` & `name_ar` both required & unique; `unit` ∈ {pcs, gram, kg, ml, liter}; `sku` (string, nullable, **unique**); `ingredient_category_id` (nullable) — when given it must point to a **leaf** ingredient category.
  - Changing an ingredient's category is rejected (422) while the ingredient takes part in any replacement (as the original or as the replacement) — those replacements must be removed first.
  - Deleting an ingredient that is used in a recipe or as a replacement is rejected (422).
  - The ingredient resource returns `sku` and `ingredient_category_id`.
- **Ingredient categories** (`ingredient_categories`, separate from the menu `categories`): a tree mirrored from CostControl — level 0 *type* > level 1 *category* > level 2 *sub category* (`IngredientCategory::MAX_LEVEL = 2`). Fields: `cc_code` (CostControl id, nullable, unique), `parent_id`, `level`, `is_leaf`, `name_en`, `name_ar`.
  - `level` is derived from the parent (root = 0, child = parent + 1); a child cannot be created under a leaf category or below level 2.
  - The parent cannot be changed on update.
  - `is_leaf = 1` is rejected while the category has sub categories; `is_leaf = 0` is rejected while it holds ingredients. Only leaf categories hold ingredients.
  - A category can be deleted only when it has no sub categories and no ingredients (422 otherwise).
  - The list supports `leaf_only=1` (categories an ingredient can be filed under) and `parent_id` (one category's children).
  - CRUD is restricted to admin/superadmin holding any of the `ingredients.*` permissions.
- **POS reads:**
  - Main categories: `is_not_main=0 AND level IN (0,2)`.
  - Category item list: items of the category where `is_custom = 0` only.
  - Sub-category fetch returns 404-style message if a category has no children.
  - Item search: `LIKE %text%` on item name, across ALL items (no branch/category filter).
- **Custom-meal resolver** (`get-items-multiple`): given one non-custom (level-1) category and one custom (level-2) category, returns:
  - all level-2 categories **not** parented to the chosen level-1 category, containing custom items belonging to the chosen custom category, plus
  - the chosen category's own non-custom items (`is_custom_category = 0` links).
  This drives the "choose your components" screen.
- **Update semantics:** category parents and item categories/ingredients are diffed: links absent from the request are detached, new ones attached. **Sending an item update without `categories` detaches ALL of its categories** (same for ingredients — omitting `ingredients` clears the recipe). Changing an existing line's `size` updates it.
- **Recipe lines (dashboard create/update):** each line needs an existing ingredient `id` (no duplicates within the item — one line per ingredient) and a numeric `size` greater than 0 (stored with 3 decimals); `is_removable` (default false) marks an ingredient the customer may leave out; `sort_order` defaults to the line's position. The unit shown beside the quantity is the ingredient's own unit. Incomplete lines are rejected with 422. A line kept across saves keeps its id; its replacements are replaced by the submitted list; dropped lines take their replacements with them.
- **Replacements (per recipe line):** a recipe line may list ingredients that can be served instead of its ingredient. A replacement must share the original ingredient's **leaf ingredient category**; an ingredient with no category can have no replacements; an ingredient cannot replace itself; a replacement may not be listed twice on the same line. Each replacement carries its own `size` (defaults to the original line's size), `extra_charge` (AED, ≥ 0, default 0 — added to the meal line price when chosen) and `sort_order`.
- **Add-ons:** an add-on is any item linked to the menu category whose `code` is `ASP_CAT_1000` (`Item::ADDON_CATEGORY_CODE`) or to any category below it (all depths via `parent_categories`); there is no per-item add-on flag. A meal's add-ons are chosen only from such items, never the meal itself, no duplicates, at least one when `has_addons` is on; each carries a `sort_order`. When an item leaves the add-on category (dashboard item update or the platform item push), it is removed from every meal's add-ons and those meals' flags are recomputed. Deleting an item that is still an add-on of some meal is rejected (422).
- **Optional add-on group on update:** `has_addons` absent leaves the add-ons untouched, `0` clears them, `1` replaces them with the submitted `addons[]`.
- **Composition flags:** `items.has_replacement` (some recipe line has at least one replacement) and `items.has_addon` (the item has at least one add-on) are recomputed from the saved rows on every dashboard create/update; they are never set by hand.
- **Item composition response** (dashboard view/list, `GET /pos/items/view/{id}`): `has_replacement`, `has_addon`; `ingredients[]` = recipe lines (`id` = ingredient id, `item_ingredient_id`, `sku`, names, `unit`, `size`, `is_removable`, `sort_order`, `ingredient_category`, `replacements[]` with `id` = ingredient id, `replacement_id`, `sku`, names, `unit`, `size`, `extra_charge`, `sort_order`); `addons[]` at item level. POS menu endpoints that do not load the recipe keep the plain ingredient shape plus the two flags.
- **Lookup endpoints for the form:** replacement candidates are the other ingredients of the given ingredient's leaf category (none when it has no category), searchable by name or sku; add-on candidates are items of the add-on category, searchable by name, optionally excluding the meal being edited (returned as `id`, `sku`, `name`, `price`, `delivery_app_price`, `image`). Each lookup returns at most 50 results.
- Deletes are hard deletes. Menu categories have no referential checks; items are guarded only against being a live add-on; ingredients are guarded against recipe/replacement use (items already sold remain referenced by order lines via pivot rows; category/item deletion does not cascade to orders).

## Hidden Rules
- An item with `price = 0` **never counts toward the invoice "count of items"** (Module 4) — the count is a count of *paid* items.
- Items with `is_custom = 1` are only reachable through the custom-meal flow, never via normal category browsing or (in effect) direct sale.
- Because item search has no `is_custom` filter, **searching can surface custom components individually.**
- The item's `category_level` and `is_custom_category` pivot values (not the category's own fields) decide how the custom-meal resolver treats it.
- Category uniqueness of `sort_order` means inserting a category "between" two others requires renumbering others first (no auto-shift).
- DSR item reporting groups by the item's **first non-custom category** — items linked only to custom categories fall into a null category group.
- Add-on status is decided purely by menu-category membership: moving an item into or out of the `ASP_CAT_1000` subtree (on the dashboard or via the platform item push) changes whether it can be an add-on, and moving it out silently drops it from every meal that offered it.
- Replacement eligibility is decided by the ingredient category: re-filing an ingredient is blocked while it is in a replacement, so the same-leaf-category invariant cannot be broken after the fact.
- 🚩 **SPRINT 1 · 09-08-2026 (2026-08-13 → 08-15):** A "make your own meal" (MYOM) composed meal ordered from the mobile app arrives as lines carrying a `group` number. Each line is bound to a **seeded placeholder item `myom-N`** (matched by `mapper_slug`, N = the Nth composed meal within that group), but **the platform supplies the line's actual price and display name** — stored as `"<placeholder>-<line name>"` in the new column `order_items.custom_name` and shown instead of the item name everywhere the line appears. `groupId` keeps one meal's components together on the ticket. 10 placeholder items (`MYOM 1`…`MYOM 10`, price 0, category 49) are seeded by `TempMYOMMapping`. **Two consequences:** (1) the server-side "client cannot invent prices" guarantee (Module 4) does **not** hold for MYOM lines — the platform's price flows straight into the invoice and every revenue report; (2) a composed meal is **not traceable to its actual chosen components** — only the joined display name survives. This is explicitly a **temporary** mapping pending a real composed-meal model; do not delete/renumber the `myom-*` items, and a group with more than 10 meals is rejected.
- **Platform item push** (`POST /pos/items/mobile/items-creation`, handler `ItemCreationPlatform`): any platform authenticated by `auth.platform` (`PlatformAuthMiddleware`, which puts the token's platform on the request) may push items; no platform is refused. The calling platform's `slug` is the item's source (e.g. `asparagus_mobile_app`).
  - **Update-or-create keyed on `sku`.** A field left out or sent as `null` keeps the stored value; an empty `ingredients` / `addons` list clears the recipe / add-ons. Fields outside the payload contract are dropped.
  - **Field rules:** `sku` always required. `name`, `price` (numeric ≥ 0) and `category_code` (must exist in `categories.code`) are required only when creating. `slug` is optional for every platform; when sent it is stored as both `slug` and `mapper_slug` and must be unique on `items.mapper_slug`. Without a slug, a new item's `slug` is `Str::slug(name)` and `mapper_slug` stays null.
  - **Recipe and add-ons by SKU:** `ingredients[]` = `{sku (distinct, exists in ingredients.sku), size > 0, removable bool, sort_order int ≥ 0, replacements[] {sku (exists in ingredients.sku), size > 0?, extra_charge ≥ 0?, sort_order?}}`; `addons[]` = `{sku (distinct, exists in items.sku), sort_order?}`. SKUs are translated to ids and checked with the dashboard item form's composition rules (same-leaf-category replacements, add-ons only from the add-on category, no item as its own add-on); those errors are reported under `….sku` keys instead of `….id`.
  - **Create-only values:** `is_custom = 0`, `preparation_time = 10`, default image `items/default-item.png`, `created_by` = the user related to the calling platform (logged in by `auth.platform`), `created_from` = the calling platform's slug. An update never changes `created_by` or `created_from`.
  - A `category_code` syncs the item to exactly that one category; if the item ends up outside the add-on category it is removed from every meal's add-ons. Flags (`has_replacement`, `has_addon`) are recomputed.
  - **Response:** `{status, message}` with HTTP **200** whether it succeeds or fails (status is a body flag, not a status code); a validation failure adds `errors` (field → messages) and `message` is the first error. Everything runs in one transaction, so any failure stores nothing. Each push is logged on the `integration` channel tagged `[<platform slug> Item]` (e.g. `[asparagus_mobile_app Item]`).
  - **Re-posting an existing SKU overwrites a live, already-selling menu item** (name, price, category, recipe, add-ons) with no approval step.
- **Sized item push** (`POST /pos/items/cc/item-creation`, `ItemSizesCreationCC`): CostControl sends a meal with one recipe per meal size and its add-ons in full. Any `auth.platform` platform may call it; its slug is the source, as in the flat push.
  - **Only the `ready_meals` size is stored.** Its `ingredients[]` (same shape and rules as the flat push's `ingredients[]`, replacements included) become the meal's recipe; every other size (`maintain_weight`, `lose_weight`, …) and `size_type` are accepted and ignored. Without a `ready_meals` size the stored recipe is kept as it is; a `ready_meals` size with an empty `ingredients` list clears it.
  - **Add-ons are create-or-update by `sku`.** Each `addons[]` entry `{sku, name?, price?, category_code?, sort_order?, sizes[] {size, ingredients: [{sku, size, removable?, sort_order?}]}}` is saved as its own item first, through the flat push rules (an unknown sku is created and then needs name, price and a `category_code` in the add-on category; a known sku only has its sent fields updated). Its recipe is all the ingredients of its `ready_meals` size: each line needs a `sku` and a `size` > 0, is not removable unless `removable` is sent true, and takes `sort_order` from the payload or else its position in the list; add-on recipe lines carry no replacements. An empty `ingredients` list on the add-on's `ready_meals` size clears its recipe; an add-on without a `ready_meals` size keeps its stored recipe. The meal then offers exactly the listed add-ons (an empty list clears them; leaving `addons` out keeps them).
  - **`price` is also the delivery-app price** — for the meal and for each add-on, `delivery_app_price` is set to the sent `price`.
  - Errors are reported on the sized payload's own keys (e.g. `sizes.1.ingredients.0.sku`, `addons.0.sizes.1.ingredients.1.sku`). Same response shape, single transaction and `integration` logging (tagged `[<platform slug> Item Sizes]`) as the flat push.
- **Sized item push dry run** (`POST /pos/items/cc/item-creation-simulate`, `ItemCreationSimulateCC`): takes the sized payload; any `auth.platform` platform may call it and it runs as that platform (its slug is what `created_from` would get). The payload is validated and saved exactly as the sized push saves it — add-ons included — then everything is rolled back, so add-ons it would create are not stored either. Success returns `{status:true, message:"payload is valid, nothing was stored", action: create|update, payload: <validated sized payload, ready to POST to items/cc/item-creation>, item: <ItemResource preview of the meal incl. recipe lines, add-ons, flags>}`; failure returns the same error shape as the sized push. Logged tagged `[<platform slug> Item Sizes Simulate]`.
- **Recipe-line replacements are not returned by the item resources.** They are still stored, validated and used for pricing and order snapshots, and `has_replacement` is still returned, but each recipe line in `ItemResource` / `DBItemResource` carries no `replacements[]` — so the POS item endpoints, the dashboard item list/view and the simulate preview all omit them.
- The platform item endpoints (and `check-mapper-slugs`) are excluded from the POS item controller's `cashier|chief` role and `item.read` permission middleware; they are guarded only by `auth.platform`.

## Decision Tree
```
POS MAIN SCREEN:
Return categories WHERE is_not_main = 0 AND level IN (0,2)
+ all order types with their allowed payment types
+ next order number (see Module 4 numbering)

CATEGORY CREATE:
IF level == 0 → is_not_main := 0
ELSE is_not_main := (request.is_not_main == 1 ? 1 : 0)
IF parents provided → create parent links with parent_level

ITEM CREATE:
create item (is_custom = request.is_custom == 1 ? 1 : 0)
FOR each defined item attribute present in request:
   IF type == multiple → one item_attributes row per value
   ELSE → single row
IF image uploaded → store; ELSE → default image
attach categories (with category_level, is_custom_category)
save recipe lines (size, is_removable, sort_order)
FOR each line's replacements:
   replacement == original → 422
   listed twice on the line → 422
   original has no ingredient category → 422
   replacement's leaf category != original's → 422
   ELSE store (size := given ?? line size, extra_charge := given ?? 0)
IF has_addons == 1 → every add-on in ASP_CAT_1000 or below, not the item itself → store rows
recompute has_replacement / has_addon

ITEM UPDATE:
only provided scalar fields change
categories: diff-sync (absent ⇒ detach all)
ingredients: sync recipe lines, kept lines keep their id, replacements replaced (absent ⇒ clear recipe)
has_addons: absent ⇒ untouched, 0 ⇒ clear, 1 ⇒ replace
item no longer in the add-on category ⇒ removed from every meal's add-ons (meal flags recomputed)
recompute has_replacement / has_addon
IF remove_file == 1 → image := default
ELSE IF image provided → replace image

PLATFORM ITEM PUSH (items/mobile/items-creation):
platform := token's platform (auth.platform; any platform)
existing := item WHERE sku = payload.sku
validate (create: name, price, category_code required; slug optional, unique on mapper_slug)
IF slug sent → slug := mapper_slug := payload.slug
IF existing → update the non-null fields (created_by / created_from untouched)
ELSE → create (slug defaults to Str::slug(name), is_custom 0, preparation_time 10,
               default image, created_by = platform user, created_from = platform.slug)
IF category_code → sync to that single category
item outside the add-on category ⇒ removed from every meal's add-ons
ingredients / addons: null ⇒ keep, [] ⇒ clear, else replace (dashboard composition rules)
recompute has_replacement / has_addon
ANY error → rollback, {status:false, message, errors?}

SIZED ITEM PUSH (items/cc/item-creation / items/cc/item-creation-simulate):
platform := token's platform (auth.platform; any platform, simulate included)
validate structure (sizes[].size distinct; addons[].sku distinct)
FOR each addon:
   recipe := its sizes[ready_meals].ingredients
             (removable := sent ?? false, sort_order := sent ?? list position, no replacements;
              [] ⇒ clear recipe, no ready_meals ⇒ keep recipe)
   PLATFORM ITEM PUSH on the addon (create or update by sku), delivery_app_price := price
meal recipe := sizes[ready_meals].ingredients (no ready_meals ⇒ keep recipe)
meal addons := addons[].sku (absent ⇒ keep, [] ⇒ clear)
PLATFORM ITEM PUSH on the meal, delivery_app_price := price
ANY error → rollback, errors keyed on the sized payload
IF simulate → rollback (meal and add-ons), return action + validated sized payload + meal preview

ITEM DELETE:
IF item is an add-on of any meal → 422

INGREDIENT CATEGORY CREATE:
IF parent given:
   parent is leaf OR parent.level == 2 → 422
   level := parent.level + 1
ELSE level := 0

INGREDIENT CATEGORY UPDATE:
parent never changes
is_leaf := 1 while it has children → 422
is_leaf := 0 while it holds ingredients → 422

INGREDIENT CATEGORY DELETE:
IF has children OR has ingredients → 422
```

## Database Understanding
- `categories` + `parent_categories` — a DAG (not a strict tree): a category can have several parents so the same component group can appear under multiple meals.
- `items` + `item_categories` — items live in multiple categories; the pivot's `is_custom_category` flag records *in which role* the item belongs to that category (normal menu entry vs custom-meal component).
- `item_ingredients` (model `ItemIngredient`, `Item::recipeLines()`) — recipe lines: `item_id` (FK cascade), `ingredient_id` (FK restrict), `size` decimal(10,3), `is_removable` (bool, default 0), `sort_order`; unique (item_id, ingredient_id). **No stock/inventory logic exists** — ingredients are informational. The migration that adds these columns removes rows whose item no longer exists and strips thousands separators from `size` before converting it to decimal.
- `item_ingredient_replacements` (model `ItemIngredientReplacement`) — `item_ingredient_id` (FK cascade), `ingredient_id` (FK restrict), `size` decimal(10,3), `extra_charge` decimal(10,2) default 0, `sort_order`; unique (item_ingredient_id, ingredient_id).
- `item_addons` — `item_id` (meal, FK cascade), `addon_item_id` (FK restrict), `sort_order`; unique on the pair.
- `items.has_replacement` / `items.has_addon` — derived booleans maintained by `ItemCompositionService::refreshFlags`.
- `items.created_from` — string(191), nullable: where the item was created — `dashboard` (dashboard create) or the pushing platform's `slug` (platform push, e.g. `asparagus_mobile_app`). Null for items created before the column existed. Written only on create; updates never change it.
- `ingredient_categories` (model `IngredientCategory`) — self-referencing tree (`parent_id` FK restrict), `cc_code` unique nullable, `level` 0–2, `is_leaf`, `name_en`, `name_ar`.
- `ingredients.ingredient_category_id` — nullable FK to `ingredient_categories` (restrict); `ingredients.sku` — unique nullable.
- `attachments` — polymorphic file store; items keep exactly one `image`-typed attachment.
- `attributes` / `item_attributes` — EAV-style extensible item properties (e.g. size variants) without schema changes.

## State Machine
None — catalog entities have no lifecycle states, only hard create/update/delete.

## APIs (summary)
| Endpoint | Purpose | Key validation | Side effects |
|---|---|---|---|
| `GET /dashboard/categories/list` | Full tree for admin | — | — |
| `GET /dashboard/categories/parent-categories` | Categories with `has_sub=1` | — | — |
| `GET /dashboard/categories/item-categories` | Categories that can hold items (level 0/2 + custom lvl-1-parented set) | — | — |
| `POST /dashboard/categories/create` | New category | name unique, sort_order unique, parents exist | creates parent links |
| `PUT /dashboard/categories/update` | Edit category | id exists; name/sort_order unique except self | diff-syncs parents |
| `POST /dashboard/categories/delete` | Remove category | — | hard delete |
| `POST /dashboard/items/create` | New item (form-data) | name, numeric price, ≥1 category, image mime; `ingredients[n][id\|size\|is_removable\|sort_order]`; `ingredients[n][replacements][m][id\|size\|extra_charge\|sort_order]` (same leaf category); `has_addons` + `addons[n][id\|sort_order]` (add-on category only) | image, attrs, categories, recipe lines, replacements, add-ons; flags recomputed |
| `POST /dashboard/items/update` | Edit item | id exists; same composition rules as create | diff-sync; absent ingredient/category lists clear them; absent `has_addons` leaves add-ons untouched; leaving the add-on category prunes the item from other meals |
| `GET /dashboard/items/view/{id}`, `GET /dashboard/items/list` | Item detail / list | — | return `has_replacement`, `has_addon`, recipe lines (without their replacements), `addons[]` |
| `GET /dashboard/items/replacement-candidates` | Other ingredients of the same leaf category (`ingredient_id`, optional `search`); returns `ingredients` | — | max 50 |
| `GET /dashboard/items/add-ons` | Items of the add-on category; optional `category_id` narrows them to that category and the categories below it (422 unless it is the add-on category or below it); optional `exclude_item_id`, `search` | — | max 50 |
| `POST /dashboard/items/delete` | Remove item | not an add-on of any meal (422) | hard delete |
| `GET /dashboard/items/attributes/list` | Attribute definitions | — | — |
| `POST/GET /dashboard/ingredients/*` | Ingredient CRUD | bilingual unique names; unit whitelist; unique sku; leaf `ingredient_category_id`; category locked while in a replacement; delete blocked while used in recipe/replacement (422) | — |
| `GET /dashboard/ingredient-categories/list` | Flat list ordered by level (`leaf_only`, `parent_id`) | `ingredients.*` permission | — |
| `GET /dashboard/ingredient-categories/view/{id}` | Detail with parent/children | — | 404 if missing |
| `POST /dashboard/ingredient-categories/create` | New category | names; parent not leaf and level < 2 | level derived from parent |
| `PUT /dashboard/ingredient-categories/update` | Edit names / cc_code / is_leaf | parent not changeable; leaf-flag guards | — |
| `POST /dashboard/ingredient-categories/delete` | Remove category | no children, no ingredients (422) | hard delete |
| `GET /pos/categories/main` | POS boot menu | — | — |
| `GET /pos/categories/get-sub/{id}` | Drill-down | category exists | — |
| `GET /pos/categories/get-items/{id}` | Items of category | category exists | — |
| `POST /pos/categories/get-items-multiple` | Custom-meal component sets | expects one is_custom=0 and one is_custom=1 entry | — |
| `GET /pos/items/search` | Name search | — | — |
| `GET /pos/items/view/{id}` | Customise payload for one item (recipe lines without their replacements, add-ons, flags) | item exists | — |
| 🚩 `POST /pos/items/mobile/check-mapper-slugs` | Reconcile SKU↔mapper_slug across catalogs | platform items[] | matched POS items | platform token | 500 | writes platform SKU onto matched POS items |
| `POST /pos/items/mobile/items-creation` | Platform pushes an item (create or update by sku); any `auth.platform` platform; its slug becomes `created_from` | sku; name, price, category_code on create; optional slug (unique on mapper_slug); ingredients[]/addons[] by sku with dashboard composition rules | update-or-create on sku — overwrites a live item; sets `created_by`/`created_from` on create; prunes it from meals' add-ons if outside the add-on category; `{status, message, errors?}` HTTP 200 either way |
| `POST /pos/items/cc/item-creation` | CostControl pushes a meal with recipes per meal size and its add-ons in full (`ItemSizesCreationCC`) | sku; name, price, category_code on create; `sizes[].size` distinct; `ready_meals` ingredients and each add-on validated with the flat push rules; add-on `sizes[].ingredients[]` `{sku, size > 0, removable?, sort_order?}` | add-ons created/updated by sku first; meal stores only the `ready_meals` recipe; `delivery_app_price` := `price`; `{status, message, errors?}` HTTP 200 either way |
| `POST /pos/items/cc/item-creation-simulate` | Dry run of the sized push, run as the calling platform (`ItemCreationSimulateCC`) | same validation as `items/cc/item-creation`; any `auth.platform` token | nothing stored (meal and add-ons rolled back); returns `action`, validated sized `payload` (ready for `items/cc/item-creation`), `item` preview |
| 🚩 `POST /pos/ingredient/cc/ingredient-creation` | Draft · 2026-09-08 — platform pushes an ingredient | sku, name_en, name_ar, unit | success/failure flag | platform token; **no field validation** (name/sku presence untested before insert) | 200 w/ `status:false` on any exception | `updateOrCreate` keyed on sku; unit is remapped from short platform codes (`gm→gram`, `ltr→liter`, `pcs/ml/kg` pass through) — **an unrecognized unit code is an undefined-array-key warning in PHP, not a caught exception, so it can slip through as a null unit** |

## Edge Cases
- `get-items-multiple` assumes `categories[]` contains at least one custom and one non-custom entry — otherwise `[0]` index errors (500).
- Category "not found" responses return HTTP 404 **with `success: true`** (clients must check message, not flag).
- Item update with a `categories` array whose entries match existing links still runs the diff logic comparing an array to an id (`where('category_id', $category)` passes an array) — works by accident of Laravel query building.
- Duplicate item names are allowed (only slug derived; no unique rule on item name).
- If no menu category carries `code = ASP_CAT_1000`, no item qualifies as an add-on: the add-on lookup is empty and any `has_addons = 1` submission is rejected.
- An ingredient without an ingredient category has no replacement candidates, and any replacement submitted for it is rejected.

## Assumptions
- Menu is global — **all branches sell the same menu at the same prices** (no branch-specific price or availability anywhere).
- Item price includes VAT (see Module 4 math).
- Images live on the public disk and are publicly reachable.

## TODOs
- No pagination anywhere — full-table responses; menu growth will degrade POS boot.
- Deleting a category/item silently breaks the custom-meal graph (no guard).
- `CategoryTestFucntionController` exists as leftover test code (not routed).
- No branch-level menu control (likely future need).
- Ingredient categories are maintained manually on the dashboard; the CostControl sync that `cc_code` anticipates is not built.

## Open Questions
- Intended meaning of `category_level` on the item-category pivot vs the category's own `level` (both exist; resolver mixes them).
- Are attribute-driven variants actually used by the current frontend, or legacy?
- Replacement `extra_charge` (added to the meal price when a replacement is chosen) reverses the October decision-log rule "replacements never change the price" — flagged in the recipe schema PDF; needs PO decision.
- Catalog-level add-ons (`item_addons`, configured per meal) contradict the decision-log rule "add-ons have no catalog relation, they are linked in the cart only" — needs PO decision.
- Ingredient categories are filled manually today; when and how the CostControl sync for them is built is undecided.
- Do CostControl's real item field names match the sized push payload (`sku`, `name`, `price`, `category_code`, `sizes[].size/ingredients[].sku/size/removable/replacements`, `addons[].sku/name/price/category_code/sizes[].ingredients[].sku/size/removable/sort_order`)? The contract is defined on the POS side only.
- The sized push stores only `ready_meals`; when and where the other meal sizes (`maintain_weight`, `lose_weight`) and `size_type` are stored is undecided.
- With replacements hidden from the item resources, the POS can no longer offer them, yet `has_replacement` is still returned and order lines still accept replacements — is hiding temporary, and should `has_replacement` be hidden too?
- CostControl has no row in `platforms` or `PlatformSeeder`, so it cannot push items until it is given its own platform row and token; its items' `created_from` will be whatever slug that row is given.

## Evidence
- `app/Models/IngredientCategory.php`, `app/Http/Controllers/Dashboard/IngredientCategoryController.php`, `app/Http/Requests/IngredientCategoryStoreRequest.php`, `app/Http/Requests/IngredientCategoryUpdateRequest.php`, `app/Http/Resources/IngredientCategoryResource.php`
- `app/Models/Ingredient.php`, `app/Http/Requests/IngredientStoreRequest.php`, `app/Http/Requests/IngredientUpdateRequest.php`, `app/Http/Controllers/Dashboard/IngredientController.php`, `app/Http/Resources/IngredientResource.php`
- `app/Models/Item.php` (`ADDON_CATEGORY_CODE`, `recipeLines()`, `addons()`), `app/Models/ItemIngredient.php`, `app/Models/ItemIngredientReplacement.php`
- `app/Services/Dashboard/ItemCompositionService.php`, `app/Http/Requests/Concerns/ValidatesItemComposition.php`, `app/Http/Controllers/Dashboard/ItemController.php`
- `app/Http/Controllers/Pos/ItemController.php` (`show`, `ItemCreationPlatform`, `ItemCreationSimulateCC`, `ItemSizesCreationCC`, `storeSizedPayload`, `pushItem`, `validateItemPayload`, `storeItemPayload`, constructor middleware `except`)
- `app/Models/Item.php` (`created_from` fillable), `app/Http/Controllers/Dashboard/ItemController.php` (`store` sets `created_from = dashboard`), `app/Http/Middleware/PlatformAuthMiddleware.php` (merges the token's `platform` into the request)
- `tests/Feature/ItemCreationPlatformTest.php`
- `app/Http/Resources/ItemResource.php`, `app/Http/Resources/DBItemResource.php`, `app/Http/Resources/ItemRecipeLineResource.php`, `app/Http/Resources/ItemAddonResource.php`
- `routes/api/v1/dashboard.php` (`ingredient-categories/*`, `items/replacement-candidates`, `items/add-ons`), `routes/api/v1/pos.php` (`items/view/{id}`, `items/mobile/*`, `items/cc/item-creation`, `items/cc/item-creation-simulate`, `ingredient/cc/ingredient-creation`)
- `database/migrations/2026_10_22_000001_create_ingredient_categories_table.php` … `2026_10_22_000004_create_item_addons_table.php`, `database/migrations/2026_10_25_000001_add_created_from_to_items_table.php`

---

# 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`.

---

---

# Module 5 — External Platform Orders (Mobile App Integration)

> **KB version:** 2.0 · **Code baseline:** `19ed3d9` · **Last reviewed:** 2026-11-03
> **Epic:** EP-TBD · **Status:** Pending PO review
> **Pending PO review:** inbound platform-order tracing on the `integration` channel (summary line, payload, created/failed outcome); `POST /pos/orders/mobile/cancel` platform-initiated cancellation (Draft · 2026-09-10) — see V-02 findings in `KB_UPDATE_REPORT.md`; Noon tracker `error` surfacing on the external-orders list (Draft · 2026-09-17); provider-initiated rejection of a delivery order — both when the provider cancels an accepted task and when the create-task dispatch fails outright (order → `cancelled`/`cancelled` + `rejected` callback to the platform), including the skip when the order is already cancelled or belongs to a closed day, and the fact that an approved delivery order can therefore flip to rejected asynchronously with no cashier action until the delivery is delivered, after which no provider cancellation can reject it; the `is_delivery_rejected` boolean on the *rejected* callback (`true` for both provider-side rejections, `false` for a cashier rejection), which lets the mobile app attribute the rejection; add-ons on platform order lines — `items[].addons[]` (`sku`, `qty`) mapped by SKU, checked against the meal's offered add-ons, and priced into the server-side items total as add-on lines; the platform's `package_name` stored as `plan` in `external_orders_data.data` (alongside `type`), which is how a Branch-plan order (`branch-plan`) carries its plan name; the `plan` field on the external-order and kitchen resources shown in Title Case (`_`/`-`/repeated spaces → one space, each word capitalised; empty or literal "null" → null; stored value unchanged); an external order's CostControl `source` — `branch-plan` when the stored `type` is `branch-plan`, otherwise `standard`; the branch-plan payload's `plan` sent as CostControl's key (lowercase with underscores, e.g. `maintain_weight`), not the Title Case display form
> **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:** Accept orders created outside the POS (currently the Asparagus Mobile App), route them through cashier review, and push status updates back to the platform.
- **Business Goal:** Turn the POS into the fulfillment hub for online/mobile orders without double entry.
- **Main Actors:** Platform (submits orders), Cashier (approves/rejects/fulfills), Customer (orders remotely), Noon (delivers — Module 8).
- **Dependencies:** Platform auth (Module 1), item/branch `mapper_slug` mapping, payment-type↔platform mapping, Noon integration, order engine (Module 4).
- **Entry Points:**
  - `POST /v1/pos/orders/mobile/create` (platform token)
  - `GET /v1/pos/orders/listExternalOrders/{platformSlug}` (cashier)
  - `POST /v1/pos/orders/approveRejectExternalOrder` (cashier)
  - `POST /v1/pos/orders/deliverExternalOrder` (cashier)
  - `GET /v1/pos/orders/trackOrderDelivery/{providerReferenceNumber}` (cashier)

## Business Workflow
1. Customer places an order in the mobile app. The app's backend calls Dinex `mobile/create` with: its own `order_id` (becomes `external_order_id`), `order_number` (the app's number), branch `branch_slug`, items by `sku` + qty — each optionally with `addons[]` `{sku, qty}` — `order_type` (id), a `type` string that selects which mapped payment type applies (e.g. `branch-order` vs `branch-plan`), customer/delivery data (name, mobile, address, lat/long, city, area, car number), optional delivery fee, optional discount, note.
2. Dinex validates: order_id not already imported for that payment type; items exist by `sku`; qty ≥ 1; every add-on SKU exists in `items.sku` with qty ≥ 1; branch slug exists; `type` is one of the platform's mapped payment-type pivots.
3. The request is translated into a normal POS order request: each line's add-on SKUs are mapped to item ids and checked with the same rule as a POS order — only add-ons the meal offers (`item_addons`), each once; a violation aborts the order with the failing field and message; the items total is computed server-side by the POS line pricing (item price, or delivery app price when the payment type asks for it, × qty, plus each add-on line at its own price × qty); payment = the platform's mapped payment type (amount = full total); `is_paid = 1`; **`is_approved = 0` → the order lands in `inReview` / kitchen `inReview`.**
4. The order is created by the standard engine (Module 4), including `mobile_delivery_fee` handling and the customer payload stored in `external_orders_data`.
5. The cashier's "external orders" tab (`listExternalOrders/{slug}`) shows open orders whose payment type is external **and** belongs to that platform, with customer/delivery details and Noon tracking state.
6. Cashier **approves** (→ status `completed`, kitchen `inProgress`; platform notified with event *approved*; if the order type is Delivery, the order is dispatched to Noon) or **rejects** (→ `cancelled`/`cancelled`; platform notified with event *rejected*).
7. A delivery order that was already approved can still be **rejected on the delivery provider's account**, in two situations: the provider never accepts the task (the Noon create-task dispatch fails with an error response or an exception) or the provider cancels a task it had accepted. In both, the order is set to `cancelled`/`cancelled` and the platform receives the same *rejected* event a cashier rejection sends, flagged `is_delivery_rejected = true`, so the mobile app marks the order rejected and knows the delivery side caused it. The POS outcome is mechanically identical to the cashier reject path — only the trigger and that flag differ (see Module 8 for the Noon dispatch and webhook sides).
8. For pickup/car-pickup orders, when handed over the cashier hits **deliverExternalOrder** → marks `is_delivered` on the external data and notifies the platform with statuses forced to `completed/completed`.
9. Any later status change on an external order re-notifies the platform.
10. 🚩 **Draft · 2026-09-10:** The platform can also **cancel** an order it submitted, via `POST /pos/orders/mobile/cancel` (`order_id`, `type`). Dinex looks up the `ExternalOrder` by `data->type` + `external_order_id`, then the underlying `Order` scoped to `created_by = platform's related user` (bypassing `BranchScope`), and sets **both** `status` and `operation_status` to `cancelled`. If either lookup misses, it **silently returns `false`** (logged, not thrown) — no error surfaces to the platform beyond that boolean.

## Business Rules
- **Deduplication:** an external `order_id` may be imported only once per mapped payment type (validation `unique:orders,external_order_id` scoped by payment type). Note: the DB-level unique index that backed this was dropped when `payment_type_id` became nullable — enforcement is validation-only now.
- Items **must** be pre-mapped by SKU: an unknown item or add-on SKU aborts the whole order ("No item found with SKU: …"). A line without `addons` gets none. Add-ons are written to the order exactly as for a POS order (their own `addon` lines under the meal — Module 4). Platform order lines carry no ingredient changes: every recipe line is served as in the recipe.
- The platform↔payment-type mapping (`platform_payment_type.type`) is the routing key: e.g. Asparagus maps `branch-order` → payment type "Asparagus Mobile App", `branch-plan` → payment type "Branch plan". An unmapped `type` fails validation ("Invalid type for this platform.").
- Platform orders are always considered **paid** (`is_paid=1`) — money was collected by the platform.
- Platform orders always require cashier approval before the kitchen sees them (`inReview` kitchen status keeps them off the kitchen board, which only shows `inProgress`/`pending`).
- Delivery fee comes from the platform (`delivery_fee`/`mobile_delivery_fee`, ≥ 0) as a **net/VAT-exclusive** amount; it is folded into the pre-discount net base, so both the discount and the 5% VAT apply to it.
- Approval semantics: **approved ⇒ financial status `completed`** immediately (money already collected), kitchen starts `inProgress`.
- Only **Delivery**-type approved orders go to Noon; pickup/dine-in types don't.
- Platform callback (`notifyPlatformOrderUpdated`): fires only if the order's first payment's payment type `is_external == 1` and is linked to a platform; POSTs `{type, order_status, operation_status}` — plus a fourth key `is_delivery_rejected` (boolean) on the *rejected* event only, never on `approved`, `delivered`, `order_status_changed` or `kitchen_status_changed` — to the platform's configured `change_status_url` + external order id, with the platform's API key header; 30 s timeout; failures only logged (fire-and-forget — **no retry**).
- The status names sent to the platform are the human enum names (`completed`, `cancelled`, …); for the "delivered" event both statuses are hardcoded to completed.
- **Rejection has three triggers, one POS outcome.** A delivery order is rejected by the cashier (`approveRejectExternalOrder` with `approved = 0`), by a **failed dispatch** (Noon refuses or never receives the create-task call — Module 8), or by a **provider cancellation** (Noon cancels a task it had accepted — Module 8). All three set `status = cancelled` and `operation_status = cancelled` and queue the same *rejected* platform callback. The callback's `is_delivery_rejected` flag tells the platform which side initiated it: both provider-side triggers run through `OrderService::rejectExternalOrderByProvider` and send `true`, while a cashier rejection sends `false`. The two provider-side triggers are therefore indistinguishable from each other, but a cashier rejection is distinguishable from a provider one. The **reason** is still never sent — the provider's own message (the parsed error message for a failed dispatch, the cancellation reason for a cancellation) goes into the `integration` log and the order tracker only.
- **Approving a delivery order is not a terminal decision for the customer.** Dispatch and provider callbacks both run in queue workers, so an order the cashier just approved can flip from approved to rejected moments later, asynchronously and with no further cashier action — in the failed-dispatch case, within seconds of approval. The cashier learns about it only from the external tab's tracker state. The window is bounded, though: it closes as soon as the delivery is **delivered**, or the order is already cancelled, or its day is closed. Past any of those points a provider cancellation can no longer move the order.
- A provider-initiated rejection is **skipped** — logged to the `integration` channel and nothing else — when the order is already `cancelled` (nothing left to reject) or when the order belongs to a **closed day** (`day_closed` set or `closed_at` not null). A frozen order's status is never rewritten, so no closed-day DSR figure can move after the fact; the price is that such an order keeps showing as approved while its delivery task is dead.
- **A delivered order is never retro-rejected.** Once the delivery has reached `delivered` the tracker is terminal, so a provider cancellation arriving afterwards is discarded before it can reach the rejection path (Module 8). Provider-initiated rejection therefore only ever hits an order whose delivery never completed. The bound matters because the platform has already been told that order was completed/delivered: rejecting it afterwards would hand the mobile app two contradictory outcomes for one order.
- 🚩 **SPRINT 1 · 09-08-2026 (2026-08-09):** A third external tender, **"Loyalty Points"**, is seeded and mapped to the Asparagus platform under pivot type `loyalty-points`. When the mobile app sends `payed_with_loyalty_points = true`, the routing type is **overridden to `loyalty-points`** regardless of the request's `type` field, so the order books against that tender and appears under it in every money report. The redeemed `points` are stored on the order's external payload and shown to the cashier. **Business gap:** Dinex records the points but owns no balance — it neither validates nor deducts them; reconciliation lives entirely in the mobile app.
- The stored platform payload (`external_orders_data.data`) carries **`plan`** and **`type`** (the routing type), alongside `points`. `plan` is taken from the request's `package_name` (null when absent); for a Branch-plan order (payment type "Branch plan", pivot type `branch-plan`) it is the customer's meal-plan/package name. The kitchen list shows `plan` for external orders only; the external tab shows plan, points, delivery instructions, and the external order type. The `type` echoed back to the platform on every status callback is read from this stored payload rather than re-derived from the payment-type pivot, so the platform gets back exactly the type it sent — which is what its own order matching keys on.
- **Plan display format.** The `plan` field on the external-order resource (external tab) and the kitchen-order resource is shown in Title Case via `Helpers::formatPlanName`: underscores, hyphens and runs of whitespace collapse to a single space, the result is trimmed, and each word is capitalised (`maintain_weight` / `maintain-weight` → "Maintain Weight"). An empty value or the literal string "null" (any case) is shown as `null`. Only the output is formatted — the stored `plan` keeps exactly what the platform sent.
- **CostControl source of an external order.** When an external order is sent to CostControl, its `source` is `branch-plan` if the stored `data.type` is `branch-plan` — and the payload's `plan` is then the plan as CostControl keys it (`Helpers::planKey`: normalised like the display form, then lowercased with spaces → underscores, e.g. `maintain_weight`, `lose_weight`; null when empty) — not the Title Case display form — otherwise `standard` (with `plan` null). An external order is never classed `delivery-app`. The full CostControl source rule, including in-house POS orders, is in Module 4.
- 🚩 **SPRINT 1 · 09-08-2026 (2026-08-11):** The customer's delivery instructions from the mobile app are stored with the order, shown to the cashier on the external tab, and sent to Noon as `delivery_notes` on the create-task payload (Module 8). Nothing sanitizes or truncates this text before it reaches the courier.
- 🚩 **SPRINT 1 · 09-08-2026 (2026-08-10):** A platform order's branch is now the branch named by `branch_slug` (via `PlatformAuthMiddleware` + the reworked `BranchScope`, see Module 2) rather than the platform's related user's branch — fixes wrong/duplicate mobile order numbers that occurred previously.

## Hidden Rules
- Every platform order-creation entry point funnels through `OrderService::createMobileOrder`, which traces the inbound order to the `integration` log channel before mapping it: one summary line (platform slug, external order id, external order number, branch slug, `type`, `order_type`, line count), the payload as the platform sent it, and then either a `Created` line (POS order number, branch, items total, delivery fee) or a `Failed` line with the exception message. The `platform`, `branch` and `user` models that `PlatformAuthMiddleware` merges into the request are stripped from the logged payload, since they are not platform input. A rejected platform order therefore leaves a local record of what was sent and why it failed; the exception itself is still what the controller returns to the platform.
- The callback URL is environment-aware (**fixed 2026-07-07**): `services.{slug}.live_url` in production, `services.{slug}.test_url` otherwise. If either the base URL or `change_status_url` is missing from config, the callback is skipped with a warning in the `integration` log.
- If a platform order's payment type mapping is removed later, callbacks silently stop (guard returns early).
- `deliverExternalOrder` does **not** change the order's own status — it only flips `external_orders_data.is_delivered` and tells the platform "completed". The POS list still shows the previous status.
- ~~The mobile-order branch is resolved by `branch_slug` → the *order's* branch may differ from the platform's related-user branch; the platform user's branch scope (global scope) is set from the related user...~~ **Fixed 🚩 SPRINT 1 · 09-08-2026 (2026-08-10):** `PlatformAuthMiddleware` now resolves the branch from `branch_slug` itself and re-registers `BranchScope` pinned to it, so the order's branch and the request's scope always agree — order numbering and listing for mobile orders is no longer dependent on the related-user's branch (see Module 2).
- Because approval sets financial status `completed`, external orders are countable in the DSR the moment they're approved, even before food is prepared or delivered.
- `related_payment_type_id` on platforms is legacy (superseded by the pivot table).
- The external list includes each order's Noon tracker state (humanized), so cashiers track delivery without leaving the tab. 🚩 **Draft · 2026-09-17:** it now also surfaces `orderTrackerDetail.error` — the human-readable failure reason when Noon dispatch failed (see Module 8) — and a dispatch-error tracker now renders as the literal status "Error" instead of an unmapped numeric code.
- `orderTrackerDetail.status` on the external list carries one literal (non-numeric) state next to the Noon lifecycle codes: **"Error"**, meaning the delivery is dead — either because Dinex never managed to hand the task to Noon, or because Noon cancelled a task it had accepted. `orderTrackerDetail.error` holds the human-readable reason in both cases: the parsed dispatch error, or Noon's cancellation text (Module 8). Either way the order has already been rejected back to the platform, so a cashier reads "Error" as "this delivery is gone, and the customer has already been told the order is rejected"; the tracker state itself does not say how far the task got — only the reason text does.
- Provider-initiated rejection never throws: it runs inside a queue worker (dispatch job or webhook job), so an order it cannot reject — and any error raised while rejecting — is logged and the job still succeeds rather than retrying. A skipped rejection is only visible in the `integration` / `noon_integration` logs; the external tab shows the mismatch indirectly, as a tracker in "Error" on an order still marked approved.
- 🚩 **SPRINT 1 · 09-08-2026 (2026-07-27):** New flag `external_orders_data.is_seen` (existing rows back-filled to seen) lets the cashier's external tab badge unread incoming mobile orders. `POST /pos/orders/change-is-seen-external-order` (`order_ids[]`, `is_seen`) toggles it. The lookup **deliberately ignores branch scope** — the seen/unseen flag belongs to the order, not the viewing branch — and unknown ids, or ids without external data, raise an error rather than silently succeeding.
- ~~`storeMobileOrder`'s exception handler returns the raw exception object (debug leak)~~ **Fixed 🚩 SPRINT 1 · 09-08-2026.**
- 🚩 **SPRINT 1 · 09-08-2026:** `approveRejectExternalOrder` and `deliverExternalOrder` now validate their input (id exists, `approved` is boolean) instead of trusting it — previously a non-existent id null-deref'd into a 500.
- 🚩 **SPRINT 1 · 09-08-2026:** Outbound platform-callback and Noon calls now run on **queued jobs** rather than inline — an operational consequence is that **a running queue worker is now a hard production dependency**: without one, mobile customers never learn their order's outcome and no courier is ever booked, with no user-visible error (see Module 8).

## Decision Tree
```
MOBILE ORDER IN:
IF platform token invalid → 401
IF order_id already imported for this platform's payment type → 422
IF any item slug unmapped → error, whole order rejected
IF type not mapped for platform → 422
→ map to POS order: is_paid=1, is_approved=0, payments=[mapped type],
  totals from server prices, delivery fee, external data stored
→ order created in inReview/inReview

CASHIER DECISION:
IF approved == 1 → status := completed, operation := inProgress, event 'approved'
   IF order_type == Delivery (id 3) → after commit: send to Noon (Module 8)
ELSE → status := cancelled, operation := cancelled, event 'rejected' (is_delivery_rejected = false)
AFTER COMMIT → notify platform (if mapped & configured)

PROVIDER NEVER TAKES / DROPS THE DELIVERY (Module 8):
  dispatch fails (error response or exception) → tracker "Error" + parsed error message
  provider cancels an accepted task (webhook)  → tracker "Error" + cancellation reason
THEN, for either:
IF the delivery already reached delivered → skip at the webhook, order untouched (Module 8)
IF order already cancelled → skip (log only)
IF order's day is closed (day_closed / closed_at) → skip (log only; task dead, order still approved)
ELSE → status := cancelled, operation := cancelled, event 'rejected' (is_delivery_rejected = true)
       (same POS outcome as a cashier rejection; provider's reason logged, not sent)

HANDOVER (non-delivery):
deliverExternalOrder → external_data.is_delivered := true
→ notify platform with completed/completed
```

## Database Understanding
- `external_orders_data` — the platform's raw context per order: platform's own order id + number, and a JSON blob (customer name/mobile, address, geo, city, area, car number, delivery instructions, `plan` from `package_name`, routing `type`, `points`). Kept apart from `orders` because it's platform-specific, schema-less, and irrelevant to in-house orders. `is_delivered` flag records physical handover.
- `platform_payment_type` — many-to-many with a `type` discriminator; it answers "when platform X says type Y, which tender do we book it under?" Payment types double as the *channel* dimension in all money reporting.
- `orders.external_order_id` — the cross-system correlation key used in callbacks.

## State Machine (external-order overlay on Module 4 states)
```
inReview/inReview  --approve-->  completed/inProgress  --(delivery)-->  Noon lifecycle (Module 8)
                   --reject-->   cancelled/cancelled                               (event 'rejected', is_delivery_rejected = false)
completed/*        --deliverExternalOrder--> is_delivered=true (order status unchanged)
completed/*        --Noon never accepts the task (dispatch failed)--> cancelled/cancelled  (event 'rejected', is_delivery_rejected = true)
completed/*        --Noon cancels an accepted task------------------> cancelled/cancelled  (event 'rejected', is_delivery_rejected = true)
                   --…but delivery delivered, order cancelled, or day closed--> unchanged (log only)
```
Triggers: the cashier (`api.role:cashier` + `order.change_status`), and the delivery provider for the two transitions above (Noon dispatch job / webhook job, no user session). Platforms can never change status directly except via `mobile/cancel`.

## APIs
| Endpoint | Purpose | Input | Output | Validation | Errors | Side effects |
|---|---|---|---|---|---|---|
| `POST /pos/orders/mobile/create` | Import platform order | see workflow | invoice PDF URL | dedupe, item / add-on SKUs exist, type map, qty ≥ 1, add-ons offered by the meal | 401/422/500 | full order creation; ~~raw exception returned on error~~ 🚩 **fixed SPRINT 1 · 09-08-2026** |
| `GET /pos/orders/listExternalOrders/{slug}` | Cashier external tab | platform slug | platform info + orders (customer, tracker, is_delivered, is_seen) | `order.read` | 500 | — |
| `POST /pos/orders/approveRejectExternalOrder` | Accept/decline | id, approved (1/0) | success | id/approved validated 🚩 **SPRINT 1 · 09-08-2026** | 500 | status change, platform callback (a cashier rejection sends `is_delivery_rejected = false`), Noon dispatch (a dispatch that fails rejects the order again, asynchronously, with `is_delivery_rejected = true`) |
| `POST /pos/orders/deliverExternalOrder` | Mark handed over | id | success | validated 🚩 **SPRINT 1 · 09-08-2026** | 500 | is_delivered flag, callback |
| `GET /pos/orders/trackOrderDelivery/{ref}` | Live Noon status | provider ref | raw Noon task payload | tracker must exist | 500 | — |
| 🚩 `POST /pos/orders/change-is-seen-external-order` | Badge/unbadge incoming orders | order_ids[], is_seen | success | ids exist w/ external data | 422/500 | flips `is_seen` (branch-scope ignored by design) |
| 🚩 `POST /pos/orders/mobile/cancel` | Draft · 2026-09-10 — platform cancels its own order | order_id, type | true/false | none (silent miss) | 500 | sets status + operation_status to cancelled |

## Edge Cases
- Approving an order twice: second call re-runs the same transition (idempotent in effect) and re-dispatches to Noon — Noon-side idempotency (X-Idempotency-Key = order id) is the only protection against double delivery tasks.
- ~~`approveRejectExternalOrder` with a non-existent id → null deref → 500.~~ **Fixed 🚩 SPRINT 1 · 09-08-2026** — id/approved now validated.
- ~~`deliverExternalOrder` on an in-house order (no external data) → crash.~~ **Fixed 🚩 SPRINT 1 · 09-08-2026** — input validated.
- Platform sends `lat`/`long` keys; Dinex stores `lat`/`lng` — the mapper handles the rename; missing coordinates become null and are normalized later for Noon (Module 8).
- Discount from platform is passed through the same engine — a platform could apply arbitrary discounts (no cap).
- A provider cancellation or a failed dispatch lands on an order whose day is already closed: the tracker goes to "Error" with the provider's reason, but the order stays `completed`/`inProgress` and **no `rejected` callback is sent** — the mobile app still believes the order is being fulfilled, and the discrepancy has to be settled manually.
- A cashier rejects an order the provider had already dropped: the order is already `cancelled`, so the provider path had nothing to do and the cashier path simply re-sends `rejected` — this time with `is_delivery_rejected = false`, so the platform's last word on the order attributes it to the cashier; no cancel request is pushed back to Noon for a task already in "Error".
- Noon pushes a cancellation after the delivery was already marked delivered: the push is discarded, the order keeps its completed status, and the platform keeps the completed/delivered outcome it was already given — a customer who received their food is never told afterwards that the order was rejected.
- A delivery order approved while Noon is unreachable is rejected almost immediately — the customer can see "approved" and then "rejected" within seconds, and the kitchen may already have started the order since approval set kitchen status `inProgress`. Nothing walks the kitchen state back.

## Assumptions
- The platform collected payment before submitting (all mobile orders are prepaid).
- One platform ↔ one config block in `config/services.php` named exactly after the platform slug.
- The mobile app can consume "pdf" invoice URLs (returned but probably unused).

## TODOs
- Point callbacks at `live_url` per environment.
- ~~Add server-side validation to approve/reject (id exists, approved boolean, order actually inReview).~~ **Done 🚩 SPRINT 1 · 09-08-2026** (id/approved validated; "actually inReview" check not confirmed — verify).
- Restore a DB-level uniqueness guarantee for `external_order_id` per platform.
- ~~Replace the `return $e;` debug leak in `storeMobileOrder`.~~ **Done 🚩 SPRINT 1 · 09-08-2026.**
- Callback retries / outbox — currently a lost callback desyncs the customer app permanently. (Partially mitigated 🚩 SPRINT 1 · 09-08-2026 by moving callbacks onto queued jobs, which get Laravel's built-in retry/backoff — but a queue worker outage still means silent, total callback loss.)

## Open Questions
- What is "Branch plan" (`branch-plan` type) commercially — a subscription/meal-plan product? (`is_subscription` column hints at it; the only Branch-plan-specific logic is storing/displaying the `plan` name and classing the order `branch-plan` for CostControl.) The set of valid `package_name` values is not defined or validated in Dinex — any string is stored and Title-Cased for display.
- Should rejected orders refund via the platform? (No signal beyond the `rejected` event and its `is_delivery_rejected` flag.) This now also covers orders rejected because the provider never took or later dropped the delivery — the customer paid, the food may already have been prepared, and the platform receives the same `rejected` event, flagged `is_delivery_rejected = true` but carrying no reason.
- When a provider-side rejection is skipped because the day is closed, who reconciles the order (reopen the day, manual refund, re-dispatch a courier)? No process exists in code.

## Evidence
- `app/Services/Pos/OrderService.php` — `mapMobileOrderToStoreRequest` (external data: `plan` ← `package_name`, `type`, `points`, delivery instructions).
- `app/Helpers/Helpers.php` — `formatPlanName` (Title Case plan display), `planKey` (CostControl plan key).
- `app/Http/Resources/ExternalOrderResource.php`, `app/Http/Resources/KitchenOrderResource.php` — formatted `plan` output.
- `app/Services/Pos/CostControlService.php` — `source()` and payload `plan` for external orders.

---

---

# Module 6 — Day Close & Daily Sales Report (DSR)

> **KB version:** 2.0 · **Code baseline:** `b71c156` · **Last reviewed:** 2026-10-26
> **Epic:** EP-TBD · **Status:** Pending PO review
> **Pending PO review:**
> - Non-overridable "undelivered external order" day-close guard — confirmed to include `cancelled` orders with no exclusion or escape hatch; see BL-12 in `ISSUES_TRACKER.md` and V-01 in `KB_UPDATE_REPORT.md`.
> - Items DSR per-item value summed from each order line's own stored price.
> - Add-on lines are Items DSR item rows in their own right, listed under their own add-on menu category and never folded into the meal they were sold with.
> - A customised meal's Items DSR value includes its ingredient-replacement extra charges, so two lines of the same item in one window can carry different values.
> - `orders.count_of_items` counts add-on lines, so a meal plus two paid add-ons is stamped as three items.
> **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:** End-of-day closing: freeze the day's orders, produce printable sales & item reports, reset order numbering.
- **Business Goal:** Daily branch reconciliation (cash-up) by payment method, order type and item mix; establish the immutable "business day" used by all head-office reporting.
- **Main Actors:** Cashier (closes day, prints), Admin/Superadmin (may also access; role list includes them).
- **Dependencies:** Orders, Payment Types, Order Types, Items/Categories, Dashboard settings (company name), PDF engine.
- **Entry Points:**
  - `GET /v1/pos/daily-sales/close-day` (with `close_day=0` preview / `close_day=1` commit, optional `close_orders=1` override)
  - `GET /v1/pos/daily-sales/list`, `GET /v1/pos/daily-sales/print/{id}`

## Business Workflow
1. Cashier requests day close. Scope = the branch's orders with `day_closed=0`, `closed_at IS NULL`, `related_date IS NULL`.
2. **Guard:** if committing (`close_day=1`) and there are still `active` (unpaid) orders and no override flag, the close is refused: *"There is many active orders, please complete them first."* The cashier must complete/cancel them or pass `close_orders=1` to force.
2b. 🚩 **SPRINT 1 · 09-08-2026 (2026-07-15):** A **second, non-overridable guard**: committing is also refused while any order is still `inReview` (unapproved platform orders): *"There are pending external orders in review, please approve or reject them first."* Unlike the active-orders guard, **`close_orders=1` does not bypass this one** — sweeping an unapproved mobile order into a closed day used to lose the order and leave the customer's app without an outcome; that is no longer possible.
2c. 🚩 **Draft · 2026-09-16:** A **third guard**, also non-overridable: committing is refused while any in-scope order has an external-order row with `is_delivered = 0` — *"There are many mobile orders that have not been marked as delivered. Please mark them as delivered so you can close the day."* This checks ALL external orders in scope regardless of financial status, not just `inReview` ones — so an *approved* delivery order that Noon hasn't finished (or that the cashier hasn't manually marked delivered) also blocks day close. The automatic-deliver-on-webhook behavior (Module 8) reduces how often cashiers hit this manually.
3. If there are no open orders at all system-wide → "There is no open orders".
4. The report window is **opened-at = created time of the earliest open order** to **closed-at = now** (both Dubai time). `related_date` := the open date (Y-m-d) — this becomes the business-day key.
5. Only **completed** orders feed the sales figures; **cancelled** orders are shown as a separate count/value line; `active/returned/inReview` orders are excluded from figures but still get stamped closed.
6. Two PDFs (65×250 mm) are rendered and stored (`dsr_pdf/{Y}/{m}/branch_{id}/…`): the **Sales DSR** (totals, order types, payments) and the **Items DSR** (per-item qty/value grouped by category).
7. If committing: two `daily_sales_reports` rows are inserted (items report is a child via `parent_id`, `type='items'`), and **all in-scope orders (any status)** are updated `day_closed=1, closed_at=now, related_date=open-date`. **Added 2026-07-11:** each row also persists a `data` JSON snapshot of the computed figures — the parent (sales) row stores `header/dates/general/order_types/payment`, the child (items) row stores `header/dates/order_items` — so the numbers can be re-served without re-rendering or recomputing.
8. The DSR list shows past parent reports; `print/{id}` returns the stored PDF URL and stamps `last_printed_at`.

## Business Rules (report figure definitions — the reconciliation contract)
For the set **C** = completed open orders of the branch:
- **Gross sales** = Σ(price_before_discount) + Σ(vat); qty = |C|.
- **Gross sales w/o tax** = Σ(price_before_discount).
- **Total cancelled** = count & Σ(final_price) of cancelled open orders.
- **Total discount** = Σ(before − after); qty = number of orders where before > after.
- **Total tax** = Σ(vat).
- **Net sales** = Σ(final_price) − Σ(vat).
- **Guests** = |C| (1 guest per order).
- **Average per order = average per guest** = (Σ final − Σ vat) / |C|.
- **Per order type:** count & Σ(final_price) of completed orders of that type.
- **Per payment method:** from `order_payments` of completed open orders — split payments are attributed to each method by actual amount; count = number of payment rows (not orders); plus `total_payment` = Σ amounts and `net_payment` = Σ amounts − Σ vat. Payment names localized (Arabic name when locale is `ar`).
- **Items DSR:** per item name — qty = Σ line qty, value = **Σ (each order line's own stored `order_items.item_price` × that line's qty)**, grouped by the item's (first non-custom) category. Because this sums every line individually, lines of the same item priced differently within the window (delivery-app price vs. normal price, different payment-surcharge rates, a mid-day price change) each contribute their own actual price, not one price applied to the combined quantity.
- **Items DSR scope — add-on lines are DSR items in their own right:** every `order_items` row of a completed open order feeds the Items DSR. The query joins `order_items` → `items` with **no filter on `order_items.type`**, so an add-on sold with a meal — written as its own `order_items` row with `type = addon` and `parent_order_item_id` pointing at the meal line — appears in the Items DSR as its own item row, grouped under its own menu category (the add-on category `ASP_CAT_1000` or a category below it), **not** folded into the meal it was sold with. `order_items.type` is stored on every line but no DSR figure reads it, so nothing in either report distinguishes a meal line from an add-on line.
- **A customised meal's Items DSR value includes its replacement extra charges.** Each line's stored `order_items.item_price` is the item's price for the selected price mode **plus the sum of the chosen replacements' `extra_charge` plus that line's payment-surcharge share**. Because the Items DSR sums `item_price × qty` per line, a line where the cashier swapped an ingredient contributes **more** than the item's menu price × qty, and two lines of the same item within one window can carry different values. Removing an ingredient never lowers the line price, so removals leave the Items DSR value unchanged.
- Report header shows company name (global setting), branch name, open/close datetimes.

## Hidden Rules
- **Order numbering resets as a consequence of day close** — next order after close finds no open orders and restarts from `order_number_start`+1.
- `related_date` is the **open** date: a shift that runs past midnight books all sales to the day it started.
- The open-at timestamp query originally relied on the **invisible request-bound `BranchScope`** for branch filtering (correct for authenticated cashier requests, but silently cross-branch in console/queued contexts such as a future automatic day close). **Hardened 2026-07-07:** the query now filters `branch_id` explicitly.
- The active-orders guard is buggy: condition `!$request->has('close_orders') && $request->close_orders != 1` means **merely sending `close_orders=0` also bypasses the guard** (any presence of the parameter skips the check).
- Preview mode (`close_day` ≠ 1) still generates and stores both PDFs — storage fills with unprinted previews.
- ~~Orders in `inReview` (unapproved platform orders) get force-closed silently — a pending mobile order can be swept into a closed day and disappear from the external tab without ever being approved or rejected.~~ **Fixed 🚩 SPRINT 1 · 09-08-2026 (2026-07-15)** — day close is now hard-blocked (non-overridable) while any `inReview` order exists; see Business Workflow 2b.
- The items DSR values use each line's stored `order_items.item_price` (Module 4's fee-inflated, delivery-app-price-aware snapshot), so items reports include payment surcharges and delivery-app pricing, while the sales DSR figures don't break fees out at all (the `total_additional_fees` block is commented out).
- `orders.count_of_items` — the per-order item count stamped on the order — **counts add-on lines**: every line priced above 0 is counted, add-on lines included, so a meal sold with two paid add-ons stamps 3. Free lines (price 0) are left out. The DSR's own figures never read this column (the Sales DSR counts orders; the Items DSR sums line quantities), so the stamped per-order count and the Items DSR quantities are computed independently.
- Settings `close_day_automatic` / `automatic_day_end_time` / `close_day_manual` exist in the dashboard **but no scheduler implements them** — day close is manual-only today (Console kernel schedule is empty).
- DSR permission (`dsr.read`) is granted to cashier but **not** chief; the extra role gate also admits admin/superadmin, but those users' branch drives the scope.

## Decision Tree
```
close-day request:
IF close_day == 1:
    IF count(open inReview orders of branch) > 0 → refuse (🚩 SPRINT 1 · 09-08-2026 — NOT overridable by close_orders)
    IF any open order has external data with is_delivered=0 → refuse (🚩 draft · 2026-09-16 — NOT overridable by close_orders)
    IF close_orders param absent AND count(open ACTIVE orders of branch) > 0 → refuse with message
IF no open order exists anywhere → "There is no open orders"
compute window (first open order created_at → now)
C := completed open branch orders
IF C is empty → exception "There is no open orders"
build figures; render Sales PDF + Items PDF
IF close_day == 1:
    insert parent DSR row + child items DSR row (parent_id, type='items')
    UPDATE all open branch orders (any status):
        day_closed := 1, closed_at := now, related_date := open-date
        (each DSR row also stores a `data` JSON snapshot of its figures)
return both PDF URLs + the full figure structure (header/dates/general/order_types/payment/order_items)
```

## Database Understanding
- `daily_sales_reports` — a parent/child pair per close: parent = sales report, child (`parent_id`, `type='items'`) = item mix report; stores the PDF path, window dates, who closed, branch, and `last_printed_at` (reprint audit). **`data` (JSON, nullable, added 2026-07-11)** — snapshot of the computed figures (parent: `header/dates/general/order_types/payment`; child: `header/dates/order_items`). Nullable so **legacy rows predating the column read as `null`** — API resources fall back to the old shape when it is absent.
- `orders.day_closed/closed_at/related_date` — denormalized day-close stamps on each order; `related_date` is *the* reporting dimension joining orders to business days.

## State Machine
Business day (per branch, implicit): `OPEN (has orders with day_closed=0)` → *(cashier close-day)* → `CLOSED (all stamped)`. One-way; no reopen mechanism exists.
DSR row: `created → reprinted (last_printed_at bumped)`.

## APIs
| Endpoint | Purpose | Input | Output | Validation | Errors | Side effects |
|---|---|---|---|---|---|---|
| `GET /pos/daily-sales/close-day` | Preview or commit day close | `close_day` (0/1), `close_orders` (force) | dsr_url + items_dsr_url **+ full figures** (header/dates/general/order_types/payment/order_items), or refusal message | guard above | 500 / message | PDFs always stored; on commit: DSR rows (+ `data` snapshot) + orders stamped |
| `GET /pos/daily-sales/list` | Past reports | — | parent DSRs, newest first — each includes its `data` snapshot and a nested `items_dsr` (id, report_url, data); legacy rows return `data=null` and `items_dsr` as id only | `dsr.read` | 500 | — |
| `GET /pos/daily-sales/print/{id}` | Reprint | id | report URL | id exists | invalid-id msg | last_printed_at updated |

## Edge Cases
- Day with only cancelled orders → C empty → hard exception even though cancelled totals exist.
- Two cashiers closing simultaneously → double DSR rows and double stamping (no lock).
- Close with zero completed orders but active ones + `close_orders=1` → exception (C empty) — force flag can't close a day with no completed sales.
- A branch that never orders is invisible to closing (nothing to stamp).

## Assumptions
- One close per business day per branch; the operator closes before starting next day's sales.
- Thermal printer for DSRs (65 mm), same hardware as invoices.
- "Guests" ≈ orders is an accepted approximation (no covers tracking).

## TODOs
- Implement the automatic close (settings exist, no job).
- Branch-filter the open-at query (cross-branch window bug).
- Fix the `close_orders` guard condition.
- Don't persist preview PDFs, or garbage-collect them.
- ~~Consider blocking `inReview` orders from silent closing.~~ **Done 🚩 SPRINT 1 · 09-08-2026.**

## Open Questions
- Wanted behavior for shifts crossing midnight in reporting (currently: open date).
- Should `returned` orders reduce net sales in the DSR? (Currently excluded entirely.)
- Should the DSR split meals from add-ons? `order_items.type` is stored on every line but no DSR figure reads it, so add-on sales are reported as ordinary item rows under the add-on category. And should ingredient-replacement extra charges be reported as their own figure rather than inside the item's own value, where they are indistinguishable from the item's menu price? Module 4 (M04) raises the same question from the order side.
- 🚩 **Draft · 2026-09-16:** the new `is_delivered=0` close-day block is non-overridable — what's the operational escape hatch if a delivery order can never be marked delivered (e.g. Noon dispatch failed, branch has no linked outlet)? Today the branch simply cannot close its day.

---

---

# Module 7 — Head-Office Reports, Charts & Settings

> **KB version:** 2.0 · **Code baseline:** `b71c156` · **Last reviewed:** 2026-10-26
> **Epic:** EP-TBD · **Status:** Pending PO review
> **Pending PO review:**
> - Add-on lines count as their own item rows (own qty, own gross, own name) in the branch items report and in the per-order item list of the invoice export — never folded into the meal they were sold with.
> - The top-items chart counts add-on quantities alongside meal quantities, so an add-on can win "best-selling item".
> - Replacement extra charges are reported inside the customised meal's own gross (via the stored line price) and are not attributable to the replacement that caused them; ingredient removals never reduce the reported line value.
> **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:** Give head office period-based sales analytics, exports, dashboard KPIs/charts, and control of global business settings.
- **Business Goal:** Monitor multi-branch performance (sales, discounts, tax, payments, item mix), spot open/closed branches, export data for accounting.
- **Main Actors:** Admin, Superadmin.
- **Dependencies:** Orders (`related_date` from day close), Order payments, Branches, Items/Categories, Payment types, Dashboard settings.
- **Entry Points:**
  - `GET /v1/dashboard/reports/branches-items`, `/reports/payment-gateway-summary`, `/reports/ordersReport` (all accept `period`, `branches[]`, `startDate`, `endDate`, `export=1`)
  - `GET /v1/dashboard/charts/list`
  - `GET/POST /v1/dashboard/settings/*`
  - `GET /v1/dashboard/users/*` (staff management — rules in Module 1)

## Business Workflow
1. Admin picks a period (`day | week | month | 6month | year | custom` + dates) and optional branch filter.
2. Reports/charts query orders by **`related_date`** (the business-day key). For any non-custom period, **today's not-yet-closed orders are additionally included** (orders with `related_date NULL`, `day_closed 0`, created today) so dashboards are live before day close.
3. `export=1` turns a report into a CSV stored under `exports/{Y}/{m}/{type}/` and returns its public URL.
4. Charts endpoint returns KPI totals, two line charts (Total vs Net sales; Tax vs Discount) bucketed by hour/day/week/month depending on period, the open/closed status of each branch, and the top-selling branch.

## Business Rules
- **Excluded from all sales aggregates:** `cancelled` and `returned` orders (they still appear in the KPI counters as their own counts).
- **KPI totals:** total orders (all statuses in period), cancelled count, returned count, "delivered" count = completed count, gross = Σ(before_discount+vat), total sales = Σ(final), discount = Σ(before−after), net = Σ(final−vat), VAT total — computed only over non-cancelled/non-returned rows.
- **Orders report (daily rows):** per `related_date`: customers = orders count (again 1 guest/order), vat, gross (= before+vat), net (= final−vat), discount, final amount.
- **Payment gateway summary:** built from `order_payments` joins — split payments attribute the exact tendered amount per method; count = distinct orders per method. Export variant pivots per-day × payment-method amounts.
- **Branch items report:** per item per branch — qty = Σ qty, gross = Σ(qty × line price), first category name; only completed orders.
- **Add-ons are reported as items, not as modifiers.** The branch items report groups `order_items` rows by branch and item with **no filter on `order_items.type`**, so an add-on sold with a meal — its own `order_items` row with `type = addon` and `parent_order_item_id` pointing at the meal line — surfaces as its own report row, under its own item name, with its own qty and its own gross. Nothing in the report ties it back to the meal it accompanied, and a meal's reported qty/gross is unchanged by the add-ons sold with it. The per-order item list in the invoice export behaves the same way: every line of the order is listed flat, add-on lines included, with no meal/add-on distinction.
- **Top-items chart counts add-on quantities as items sold.** The best-selling-item query joins `order_items` to `items` and takes Σ `order_items.qty` grouped by item, again without filtering `order_items.type` — so an add-on competes for the top spot against meals, and a meal's rank is unaffected by how many add-ons were sold with it.
- **Replacement extra charges are reported inside the meal's own gross.** Both report and chart money figures read the line's stored `order_items.item_price`, which is written at order time as the item's price for the selected price mode **plus** the sum of the chosen replacements' `extra_charge` **plus** that line's payment-surcharge share. Consequences for head office: a customised meal's gross sales exceed its menu price × qty; two lines of the same item inside one reporting window can carry different unit values; and no report or chart attributes that uplift to the replacement that produced it. An ingredient **removal** never lowers the line price, so a stripped-down meal reports at full value.
- **Period definitions:** 🚩 **day = the business day, not the calendar day (SPRINT 1 · 09-08-2026, 2026-08-13)** — matches orders whose `related_date` is today, **plus the day that is still open**, whichever calendar date its orders were created on; week = current ISO week; month = current calendar month; 6month = last 6 months clamped to the current year's start if it crosses years; year = current calendar year; custom = `[startDate, endDate]` inclusive on `related_date`.
- 🚩 **SPRINT 1 · 09-08-2026 (2026-08-13):** The still-open business day is now identified **exactly as the POS closes one** (`closed_at IS NULL`, `day_closed = 0`, `related_date IS NULL`) and never by `created_at` — so a shift running past midnight is not half-counted, and the dashboard agrees with the DSR the branch prints. This open-day inclusion applies to **every** period, custom ranges included, but only for branches whose open day actually belongs to that window (decided per branch by the day's *opening* order) — a branch that opened on the 31st is not double-counted into the next month. **Previously `day` had no case of its own and silently fell through to `week` — "today" reported a whole week's numbers.**
- **Chart bucketing:** day → hourly buckets; week → daily; month → "Week n"-of-month; 6month/year → monthly; custom → daily (≤ 30 days) or monthly (> 30 days).
- **Open/closed branch:** a branch is "open" if its most recent order has `closed_at IS NULL` (shows opened-at = earliest open order time); "closed" otherwise (shows closed-at, business date and that day's order count).
- **Top branch:** the branch with the highest Σ(final_price) in the period (excludes cancelled/returned, excludes branch_id 0).
- 🚩 **SPRINT 1 · 09-08-2026 (2026-08-07) — Order History report:** a new order-by-order ledger sits behind the aggregate reports above — `GET /dashboard/reports/orders-history` returns order number, branch, timestamp, business date, order type, cashier, all payment methods, status, net, discount, sub total, additional fees, delivery fee, VAT, total and invoice link, one row per order. Same shared period presets as other reports (`day|week|month|6month|year|custom`, default `month`, resolved window echoed back as `filters`), filterable on `branches[]` and `status[]` (enum names **or** stored codes), paged (`page`/`per_page`, default 25, max 500), and `export=1` writes a CSV of the **whole** matching selection (not just the current page).
- 🚩 **SPRINT 1 · 09-08-2026 (2026-08-07) — Invoice ZIP export:** `POST /dashboard/reports/orders-history/invoices` zips the invoice PDFs of a picked set of orders, or of every order matching the current filters if none are picked — one folder per branch inside the zip, files named `<business date>_order_<number>.pdf`, non-ASCII branch names preserved. **Caps:** 10,000 invoices per download, up to 100 missing invoices regenerated per request, disk flushed every 1,000 files; anything still missing after that is reported back in `missing_invoices`.
- **Dashboard settings** (see Module list in Constants section): key/value store; keys derived from label (`lowercase, spaces→underscores`); types include text/image/editor/checkbox/time; file/image values stored on the public disk. Settings drive invoice branding, TRN display, order-number display, order-number start, and the (unimplemented) automatic close.

## Hidden Rules
- Anything **cancelled after a day is closed** stays cancelled in a closed day — reports recompute from statuses live, so historical report figures can drift if statuses are changed retroactively (no snapshotting except the DSR PDFs).
- "deliveredOrders"/"totalSales" KPI duplication: `totalSales` is set to the *delivered count* (copy-paste bug) — front-end must use `total_sales` instead.
- ~~`topItemSellings` in the charts response is actually a duplicate of `topBranchSelling` (the top-item query exists but returns nothing — dead code).~~ **Fixed 🚩 SPRINT 1 · 09-08-2026 (2026-08-10):** it's now a real query — the item with the highest total quantity sold in the period, excluding cancelled/returned orders, returned as `{item_name, total_quantity_sold}`. `totalSales`/`deliveredOrders` duplication (below) is **still open**.
- Settings update endpoint iterates *all* request keys — sending an unknown key aborts with "Invalid Setting key" after possibly updating earlier keys (partial update, no transaction rollback since commit/rollback calls are unbalanced).
- CSV exports are publicly accessible URLs on the public disk — anyone with the link (guessable timestamped path) can read revenue data.
- `ReportsRepository2` is an older unused copy (kept in repo).
- 🚩 **SPRINT 1 · 09-08-2026 — risk to flag:** the Order History invoice ZIPs land on the **public disk at guessable paths** and bundle **customer names, phones and addresses** for external orders — same public-disk exposure pattern as the CSV exports noted above, but with PII this time.
- Charts "day" period line chart groups today's live orders by creation hour; after day close the same orders re-bucket by `related_date` (which has no time) — intraday charts of closed days collapse.

## Decision Tree (period resolution — shared by reports & charts)
```
period := request.period or 'month'
IF period == 'custom' → WHERE related_date BETWEEN startDate AND endDate
ELSE → (WHERE period-window on related_date) OR (today's open orders: related_date NULL AND day_closed=0 AND created today)
IF branches[] given → AND branch_id IN branches
Aggregates exclude status IN (cancelled, returned) where noted
IF export == 1 → write CSV, return URL ELSE return JSON rows
```

## Database Understanding
- Reporting reads only denormalized order money columns + `order_payments`; there is no separate fact table — the orders table *is* the ledger. This is why day close stamps `related_date`: it converts an operational table into a reportable one.
- `daily_sales_reports` provides the immutable PDF audit trail; JSON reports are always recomputed live.

## State Machine
None beyond order states (Module 4) and day state (Module 6).

## APIs
| Endpoint | Purpose | Input | Output | Errors | Side effects |
|---|---|---|---|---|---|
| `GET /dashboard/reports/branches-items` | Item mix per branch | period/branches/export | rows or CSV URL | 500 | CSV file when export=1 |
| `GET /dashboard/reports/payment-gateway-summary` | Tender totals | period/branches/export | rows or CSV URL | 500 | CSV |
| `GET /dashboard/reports/ordersReport` | Daily sales table | period/branches/export | rows or CSV URL | 500 | CSV |
| `GET /dashboard/charts/list` | KPIs + line charts + branch status + top branch | period/branches | composite JSON | 500 | — |
| 🚩 `GET /dashboard/reports/orders-history` | Order-by-order ledger | period/branches/status/page/per_page/export | paged rows or CSV of full selection | 500 | CSV when export=1 |
| 🚩 `POST /dashboard/reports/orders-history/invoices` | ZIP invoice PDFs | order ids[] or current filters | ZIP download URL + `missing_invoices` | 500 | regenerates up to 100 missing PDFs; writes ZIP to public disk |
| `GET /dashboard/settings/list`, `view/{key}` | Read settings | — | settings | 500 | — |
| `POST /dashboard/settings/update` | Change settings | map of key→value | success | 404 unknown key (partial) | file uploads stored |
| `POST /dashboard/settings/create` (route `update` group; store) | New setting | label (unique), type, value | success | 500 | key derived from label |

## Edge Cases
- Custom period without dates → nulls → BETWEEN null → empty results (no validation).
- Branch filter values are raw request input straight into `whereIn` (array expected; scalar breaks).
- Reports on a day currently trading mix stamped and unstamped orders correctly *only* for non-custom periods (custom periods miss today's live orders).
- Division by zero avoided in DSR but **not** relevant here; `average` fields absent.
- An item that exists on the menu both as a standalone meal and as an add-on reports under one single name/row — grouping is by item, so standalone sales and add-on sales of that item are summed together and cannot be separated in the report output.
- Per-item gross in the branch items report is Σ(qty × stored line price), so it cannot be reconciled against the menu price list once any line on the order carried a replacement extra charge or a payment surcharge.

## Assumptions
- Head office trusts live recomputation over snapshots for JSON reports.
- Excel/CSV consumers accept the CSV formats produced by `fputcsv` defaults.

## TODOs
- Fix `totalSales` KPI duplication (still open). ~~`topItemSellings` duplication; finish `topItemSelling`.~~ **Done 🚩 SPRINT 1 · 09-08-2026.**
- Validate custom period dates; validate `branches` input.
- Protect export URLs (signed URLs or auth).
- Delete `ReportsRepository2`.

## Open Questions
- Which figures does accounting treat as canonical: DSR PDFs (frozen) or dashboard JSON (live)? They can disagree after retroactive status changes.
- Should head-office reports and charts split meals from add-ons using `order_items.type` (separate rows, separate rankings, or an add-ons-only view), or is counting add-ons as ordinary items the intended reading of item mix and best sellers? See M04 and M06, which raise the same question from the order and day-close sides.
- Should replacement revenue be reportable in its own right — per replacement ingredient, or at least as a separate uplift column — rather than buried in the customised meal's gross? See M04 and M06.

---


---

# Module 8 — Noon Delivery Integration

> **KB version:** 2.0 · **Code baseline:** `b71c156` · **Last reviewed:** 2026-10-26
> **Epic:** EP-TBD · **Status:** Pending PO review
> **Pending PO review:**
> - automatic delivery handover on the final Noon `delivered` webhook (Draft · 2026-09-16/17) — includes a confirmed tech-debt item (webhook Request object reuse), see DC-2 in `ISSUES_TRACKER.md` and V-06 in `KB_UPDATE_REPORT.md`
> - a Noon-initiated cancellation auto-rejects the order back to the customer platform (order → `cancelled`/`cancelled` + queued `rejected` callback) and parks the tracker in the terminal `error` state carrying Noon's cancellation reason
> - a failed dispatch (Noon refuses the `create-task`, or the call throws) auto-rejects the order the same way, carrying Noon's error message as the reason, instead of leaving it approved with only a tracker `error` row
> - a `cancelled` webhook arriving after the delivery already completed is ignored, so a delivered order is never rejected back to the platform
> - the `rejected` platform callback for a provider-initiated rejection (a failed Noon dispatch, or Noon cancelling an accepted task) carries `is_delivery_rejected: true`, so the mobile app can tell a delivery-provider rejection from a cashier rejection
> **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:** Dispatch approved delivery orders to the Noon fleet, track the delivery task lifecycle via webhooks and polling, and log all traffic.
- **Business Goal:** Outsourced last-mile delivery for platform delivery orders with cashier-visible tracking.
- **Main Actors:** Noon (external API + webhooks), Cashier (triggers dispatch by approving; watches tracking), System (queue worker).
- **Dependencies:** External orders (Module 5), branches ↔ `noon_outlets` mapping, queue infrastructure (`noon` queue), config `services.noon`, log channel `noon_integration`.
- **Entry Points:**
  - Outbound: automatic on approval of a delivery-type external order; test endpoints `POST /api/v1/test/noon-integration`, `GET /api/v1/test/noon-integration/task/{reference}` (⚠ unauthenticated).
  - Inbound webhooks: `POST /api/v1/webhooks/noon/order-status`, `POST /api/v1/webhooks/noon/track-driver` (API-key protected, throttled 60/min).
  - Cashier polling: `GET /v1/pos/orders/trackOrderDelivery/{providerReferenceNumber}`.

## Business Workflow
1. Cashier approves an external **delivery** order → after DB commit, `sendOrderToNoon` runs.
2. Precondition: the order's branch must be linked to a Noon outlet (`branches.noon_outlet_id`); otherwise dispatch is skipped and an error is logged — **the order stays approved but no delivery is arranged**. This path returns before any Noon call, writes no tracker and does not reject the order, so it is the one remaining dispatch failure that is silent from the customer's perspective.
3. Dinex POSTs a `create-task` to Noon with: order reference (= the platform's order number), outlet codes/names, client code `noon_food`, drop-off (normalized lat/lng, address, contact name/phone, country `ae`, city), 🚩 `delivery_notes` = the customer's delivery instructions from the mobile app (**SPRINT 1 · 09-08-2026**, unsanitized/untruncated — see Module 5), `cod_value: 0`, `prepaid_value = ceil(final_price)`, idempotency key = internal order id.
4. On a **`200` response with body `status: 'successful'`** (🚩 tightened draft · 2026-09 — previously just checked the body field, which could warning-out if Noon returned no body at all), an `order_trackers` row is stored: provider `noon`, `provider_reference_number = mp_task_nr`, status `pending_assignment` (code 1), `error_msg = null`. **On any other response** (🚩 draft · 2026-09 — previously only a thrown exception produced this), a tracker with status `error` is stored, and `error_msg` is populated by parsing Noon's error body (checks `error`/`message`/`error_message`/`detail`/`errors` keys, falls back to the raw response body or `"HTTP <code>: Unknown error from Noon."`). This message is surfaced to the cashier on the external order as `orderTrackerDetail.error` (Module 5). Because a task Noon never accepted can never be delivered, both failure branches — the refused response and a thrown exception — also reject the order back to the platform through `OrderService::rejectExternalOrderByProvider()`: `status = cancelled`, `operation_status = cancelled`, and the queued `rejected` platform callback, so the customer's mobile app marks the order rejected. That callback body carries `is_delivery_rejected: true`, telling the mobile app the delivery provider — not the cashier — killed the order. The reason recorded with the rejection is the parsed Noon error message (or the exception message); it is logged and stored on the tracker only, and never travels to the platform.
5. Noon pushes status changes to the webhook: payload `{order_nr, status_code, order_reference}` (🚩 **bug fix, draft · 2026-09:** the handler previously read a non-existent `mp_task_nr` key from the payload — every inbound webhook was silently dropped with a "no tracker found" warning until this was corrected to read `order_nr`, the field the request validation had been checking all along). The endpoint verifies `X-API-Key` against `NOON_WEBHOOK_API_KEY`, immediately ACKs `202 Accepted`, and queues a job (`noon` queue, unique per task+status, 3 tries, backoff 30/60/120s).
6. The job finds the tracker by `order_nr`; if the status is unchanged, or is a regression, → skip (idempotent/rejected). Otherwise it updates the tracker's status (numeric code) and writes an `integration_logs` audit row. 🚩 **Draft · 2026-09-16/17 — automatic delivery handover:** if the new status is `delivered` and it's the tracker's first time reaching that status, the same transaction now also sets `external_orders_data.is_delivered = 1` and calls `OrderService::deliverExternalOrder()` (Module 5) automatically — the cashier no longer has to manually mark a Noon-delivered order as handed over; the platform gets its "completed" callback automatically too. ⚠ Implementation detail worth flagging: this reuses the **inbound webhook's own Request object** (via the `Request()` global helper, mutated with `->id = $order->id`) rather than constructing a clean value object — fragile if `deliverExternalOrder` ever comes to read other request fields.
7. If the new status is `cancelled` (Noon dropped the delivery), the same transaction parks the tracker in the local terminal state `error` — **not** the numeric `cancelled` code 7 — and stores Noon's cancellation reason in `order_trackers.error_msg`, so the cashier sees status "Error" plus the reason on the external order. In the same transaction the order itself is then rejected back to the platform exactly as a cashier rejection does: `status = cancelled`, `operation_status = cancelled`, and the queued `rejected` platform callback (`NotifyPlatformOrderUpdatedJob`, after commit) so the customer's mobile app marks the order rejected. That callback carries `is_delivery_rejected: true`, exactly as a failed dispatch does; Noon's cancellation reason is not part of it.
8. Cashier can poll live task details via `trackOrderDelivery` (proxied Noon `GET /tasks/{ref}`); the external-orders list also displays the tracker's humanized status.

## Business Rules
- Delivery status vocabulary (`NoonOrderStatusEnum`, stored codes): `pending_assignment(1) → assigned(2) → arrived_at_pickup_location(3) → picked_up(4) → arrived_at_delivery(5) → delivered(6)`, plus `cancelled(7)`.
- All Noon deliveries are **prepaid** (`cod_value` always 0); the prepaid value is the order total **rounded up to the whole dirham** (`ceil`).
- Coordinates are normalized to Noon's integer micro-degree format: strip non-digits, keep sign, truncate to 9 digits.
- Webhook security: if `NOON_WEBHOOK_API_KEY` is unset in non-local environments the webhook answers **503** (fails closed); in `local`/`test` (🚩 draft · 2026-09-16 — was `local`/`testing`; note this app's environment name is `test`, not Laravel's conventional `testing`, so the old check likely never actually matched) it's bypassed. Missing/incorrect key → 401. Constant-time comparison used.
- Webhook payload validation: `order_nr`, `status_code`, `order_reference` all required (else 422). `driver_id` and the cancellation-reason keys (`reason`, `cancellation_reason`, `cancel_reason`, `status_reason`, `message`) are accepted as optional nullable fields — they must be declared on the FormRequest or `validated()` strips them and the reason never reaches the tracker.
- A Noon-side cancellation (`status_code = cancelled`) maps to the tracker state `error`, never to the numeric code 7, and `error_msg` carries Noon's reason: the first present of `reason`, `cancellation_reason`, `cancel_reason`, `status_reason`, `message` in the webhook payload (string or JSON-encoded array), falling back to the literal `"Cancelled by Noon."` when none is present. The tracker therefore always records *why* the delivery died.
- A tracker already in `error`, `cancelled` (7) or `delivered` (6) is terminal: any further Noon webhook for that task is skipped and logged. A finished delivery is finished whichever way it finished — a late in-flight push cannot resurrect a dead task, and **a delivered delivery cannot be un-delivered**. The `delivered` half of that set matters because `cancelled` (7) outranks `delivered` (6) numerically and would otherwise pass the ordering guard, parking the tracker in `error` and rejecting an order already handed to the customer — sending the platform a `rejected` event for a delivery that had completed, after `is_delivered` was set and the `completed` callback already went out. This check is separate from the numeric ordering guard, which also cannot catch `error` (it is not a lifecycle rank and casts to `0`).
- A dispatch that Noon refuses (any response other than `200` with body `status: 'successful'`) or that throws stores the tracker as `error` + `error_msg` **and** rejects the order back to the platform with that message as the reason — an undeliverable order is never left sitting approved. The queued `rejected` callback carries `is_delivery_rejected: true`; the reason itself stays local. This holds for the manual test endpoint too, since it calls `sendOrderToNoon` directly.
- `error` is the single local tracker state for a dead delivery, whatever killed it — Noon refused the task at dispatch, or Noon cancelled a task it had accepted — and `error_msg` always carries the matching Noon message. The numeric `cancelled` (7) is reserved for a task Dinex itself recalled. Both are terminal, as is `delivered` (6).
- Rejecting the order after a provider cancellation never fails the webhook worker: if the order is already `cancelled` the rejection is skipped (logged), and if the order belongs to a closed day (`day_closed` set or `closed_at` not null) its status is left untouched and a warning is logged with the reason. In both cases the tracker still records `error` + the reason, and whenever the callback does go out it carries `is_delivery_rejected: true`.
- Duplicate webhook deliveries are ignored twice over: job uniqueness (task+status) and the tracker same-status check.
- Every processed webhook is written to `integration_logs` (order id, provider, url tag, payload, status).
- Retry policy: 3 attempts, exponential backoff; permanent failure only logs (order tracker stays stale).

## Hidden Rules
- **Noon status changes never touch the order's own `status`/`operation_status`** — only the tracker moves, with two exceptions: 🚩 **draft · 2026-09-16/17** — the final `delivered` webhook also flips `external_orders_data.is_delivered` and fires the platform "completed" callback automatically (see Business Workflow step 6); and a `cancelled` webhook rejects the order outright — `status`/`operation_status` both become `cancelled` and the platform gets a `rejected` callback (see Business Workflow step 7). Mid-lifecycle statuses (assigned, picked up, etc.) still only update the tracker; the platform is not notified of those (Noon presumably notifies separately).
- The order rejection on a Noon cancellation **and** on a failed dispatch is performed by the same method, `OrderService::rejectExternalOrderByProvider(Order, provider, reason)` — the same cancelled/cancelled + queued `rejected` callback the cashier reject path produces — and it is deliberately total: it logs and returns instead of throwing, so a queue worker is never retried to death over an order it cannot legally touch. The dispatch path wraps the call on top of that, logging any failure to the `noon_integration` channel so a rejection problem can never bubble out of `sendOrderToNoon`. Every rejection it produces flags the platform callback `is_delivery_rejected: true` — the same for both Noon triggers (a refused or failed `create-task`, and Noon cancelling a task it had accepted) — against `false` for a cashier rejection through the POS endpoint (Module 5). The flag rides the `rejected` event only; the `delivered` and status-change callbacks never carry it. The rejection reason is not part of the callback at all: it reaches only the `integration` / `noon_integration` logs and `order_trackers.error_msg`.
- The already-cancelled and closed-day skips therefore apply identically to both triggers: in either case the tracker still records `error` with the reason, and only the order's own status is left alone.
- `cancelNoonOrder` sets the tracker to the numeric `cancelled` (7), while a Noon-initiated cancellation sets `error`: the numeric 7 therefore means Dinex recalled the courier, and `error` covers every delivery that died on Noon's side — refused at dispatch or cancelled afterwards — without distinguishing the two.
- A cashier-initiated cancel does not re-send a Noon recall when the tracker is already `error` (Noon never had the task, or cancelled it on its own side) — `CancelNoonOrderJob` is skipped for `error`, `cancelled` and `delivered` trackers.
- ~~The webhook validation rule `unique:order_trackers,provider_reference_number,provider` is malformed...~~ **Fixed 🚩 draft · 2026-09:** the rule is now `exists:order_trackers,provider_reference_number` — an honest "the tracker must already exist" check, replacing the previous malformed `unique` rule.
- `trackDriverNoonWebHook` requires `status_code` (same FormRequest) and logs a `driver_id` field that validation never guarantees — driver-tracking pushes without `status_code` are rejected 422; the endpoint is effectively the same as order-status.
- ~~`getOrderByProviderReferenceNumber` contains an early `return $responseData;` — the code that would sync the tracker status after polling is **dead**; polling never updates local state.~~ **Fixed 🚩 SPRINT 1 · 09-08-2026** — polling now syncs the local tracker status.
- ~~`cancelNoonOrder` exists (would POST `/tasks/{ref}/cancel`) but is **never called** — there is no way to cancel a dispatched delivery from Dinex today.~~ **Fixed 🚩 SPRINT 1 · 09-08-2026** — `cancelNoonOrder` now recalls a cancelled delivery (wired into the reject/cancel flow).
- A dispatch exception returns no Noon response to the caller, so the tracker `error` row plus the order’s own rejection are the signals that the dispatch died — the caller cannot tell from the return value.
- The two `/api/v1/test/noon-integration*` endpoints were unauthenticated — **fixed 2026-07-07**: they now require a staff bearer token (`auth_api`). They remain manual test tools, not part of the normal flow.
- Environment switch: `APP_ENV=production` → live Noon URL + `NOON_LIVE_API_KEY`; anything else → staging URL + `NOON_TEST_API_KEY`.

## Decision Tree
```
DISPATCH (on approve of delivery external order, or a manual test re-dispatch):
IF branch has no noonOutlet → log error, skip (no tracker; order remains approved, undelivered)
ELSE POST create-task
    IF HTTP 200 AND response.status == 'successful' → tracker := pending_assignment + mp_task_nr, error_msg := null
    ELSE (refused response OR exception)
        → tracker := 'error' + error_msg := parsed Noon error (or the exception message)
        → rejectExternalOrderByProvider(order, 'noon', that message)  [same skips as below; failures only logged]
           → queued 'rejected' callback carries is_delivery_rejected: true (the reason is logged only, never sent)

WEBHOOK IN:
IF secret unconfigured AND env not local/testing → 503
IF X-API-Key missing → 401 ; IF mismatch → 401
IF payload incomplete (order_nr / status_code / order_reference) → 422
→ 202 + queue job
JOB:
IF no tracker for mp_task_nr → warn, drop
ELSE IF status_code not a known Noon status → warn, drop
ELSE IF tracker.status IN ('error', 7 cancelled, 6 delivered) → drop (task already terminal;
                                                                 a 'cancelled' push cannot undo a delivery)
ELSE IF tracker.status == status_code → drop (duplicate)
ELSE IF status_code is a regression vs tracker.status → drop (🚩 SPRINT 1 · 09-08-2026, BL-9)
ELSE (one transaction)
    IF status_code == 'cancelled'
        → tracker := 'error' + error_msg := Noon's reason (or "Cancelled by Noon.")
        → write integration log
        → rejectExternalOrderByProvider(order, 'noon', reason):
              IF order.status == cancelled → log, skip
              ELSE IF order.day_closed OR order.closed_at → warn, leave order status untouched
              ELSE order.status := cancelled; order.operation_status := cancelled
                   → queue NotifyPlatformOrderUpdatedJob(order, 'rejected', is_delivery_rejected: true) after commit
    ELSE
        → tracker := numeric status code; write integration log
        → IF first time reaching 'delivered' → is_delivered := 1; deliverExternalOrder()
```

## Database Understanding
- `order_trackers` — one row per (order, provider): the delivery task's local mirror (provider ref + latest status + 🚩 `error_msg`, draft · 2026-09-17, populated whenever dispatch fails and whenever Noon cancels the delivery, where it holds Noon's cancellation reason). `status` holds either a numeric lifecycle code (1–7) or the literal local state `error`. Exists so POS screens don't call Noon synchronously.
- `noon_outlets` — mirror of Noon's outlet registry (codes, bilingual names, geo, address code, serviceability/active flags) seeded via `NoonOutletSeeder`; the dispatch payload is assembled from it.
- `integration_logs` — request/response audit for integration events (currently only webhook processing writes it).

## State Machine (delivery tracker)
```
(created) pending_assignment → assigned → arrived_at_pickup_location → picked_up → arrived_at_delivery → delivered
any state → cancelled (7) — Dinex recalled the courier (cashier cancel → cancelNoonOrder)
any state → 'error'   — the delivery is dead: Noon refused the task at dispatch (bad response or exception) or cancelled a task it had accepted (webhook status 'cancelled'); error_msg = Noon's message, and the order is rejected back to the platform
terminal states: delivered (6), cancelled (7), 'error' — no further webhook is applied to a tracker in any of them, so 'cancelled' never follows 'delivered'
```
Triggers: Noon webhooks (lifecycle advances, `error` on a Noon cancellation), cashier polling (syncs a known lifecycle code), and the cashier cancel path (`cancelled` via `cancelNoonOrder`).

## APIs
| Endpoint | Purpose | Input | Output | Validation | Errors | Side effects |
|---|---|---|---|---|---|---|
| `POST /api/v1/test/noon-integration` | Manually (re)dispatch an order | order_id | raw Noon response | order exists | 500 | Noon task creation; tracker upsert; a failed dispatch also rejects the order (cancelled/cancelled + queued `rejected` platform callback) |
| `GET /api/v1/test/noon-integration/task/{ref}` | Fetch Noon task | ref | raw Noon payload | tracker exists (404-style via firstOrFail) | 500 | none (sync is dead code) |
| `POST /api/v1/webhooks/noon/order-status` | Delivery status push | order_nr, status_code, order_reference (+ optional reason keys) | 202 | API key + fields | 401/422/503 | queued tracker update + log; auto-delivers on final `delivered` status 🚩 draft 2026-09; on `cancelled` parks the tracker in `error` with Noon's reason and rejects the order (cancelled/cancelled + queued `rejected` platform callback) |
| `POST /api/v1/webhooks/noon/track-driver` | Driver push | same + driver_id | 202 | same | 401/422/503 | same job |
| `GET /pos/orders/trackOrderDelivery/{ref}` | Cashier live view | ref | Noon task JSON | tracker exists | 500 | none |

## Edge Cases
- Noon responds non-JSON / missing `status` key → exception path → tracker `error` and the order is rejected back to the platform.
- A manual re-dispatch through the test endpoint fails → the order is rejected, so the endpoint is not a safe retry tool on a live order.
- Dispatch fails on an order whose day is already closed, or that the cashier already cancelled → the tracker records `error` + the message, but the order's status is left untouched (logged).
- Webhook arrives before dispatch response was processed (race) → "no tracker" warning; Noon's retry (if any) later succeeds.
- Order deleted (cascade) removes trackers and logs (FK cascade).
- Same order re-approved → same idempotency key → Noon should return the original task.
- Noon cancels a delivery on an order whose day is already closed → the tracker becomes `error` with the reason, the order's status is deliberately left as it was, and a warning carrying the reason is logged; the discrepancy has to be settled manually.
- Noon cancels a delivery on an order the cashier had already cancelled → the rejection is skipped (nothing to reject) but the tracker still records `error` + the reason.
- A mid-lifecycle status arrives after a `cancelled` webhook (out-of-order push) → dropped, because the tracker is terminal in `error`.
- A `cancelled` webhook arrives after the tracker already reached `delivered` → skipped and logged: the tracker stays `delivered` and the completed order is not rejected back to the platform.
- Noon sends `cancelled` with no recognizable reason key → `error_msg` reads "Cancelled by Noon." so the cashier never sees an empty failure.

## Assumptions
- Noon retries webhooks on non-2xx (Dinex ACKs early to be safe).
- A queue worker for the `noon` queue is running in production (nothing in-repo provisions it).
- One delivery task per order (tracker unique per order+provider via updateOrCreate semantics).

## TODOs
- ~~Wire `cancelNoonOrder` to the reject flow for already-dispatched orders.~~ **Done 🚩 SPRINT 1 · 09-08-2026.**
- ~~Remove/protect the test endpoints~~ — protected with `auth_api` (2026-07-07); consider removing entirely once Noon integration is stable.
- ~~Fix dead tracker-sync code in polling~~ **Done 🚩 SPRINT 1 · 09-08-2026.** Fix undefined `$response` in the catch — still open.
- Notify the customer platform on mid-lifecycle delivery progress (only the terminal outcomes reach it today: `delivered` → "completed", Noon `cancelled` → "rejected"; assigned/picked up/etc. are cashier-only).
- Alerting when dispatch is skipped for a missing outlet link — the only dispatch failure that neither writes a tracker nor rejects the order (today it's just a log line).

## Open Questions
- ~~Contractual Noon webhook retry/ordering guarantees (out-of-order status regressions are accepted blindly...)~~ — **Fixed 🚩 SPRINT 1 · 09-08-2026 (BL-9):** webhook status regressions are now rejected, not just exact duplicates. (Underlying contractual ordering guarantee from Noon itself is still unknown — worth confirming.)
- Is `package_count: 0` intentional?

---


---

# Module 9 — Payment Types, Order Types & Promo Codes (Commercial Configuration)

> **KB version:** 2.0 · **Code baseline:** `b71c156` · **Last reviewed:** 2026-10-26
> **Epic:** EP-TBD · **Status:** Pending PO review
> **Pending PO review:**
> - The `has_additional_fee` surcharge is charged on replacement extra charges (a customised meal's uplift), not only on menu prices.
> - The `has_additional_fee` surcharge is charged on every add-on line on its own price.
> - `enable_delivery_app_price` is resolved per line — add-on lines are charged the add-on item's own `delivery_app_price`, so one order can mix lines priced with and without the delivery-app price.
> - A payment type with `enable_delivery_app_price` must be the order's sole payment method (split payments rejected).
> **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:** Define tender methods (with surcharges and platform links), service modes (dine-in/pickup/delivery), and promotional discount codes.
- **Business Goal:** Control which payment methods each service mode accepts, price in card/gateway fees, and run named promotions.
- **Main Actors:** Admin/Superadmin (CRUD), Cashier (reads; applies promo codes), Platforms (mapped to payment types).
- **Dependencies:** Orders (pricing engine consumes all three), Platforms.
- **Entry Points:** `/v1/dashboard/payment-types/*`, `/v1/dashboard/order-types/*`, `/v1/dashboard/promo-code/*`; POS: `/v1/pos/payment-types/*`, `/v1/pos/promo-code/view/{name}`.

## Business Workflow
1. Admin creates payment types (name unique; optional settlement `account_no`; optional surcharge: `has_additional_fee` + integer `additional_fee` percent).
2. Admin creates order types (seeded: **Dine-in, Pickup, Delivery**) and assigns which payment types each accepts (`order_type_payment_type`). The POS boot payload delivers order types *with* their allowed payment types, so the cashier can only choose valid combinations client-side.
3. Admin creates promo codes: unique `name` (the code itself), `type` ∈ {percentage, amount}, `amount`.
4. At sale time the cashier looks up a promo code **by name**; if found, its type/amount are applied by the pricing engine (Module 4). Promo data is copied onto the discount row (promo_code_id retained for analytics).

## Business Rules
- Payment type name unique (create and update).
- `additional_fee` is an **integer percentage** (DB column int, default 0); fee applies only when `has_additional_fee` truthy.
- The surcharge of every selected payment type with `has_additional_fee` contributes its `additional_fee` percent to the order's **effective fee rate**, which is then applied **per order line** as `feeRate × basePrice / 100`.
- A line's `basePrice` is the item's price for the selected price mode **plus the sum of the chosen ingredient replacements' `extra_charge`**. The surcharge is therefore charged on a customised meal's uplift as well as on its menu price.
- Each add-on sold with a meal is its own order line and carries the same surcharge on its own price — the surcharge reaches every line of the ticket, not only the meal lines. (Line pricing itself is Module 4.)
- `enable_delivery_app_price` makes the order charge items at their `delivery_app_price`; the choice is resolved **per line** (meal item and each add-on item separately). A line whose item has no delivery-app price falls back to that item's normal price.
- A payment type with `enable_delivery_app_price` can only be used as the order's **sole** payment method: an order whose selected payment types include one with the flag on is rejected if it carries 2 or more payments (validation detail in M04).
- `is_external = 1` marks platform tenders: such orders are hidden from the in-house POS list and shown in the external tab; external payment types are created by seeders, **not** settable via the dashboard API (store/update never touch `is_external`).
- `is_subscription` column exists (default 0) — **no behavior implemented.**
- Deleting a payment type is a hard delete; `order_payments.payment_type_id` FK is `restrictOnDelete` → deleting a used tender fails at DB level (500 to the admin).
- Order types: name required; payment types synced when provided; hard delete with no usage check (orders keep the dangling `order_type` id; resources then render name `null`).
- Promo codes: no expiry dates, no usage limits, no minimum order value, no per-customer limits — **a promo code is valid forever for everyone** until deleted/renamed.
- Promo lookup is by exact name; response includes type/amount so the POS can show the discount before submitting.
- Manual discounts (no promo) are equally allowed by the engine — any cashier with `order.create` can grant an arbitrary discount of any size (no cap, no approval).

## Hidden Rules
- **Order-type IDs are a company standard (confirmed by the business 2026-07-07):** `1 = Dine-in`, `2 = Pickup`, `3 = Delivery` — fixed by convention, never to change; new order types take IDs ≥ 4. The standard is encoded as constants on the `OrderType` model (`OrderType::DINE_IN/PICKUP/DELIVERY`), and the Noon dispatch check uses `OrderType::DELIVERY`. The legacy `OrderTypesEnum` is `@deprecated` (2026-07-07) and survives only for dead legacy code — do not add new usages. Any seeder or manual DB work on `order_types` MUST preserve these IDs.
- POS `payment-types/list` actually returns **order types with nested payment types** (the `payment_type` collection is built then discarded) — the endpoint name is misleading.
- Because the order-type↔payment-type constraint is enforced only client-side, the API will happily accept any payment type for any order type.
- A promo code of type `amount` larger than the order net produces a **negative subtotal** (no floor).
- Every order line records its own `used_delivery_app_price` boolean, so a single order can mix lines that were charged at the delivery-app price with lines that fell back to the normal price (an add-on without a `delivery_app_price` sold under a delivery-app-price tender).
- The surcharge is configured as a tender fee on the menu price, but because it is applied to the line's `basePrice` it also scales with the replacement extra charges the cashier picked.
- `Discount` model has `discount_amount` in fillable but the table column is `value` — leftover; the real fields are `discount_type`, `value` (entered), `calculated_value` (money effect).

## Decision Tree
```
PROMO APPLY (inside order create/update):
IF discount.promo_code_id present → type := promo.type, amount := promo.amount
ELSE → type := discount.discount_type, amount := discount.value
IF type == 'percentage' → value := net × amount/100
ELSE → value := amount  (may exceed net; no clamp)
```

## Database Understanding
- `payment_types` — tender dimension for every money report; also the *channel* marker (`is_external`), the platform link (`platform_payment_type`), and the two pricing switches (`has_additional_fee` + `additional_fee`, `enable_delivery_app_price`) that change what each order line is charged.
- `order_types` — service-mode dimension (dine-in/pickup/delivery) driving DSR sections and the Noon dispatch decision.
- `order_type_payment_type` — the business matrix "which tenders are acceptable for which service mode" (advisory, client-enforced).
- `promo_codes` / `discounts` — a promo is a *template*; a discount is its *application* to one order (with the computed value frozen).

## State Machine
None (no lifecycle on these entities).

## APIs (deltas beyond generic CRUD)
| Endpoint | Notes |
|---|---|
| `POST /dashboard/payment-types/create` | name unique; syncs `order_type_ids` when provided |
| `PUT /dashboard/payment-types/update` | partial update; cannot change `is_external`/`is_subscription` |
| `POST /dashboard/order-types/create|update` | name; `payment_type_ids` sync |
| `GET /pos/promo-code/view/{name}` | 404-style with `success:true` if unknown |
| `GET /pos/payment-types/list` | returns order types with allowed payment types |

## Edge Cases
- Two promo codes differing only by case: MySQL default collation → lookup may match either (case-insensitive uniqueness not guaranteed by validation).
- Deleting an order type that platform mapping/type=3 logic relies on breaks delivery dispatch invisibly.
- An add-on item with no `delivery_app_price` sold under a delivery-app-price tender is charged its normal price while the meal beside it is discounted to the delivery-app price — the ticket mixes both price modes with no warning.
- Turning on both switches on one payment type stacks them: the line is priced at the delivery-app price and then the surcharge percent is added on top of it (plus any replacement extra charges).

## Assumptions
- Fee percentages are whole numbers.
- Promo codes are distributed offline (no customer accounts exist).

## TODOs
- Promo governance: expiry, usage caps, min order, audit of who applied manual discounts.
- ~~Replace `order_type == 3` with a semantic flag on order types~~ — not needed: fixed order-type IDs (1/2/3) are an accepted company standard (2026-07-07). Guard the standard operationally instead (don't reseed `order_types` in production; new order types get IDs ≥ 4).
- Discount cap / negative-total guard.

## Open Questions
- Meaning of `payment_types.account_no` (settlement account? printed nowhere).
- Intended UX for `is_subscription` tenders.
- Is charging the `additional_fee` surcharge on ingredient replacement extra charges intended? The surcharge is configured as a tender fee on the menu price, yet it is applied to the customised line price (menu price + replacement extras).
- Should a payment type with `enable_delivery_app_price` also force the add-on lines to a delivery-app price, i.e. is the fallback to the add-on's normal price the wanted commercial behaviour or a catalog gap to be filled per add-on item?

---


---

# Cross-Cutting Reference

> **KB version:** 2.0 · **Code baseline:** `21e26e1` · **Last reviewed:** 2026-11-03
> **Epic:** EP-TBD · **Status:** Pending PO review
> **Pending PO review:**
> - kitchen status `pending`→`ready` rename, cross-referenced from Module 4
> - recipe/replacement/add-on/order-line-type glossary + constants, cross-referenced from M03/M04
> - item source (`items.created_from`) glossary: `dashboard` or the pushing platform's `slug`; any authenticated platform may push items; CostControl item sync via `/pos/items/cc/item-creation` (+ dry-run simulate endpoint, runs as the calling platform); risk/open questions on CostControl's missing platform row
> - CostControl (CC) order send: glossary entry; source constants `standard` / `branch-plan` / `delivery-app`; `services.cost_control` config (UAT host in production, QC host elsewhere; token TTL); `cost_control` daily log channel with masked secrets; `integration_logs` one row per request with nullable `attempt`; risk/open questions on `order_number` uniqueness and the assumed login token field shape
> **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)

---

## Business Glossary
| Term | Meaning in Dinex |
|---|---|
| **Order** | A single customer transaction at a branch; the financial document with VAT-inclusive pricing. |
| **Business day** | The window from the first open order until the cashier closes the day; keyed by `related_date` (the open date). |
| **Day close** | The end-of-day operation freezing orders, producing the DSR pair, and resetting order numbers. |
| **DSR** | Daily Sales Report — the printable end-of-day reconciliation (sales report + items report). |
| **Open order** | `day_closed = 0` and `closed_at IS NULL` — belongs to the current business day. |
| **Operation status** | The kitchen/preparation status, independent of payment status. |
| **External order** | An order imported from a platform (mobile app), identified by `external_order_id` and paid via an `is_external` payment type. |
| **Platform** | An authenticated external system (e.g. Asparagus Mobile App) allowed to submit orders and receive status callbacks. |
| **Mapper slug** | The stable external identifier of an item or branch used by platforms (`items.mapper_slug`, `branches.mapper_slug`). |
| **Custom item / Make your own meal** | `is_custom=1` items are components assembled through the multi-category resolver; grouped on the order via `group_id`. |
| **Additional fee** | A percentage surcharge tied to a payment method (e.g. card fee), folded into the taxable base and line prices. |
| **Net (price_before_discount)** | Order value excluding VAT, including surcharges, before discount. |
| **Sub total (price_after_discount)** | Net minus discount. |
| **Final price** | Sub total + mobile delivery fee + VAT — what the customer pays. |
| **Tracker** | The local mirror of a Noon delivery task (`order_trackers`). |
| **Outlet** | Noon's registered pickup location, linked 1:1 to a branch. |
| **Guest** | Reporting proxy: 1 guest = 1 order. |
| **TRN** | UAE Tax Registration Number, printed on invoices per branch. |
| 🚩 **SKU** | `items.sku` — the identifier the mobile app now orders by (unique, nullable); `mapper_slug` survives only as the SKU-sync matching key. |
| 🚩 **MYOM** | "Make Your Own Meal" ordered from the mobile app — a composed meal bound to a seeded `myom-N` placeholder item, with the platform's own price/name captured in `order_items.custom_name`. Temporary mapping, distinct from the older custom-category component flow in Module 3. |
| 🚩 **Order time** | The kitchen prep-time *estimate* shown on a ticket (`orders.preparation_time` → `order_time`), derived from the slowest item's `preparation_time`, scaled by meal count. Not the same as `actual_order_time` (measured). |
| 🚩 **Order History** | The order-by-order ledger report (`/dashboard/reports/orders-history`) behind the aggregate reports, with a companion invoice-ZIP export. |
| **Ingredient category** | A node of the CostControl-style ingredient tree in `ingredient_categories`: type (level 0) > category (level 1) > sub category (level 2). Only leaf categories hold ingredients. See Module 3. |
| **Recipe line** | One `item_ingredients` row — an ingredient and its size in an item's recipe, with `is_removable` (cashier may remove it) and `sort_order`. See Module 3. |
| **Replacement** | An allowed swap for a recipe line (`item_ingredient_replacements`); the substitute must belong to the same leaf ingredient category as the original and may carry an `extra_charge`. See Modules 3 and 4. |
| **Add-on** | An item placed in the menu category with code `ASP_CAT_1000` or any category below it; a meal offers specific add-ons through `item_addons`. See Modules 3 and 4. |
| **Order line type** | `order_items.type` — `item` (meal/regular line) or `addon`; an add-on line sold with a meal points to it through `parent_order_item_id`. See Module 4. |
| **Recipe snapshot** | `order_item_ingredients` — the per-line copy of the recipe at sale time, each row with status `default`, `replaced` or `removed`. See Module 4. |
| **Item source (created_from)** | `items.created_from` (nullable string) — where an item was created: `dashboard` (dashboard item form) or the `slug` of the platform that pushed it (any platform authenticated by `PlatformAuthMiddleware` may push items; none is refused). Set on creation only; an update keeps the original source and `created_by`. Null for items created before the column existed. For platform pushes `created_by` is the platform's related user; for the dashboard it is the dashboard user. See Module 3. |
| **CostControl (CC)** | External system that consumes stock for served orders. Dinex sends it each completed order that is out of the kitchen (operation status ready or completed), once per order, with item/ingredient SKUs, replacements, removals and add-ons (`CostControlService`). |

## Domain Dictionary (entity → business identity)
- `users` — branch/back-office staff; single role, single branch.
- `branches` — locations; own tax policy, invoice identity, day cycle, Noon outlet link.
- `branch_settings` — printer/scanner/receipt-language per branch.
- `categories`/`parent_categories` — menu navigation DAG with levels 0–2.
- `items`/`item_categories`/`item_ingredients`/`item_attributes` — sellable products, their menu placement, recipe lines, variants.
- `ingredients` — bilingual recipe components (pcs/gram/ml/liter), optionally assigned to a leaf `ingredient_categories` node.
- `ingredient_categories` — three-level ingredient tree (type > category > sub category).
- `item_ingredient_replacements` — allowed same-leaf-category substitutes per recipe line, with `extra_charge`.
- `item_addons` — meal → add-on item offers.
- `orders` — sales ledger rows (UUID); frozen money + dual status + day-close stamps.
- `order_items` — line snapshots (qty, unit price incl. fee share, meal group, line `type`, `parent_order_item_id`).
- `order_item_ingredients` — per-order-line recipe snapshot (default/replaced/removed).
- `order_payments` — tender records enabling split payment; source of truth for "how was it paid". 
- `discounts` — applied discount per order (type, value, computed value, promo link).
- `promo_codes` — named discount templates (percentage/amount), no expiry.
- `payment_types` — tenders; surcharge config; `is_external` channel marker; platform mapping.
- `order_types` — service modes with **fixed standard IDs**: Dine-in (1), Pickup (2), Delivery (3); the IDs are part of the business contract (Noon dispatch keys on 3).
- `order_type_payment_type` — allowed tender per service mode (client-enforced).
- `platforms`/`platform_tokens`/`platform_payment_type` — external systems, their auth, and their tender mapping (`type` pivot: `branch-order`, `branch-plan`). A platform's `slug` is recorded as the item source (`items.created_from`) of items it pushes; it does not decide whether the platform may push items.
- `external_orders_data` — platform order context (customer, address, geo, car number) + `is_delivered`.
- `order_trackers` — Noon task mirror per order.
- `noon_outlets` — Noon outlet registry mirror.
- `integration_logs` — webhook/integration audit. For CostControl (`provider = cost_control`) one order has many rows, one per request sent (login and order creation, each try); the nullable `attempt` column records which job try wrote the row.
- `daily_sales_reports` — DSR PDF registry (parent sales + child items rows).
- `dashboard_settings` — global key/value business settings.
- `oauth_user_tokens` — staff bearer tokens (1-day, single-session).

## Acronyms
- **POS** — Point of Sale. **DSR** — Daily Sales Report. **TRN** — Tax Registration Number (UAE VAT). **VAT** — Value Added Tax (5% UAE). **COD** — Cash on Delivery (always 0 here). **EAV** — Entity-Attribute-Value (item attributes). **CRUD** — Create/Read/Update/Delete. **mp_task_nr** — Noon's marketplace task number (delivery reference).

## Important Constants (hardcoded in code)
| Constant | Value | Where |
|---|---|---|
| VAT rate | 5% (`×100/105` net factor, `×5/100` VAT) | Order pricing, DSR, reports |
| Token lifetime (users & platforms) | 1 day | Auth services |
| Token length | 80 chars (SHA-256 stored) | Auth services |
| Order statuses | active 1, completed 2, cancelled 3, returned 4, inReview 5 | `OrderStatusEnum` |
| Operation statuses | inProgress 1, completed 2, ready 3 (🚩 renamed from `pending`, draft · 2026-09; API still accepts `pending` as alias), cancelled 4, inReview 5 | `OperationStatusEnum` |
| Noon statuses | pending_assignment 1 … delivered 6, cancelled 7 | `NoonOrderStatusEnum` |
| Order-type ID standard | Dine-in 1, Pickup 2, Delivery 3 — **fixed company standard**; Noon dispatch checks `OrderType::DELIVERY` | `OrderType` model constants + `order_types` seeder (`OrderTypesEnum` deprecated) |
| Invoice paper | 70 × 140 mm, DPI 300 | OrderService |
| DSR paper | 65 × 250 mm, DPI 300 | DailySalesRepository |
| Timezone for documents | Asia/Dubai | invoices, DSR, exports |
| Split-payment tolerance | 0.01 | orderUpdate |
| Payment amount minimum | 0.01 (client-side rule) | Order requests |
| API rate limit | 60 req/min per user/IP | RouteServiceProvider |
| Noon webhook throttle | 60/min | webhook route |
| Webhook job retries | 3 tries; backoff 30/60/120 s; queue `noon` | ChangeOrderStatusNoonWebhookJob |
| Noon prepaid value | `ceil(final_price)`; COD 0 | NoonIntegrationService |
| Coordinate format | signed digits, truncated to 9 | NoonIntegrationService |
| Default item image | `items/default-item.png` | ItemController |
| Guest key | hardcoded string in `config/app.php` | GuestApiMiddleware |
| Seeded superadmin | `admin@dinex.com` / `12345678` | UserSeeder ⚠ |
| Noon client code | `noon_food`; country `ae` | dispatch payload |
| 🚩 Item default prep time | 10 minutes | `Item::DEFAULT_PREPARATION_TIME` |
| 🚩 Order prep-time scaling | 1–3 meals ×1, 4–8 meals ×2, 9+ meals ×2.5 (rounded up) applied to the slowest item's `preparation_time` | `OrderService::calculateOrderPreparationTime` |
| 🚩 Order History invoice ZIP caps | 10,000 invoices/download; 100 regenerated missing PDFs/request; flush every 1,000 | `ReportsRepository` |
| Add-on menu category code | `ASP_CAT_1000` (this category and all below it hold add-on items) | `Item::ADDON_CATEGORY_CODE` (`app/Models/Item.php`) |
| Ingredient category max level | 2 (three levels: 0 type, 1 category, 2 sub category) | `IngredientCategory::MAX_LEVEL` |
| Order line types | `item`, `addon` | `OrderItem::TYPE_ITEM`, `OrderItem::TYPE_ADDON` |
| Recipe snapshot statuses | `default`, `replaced`, `removed` | `OrderItemIngredient::STATUS_DEFAULT` / `STATUS_REPLACED` / `STATUS_REMOVED` |
| Item push dry run | `POST /pos/items/cc/item-creation-simulate` validates and saves exactly as an item push by the calling platform (any authenticated platform; its `slug` is the would-be item source), rolls back, and returns the validated payload plus an item preview; nothing is stored | `Pos\ItemController::ItemCreationSimulateCC` |
| CostControl order sources | `standard`, `branch-plan`, `delivery-app` (external order: `branch-plan` when the platform sent it as one, else `standard`; POS order: `delivery-app` when a line used the delivery-app price, else `standard`) | `CostControlService::SOURCE_STANDARD` / `SOURCE_BRANCH_PLAN` / `SOURCE_DELIVERY_APP` |
| CostControl integration provider | `cost_control` | `CostControlService::PROVIDER` |
| CostControl request timeout | 30 s | `CostControlService::request` |
| Composition lookup limit | 50 results per lookup | `ItemCompositionService::LOOKUP_LIMIT` (`app/Services/Dashboard/ItemCompositionService.php`) |

## Configuration Affecting Business (`dashboard_settings` keys)
| Key | Business effect |
|---|---|
| `logo`, `company_name`, `invoice_header_slogan`, `invoice_footer_slogan` | Invoice/DSR branding |
| `address`, `trn`, `display_trn` | Invoice legal identity (branch TRN also exists and is what invoices print) |
| `display_order_number` | Show/hide order number on receipts |
| `order_number_start` | Base of the daily order number sequence |
| `close_day_automatic`, `automatic_day_end_time`, `close_day_manual` | **Defined but not implemented** — no scheduler exists |

## Feature Flags (implicit)
- `branches.is_taxable` — per-branch VAT on/off (default on).
- `payment_types.has_additional_fee` — per-tender surcharge on/off.
- `payment_types.is_external` — routes orders to the external tab & platform callbacks.
- `payment_types.is_subscription` — **dormant** (no logic).
- `items.is_custom` — custom-meal component behavior.
- `categories.is_not_main` — hide from POS home.
- `noon_outlets.is_serviceable`, `is_active` — stored but **never checked** before dispatch.

## Environment-Dependent Behavior
| Concern | production | non-production |
|---|---|---|
| Noon API | `https://food-api-team.noon.team/public/v1` + `NOON_LIVE_API_KEY` | `…noonstg.team…` + `NOON_TEST_API_KEY` |
| Noon webhook auth | 503 if `NOON_WEBHOOK_API_KEY` unset | bypassed in `local`/`testing` |
| Platform callback (Asparagus) | `live_url` = `https://test.asparagus.ae` (⚠ Asparagus's *production* host despite the name) | `test_url` = `https://temptest.asparagus.ae` |
| CostControl API (`services.cost_control.base_url`) | `COST_CONTROL_UAT_URL` (CostControl's UAT host) | `COST_CONTROL_QC_URL` (CostControl's QC host) |
| Logging | `integration`, `noon_integration` and `cost_control` daily log channels | same |

### CostControl configuration (`config/services.php` → `services.cost_control`)
| Key | Value |
|---|---|
| `base_url` | `COST_CONTROL_UAT_URL` when `APP_ENV=production`, otherwise `COST_CONTROL_QC_URL`; when empty, orders are skipped (logged) |
| `login_url` | `/api/v1/platform/login` |
| `order_creation_url` | `/api/v1/platform/orders/order-creation` |
| `username` / `password` | `COST_CONTROL_USERNAME` / `COST_CONTROL_PASSWORD` |
| `token_ttl` | `COST_CONTROL_TOKEN_TTL` minutes (default 50) — how long the cached login token is reused; a 401 forces one fresh login and resend |

### CostControl log channel (`config/logging.php` → `cost_control`)
- Daily file `storage/logs/cost_control-YYYY-MM-DD.log`.
- One entry per request sent (login and order creation), plus one warning per skipped order (order missing, not completed-and-ready, branch without `mapper_slug`, no base URL) and permanent job failures.
- Passwords and tokens (request `Authorization` header, login `password`, `token`/`access_token`/`refresh_token` in responses) are written as `***`, both in the log file and in `integration_logs`.

## Consolidated TODO / Risk Register (highest business impact first)
1. ~~**Order update always responds as an error after committing** (missing second URL)~~ — **fixed 2026-07-07**: controller now returns only the PDF URL with an "updated" message.
2. ~~**Per-line fee bug in order creation** (used stale `$itemPrice`)~~ — **fixed 2026-07-07**.
3. ~~**Platform callbacks hit the platform's TEST URL in production**~~ — **fixed 2026-07-07**: callbacks now use `live_url` when `APP_ENV=production`, `test_url` otherwise.
4. ~~**Day-close window uses the globally-earliest open order**~~ — re-analysis showed the request-bound `BranchScope` global scope was implicitly branch-filtering this query in normal cashier requests; the risk was limited to non-request contexts (console/queue) and branchless users. **Hardened 2026-07-07** with an explicit `branch_id` filter in `DailySalesRepository::dsr()`.
5. **`close_orders` guard bypass** — presence of the parameter (even `0`) skips the active-orders check.
6. ~~**Unauthenticated endpoints:** dashboard branch list; Noon test dispatch/fetch endpoints.~~ — **fixed 2026-07-07**: branch list moved inside the admin auth group; Noon test endpoints now require a staff token (`auth_api`). ⚠ Verify the dashboard frontend doesn't call `branches/list` before login.
7. **No status-transition rules, no discount cap** — fraud/error surface at the POS. *Addressed 2026-07-07:* day-closed orders are now frozen (status/kitchen-status/approve-reject all blocked); qty is now validated (BL-4); free live-day transitions and uncapped discounts remain **accepted business policy** per the product owner's decision.
8. ~~**`inReview` platform orders silently swept into day close.**~~ **Fixed 🚩 SPRINT 1 · 09-08-2026** — day close is now hard-blocked while any `inReview` order exists (Module 6).
9. ~~Approved-then-dispatched delivery orders **cannot be cancelled at Noon** (cancel function unwired).~~ **Fixed 🚩 SPRINT 1 · 09-08-2026** — `cancelNoonOrder` is wired into the reject/cancel flow (Module 8).
10. ~~Duplicate discount rows on update; mobile delivery fee lost on update.~~ **Both fixed 2026-07-07** (BL-1, BL-2).
11. Automatic day-close settings exist without any scheduler.
12. Dead/legacy code: old order repositories, `MobileOrderController`, `ReportsRepository2`, `oldstore`, `dsrOld`, `CategoryTestFucntionController`, misspelled `ResponseMessageEnum` constant.
13. Public, guessable CSV export and PDF invoice URLs (revenue data exposure).
14. Seeded default superadmin credentials in the repo.
15. `orders.related_date` migration file contains nested `Schema::table` and references `closed_date` — migration hygiene risk on fresh installs.
16. Ingredient categories are maintained manually; no CostControl sync exists for ingredient categories. Items (with recipe, replacements and add-ons) sync from CostControl via `POST /pos/items/cc/item-creation` (Module 3).
17. No CostControl platform row exists in `platforms` / `PlatformSeeder`, so CostControl cannot authenticate to push items until it has its own platform row and token; its items will then be labelled (`items.created_from`) with that row's `slug`.
18. Ingredients without a category cannot have replacements until they are categorised (648 uncategorised in the local dataset) (Module 3).
19. Reports and the DSR do not distinguish meal lines from add-on lines by `order_items.type` (Modules 4, 6, 7).
20. CostControl receives the daily `order_number` as its `order_id`; order numbers reset at day close, so the same number can repeat across days for CostControl.
21. The CostControl login token is read from an assumed response field (`data.token`, `token`, `data.access_token` or `access_token`); any other shape fails every send with "returned no token".

## Consolidated Open Questions (cannot be inferred from code)
1. Refund/return money flow — `returned` status exists with no financial counterpart.
2. Canonical accounting source: frozen DSR PDFs vs live dashboard numbers.
3. Commercial meaning of "Branch plan" tender and the dormant subscription flag.
4. Whether cross-midnight shifts should report on open date (current) or close date.
5. Intended enforcement of order-type ↔ payment-type matrix (server or client?).
6. ~~Whether Noon out-of-order webhook statuses should be rejected (currently accepted).~~ **Resolved 🚩 SPRINT 1 · 09-08-2026** — status regressions are now rejected (BL-9, Module 8). Noon's own retry/ordering contract is still unconfirmed.
7. Ownership of UAE VAT-invoice compliance for the 100/105 method and rounding.
8. Why chief holds order CRUD permissions that no route exposes.
9. Whether one guest = one order is acceptable to the business for per-guest KPIs.
10. Expected behavior when a branch has no Noon outlet but sells delivery (today: silent non-delivery).
11. Replacement `extra_charge` lets a replacement raise the line price, which conflicts with the recorded decision that replacements never change the price — needs PO confirmation (Modules 3, 4).
12. Catalog-level add-ons (`item_addons`) conflict with the recorded decision that add-ons have no catalog relation and are linked in the cart only — needs PO confirmation (Modules 3, 4).
13. Whether reports/DSR should split meal and add-on sales by `order_items.type` (Modules 4, 6, 7).
14. Who creates CostControl's platform row (and chooses its slug) with its related user and token, and when — CostControl cannot push items until it exists, and that slug becomes the item source of its items.
15. Whether the CostControl dry-run endpoint (`/pos/items/cc/item-creation-simulate`) should be restricted to the CostControl platform; today any authenticated platform can call it.
16. Whether CostControl treats `order_id` (the daily `order_number`) as unique, or needs a globally unique order reference (e.g. the order UUID, or number + branch + date).
17. The exact field CostControl's login response returns the token in — the code accepts `data.token`, `token`, `data.access_token` or `access_token`.
