# 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?

---

