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

---
