# KB Changelog

> Historical record of business-behavior changes, newest first. Every dated/flagged history note removed from a module file during the KB v2 reconciliation (see `docs/kb/KB_UPDATE_REPORT.md`) lands here — module files themselves describe only the current, timeless behavior.
> Seeded from `docs/kb/SPRINT_1_UPDATES_09-08-2026.md`, `docs/kb/SPRINT_2_UPDATES_DRAFT_2026-09-17.md`, and the 2026-07-07 → 2026-07-15 review cycle in `ISSUES_TRACKER.md`. Entries added here during the module-by-module reconciliation (Section 3) are appended under the relevant date heading as they're found.

---

## 2026-11-03 — Add-ons carry several ingredients for CostControl; plan sent as CC's key (not yet PO-reviewed)

- M03: In the sized CC item push, an add-on's size is now `{size, ingredients: [{sku, size, removable?, sort_order?}]}`. **Replaces** `{size, ingredient: {sku, size}}`, which gave an add-on a single recipe line. The add-on's recipe is every ingredient of its `ready_meals` size. Why: CostControl changed its add-on payload so an add-on is a full recipe like any item.
- M04: In the CC order payload, each add-on carries `ingredients: [{sku}]`, which is its recipe as served (removed lines left out, replaced lines send the replacing sku). **Replaces** `ingredient: {sku}` of its first recipe line. Why: same CostControl contract change.
- M04 / M05: The `plan` sent to CC is now CC's key (`maintain_weight`, `lose_weight`; `Helpers::planKey`). **Replaces** the Title Case form ("Maintain Weight") sent before. The POS screens still show Title Case. Why: CostControl's order payload uses the key form.

## 2026-11-03 — CC item push moved to `item-creation` (not yet PO-reviewed)

- M03: The sized CostControl push moved from `POST /pos/items/cc/item-sizes-creation`, which is removed, to `POST /pos/items/cc/item-creation`. That URL **no longer** runs the flat push. It used to share `ItemCreationPlatform` with `items/mobile/items-creation`, which is now the only flat push route. **Client impact:** a caller sending the flat shape (`ingredients[]`, `addons[{sku}]`) to `cc/item-creation` must switch to the sized shape or to the mobile route. `POST /pos/items/cc/item-creation-simulate` now dry-runs the sized payload too, so the payload it returns is valid for `cc/item-creation` again. Why: CostControl sends the sized payload on that URL.

## 2026-11-03 — Sized CostControl item push; replacements hidden from item resources (not yet PO-reviewed)

- M03: New `POST /pos/items/cc/item-sizes-creation` (`ItemSizesCreationCC`). CostControl sends a meal with one recipe per meal size and its add-ons as full definitions. Only the `ready_meals` recipe is stored, and the other sizes and `size_type` are ignored for now. Each add-on is created, or updated by sku, as its own item with its `ready_meals` ingredient as its recipe, then offered with the meal. `price` is written as `delivery_app_price` too, for the meal and its add-ons. It reuses the flat item push's validation and composition rules. Why: CC's item payload carries meal sizes and full add-on definitions, which the flat `item-creation` push can't take (it needs add-ons to exist already).
- M03: **Changes** the documented item response shape: recipe lines in `ItemResource` / `DBItemResource` no longer carry `replacements[]` (commented out in `ItemRecipeLineResource`). Replacements are still stored, validated and priced, and `has_replacement` is still returned. **Client impact:** the POS customise screen and the dashboard item view no longer receive replacement options. Why: requested to hide replacements from the item resources for now.
- M03: The flat push's `storeItemPayload` now also writes `delivery_app_price` when the payload carries it. Only the sized push sets it, so the flat push is unchanged.

## 2026-11-03 — Completed, ready orders are sent to CostControl; plan shown in Title Case (not yet PO-reviewed)

- M04: New — an order that is completed (`status`) and ready or completed (`operation_status`) is sent to CostControl (CC) by the queued `SendOrderToCostControlJob`, dispatched after commit from the cashier and kitchen status changes. CC is called with a bearer token from `POST /api/v1/platform/login` (username/password, cached, one re-login on a 401), then `POST /api/v1/platform/orders/order-creation` with `order_id` = order number, `branch_slug` = the branch's `mapper_slug`, `source`, `plan`, `total_price` and the lines with their ingredients and add-ons. Why: CC consumes the stock of what was served.
- M04 / M05: `source` is `branch-plan` for an external order whose `data.type` is `branch-plan` (with its plan), `standard` for any other external order, `delivery-app` for a POS order with a line priced at the delivery-app price, else `standard`.
- M04: **Replaces** the unmerged design on `feature/pos-enhances` (`4275c53`, `1c28c4a`), which was dropped with that branch's last commits: it sent on handed-over (completed) orders only, used a hand-entered `branches.cost_control_slug` column, sent ingredient `grammage` and a meal `size`, and called CC without auth. The `cost_control_slug` migration and the three 2026-10-29 meal-size migrations (`items.size_type`, `item_ingredients.meal_size`, `order_items.meal_size`) are not part of this branch.
- M05: The `plan` of external and kitchen order resources is formatted to Title Case (`maintain_weight` → `Maintain Weight`) by `Helpers::formatPlanName`; the stored value is unchanged. The old SPRINT-flagged `plan`/`type` note in M05 is rewritten as a timeless rule (`plan` = the platform's `package_name`, stored for every platform order).
- M10: New `cost_control` log channel (`storage/logs/cost_control-*.log`) records every CC request; each is also an `integration_logs` row (one order → many rows), which gains a nullable `attempt` column for the job try. New env keys `COST_CONTROL_USERNAME`, `COST_CONTROL_PASSWORD` (plus the existing `COST_CONTROL_QC_URL` / `COST_CONTROL_UAT_URL`).

## 2026-10-26 — Payment amounts must equal the order's computed price (not yet PO-reviewed)

- M04: The amounts on `payments[]` must add up to the order's **final price** within ±0.01 on both `POST /pos/orders/create` and `POST /pos/orders/update`, for a single payment as much as for a split one. A mismatch is now a validation error on the `payments` field ("The payment amounts (X) must add up to the order price (Y).") raised by the new `ValidatesOrderPayments` concern, so the order is rejected before anything is written instead of coming back as a generic exception response.
- M04: **Supersedes** the previous rule that a single payment's amount was ignored and silently overwritten with the computed final price (and the 2026-07-07 rule that only split payments — 2+ methods — were reconciled, single ones being exempt). A client that sent one payment for the wrong amount, e.g. the items total instead of the price after surcharges, delivery fee, discount and VAT, had its order accepted and the payment row rewritten; it is now refused with a 422 naming both figures. **Client impact:** a POS build that sends `total_price` as the single payment's amount, or any amount other than the final price, will start failing — it has to send the final price. Why: an order's payments are the record of money collected, so they must state what the order actually cost.
- M04: A single payment is still *stored* at exactly the final price, so the payment row cannot sit a cent away from `orders.final_price` within the ±0.01 tolerance. Split amounts are stored as collected.
- M04: A single payment whose `amount` is null keeps its meaning — "charge the whole order to this method", filled in when the row is written — and is not treated as a mismatch. This is the shorthand `OrderService::mapMobileOrderToStoreRequest` uses, so platform/mobile order creation (M05) is unchanged: it never sends an amount and never saw the old override.
- M04: The price is now computed in one place, the new `OrderPricingService` (`breakdown`, `finalPrice`, `paymentsTotal`, `paymentsCoverPrice`). `OrderService::createOrder` and `orderUpdate` each held their own copy of the surcharge / 100-105 VAT extraction / delivery fee / discount / VAT arithmetic (`calculateAdditionalFees` and `applyDiscount`, both now removed), and validating the payment amounts needed a third copy. The arithmetic is unchanged — same formulas, same order of operations — it just has a single home that the request layer and the service layer share, so the price that is validated and the price that is charged cannot drift apart. The service-side reconciliation that remains (`assertPaymentsMatchPrice`) is the last line of defence for callers that build an order request themselves.
- Unit coverage for the pricing and reconciliation rules: `tests/Unit/OrderPricingServiceTest.php`.

## 2026-10-26 — Sprint 3 documentation pass: reporting, auth and overview consequences (not yet PO-reviewed)

Documentation-only pass over the whole 2026-10-17 → 2026-10-26 sprint (`dbbe746`..`b71c156`). No code changed. Every module file is now baselined at `b71c156`. The sprint is registered in `docs/kb/SPRINT_3_UPDATES_2026-10-26.md`, and written up for the client teams in `docs/FRONTEND_CHANGES_SPRINT_3_2026-10-26.md`.

- M06 / M07: Newly documented consequence of the sprint's add-on order lines — `DailySalesRepository::$baseOrderItems`, `ReportsRepository`'s branch item report and `DashboardChartsRepository`'s top-items chart all join `order_items` with **no filter on the new `type` column**, so an add-on sold with a meal is reported as its own item (its own Items DSR row under the add-on category, its own report row, its own chart quantity) and is never folded into the meal it was sold with. Because all three read the line's stored `item_price`, a customised meal also reports above its menu price — the replacement `extra_charge` and the payment-surcharge share are inside it — while a removal never lowers the value. Raised as an open question in both modules, matching the one M04 already carried.
- M06: `orders.count_of_items` counts add-on lines priced above 0. Recorded as the count stamped on the order and printed on the invoice; the DSR computes its own quantities and never reads it.
- M09: Newly documented — the payment-type surcharge is applied **per line** on the item price *plus* that line's replacement extra charges, and on every add-on line, so a tender fee configured against menu prices is also charged on customisation uplift. `enable_delivery_app_price` is likewise resolved per line, add-on items included, each line recording its own `used_delivery_app_price` and falling back to its normal price when it has no delivery-app price. Both raised as open questions.
- M01: Newly documented authorisation surface — `Pos\ItemController` excludes `CheckMapperSlugs`, `ItemCreationPlatform` and `ItemCreationSimulateCC` from its `cashier|chief` + `item.read` middleware, so the four platform item routes are gated by a valid platform token alone: any authenticated platform can write the whole catalog, with no role, permission or per-platform scope.
- M01: The Sprint 1 claim that the `$request->user()` fix made the mobile item-creation endpoint "permission-gated instead of wide open" is **superseded** — that middleware does enforce on platform-authenticated routes, but the item-push routes no longer carry it. Rewritten as a timeless rule plus the risk note; the dated flag moved out of the module prose into this log.
- M01: Newly documented — `IngredientCategoryController` applies one middleware list to all five actions: `api.role:admin|superadmin` plus `api.permission:ingredients.read|create|update|delete`. `CustomPermissionMiddleware` requires **every** listed permission (it is AND, not OR; only `api.role` is OR), so a role holding just `ingredients.read` is refused on all five endpoints, listing included. `config/permission.php` grants admin/superadmin all four, so it passes in practice. Raised as an open question.
- M05: Newly documented — every platform order-creation entry point funnels through `OrderService::createMobileOrder`, which traces the inbound order to the `integration` channel (summary line, the payload as sent minus the `platform`/`branch`/`user` models merged in by `PlatformAuthMiddleware`, then `Created` or `Failed` with the exception message). A rejected platform order now leaves a local record of what was sent and why it failed; the exception is still what the controller returns to the platform.
- M00: The stale "last reverse-engineered as of commit `7c4d737`" blockquote, with its 🚩 SPRINT 1 / Draft flag legend, is replaced by a timeless provenance note pointing at each module's own header and at this changelog. The core-flow overview now mentions per-line customisation and platform-pushed catalog data.
- M02: Verified unaffected by the sprint (no branch controller, model or repository in the diff) and rebaselined.
- No module file's existing rules were contradicted by this pass except the M01 item noted above.

## 2026-10-25 — Add-on lookup filters by category (not yet PO-reviewed)

- M03: `GET /dashboard/items/add-ons` accepts an optional `category_id`. When sent, only items of that category and the categories below it are listed; the category must be `ASP_CAT_1000` or a category below it, otherwise 422. Without it the endpoint lists the whole add-on tree, as before. Implemented via a third `$categoryId` argument on `ItemCompositionService::addonCandidates` and the new `categoryTreeIds()` helper. Why: the edit-item screen lists add-ons per category.

## 2026-10-25 — Mobile orders carry add-ons (not yet PO-reviewed)

- M05: `POST /pos/orders/mobile/create` lines accept `addons[]` (`sku`, `qty` ≥ 1), validated in `MobileOrderStoreRequest`. `OrderService::mapMobileOrderToStoreRequest` maps them by SKU to the POS line shape (`mapMobileLineAddons`; null when not sent), checks them with `OrderLineService::customisationErrors` (only the meal's offered add-ons, once each; a violation aborts the order with `items.N.addons.M.id: message`), and computes the items total with `OrderLineService::itemsTotal`. Previously the mobile items total was item price × qty only and add-ons could not be sent; a line without `addons` totals the same as before. Replacements / removals are not accepted from the platform. Why: mobile orders need the same add-ons as POS orders.
- M05: The KB's mobile payload description is corrected to items by `sku` (the code had already moved from `slug` / `mapper_slug`).

## 2026-10-25 — POS order lines accept `default` ingredient entries (not yet PO-reviewed)

- M04: `items.*.ingredients.*.status` accepts `default` alongside `replaced` / `removed` (`ValidatesOrderLines`). A `default` entry is served as in the recipe, the same as leaving the line out; it is still checked to be a recipe line of the item and not repeated. Previously only changed lines could be sent, so a POS client sending every recipe line (unchanged ones as `default`) got `The selected items.N.ingredients.M.status is invalid.` and the order failed. Why: the POS client sends the full recipe of a customised line.

## 2026-10-25 — Recipe snapshot ingredient columns renamed (not yet PO-reviewed)

- M04: `order_item_ingredients.original_ingredient_id` (the recipe ingredient) is renamed `ingredient_id`, and the old `ingredient_id` (what was served) is renamed `replaced_ingredient_id`; foreign keys are re-created under the new names and the unique key is now (`order_item_id`, `ingredient_id`). Values are unchanged: a `default` row still has `replaced_ingredient_id` = `ingredient_id`, a `removed` row null. `OrderItemResource` `modifications[]` uses the same new keys (`original_ingredient_id` → `ingredient_id`, `ingredient_id` → `replaced_ingredient_id`), so API consumers reading those keys must switch. The name columns (`original_name`, `ingredient_name`) are not renamed. Why: `ingredient_id` names the recipe ingredient, matching `item_ingredients.ingredient_id`.

## 2026-10-25 — One item push for every platform, item source tracked, dry-run endpoint (not yet PO-reviewed)

- M03 / M10: The 2026-10-24 `ItemSyncService` design (entry below) was reverted (commit `2bcafea`) and rebuilt inside `Pos\ItemController` without a service. `ItemCreationMobile` is renamed `ItemCreationPlatform` and serves both `POST /pos/items/mobile/items-creation` and `POST /pos/items/cc/item-creation`; the platform comes from the token (`PlatformAuthMiddleware`) and any authenticated platform may push. No source constants or platform map are kept in the model. Why: one entry point, the platform token already identifies the caller, and a new platform needs no code change.
- M03: Payload semantics of the 2026-10-24 entry are kept (sku key, left-out/null fields kept, empty list clears, composition by SKU with dashboard rules). Changes against it: validation is a Laravel validator returning `errors` per field alongside `status:false`; `slug` is optional for every platform (when sent it is also the mapper slug, unique on `items.mapper_slug`); unknown payload fields are dropped.
- M03 / M10: New column `items.created_from`: the pushing platform's slug, or `dashboard` (null for older items). Platform-created items record `created_by` = the platform's related user instead of the hard-coded `1`; the dashboard store sets `created_from = dashboard`. Updates never change either. Why: know where and by whom an item was created.
- M03: New `POST /pos/items/cc/item-creation-simulate`: validates and saves a payload as the calling platform inside a transaction, rolls it back, and returns the validated JSON payload (ready to send to `item-creation`) plus an item preview with recipe, replacements and add-ons. Why: give CostControl a checked payload before it creates items for real.
- M03: The item push routes are excluded from `ItemController`'s cashier/chief + `item.read` middleware (platform token only), closing the open question about `ItemCreationMobile`.

## 2026-10-24 — CostControl item push; item sync accepts partial payloads (not yet PO-reviewed)

- M03 / M10: New endpoint `POST /pos/items/cc/item-creation` (`ItemController::ItemCreationCC`, `auth.platform`): CostControl pushes an item with its recipe, replacements and add-ons by SKU, through the same `ItemSyncService` and payload as the platform push. Why: items and recipes are owned in CostControl; ingredients were already pushed via `/pos/ingredient/cc/ingredient-creation`, items had no CostControl entry point.
- M03: `ItemSyncService` was split into client-agnostic steps (`mapAttributes`, `resolveCategory`, `mapRecipe`, `mapAddons`, `storeItem`; `mapMobileItemToStoreRequest` renamed `mapItemToStoreRequest`) so another client can reuse it without changing it. Behaviour changes for both pushes:
  - Only `sku` is always required. `name`, `price`, `slug`, `category_code` left out or sent as null keep the stored values; previously every push wrote all of them, so a missing field was written as null.
  - Creating an item requires `name`, `price` and `category_code`. A missing `slug` on create defaults to `Str::slug(name)`.
  - `is_custom = 0`, `preparation_time = 10` and `created_by = 1` are set only when the item is created; previously every push reset them, which overwrote a preparation time edited on the dashboard.
  - An explicit `ingredients: null` / `addons: null` now keeps that part; previously it cleared it like an empty list.
  - Log label per client: `[Mobile Item]` / `[CostControl Item]`.

## 2026-10-24 — The `rejected` platform callback says who rejected the order (not yet PO-reviewed)

- M05/M08: The platform status callback (`OrderService::notifyPlatformOrderUpdated()`) carries a new boolean `is_delivery_rejected` in its body, **only on the `rejected` event**: `true` when the delivery provider killed the delivery (Noon refused/failed the create-task dispatch, or cancelled a task it had accepted — both go through `OrderService::rejectExternalOrderByProvider()`), `false` when the cashier used the POS reject endpoint (`approveRejectExternalOrder` with `approved = 0`). The flag travels on a new third constructor argument of `NotifyPlatformOrderUpdatedJob` (`isDeliveryRejected`, nullable, default `null`); `null` is what every non-rejection event passes and it omits the key from the body rather than sending `false`. Until now all three rejection triggers produced an identical callback and the mobile app could not tell a branch rejection from a delivery failure; it still cannot tell the two provider-side triggers apart from each other, and the rejection *reason* is still logged and stored on the tracker only — it is never sent to the platform.
- M05: This supersedes the documented rule that "the platform is never told which side initiated it" — only the reason, and the distinction between the two provider-side triggers, remain invisible to the platform.
- No change to POS state or guards: both paths still set `status`/`operation_status` to `cancelled`, and the provider path still skips (with a log) an order that is already cancelled or belongs to a closed day.

## 2026-10-23 — Recipe schema: ingredient categories, per-ingredient replacements, category add-ons, order recipe snapshot (not yet PO-reviewed)

Replaces the 2026-10-22 item-composition design below (that design was never merged; its four migrations were rewritten in place), following the "Dinex POS — Menu Catalog — Proposed Schema" document.

- M03: New ingredient category tree `ingredient_categories` (type > category > sub category, `cc_code` = CostControl id, only leaves hold ingredients) with dashboard CRUD `dashboard/ingredient-categories/*` under the `ingredients.*` permissions. `ingredients` gets `ingredient_category_id` (must be a leaf) and `sku` becomes unique.
- M03: Replacements now hang off a recipe line instead of the item. **Removed:** `items.is_addon`, `item_replacements` (meal → default component → replacement item), `item_add_ons` with `max_per_meal`, the "default component" concept (catalog item matching an ingredient by id and size), the replacement scope "default component's category and sub-categories", `delivery_app_adjustment`, and `GET dashboard/items/default-components`. **Now:** `item_ingredient_replacements` (recipe line → replacement ingredient, size, extra_charge); a replacement must share the original ingredient's leaf category. Why: the PO asked for replacements to refer to the recipe ingredient (`item_ingredients`), not to items.
- M03: Add-ons are no longer flagged per item: an add-on is any item in menu category `ASP_CAT_1000` (or below), linked to meals through `item_addons` (`sort_order`). An item leaving that category is dropped from every meal's add-ons.
- M03: `item_ingredients` gains `is_removable`, `sort_order`, decimal `size`, a unique (item, ingredient) pair and real foreign keys. The migration deletes 40 local orphan rows (item already deleted) and strips thousands separators from 11 sizes such as "1,000.00". `items.has_replacement` / `items.has_addon` are recomputed on every save.
- M03: Item responses (`dashboard/items/view|list`, new `pos/items/view/{id}`) nest each ingredient's `replacements` inside `ingredients[]` and list `addons[]` at item level. `GET dashboard/items/replacement-candidates` now takes `ingredient_id` and returns ingredients.
- M04: Order lines carry `type` (item | addon) and `parent_order_item_id`. A POS line may send `ingredients[]` (replaced / removed recipe lines) and `addons[]`; each add-on becomes its own line under the meal. Meal price adds the chosen replacements' `extra_charge`, and the `total_price` check includes extra charges and add-on lines. Every line gets a recipe snapshot in the new `order_item_ingredients` table. `OrderItemResource` returns `order_item_id`, `type`, `parent_order_item_id` and `modifications[]`.
- Open, needs PO decision: `extra_charge` reverses the October decision-log rule that replacements never change the price, and catalog add-ons (`item_addons`) contradict the rule that add-ons are linked in the cart only.

## 2026-10-22 — Dashboard item composition: ingredients, replacements, add-ons (not yet PO-reviewed)

- M03: The dashboard edit-item form can now configure, besides its ingredient rows, a **default component with replacements** and **add-ons**. New tables `item_replacements` (meal, default component, replacement) and `item_add_ons` (meal, add-on), and a new `items.is_addon` flag; both link tables cascade-delete with their items.
- M03: A default component is a catalog item sharing an ingredient with the meal (same ingredient id and same quantity); replacements must be items in the default component's category or any sub-category below it; add-ons must be items flagged `is_addon`. The default component is validated against the meal's final ingredient rows.
- M03: `POST /dashboard/items/create|update` accept `is_addon`, `has_replacements` + `replacements{default_item_id, item_ids[]}`, `has_addons` + `addons[]`. On update an absent `has_*` flag leaves that group untouched, `0` clears it, `1` replaces it. Clearing `is_addon` on an item removes it from every meal's add-ons.
- M03: Ingredient rows are now validated (existing ingredient id, no duplicates, numeric `size` > 0; incomplete rows give 422) and saved with `sync()`. Previously nothing validated them, and an update could not change the `size` of an ingredient the item already had — the diff only attached new rows.
- M03: New admin lookups for the form's dropdowns: `GET /dashboard/items/default-components`, `/replacement-candidates`, `/add-ons`. `GET /dashboard/items/view/{id}` and `/list` return `is_addon`, `has_replacements`, `replacements`, `has_addons`, `addons`. POS menu endpoints are unchanged and do not return them.
- M03: Not yet consumed anywhere downstream — POS reads and the order flow ignore replacements and add-ons.
- M03: Refined after reviewing the form screens the same day — replacements and add-ons carry no images; each replacement row stores this meal's `extra_charge` and `delivery_app_adjustment` (AED, ≥ 0, default 0; new `item_replacements` columns), each add-on row stores `max_per_meal` (required integer ≥ 1; new `item_add_ons` column). The request shape changed accordingly to `replacements.items[]` (`id`, `extra_charge`, `delivery_app_adjustment`) and `addons[]` (`id`, `max_per_meal`), replacing the bare id lists. Every component is returned with its mapped ingredient and portion (the item's ingredient and quantity), and the default component also with its categories (shown as its "POS section"). Assumption to confirm with the PO: the adjustments are non-negative, and "POS section" means the component's category.

## 2026-10-17 — A failed or cancelled Noon delivery rejects the order back to the platform (not yet PO-reviewed)

- M08/M05: A delivery that Noon never accepts, or that Noon cancels after accepting, now rejects the order back to the platform instead of leaving it approved. Both paths call the new `OrderService::rejectExternalOrderByProvider(Order, provider, reason)`, which reproduces the cashier reject path exactly — `status = cancelled`, `operation_status = cancelled`, plus the queued `rejected` platform callback (`NotifyPlatformOrderUpdatedJob`, after commit) — so the customer mobile app marks the order rejected. Previously neither path touched the order: it stayed approved/in-progress, the platform was never told, and the customer waited for an order that was never coming ("silent failure from the customer's perspective").
- M08: Dispatch failure (`sendOrderToNoon`, any non-200/non-`successful` create-task response or a thrown exception) keeps recording the tracker as status `error` with the parsed `error_msg`, and now additionally rejects the order, passing the parsed Noon error (or exception) message as the reason. The rejection is wrapped in its own try/catch so it can never bubble out of the dispatch path; failures are logged on the `noon_integration` channel. This also applies to the manual test endpoint `POST /api/v1/test/noon-integration`, which calls `sendOrderToNoon` directly.
- M08: A Noon `order-status` webhook reporting `cancelled` now parks the tracker in the same terminal `error` state and stores Noon's cancellation reason in `order_trackers.error_msg`, rather than recording the numeric lifecycle code `7`. The reason is taken from the first present of `reason`, `cancellation_reason`, `cancel_reason`, `status_reason`, `message`, falling back to the literal `"Cancelled by Noon."`. These keys — plus `driver_id`, which the track-driver controller already logged — were added to `NoonWebhookRequest` as nullable rules; without them `validated()` stripped the reason before it ever reached the handler, and `driver_id` was an undefined-key access.
- M08: `error` is therefore the single local tracker state for a dead delivery, whatever killed it (Noon refused the task, or Noon cancelled an accepted one). The `failed` state the KB previously described as "defined but unreachable" is not used at all and is no longer documented. The numeric `cancelled` (7) remains what `cancelNoonOrder` sets when Dinex recalls a task on a cashier cancellation.
- M08: A tracker already in `error`, `cancelled` (7) or `delivered` (6) is terminal: all further Noon webhooks for that task are skipped. A late in-flight status push therefore cannot resurrect a dead task, and a `cancelled` push arriving after delivery cannot undo a delivery already handed to the customer. The pre-existing numeric ordering guard caught neither case: `error` is not a lifecycle rank and casts to `0`, while `cancelled` (7) outranks `delivered` (6) and so would have parked a delivered task in `error` and sent the platform a `rejected` event for an order that had already received its `completed` callback.
- M05: That also bounds the non-terminal-approval consequence above — the window in which an approved delivery order can still flip to rejected closes once the delivery is delivered (or the order is cancelled, or its day is closed).
- M08: A cashier-initiated cancel no longer fires `CancelNoonOrderJob` for an `error` tracker (the task is already dead on Noon's side), alongside the existing `cancelled`/`delivered` skips.
- M05: Because it is called from queue workers, `rejectExternalOrderByProvider` never throws: an order that is already `cancelled` is skipped, and an order frozen by a closed day (`day_closed`/`closed_at`) keeps its status and only logs a warning — in both cases the tracker still records `error` + the reason. The closed-day case is a known gap: such an order keeps showing as approved while its delivery task is dead.
- M05: Approving a delivery order is therefore no longer a terminal decision for the customer — it can flip from approved to rejected moments later, asynchronously and with no cashier action, since both rejection triggers run in queue workers. The platform now sees three sources of a `rejected` event on a delivery order: the cashier, a failed dispatch, and a provider cancellation.
- M08: The one dispatch failure that still does not reject the order is the missing branch↔outlet link (`branches.noon_outlet_id` null), which returns before any Noon call and writes no tracker — the order stays approved and undelivered. That is now the only remaining silent-failure path.
- M06: No day-close regression from either trigger — the `is_delivered = 0` close guard in `DailySalesRepository::dsr()` already excludes `cancelled` orders, so auto-rejected delivery orders do not block the day close.

## 2026-10-12 — Order-item delivery-app-price flag (not yet PO-reviewed)

- M04: `order_items.used_delivery_app_price` (boolean) added. Recorded per line at create/update: true only when the order's payment method has `enable_delivery_app_price` on **and** the item itself has a non-empty `delivery_app_price` (matches the condition `Item::priceFor()` already used to pick the charged price) — so a delivery-app-enabled payment method with an item that has no `delivery_app_price` still records `false`. Exposed via `OrderItemResource.used_delivery_app_price`.
- M06: Bug fix — Items DSR (`DailySalesRepository::dsr()`, the `order_items` grouping) previously computed each item's reported value as `items.price` (today's catalog price) of just the *first* matching order line, multiplied by the *total* summed quantity across all matching lines. This ignored the actual `order_items.item_price` price snapshot entirely and was wrong whenever an item's lines in the window had different prices (delivery-app price vs. normal price, different payment-surcharge rates, or a price change mid-window). Fixed to sum `order_items.item_price × qty` per line.
- M04: Validation added — `OrderStoreRequest`/`OrderUpdateRequest` now reject a split payment (2+ entries in `payments`) when any selected `payment_type_id` has `enable_delivery_app_price` on, reusing `PaymentType::deliveryAppPriceEnabledFor()`. A delivery-app-price payment method must be the order's sole payment method.

## 2026-09-16 → 2026-09-17 — Draft period (2026-08-25 → 2026-09-17), not yet PO-reviewed

- M03: Ingredient creation/SKU-sync endpoint added (`POST /pos/ingredient/cc/ingredient-creation`); `ingredients.sku` (nullable, **not unique**) and unit `kg` added.
- M03: Mobile item-creation category lookup changed from `category_slug` to the new `categories.code` column (unique, nullable).
- M04: Kitchen `operation_status` code 3 renamed internally from `pending` to `ready`; the `kitchen-change-status` API still accepts `pending` as an input alias.
- M05: Platform can cancel its own order via `POST /pos/orders/mobile/cancel` — sets `status`/`operation_status` to `cancelled` directly (see `KB_UPDATE_REPORT.md` V-02 for what this path does and doesn't do vs. the cashier's status-change path).
- M05: External-orders list now surfaces `orderTrackerDetail.error` — the parsed failure reason when Noon dispatch fails.
- M06: A third, non-overridable day-close guard added: refuses close while any in-scope order has `external_orders_data.is_delivered = 0` (see `ISSUES_TRACKER.md` BL-12 — confirmed not to exclude `cancelled` orders).
- M08: Noon dispatch failures (any non-200/non-`successful` response, not just thrown exceptions) now store a tracker with status `error` and a parsed `error_msg` (new `order_trackers.error_msg` column).
- M08: Dispatch success check tightened to require both HTTP 200 and body `status: 'successful'`.
- M08: Webhook handler bug fix — was reading a non-existent `mp_task_nr` payload key; corrected to `order_nr` (the field the request validation had always checked). Webhooks may have been silently dropped before this fix — worth checking `integration_logs` history.
- M08: Malformed `unique:...` webhook validation rule replaced with an honest `exists:order_trackers,provider_reference_number` check.
- M08: Webhook fail-open environment check corrected from `local`/`testing` to `local`/`test` (this app's actual non-production environment name).
- M08: Final Noon `delivered` webhook now automatically sets `external_orders_data.is_delivered = 1` and fires the platform "completed" callback via `OrderService::deliverExternalOrder()` — see `ISSUES_TRACKER.md` DC-2 for the implementation's request-object-reuse fragility.

## 2026-08-25 — Sprint 1 finalized (code through commit `ec29bb9`)

- M03/M04: Item `preparation_time` (default 10 min) added; per-order `order_time` estimate computed from the slowest item on the ticket, scaled by meal count (1–3 ×1, 4–8 ×2, 9+ ×2.5).
- M04: `inProgressTime` / `actual_order_time` measured timings added, built on the new order status activity log.
- M04: New `active_order_status_activity_logs` table + `OrderObserver` — two rows written at order creation, one row per later `status`/`operation_status` change, recording `time_between` and `updated_by`.
- M04: Discount floor — an `amount` discount larger than the order net now floors the subtotal at 0 instead of going negative (entered discount value still recorded as given).
- M04: Order-number allocation locked against concurrent creates.
- M05: Third external tender "Loyalty Points" (`loyalty-points` pivot type) added; `payed_with_loyalty_points = true` overrides routing to it regardless of the request's `type` field.
- M05: Stored platform payload gained `plan`, `type`, `points`; the `type` echoed back on platform callbacks is now read from this stored payload instead of re-derived from the payment-type pivot.
- M05: Customer delivery instructions from the mobile app stored and sent to Noon as `delivery_notes`.
- M05/M02: Mobile orders pinned to the branch named by `branch_slug` (via `PlatformAuthMiddleware` + reworked `BranchScope`), not the platform's related user's branch — fixed wrong/duplicate mobile order numbers.
- M05: "Make Your Own Meal" (MYOM) mobile ordering — composed-meal lines bound to seeded `myom-N` placeholder items, with the platform supplying price and name (stored in new `order_items.custom_name`); temporary mapping, not traceable to actual chosen components.
- M05: `is_seen` flag + `POST /pos/orders/change-is-seen-external-order` added for unread-order badging on the external tab.
- M05: `storeMobileOrder`'s raw-exception debug leak removed; `approveRejectExternalOrder`/`deliverExternalOrder` gained input validation.
- M06: Day close gained a non-overridable guard blocking commit while any order is still `inReview` (previously such orders were silently force-closed).
- M07: `topItemSellings` chart query fixed (was a dead duplicate of `topBranchSelling`).
- M07: Dashboard `day` period redefined as the actual business day (today's `related_date` + the still-open day, identified the same way the POS closes one) — applied to every report/chart period including custom ranges; previously `day` silently fell through to `week`.
- M07: Order History report (`GET /dashboard/reports/orders-history`) and invoice-ZIP export (`POST /dashboard/reports/orders-history/invoices`) added.
- M01: Mobile item-creation endpoint added (`POST /pos/items/mobile/items-creation`, `updateOrCreate` keyed on `sku`); role/permission middleware fixed to read `$request->user()` so gates also apply to platform-authenticated routes.
- M03: `items.sku` (unique, nullable) became the identifier the mobile app orders by; `mapper_slug` survives only as the SKU-sync matching key. `POST /pos/items/mobile/check-mapper-slugs` reconciles the two catalogs.
- M08: `cancelNoonOrder` wired into the cashier's cancel/reject flow; dead tracker-sync code in polling fixed; webhook status regressions (not just duplicates) now rejected.

## 2026-07-07 → 2026-07-15 — Review cycle fixes (see `ISSUES_TRACKER.md` for full detail)

- Order update no longer responds as an error after committing (was missing the second URL in the response).
- Per-line fee calculation bug fixed (`$itemPrice` used before assignment in `createOrder`).
- Platform callbacks now use `live_url` in production / `test_url` otherwise (previously hit the test URL regardless of environment).
- Day-close window-start query hardened with an explicit `branch_id` filter (previously relied on implicit `BranchScope`).
- Dashboard `branches/list` endpoint moved inside the admin/superadmin auth group (was unauthenticated).
- Noon dispatch's order-type check moved off the deprecated `OrderTypesEnum` onto `OrderType::DELIVERY`; order-type IDs 1/2/3 (Dine-in/Pickup/Delivery) confirmed as a fixed company standard.
- Status changes on day-closed orders blocked (orders frozen after close); free live-day status transitions kept as accepted policy.
- `orderUpdate` discount-row stacking fixed (BL-1); `mobile_delivery_fee` no longer dropped on update (BL-2).
- Split-payment sum validated on create (BL-3); order quantity validated ≥ 1 (BL-4); `total_price` validation moved off fragile string comparison to numeric tolerance (BL-5).
- Delivery fee VAT treatment corrected: treated as net/VAT-exclusive, folded into the pre-discount base so discount and VAT both apply and gross ≥ net (BL-11).
- Noon test endpoints protected with `auth_api` (previously unauthenticated).
- Dashboard items list and kitchen/order lists N+1 query issues fixed (HL-3, HL-4); dashboard charts correlated-subquery issue fixed (HL-6).

## 2026-07-10 — Additional fixes (see `ISSUES_TRACKER.md`)

- Outbound platform-callback and Noon HTTP calls moved off the synchronous request path onto queued jobs (HL-1) — a running queue worker (`default` and `noon`) became a production dependency.
- Items fetched once instead of twice per order line during create/update (HL-5).
- `storeMobileOrder` raw-exception leak removed (BL-7); order-number allocation race fixed with row locking (BL-8).
- Noon integration leftovers fixed: dead tracker-sync code in polling, undefined `$response` in the dispatch catch block, `cancelNoonOrder` wired into the cancel/reject flow, webhook regressions rejected (BL-9 a–d).
- `approveRejectExternalOrder`/`deliverExternalOrder` input validation added (BL-10).
- Duplicate/unused repository files deleted: old `Http\Repositories\Pos\OrderRepository`, `ReportsRepository2` (DC-1).
