# 01 — Orders & Checkout

> Snapshot of `origin/dev` on 2026-09-29 (branch `docs/codebase-map-2026-09-29`). Line numbers are from that snapshot and will drift, so re-grep before you edit.
> Anything marked **(unverified)** was inferred from reading the code. It was not run against data.

## 1. Purpose & scope

This covers everything from "item in cart" to "order settled or cancelled": web and app carts and checkout, how `combined_orders`/`orders`/`order_details` get created, and the payment paths (cash on delivery, wallet and Paymera, web and app). It also covers the seller confirmation/alert loop, the staff pipeline (Confirm Orders → Deliver Orders → Accountant box), cancellation, restock, stock zeroing, the deleted-orders archive, refunds, invoices and tracking.
Coupon internals (`CouponUtility`, `CombinedOrderCouponUtility`) and pricing helpers get only enough coverage here to explain the order flow.

## 2. Key files

| Path | Role | Notable functions (line) |
|---|---|---|
| `app/Http/Controllers/CheckoutController.php` | Web checkout, OTP phone sign-in, coupon apply, order-confirmed page, re-payment | `checkPhone` 121, `verifyPhone` 204, `in` 334 (debug), `index` 352, `checkout` 466, `createUser` 634, `checkout_done1` 721 (dead), `checkout_done` 741, `apply_coupon_code` 943, `remove_coupon_code` 1079, `order_confirmed` 1108, `updateDeliveryAddress` 1225, `updateDeliveryInfo` 1289, `orderRePayment` 1362, `orderRePaymentDone` 1396 |
| `app/Http/Controllers/OrderController.php` | **Web order creation** (`store`), admin order list/detail, admin status/payment changes, cancel, delete, item edit | `__construct` (permissions) 47, `all_orders` 66, `combined_invoice` 236, `combined_order_details` 319, `updateQty` 343, `deleteItem` 418, `show` 476, `store` 509, `admin_cancel` 784, `destroy` 872, `bulk_order_delete` 909, `sendOrderStatusForWhatsApp` 931 (dead, hardcoded key), `update_delivery_status` 995, `update_tracking_code` 1133, `update_payment_status` 1142, `assign_delivery_boy` 1215, `unpaid_order_payment_notification_send` 1267 |
| `app/Http/Controllers/Seller/OrderController.php` | Seller panel order actions | `confirm` 86, `unavailable` 125, `itemAvailability` 167, `applySellerAvailability` 222, `show` 296, `update_delivery_status` 310, `update_payment_status` 410 |
| `app/Http/Controllers/PurchaseHistoryController.php` | Customer order list (grouped per checkout), the unified order page, the customer edit endpoints, customer cancel, web Paymera refund, re-order / re-order all | `index`, `purchase_history_details`, `edit_options` / `edit_preview` / `edit_save`, `order_cancel`, `order_cancel2`, `cancelReasons`, `refundPayment`, `re_order`, `re_order_all` |
| `app/Services/CustomerOrders/` | **Customer order pages + customer edits** (§4.8): `Purchases` (grouped list + filters), `Purchase` (one checkout: totals, statuses, `toArray()` for the app), `OrderEditability` (the "Editable" rule), `OrderEditor` (options / preview / apply), `EditShipping`, `BasketFingerprint`, `CustomerOrderEdits` (audit history, seller push) | `OrderEditor::apply`, `OrderEditability::refusal`, `Purchases::paginate` |
| `app/Http/Controllers/Api/V2/CustomerOrderController.php` | App: grouped history, whole checkout, edit options / preview / save (contract `docs/api/combined-orders.md`) | `index`, `show`, `options`, `preview`, `save` |
| `app/Http/Controllers/CartController.php` | Web cart | `sign_temp_user_id` 25, `addToCart` 107, `removeFromCart` 287, `updateQuantity` 303, `updateCartStatus` 362 |
| `app/Http/Controllers/PaymeraController.php` | Web Paymera | `create` 18 (test), `rePayment` 39, `callback` 148, `trigger` 197, `status` 254, `cancel` 261 |
| `app/Http/Controllers/Payment/CashOnDeliveryController.php` | COD "gateway": redirects only | `pay` 9 |
| `app/Http/Controllers/Payment/WalletController.php` | Web wallet payment | `pay` 16 |
| `app/Http/Controllers/Api/V2/OrderController.php` | **App order creation** (COD/manual/wallet), app cancel | `store` 38, (old `store` commented 307-625), `order_cancel` 627, `order_cancel2` 655, `sendCodeForWhatsApp` 714 / `sendOrderStatusForWhatsApp` 752 (dead, hardcoded key) |
| `app/Http/Controllers/Api/V2/CheckoutController.php` | App coupon + all app Paymera endpoints | `apply_coupon_code` 45, `refundPayment` 277, `rePay` 373, `createPayment` 502, `cancelPayment` 694, `checkPayment` 748, `callback` 797, `trigger` 806, `handlePaymeraNotification` 811, `remove_coupon_code` 871 |
| `app/Http/Controllers/Api/V2/CartController.php` | App cart (identifies user by **request `user_id`**) | `summary` 28, `getList` 98, `add` 362, `changeQuantity` 549, `process` 573, `destroy` 616, `updateCartStatus` 636 |
| `app/Http/Controllers/Api/V2/PaymentController.php` | App COD/manual → `OrderController::store` | `cashOnDelivery` 9, `manualPayment` 15 |
| `app/Http/Controllers/Api/V2/WalletController.php` | App wallet payment | `processPayment` 28 |
| `app/Http/Controllers/Api/V2/ShippingController.php` | App shipping cost per seller | `shipping_cost` 23, `getDeliveryInfo` 89 |
| `app/Http/Controllers/Api/V2/PurchaseHistoryController.php` | App order history / re-order | `index` 20, `details` 37, `items` 46, `re_order` 83 |
| `app/Http/Controllers/Api/V2/Seller/OrderController.php` + `app/Services/OrderService.php` | Seller app status/payment | `update_delivery_status` 48 → `OrderService::handle_delivery_status` 20; `update_payment_status` 59 → `handle_payment_status` 110 |
| `app/Http/Controllers/Api/V2/DeliveryBoyController.php` | Delivery-boy addon app status | `change_delivery_status` 289 |
| `app/Http/Controllers/OrderFollowUpController.php` | Staff "Confirm Orders" | `index` 47, `show` 110, `confirm` 126, `reopen` 173, `applyDeliveryStatus` 194 |
| `app/Http/Controllers/OrderDeliveryController.php` | Staff "Deliver Orders" | `index` 37, `update` 77, `applyDeliveryStatus` 138 |
| `app/Http/Controllers/AccountantOrderController.php` | "Accountant box" payment settlement | `index` 48, `settings` 79, `update` 99, `applyPaymentStatus` 148, `myOrders` 181 |
| `app/Http/Controllers/DeliveryAgentController.php` | CRUD for `delivery_agents` (no delete) | `index` 19 |
| `app/Http/Controllers/SellerOrderActionController.php` | Tokenised WhatsApp buttons (`/so/...`) | `accept` 34, `outOfStock` 40, `view` 59, `submit` 114, `confirmAsSeller` 183, `reportUnavailableAsSeller` 212, `formatTotal` 236 |
| `app/Http/Controllers/Admin/DeletedOrderController.php` | Deleted-orders archive (admin user_type only, else 404) | `index` 27, `show` 73 |
| `app/Http/Controllers/InvoiceController.php` / `Api/V2/InvoiceController.php` | PDF invoice | `invoice_download` 15 (ownership check at 105) / 15 (ownership check since 2026-10-15: buyer, seller or staff, else 403 JSON) |
| `app/Http/Controllers/HomeController.php` | Public order tracking | `trackOrder` 1551 |
| `app/Services/PaymeraService.php` | HTTP client (basic auth) | `createPayment` 25, `getPaymentStatus` 66, `cancelPayment` 81 |
| `app/Utility/PaymeraUtility.php` | **Single mark-paid / refund path** | `nothingToCharge` 27, `completeWithoutGateway` 35, `syncPaymentStatus` 52, `markCombinedOrderPaid` 119, `markCombinedOrderRefunded` 186 |
| `app/Utility/PaymeraAppCheckoutUtility.php` | App Paymera: orders created only after payment | `buildSnapshot` 44, `complete` 110, `close` 156, `cancel` 173, `createOrders` 210, `takeStock` 291 |
| `app/Utility/CartUtility.php` | Cart helpers | `find_stock` 11, `orderable_items` 34, `first_short_product` 46, `get_price` 88, `save_cart_data` 154 |
| `app/Utility/CombinedOrderCouponUtility.php` | Coupon value decided at combined-order level, re-derives totals | `recalculateForOrder` 34, `recalculate` 56, `allocate` 369, `storeCheckoutCoupon` 524, `scopeCouponDiscount` 539 |
| `app/Utility/StockZeroUtility.php` | Zero stock for "out of stock" cancellations / item deletes / seller "not available" reports | `zeroForCancelledOrder`, `zeroForDeletedItem`, `zeroForSellerReport` + `undoSellerReport` (seller reports "not available"); "zero" stops at the company warehouse's pieces (`Warehouse::held()`): only the seller's shop is emptied |
| `app/Utility/SellerOrderAlertUtility.php` | One call → push + WhatsApp | `newOrder` 25 |
| `app/Utility/SellerPushUtility.php` | Seller PWA push | `newOrder` 76, `isOnlineAndUnpaid` (online = `PrepaidGateways::isPrepaid()`: Paymera, eCash) |
| `app/Utility/SellerWhatsAppUtility.php` | BuzzBip WhatsApp alert with buttons | `enabled` 59, `sellerIsInPilot` 121, `newOrder` 150, `resend` 206, `remind` 227, `findByToken` 336, `dispatch` 569, `send` 588 |
| `app/Utility/SellerOrderResponseUtility.php` | Records seller answer / closes alert | `record` 44, `close` 92, `fromDeliveryStatus` 139, `fromStaffDeliveryStatus` 162 |
| `app/Utility/NotificationUtility.php` | Order placed / status notifications | `sendOrderPlacedNotification` 21, `sendNotification` 84 |
| `app/Traits/FirebaseNotificationTrait.php` | Copy of the above for controllers using the trait | `sendOrderPlacedNotification` 511, `sendNotification` 580 |
| `app/Utility/EmailUtility.php` | No order e-mail since 2026-11-09 (ADR 0011; `order_email` removed): verification, seller registration and payout e-mails only | `SENT_TEMPLATES` |
| `app/Models/Order.php` | Audited; `deleting` → archive; `updated` → follow-up sync | `boot` 23, `SELLER_CONFIRMATION_SINCE` 141, `awaitsSellerConfirmation` 189, scopes 238-461 |
| `app/Models/OrderFollowUp.php` | Confirm/deliver state machine | constants 25-40, `forOrder` 77, `decide` 99, `confirmBlocker` 172, `awaitsSeller` 200, `confirmForSeller` 211, `syncWithDeliveryStatus` 239, `deliveryBlocker` 323 |
| `app/Http/Helpers.php` | Globals used by every path | `round_system_price` 509, `order_exchange_rate` 1147, `convert_order_price` 1176, `order_is_cancelled` 1242, `order_payable_total` 1253, `combined_order_payable_total` 1272, `cart_product_price` 1334, `cart_product_tax` 1411, `cart_shipping_base_total` 2042, `first_shippable_cart_index` 2070, `free_shipping_limit_for` 2097, `cart_shipping_cost` 2152, `getShippingCost` 2209, `checkout_done` 3050 (API legacy), `order_re_payment_done` 3087, `product_restock` 3176, `calculateCommissionAffilationClubPoint` 3200, `paymera_lang` 4995, `acting_user*` 5081-5107, `archive_deleted_order` 5187, `combined_delivery_status` 5573, `checkout_confirmed_notices` 5660, `can_settle_order_payment` 5697, `record_payment_settlement` 5739, `order_accountant_id` 5762 |
| `app/Console/Commands/ExpirePaymeraAppCheckouts.php` | `paymera:expire-app-checkouts`, every 5 min (`Console/Kernel.php:43`) | cancels pending app checkouts older than 30 min |
| `app/Console/Commands/ProcessSellerWhatsAppAlerts.php` | `seller-whatsapp:process`, every minute (`Kernel.php:47`) | retry/poll/remind/escalate |

## 3. Data model

**Tables & relationships**

- `carts`: `user_id` / `temp_user_id` (guest), `owner_id` (seller), `product_id`, `variation`, `quantity`, `price`, `tax`, `shipping_cost`, `shipping_type` (`home_delivery|pickup_point|carrier`), `pickup_point`, `carrier_id`, `address_id`, `discount`, `coupon_code`, `coupon_applied`, `status` (1 = selected for checkout, the `active()` scope), `product_referral_code`. Per the price contract, `price`/`tax`/`shipping_cost` are BASE.
- **Rows of an unpublished / unapproved product are hidden, not deleted** (since 2026-10-13): `Cart` has the global scope `App\Models\Scopes\CartProductOnSaleScope` (`EXISTS` a product with `published = 1` and `approved = 1`), so every Eloquent cart read - web and `Api/V2` cart, counts, nav cart, coupons, shipping, checkout, order placement, abandoned-cart reminders - sees only what the customer can buy, and placing an order leaves a hidden row in place. Publishing the product again brings the row back with its variant and quantity. What must see every row uses `Cart::withHidden()`: the product-delete paths (`Product::carts()` also bypasses the scope), seller delete, product transfer, account deactivation, every guest-to-account cart move (web/app login, OTP, register, checkout `createUser`, app guest account) and the admin cart logs / report. A new path that deletes or moves cart rows by product or by owner must use it too, or it leaves hidden rows behind. Adding an unpublished product to the cart is refused (web `addToCart`, app `add`). Removing a hidden row from a page or app screen opened before deletes it for real (web `removeFromCart`, app `destroy` use `withHidden()`), so it does not come back; a web quantity change on it answers with the current cart instead of a 404. Live check: `CartUnpublishedProductRuntimeTest` (opt-in, rolled back). Raw `DB::table('carts')` queries (the cart report in `ReportController`, cart retargeting) are not scoped.
- `combined_orders` (one per checkout): `user_id`, `shipping_address` (JSON), `grand_total` (BASE, **includes shipping**), `shipping_cost`, `payment_id` (Paymera), `coupon_id`/`coupon_code`/`coupon_discount` (migration `2026_09_06_000001`). Relation: `hasMany(Order)`.
- `orders` (one per seller per checkout): `combined_order_id`, `user_id`, `seller_id`, `shipping_address` (copy of combined), `payment_type` (`cash_on_delivery|paymera|wallet|<manual name>`), `payment_status` (`paid|unpaid`), `payment_details`, `manual_payment`/`manual_payment_data`, `delivery_status`, `grand_total`, `coupon_discount`, `coupon_id`/`coupon_code` (migration `2026_09_05_000001`), `code`, `date` (epoch, **the authoritative timestamp**, because `created_at` is skewed), `order_from` (`web|app`, `sqlupdates/v740.sql`), `viewed`, `delivery_viewed`, `payment_status_viewed`, `notified`, `commission_calculated`, `tracking_code`, `delivered_date`, `assign_delivery_boy`, `pickup_point_id`, `carrier_id`, `shipping_type`, `additional_info`.
  - Custom: `seller_confirmed_at` (`2026_09_14_000002`), `seller_unavailable_at`, `seller_unavailable_note` (`2026_09_21_000001_add_seller_unavailable_to_orders`), `payment_settled_by`, `payment_settled_at` (`2026_09_21_000001_add_payment_settled_by...`), `cancel_reason`, `cancel_note`, `cancel_image`, `cancelled_at`, `cancelled_by`, `cancelled_by_type`.
  - Relations (`Order.php`): `orderDetails`, `user`, `shop` (hasOne Shop by `seller_id`), `coupon`, `couponUsages`, `combinedOrder`, `commissionHistory`, `followUp` (hasOne OrderFollowUp), `paymentSettledBy`, `delivery_boy`, `refund_requests`.
- `order_details`: `order_id`, `seller_id`, `product_id`, `variation`, `price` and `tax` (line totals = unit × qty, BASE), `shipping_cost` (0 on web/app-COD, per-line on app-Paymera), `quantity`, `payment_status`, `delivery_status`, `shipping_type`, `product_referral_code`, `earn_point`, `seller_unavailable` (bool, custom).
- `order_follow_ups` (`2026_09_12_000001`, plus `2026_09_16_000001` delivery columns, `2026_09_16_000004` legacy accountable employee, `2026_09_19_000003` `accountant_user_id`): `order_id` (unique), `status`, `not_ready_reason`, `decided_*`, `delivery_employee_id` → `delivery_agents`, `delivery_assigned_*`, `accountant_user_id` → `users`, `accountable_assigned_*`. Rows are created lazily by `OrderFollowUp::forOrder()`. Neither creation path makes one.
- `order_follow_up_logs` (audit trail), `order_follow_up_items` (legacy checklist, no longer read), `delivery_agents` (`name`, `phone`, `is_active`).
- `seller_order_notifications` (`2026_09_18_000001`): one row per order, holding the WhatsApp alert state (`send_status`, hashed token + `token_expires_at`, `response`, `response_source`, `closed_reason`, reminders, BuzzBip `bulk_group_id`, `wa_state`).
- `paymera_app_checkouts` (`2026_09_10_000001`): `user_id`, `combined_order_id`, `payment_id`, `amount` (SYP int), `grand_total` (BASE), `cart_snapshot` (JSON), `status` (`pending|completed|cancelled|failed|expired`), `completed_at`.
- `deleted_orders` (`2026_09_05_000001`, plus `acting_as_*` in `2026_09_07_000001`): full snapshot (`order_data`, `order_details_data` with `item_snapshot`) plus who, where and why.
- `stock_zeroings` (`2026_09_20_000001`), `coupon_usages` (+`order_id`), `commission_histories`, `refund_requests` (addon), `delivery_histories` (addon), `wallets`, `temp_auths` (checkout OTP), `guest_cart_infos` (`sqlupdates/feature_guest_cart_infos.sql`).

**Where the schema lives:** the base CMS tables come from the vendor dump and `sqlupdates/v*.sql`, and the Kapitano additions are in `database/migrations/2026_09_*`. **No schema source in the repo** was found for `orders.cancel_reason/cancel_note/cancel_image/cancelled_at/cancelled_by/cancelled_by_type`, `orders.notified`, `combined_orders.payment_id`, `combined_orders.shipping_cost`. They exist only in production and in test-built schemas (for example `tests/Feature/PaymeraRefundCancelsOrderTest.php`). They were probably added by hand, which is **(unverified)**. A fresh install built from the repo would lack them.

## 4. Flows

### 4.1 Web checkout (COD / wallet / offline)
1. The cart page (`CartController::index`) uses guest carts keyed by `session.temp_user_id` plus the HMAC-signed `temp_user_id` cookie (`sign_guest_cart_id()` / `guest_cart_id_from_cookie()`, Helpers.php). File sessions are not locked, so a request that was running when the guest added to cart used to write its older session back and drop the id (cart page empty until the next add, the only reader of the cookie); since 2026-10-17 the `RestoreGuestCartId` middleware (web group) puts the cookie's id back into the session of every guest request. Pinned by `tests/Feature/GuestCartSessionRestoreTest.php`. On sign-in every path adds the guest rows to the account's cart; the Apple / social logins and `HomeController@register` used to delete the account's rows first. `updateCartStatus` sets `carts.status`, which controls which rows are "selected" for checkout.
2. `GET /checkout` → `CheckoutController::index` (352). It sets `address_id` on active carts and writes `shipping_cost` per row via `getShippingCost` (429/431). The displayed total is recomputed with `getShippingCost($carts, 0, …)` (440-446).
3. Guest phone sign-in happens by OTP: `checkPhone` (121) stores a 4-digit OTP in `temp_auths` and sends it via `OtpSenderService`, then `verifyPhone` (204) logs the customer in and moves the guest cart to them (292-301).
4. Address and shipping changes go through `updateDeliveryAddress` (1225) and `updateDeliveryInfo` (1289), which rewrite `carts.shipping_cost`.
5. `POST /checkout/payment` → `checkout` (466):
   - A guest without a login goes through `createUser` (634), which creates the user and address, re-keys the cart and logs them in.
   - The minimum-order check runs at 503-513 (web only).
   - `(new OrderController)->store($request)` (519) is a direct call, so **the route middleware `permission:edit_order` on `store` does not apply here**.
   - `OrderController::store` (509) steps:
     1. Take `CartUtility::orderable_items` (513).
     2. Run the up-front stock check `first_short_product` (524).
     3. Re-validate the coupon (`CouponUtility::rejectionReason`, 534-550; `refreshCartCoupon` - coupon minimum and covered products, 554).
     4. Build the address JSON (564-580) and create the `CombinedOrder` (582).
     5. Group rows by `product->user_id` (588-596).
     6. Per seller: create an `Order` (605-616, `order_from='web'`, `payment_type = payment_option`, `code = date('Ymd-His').rand(10,99)`). For each line, **decrement `product_stocks.qty`** (638-641), create the `OrderDetail` with `shipping_cost = 0` (653), increment `num_of_sale` for product and seller, and run the affiliate stats.
     7. Set `order.grand_total = subtotal + tax - coupon_discount` (694-698), `CouponUtility::recordUsage` (712), and `EmailUtility::order_email` unless Paymera (722-724).
     8. `CombinedOrderCouponUtility::storeCheckoutCoupon` (728).
     9. **Shipping goes on the combined order only**: `round_system_price(cart_shipping_cost($carts), 1000)` (737), then it is added to `combined_order.grand_total`.
     10. Store `combined_order_id` in the session (741).
   - There is **no DB transaction** and no row lock (compare the API version).
   - The active carts are deleted (526).
   - The payment-option dispatcher (551): the `App\Http\Controllers\Payment\{Studly}Controller` class if it exists, else the Paymera branch (562), else manual/offline payment (617-629, `manual_payment=1`).
6. COD: `Payment\CashOnDeliveryController::pay` (9) just redirects to `order_confirmed`. Wallet: `Payment\WalletController::pay` (16) debits `users.balance` by `combined_order.grand_total` and calls `CheckoutController::checkout_done` (741), which marks paid and runs `calculateCommissionAffilationClubPoint`. If the balance is too low it returns null (no response).
7. `GET /checkout/order-confirmed` → `order_confirmed` (1108):
   - Facebook CAPI and GA4 purchase events (1122-1159).
   - `SellerOrderAlertUtility::newOrder($order)` for each order (1171). **This is the only seller alert for web COD/wallet/offline orders.**
   - Sets `notified = 1`.
   - Creates a loyalty `UserLoyalty` row from `combined_order.grand_total` (1180-1213).
   - Forgets `combined_order_id`, so a reload 404s.

### 4.2 App (API v2) checkout, non-Paymera
1. The cart endpoints (`routes/api.php:263-273`, no `auth:sanctum`) identify the customer by **request `user_id` / `temp_user_id`**. `ShippingController::shipping_cost` (23) and `AddressController::updateAddressInCart` write `carts.shipping_cost`. A missing id must never reach `where('temp_user_id', $id)`: Laravel turns a null value into `IS NULL`, which is every signed-in customer's cart. App register, app guest account and web `createUser` moved all of those into the new account until 2026-10-17 (now guarded; pinned by `AccountDeactivationRuntimeTest::test_app_register_without_a_guest_cart_leaves_other_customers_carts_alone`). The read/shipping/coupon endpoints that pick "the user's rows, else the guest's" go through `Cart::ofOwner($userId, $tempUserId)`, which matches **no row** when both are empty (since 2026-10-17; before, a request with neither id read every signed-in cart and wrote shipping cost / type / coupon on them): app `ShippingController` (`shipping_cost`, `getDeliveryInfo`), `CarrierController`, `AddressController@updateShippingTypeInCart`, `PaymentTypesController`, `Api/V2/CheckoutController` (apply / remove coupon), `seller_base_carrier_list()`, and on the website `CheckoutController` (apply / remove coupon, `updateDeliveryAddress`) and `CartController@updateCartStatus` (a guest without a session id). A new "user else guest" cart query must use it too. Pinned by `tests/Feature/CartWithoutOwnerTest.php`. Still open: these endpoints trust the `user_id` the request sends (no `auth:sanctum` on most of them), so any caller can name another customer's id.
   - **New app versions** (2026-10-22, `docs/api/app-data-sync.md`): `POST /api/v2/checkout/overview` (`App\Services\AppData\CheckoutOverview`) runs the checkout page's seven calls (carts, addresses, `update-address-in-cart`, `delivery-info`, `shipping_cost`, `payment-types`, `cart-summary`) in the page's order through `LegacyEndpoint` and answers each body as it is, the cart owner from the token. A change to one of those endpoints' answers reaches it with no extra work; a new step of the page = one line in `CheckoutOverview::PARTS`. The cart rows of `POST carts` carry the product page's discount fields, `carts/add|process` and `DELETE carts/{id}` answer `cart_count` (`CartUtility::count_items()`, the one count `cart-count` gives) and `products/{slug}/{user_id}?with=cart` adds the product's rows of the caller's cart (`CartUtility::product_in_cart()`). Pinned by `CheckoutOverviewTest`, `ShippingEngineCharacterizationTest` and `ApiHotPathsRuntimeTest` (each part equal to the old call on the MySQL copy). The pickup points `delivery-info` offers follow the website's rule through the same `get_all_pickup_points($user)` (Helpers.php): the customer's city and state, every active point for a guest, read once per request. Until 2026-10-22 a guest got a 500 there whenever the pickup point setting was on (`User::find(null)->city_id`), so the app's guest checkout broke at the delivery step; pinned by `ShippingEngineCharacterizationTest`.
2. Coupon: `POST /api/v2/coupon-apply` → `Api\V2\CheckoutController::apply_coupon_code` (45). It takes `user_id` from the request and supports `cart_base`, product-scoped, `governorates_base` (app-only) and `welcome_base` (broken, see §8).
3. `POST /api/v2/payments/pay/cod` or `/payments/pay/manual` → `Api\V2\PaymentController` → `Api\V2\OrderController::store` (38). `POST /api/v2/order/store` also reaches it directly (`routes/api.php:388`).
   - The same pre-checks as web (45-107), then `cart_shipping_cost($cartItems, null, true)` recomputes and persists row shipping (140).
   - **One DB transaction** (147-279) with `ProductStock ... lockForUpdate()` (201-203) and `CartStockShortException` rollback.
   - `order_from='app'`, `payment_type = $request->payment_type`, `payment_status = $set_paid ? 'paid' : 'unpaid'` (177).
   - Detail `shipping_cost = 0` (221). Shipping is summed from `round_system_price($cartItem->shipping_cost)` (225) onto `combined_order.shipping_cost` (271-272).
   - Deletes only the rows selected for the order (`active()`), like the web (since 2026-10-17; it used to delete every cart row of the user, so the items the customer had unticked were lost). Pinned by `CartUnpublishedProductRuntimeTest::test_app_order_keeps_the_rows_the_customer_unticked` (opt-in, live DB).
   - After commit it calls `NotificationUtility::sendOrderPlacedNotification` for each order (290-299), which calls `SellerOrderAlertUtility::newOrder` (`NotificationUtility.php:81`).
   - There is no minimum-order check and no loyalty points (the old code is commented out at 596).
4. Wallet (app): `POST /api/v2/payments/pay/wallet` → `Api\V2\WalletController::processPayment` (28), which calls `store($request, true)` and then debits `User::find($request->user_id)` by `$request->amount`. See §8 (critical).

### 4.3 Paymera, web
1. In `checkout` (562-609), the orders **already exist** (unpaid, stock taken, cart deleted).
   - If `PaymeraUtility::nothingToCharge` → `completeWithoutGateway`.
   - Otherwise `amount = (int)(combined.grand_total × order_exchange_rate(combined))`, which truncates. The callback is `payment.paymera.return/{id}` and the trigger is `payment.paymera.trigger/{id}` (`routes/web.php:246-250`). `payment_id` is saved on `combined_orders`, then the customer is redirected to Paymera.
2. The browser returns to `PaymeraController::callback` (148) and the server calls `trigger` (197). Both go through `PaymeraUtility::syncPaymentStatus` (52), which calls `getPaymentStatus`.
   - On `A`: `PaymeraAppCheckoutUtility::complete` (a no-op for web) and `markCombinedOrderPaid` (119). That runs in a transaction with `lockForUpdate`: each order and its details become paid, commission is calculated, and then for each order that transitioned it sends `order_email('paid')`, `NotificationUtility::sendNotification('paid')` and **`SellerOrderAlertUtility::newOrder`** (162).
   - On `C`/`F`: `PaymeraAppCheckoutUtility::close` (a no-op for web).
3. Pay again later: `POST /paymera/re-payment` → `PaymeraController::rePayment` (39). It checks ownership and uses `(int) round(...)` for the amount.
4. **Abandoned web Paymera orders are never expired.** They stay `pending/unpaid` with stock deducted (only app checkouts expire). The seller is not alerted because `isOnlineAndUnpaid` returns true (`SellerPushUtility.php:537`).

### 4.4 Paymera, app (orders exist only after payment)
1. `POST /api/v2/paymera/create` → `createPayment` (502).
   - Coupon re-check, then `cart_shipping_cost(...,true)` (584), then `PaymeraAppCheckoutUtility::buildSnapshot` (44). The snapshot's per-order `grand_total` **includes shipping** (92).
   - Reserves an empty `CombinedOrder` with `grand_total = snapshot total` and `shipping_cost = sum(carts.shipping_cost)` (609-616).
   - `createPayment` uses the callback/trigger URLs `api.paymera.callback`/`api.paymera.trigger` (`routes/web.php:183-187`, which point to `Api\V2\CheckoutController`).
   - Saves a `paymera_app_checkouts` row with status `pending` (653-661).
   - A zero total goes to `completeWithoutGateway`.
2. Callback, trigger and `POST /api/v2/paymera/check-payment` (748, **unauthenticated**) all go through `syncPaymentStatus`. On `A`, `PaymeraAppCheckoutUtility::complete` (110) runs under a lock and is idempotent:
   - `createOrders` (210) creates `orders`/`order_details` with detail `shipping_cost = item shipping` (247) and `order.grand_total = snapshot grand_total` (254), which includes shipping.
   - `takeStock` counts the sale (`num_of_sale`) and logs an oversell. It decrements stock (clamping at 0) only for an order outside "stock on confirmation" (`OrderStockUtility::appliesTo()`); under the rule the stock is taken once, when the line is confirmed. Until 2026-10-31 it always decremented, so a paid app order took its stock twice and a cancel before confirming gave none back.
   - It records coupon usage and deletes only the snapshotted cart ids.
   - `sendOrderPlacedNotification` runs, then `markCombinedOrderPaid`.
3. `POST /api/v2/paymera/cancel-payment` (694, **unauthenticated**, keyed by `payment_id`) → `PaymeraAppCheckoutUtility::cancel` (173). It re-checks Paymera first and voids the payment link only when the status is still `P`.
4. The cron `paymera:expire-app-checkouts` expires pending checkouts after 30 minutes (`PaymeraAppCheckoutUtility::EXPIRE_AFTER_MINUTES` 34) without voiding at the gateway.
5. `POST /api/v2/paymera/re-pay` → `rePay` (373). Its input `combined_order_id` is actually an **order id**, validated `exists:orders,id` (378).

Since 2026-10-31 the cart preparation of `createPayment` lives in `App\Services\Payments\AppCheckoutCart` (shared with eCash, moved unchanged), and `paymera_app_checkouts` has a `gateway` column: Paymera's paths read `PaymeraAppCheckout::ofGateway('paymera')`.

### 4.4a Online payments as modules, and eCash (since 2026-10-31)

Paymera's code moved into `app/Services/Payments/Paymera/` with its behaviour pinned (`PaymeraCheckout` holds the four entry points above, `PaymeraPayments::sync` is the old `syncPaymentStatus`, `PaymeraUtility` keeps the old names); every payment is also recorded in `online_payments` for Admin → Online payments. eCash is the second online gateway, next to Paymera. "Online prepaid" is `App\Services\Payments\PrepaidGateways::isPrepaid()` (Paymera, eCash), never `=== 'paymera'`. Its limits per gateway are checked before any order exists. eCash's callback (`POST api/v2/ecash/webhook`) is the only thing that marks an eCash checkout paid. Refunds reverse the whole payment within 6 hours (through the API, or by staff in eCash's dashboard). Full map, cases and deploy: [`features/online-payments.md`](features/online-payments.md). App contract: `docs/api/ecash.md`.

**Since 2026-10-31 the website orders only once paid (§4.3 describes the flow before):** an online payment never reaches `OrderController@store`. `PrepaidCheckouts::startWeb()` freezes the cart, and the orders are built by `WebCheckoutOrders::create()` (the code `store()` itself now calls for cash on delivery) when the gateway reports the money. A payment not made leaves no order and keeps the cart. Every gateway implements `App\Services\Payments\Gateway`; the order pages' "Pay" button is the one route `online_payment.pay`.

### 4.5 Placement → seller alert → confirm → deliver → settle
1. **Seller alert:** `SellerOrderAlertUtility::newOrder` (25) → `SellerPushUtility::newOrder` (76; Cache lock per order) plus `SellerWhatsAppUtility::newOrder` (150).
   - The WhatsApp alert goes out only when all of these hold: `seller_whatsapp_alerts == 1`, the order is not online-and-unpaid, it is not in-house, and the seller is in the pilot list.
   - It creates the `seller_order_notifications` row and sends after the response via `app()->terminating` (`dispatch`, 569).
   - **Staff changes, sent when staff choose** (2026-11-04, `App\Services\OrderAlerts\SellerOrderChangeMessages`, its docblock is the map): a seller order that has a WhatsApp alert shows, on the combined order page, the product changes staff made after the alert (`StaffOrderEdits` kinds quantity / variant / item_cancelled / product_added; coupon and shipping are not the seller's) and not yet sent, with "Send the changes to the seller" (a dialog shows the message first) and "No need to send" (`orders.seller_changes.send|skip`, permission `edit_order`). Every unsent change goes in ONE message: template `seller_whatsapp_changes_template` (picked on the settings page; no .env value; none = nothing can be sent), five variables (shop, order code, the changes with the SKU of the variant concerned and "from 1 to 2" in words - an arrow read backwards in WhatsApp -, the products now, the seller's total now) and one button, `/so/v/{{1}}` with the alert's own token. It informs only (decided with the user): no new confirmation is asked. `seller_order_change_messages` (migration `2026_11_04_000001`) records each send / skip up to an audit id; a failed send covers nothing. A product added for a new seller opens a new order, whose new-order alert already tells them. A whole cancelled order is not offered (the seller panel notification covers it). The edit records carry `product_id` from 2026-11-04 (older ones show no SKU). Pinned by `SellerOrderChangeMessagesTest`.
   - **The products variable** ({{3}}, `SellerWhatsAppUtility::itemsSummary()`) is `name (SKU) ×qty` per line, joined by "، " (2026-11-04): the SKU of the ordered variant's `product_stocks` row (product id + `order_details.variation`, one query per message, `App\Services\OrderLineSkus`), none when the row has no SKU. The same SKU is shown on the seller panel's order page (`seller/orders/partials/line_sku`), the WhatsApp "accept / out of stock" page (the products it lists) and the seller app's order items (`sku`, `""` when none; 2026-11-05). A partner store's products carry the partner's own SKUs. Older rows keep whatever SKU they were saved with (often the variant text, e.g. `Beige`), since built SKUs only fill empty ones. That one variable may run to `ITEMS_PARAM_MAX` (600) characters, the others stay at `PARAM_MAX` (120). No template change: every seller template carries it.
   - **Its links are tokens, not Laravel signed URLs** (`/so/a|o|v/{token}`, only the sha256 is stored, expiry `seller_whatsapp_token_hours`, default 72, max 720). **One alert = one link** (decided 2026-10-21): `rotateToken()` derives it as an HMAC of the alert id under `APP_KEY` (random only when there is no key), so the first message, every reminder (`seller_whatsapp_reminder_minutes`, default 15 and 45) and every resend carry the same link, and each send moves the expiry forward. Before, each one minted a random token and the earlier message answered "This link is no longer valid" from the 15-minute reminder on. The first answer still wins whichever message it came from (`SellerOrderResponseUtility::record` is a conditional update). Changing `APP_KEY` ends every open link.
   - **The buttons are fixed in the BuzzBip template**, the code only fills `{{1}}` with the token (button 0, button 1). Template 1548 (`seller_order_accept_or_view_ar`, approved) has "قبول الطلب" → `https://kapitano.shop/so/a/{{1}}` and "عرض الطلب" → `https://kapitano.shop/so/v/{{1}}`: its domain is written in the template, so an alert sent from the test server opens the live site, which answers "This link is no longer valid" (a test server needs its own copy of the template). The reminder template must carry the same two buttons.
   - **Which templates** (2026-11-04): picked on Admin → Seller WhatsApp alerts (`seller_whatsapp_order_template` / `seller_whatsapp_reminder_template` in `business_settings`), else `BUZZBIP_SELLER_ORDER_TEMPLATE_ID` / `BUZZBIP_SELLER_REMINDER_TEMPLATE_ID` as before (`SellerWhatsAppUtility::templateId()` / `reminderTemplateId()` / `templateSource()`). The pickers list the account's approved templates, fetched by the page after it loads (`seller_whatsapp_settings.templates`, cached 10 min, a failure not cached: BuzzBip's list can take 20 s). A newly picked template is read from BuzzBip and refused, with nothing saved, unless `SellerWhatsAppUtility::templateProblems()` finds nothing: approved, body variables exactly {{1}}..{{5}}, exactly two URL buttons opening this server's `/so/a/{{1}}` then `/so/v/{{1}}` (or `/so/o/{{1}}`), host compared without scheme or `www.` - this is what catches a button "tracked" in the campaign template builder (`kapitano.shop/wc/...`) and the other server's template. The status card lists the same problems for the templates in use. Pinned in `SellerWhatsAppSettingsTest`.
   - **"عرض الطلب" on Android** (2026-11-04): WhatsApp opens the link in its in-app browser, which never hands it to the installed seller PWA, so on Android `view()` redirects (no page in between, the user asked for none) to an `intent://` of the order's panel URL with no package: Android picks the app that owns the URL (the PWA, scope `/`, when installed - or the customer app `shop.kapitano`, whose `assetlinks.json` claims every kapitano.shop link, so a chooser may appear), else the browser loads `S.browser_fallback_url`. A plain WebView (`; wv)` in the user agent) may answer `intent://` with an error page, so it gets the panel URL, like iPhone and desktop; iOS never opens a PWA from a link. Whether WhatsApp's in-app browser follows the intent is only known from a real phone. Pinned in `SellerOrderActionLinkTest`.
   - Call sites: `CheckoutController::order_confirmed:1171`, `NotificationUtility::sendOrderPlacedNotification:81`, `FirebaseNotificationTrait:577`, `PaymeraUtility::markCombinedOrderPaid:162`.
2. **Seller answers** (any of these, first one wins via `SellerOrderResponseUtility::record`):
   - The WhatsApp button `/so/a/{token}` → `SellerOrderActionController::submit` (114) → `confirmAsSeller` (183). That does `Auth::onceUsingId(seller)` and reuses `Seller\OrderController::confirm` (86), which **only** sets `orders.seller_confirmed_at`.
   - `/so/o/{token}` → `reportUnavailableAsSeller` (212) → `Seller\OrderController::unavailable` (125) → `applySellerAvailability` (222). That sets `order_details.seller_unavailable` and `orders.seller_unavailable_*`, and puts the follow-up into `not_ready` with the note. It never cancels the order, but (since 2026-10-13) a line that turns unavailable has its variant's `product_stocks` row set to 0 at once (`StockZeroUtility::zeroForSellerReport`, `stock_zeroings.source = seller_unavailable`), and taking it back restores `qty_before` (`undoSellerReport`, deletes the row) only if the stock row was not written since and no other open order still flags that variant. A later staff "out of stock" cancel finds the stock already 0 and records nothing twice. The panel modal and the per-product buttons (`item-availability`) take the same path.
   - The seller panel status dropdown (`Seller\OrderController::update_delivery_status` 310), where `confirmed` counts as an answer via `fromDeliveryStatus`, and the seller app (`OrderService::handle_delivery_status` 20).
3. **Staff confirm** (`/admin/order-follow-up`): the list shows every `delivery_status = 'pending'` order, grouped per combined order (`OrderFollowUpController::index` 47).
   - `confirm` (126) optionally runs `OrderFollowUp::confirmForSeller` (211) first, then checks `confirmBlocker` (172), which blocks when the order awaits the seller (`Order::SELLER_CONFIRMATION_SINCE = 1789388388`, only orders after 2026-09-14).
   - It then calls `decide(ready)`, and `applyDeliveryStatus('confirmed')` calls **`OrderController::update_delivery_status` in-process** (196).
   - `reopen` (173) sends `confirmed` back to `pending`.
4. **Deliver** (`/admin/order-delivery`, `OrderDeliveryController::update` 77): the actions are `assign` (delivery agent), `accountant` (must `isAccountant()`), `on_the_way` and `delivered`. The last two go through `deliveryBlocker` (`OrderFollowUp.php:323`), which requires `ready`, an assigned employee, and not cancelled, and then through `OrderController::update_delivery_status`.
5. **Settle** (`/admin/accountant-orders`, `AccountantOrderController::update` 99):
   - Checks `can_settle_order_payment` (`Helpers.php:5697`), which allows the Super Admin, or an accountant who is either assigned to the order or covered by `accountants_settle_any_order`.
   - A **cancelled order that is unpaid** stays unpaid: `order_payment_status_locked()` (Helpers.php) makes the three manual paths (`OrderController::update_payment_status`, `Seller\OrderController::update_payment_status`, `OrderService::handle_payment_status`) refuse with 422 for everyone, the Super Admin included, and the admin/seller order pages and the accountant box hide the control. A cancelled order that was already paid, and a returned order, can still be changed (decided 2026-10-11).
   - Then calls `OrderController::update_payment_status` (1142) in-process. That writes the detail and order `payment_status` and `record_payment_settlement` (5739, `payment_settled_by/at`), runs commission when it is 0, and sends `order_email('paid')`, `NotificationUtility::sendNotification` and SMS.
6. **Sync hook:** any save that changes `orders.delivery_status` fires `Order::updated` → `OrderFollowUp::syncWithDeliveryStatus` (239). Back to `pending` reopens a `ready` follow-up. Any status past pending forces `ready`. `cancelled` is ignored.

### 4.5a Managing a whole checkout (combined order page, 2026-10-20)
`/admin/orders/combined/{id}` (`combined_order_details`, view `backend/sales/show_combined`) shows one card per seller order and controls both for the whole checkout and for each order. The bar on top acts on the ticked orders, or on all of them when none is ticked:
- **Delivery status** (`orders.combined.delivery_status` → `combined_update_delivery_status`): `pending` … `delivered` only (cancel and return have their own buttons). Void, delivered and already-at-that-status orders are skipped. The stock of **every** target is checked first (`OrderStockUtility::shortage`), so one short order changes nothing; then each order goes through `applyDeliveryStatus()`, the body of the order page's `update_delivery_status`, so stock, lines, e-mail, SMS, notifications and the `Order::updated` hooks behave the same.
- **Payment status** (`orders.combined.payment_status`): each order through `update_payment_status`; orders the user cannot settle (`can_settle_order_payment`) or locked ones are skipped and counted.
- **Cancel**: C1b below.
- **Return products** (`orders.combined.return` → `combined_return`, perm `update_order_delivery_status` like `admin_return`): one modal lists the returnable lines (picked up / on the way / delivered) of every seller order, grouped by seller, with one reason; each order returns its own ticked lines through `returnLinesByStaff()`, the body of `admin_return`, so a fully returned order becomes `returned` and a partial one keeps its status. Lines of another checkout are ignored. Since 2026-10-22 every return (this one, `admin_return`, a received customer return request: all go through `OrderStockUtility::returnLines()`) sets the returned lines `unpaid`, and the order too once it is fully `returned`; `order_payment_status_locked()` keeps it unpaid (migration `2026_10_22_000006` did the earlier returns, backup `returned_payment_backups`).

- **Shipping**: the order page's `update_shipping_cost`, from the summary box.
- **Change a line option** (pencil on a line whose product has several options; `orders.combined.change_variant` → `combined_change_variant`, perm `edit_order`): same product and quantity, priced like the cart today for the new option, cost taken again (`SellerPricing::orderLineCost`, `OrderLinePricesUtility::stamp`). The line is moved to `cancelled` with `OrderStockUtility::moveLines()` (the old option stock back when held), switched, then moved back to its own status (the new option stock taken when held; a shortage rolls the whole change back), then `recalculate()`. Refused on a paid checkout, a delivered or Logistic-locked order, the same option, or an option already a live line of the order.
- **Coupon** (`orders.combined.coupon` → `combined_apply_coupon`, perm `edit_order`): add, change or remove (empty code). Checked like the checkout (`combinedCouponRefusal()`: switched on, dates / the welcome coupon's own expiry, the order's platform (`orders.order_from`), allowed customers, already used and user limit with this checkout's own use not counted (`usedByUser` / `userLimitReached` take `$exceptOrderIds`: an edit that dropped the coupon below its minimum leaves its usage behind, and putting it back answered "already used", #5420, 2026-11-03), new customers only with this checkout's own orders not counted, governorate), then written on the combined order and on every seller order (with their `coupon_discount` zeroed, or `recalculate()` would keep a discount they still carry), and `CombinedOrderCouponUtility::recalculate()` judges it on the products (minimum, covered products) and splits it. A coupon that gives nothing here is refused and the transaction rolled back. The usage moves with it: the old coupon's `coupon_usages` rows of these orders are deleted, the new one is recorded on the first live order. Refused once any live order is paid. The summary box reads: subtotal (products + tax), shipping, coupon (with its code), grand total, then the shipping and coupon edits.

- **Products** (perm `edit_order`, like `updateQty` / `deleteItem`): each live line of an order that is not delivered has − / + (`orders.update_qty`) and cancel (`orders.delete_item`: "Customer cancelled" or "Cancel and set its stock to zero", see below). **Add product** (`orders.combined.product_search` → JSON, `orders.combined.add_product` → `combined_add_product`): published, approved, not digital / auction products, one button per option with its stock and price. The line joins the live order of the product's seller (`orders.seller_id`), or a new seller order of the checkout (copied from the first order: customer, address, payment type, shipping type; pending / unpaid; `SellerOrderAlertUtility::newOrder()` alerts the seller). Priced like the cart today (`cart_product_price` / `cart_product_tax`, BASE); its cost is frozen by `OrderDetail::creating`. The line is created `cancelled` and moved to its order's status with `OrderStockUtility::moveLines()`, so it takes stock only when the order holds stock, a shortage refuses it with nothing written (the transaction is rolled back), and `num_of_sale` counts it. Then `CombinedOrderCouponUtility::recalculate()`. Refused when any live order is paid, when the seller's order is delivered or locked by Kapitano Logistic (`EditGuard`), and when the same product + option is already a live line (change its quantity instead).

- **Wrapping is the store's setting in Logistic (since 2026-11-07).** "Wrap every order" (`store_clients.wrap_all_orders`) on the store's wrapping settings in the Logistic store portal: when on, Logistic writes every order of the store as the collection and delivery legs. The shop no longer marks orders: no checkout checkbox, no seller setting, and a `needs_wrapping` sent by a client is ignored (`orders.needs_wrapping` and the mapper's field remain, unused). Test: `CartUnavailableVariantCheckoutTest`.
- **Captain Orders Deliver, short** (`logistic._order_strip`, only with `logistic.enabled`): one strip per seller card from `OrderLogisticView::forOrder()` - the status badge, the Logistic number, "not reported yet" or the time of the last callback, the captain and phone, wrapping, and a problem as its code made readable (the whole `last_error` in the tooltip); the full panel (`logistic._order_panel`) is on the seller order page, opened from the order code of the card.

Each card's own controls post to the order page's endpoints (`orders.update_delivery_status`, `orders.update_payment_status`, `orders.admin_cancel`). The coupon and the shipping are shown once, in the summary.  Pinned by `tests/Feature/CombinedOrderManageTest.php`.

**Pickup branches (since 2026-10-29, only with `logistic.enabled`).** The shop itself (`shops` + `logistic_branches`, resolved by `ShopBranchResolver`) is the *main branch*; a seller adds others at Seller → Pickup branches (`seller.branches.*`, `Seller\BranchController`; name, phone, a point from the Google map; table `shop_branches`, model `ShopBranch`, rules in `App\Services\Logistic\ShopBranches`). Which one an order is collected from is one row in `order_branch_choices` (`OrderBranchChoice`; no row = main branch), written when the order is confirmed: the seller's **Confirm order** button opens a pop-up (`logistic._branch_choice`) when the shop has active branches and the order goes to Logistic (`Seller\OrderController::show` passes `$branchOptions`; `confirm` takes an optional `branch_id`, empty = main, absent = leave alone), and staff setting an order to **Confirmed** on the order page / combined order page get the same pop-up (`logistic._admin_branch_pick`, `OrderController::pickupBranches` + `update_delivery_status` with `branch_id`; the bulk "change status" of a combined order does not ask, it keeps the seller's choice). `OrderPayloadMapper` passes the order id to `ShopBranchResolver::resolve()`, which sends the chosen branch (its own `branch_ref` `{shop}-{branch}`, name, address, point, `phone`) or the main branch (the shop's phone too); the pickup is read at send time, so an edited branch goes as it now is. The choice is refused once Logistic has the order (`logistic_order_links.logistic_uuid`), for another shop's or a switched-off branch; a branch an order chose is switched off, never deleted. Logistic gained an optional `pickup.phone` (`order_pickups.phone`, shown to the captain and the dashboard) and the pinned contract (`resources/logistic/`) was re-pinned for it. A store can also get its point and branches when it is created, all optional: the section `logistic._store_branches_fields` (collapsed `<details>`, Google Maps loaded only when opened) sits on the seller sign-up forms (`auth/boxed/registration` for `HomeController::registerSeller`, and the three `seller_registration` layouts for `ShopController::store`) and on the admin's Add New Seller (`SellerController::store`); `App\Services\Logistic\StoreBranchInput` validates it before the account exists (`pickup_lat/lng` = the main branch point on `shops.delivery_pickup_*`, `branches[i][name|phone|address|lat|lng]` = up to 10 `shop_branches`, untouched rows dropped, phones normalised) and saves it right after the shop row. The code tolerates the two tables not existing yet (deploy order does not matter); SQL + Arabic texts: `sqlupdates/kapitano_logistic_integration_branches_2026-10-29.sql`. Tests: `tests/Feature/Logistic/ShopBranchesTest.php` and `StoreFormBranchesTest.php` (the HTTP ones need `APP_URL=http://localhost`, like every other HTTP test here).

### 4.6 Cancel paths (every one)
| # | Entry | Guard | Restock | Other side effects |
|---|---|---|---|---|
| C1 | Admin `POST /admin/admin/orders/{id}/cancel` → `OrderController::admin_cancel` 784 (perm `edit_order`) | not already cancelled | `product_restock` per detail (838), **before** the detail status is set | `cancel_reason/note/image`, `cancelled_at/by/by_type`; `SellerOrderResponseUtility::close`; `StockZeroUtility::zeroForCancelledOrder` for ticked `zero_stock[]` (847); **no e-mail** (854 commented); no wallet refund, no commission reversal |
| C2 | Admin status dropdown / Confirm / Deliver screens → `OrderController::update_delivery_status` 995 with `status=cancelled` | none, and the status value is not whitelisted | `product_restock` (1053) | wallet refund `users.balance += grand_total` (1025-1029) **on every repeat**; commission reversal only if `$order->user->user_type == 'seller'` (1032, wrong user); `order_email`, SMS, `sendNotification`; `cancelled_by/by_type` set by `applyDeliveryStatus()`, `cancelled_at` by `Order::saving` (since 2026-10-28; before, neither) |
| C3 | Customer web `GET /purchase_history/destroy/{id}` → `PurchaseHistoryController::order_cancel` 91 | own order, `pending` + `unpaid` | yes (109) | `cancelled_*`; alert close; `order_email('cancelled')` |
| C4 | Customer web `POST /purchase_history/destro2y/{id}` → `order_cancel2` 124 | same | yes (181) | plus reason/note/image (`uploads/order-cancellations`, disk `public`) |
| C5 | App `GET /api/v2/order/cancel/{id}` → `Api\V2\OrderController::order_cancel` 627 | same | yes (643) | no e-mail |
| C6 | App `POST /api/v2/order/cancel2/{id}` → `order_cancel2` 655 (exempt from `EnsureSystemKey`) | same | yes (705) | image moved to `public/uploads/cancel_orders`; sets `cancelled_at` since 2026-10-28 |
| C7 | Paymera refund (web `PurchaseHistoryController::refundPayment` 255, app `Api\V2\CheckoutController::refundPayment` 277) → `PaymeraUtility::markCombinedOrderRefunded` 186 | own order + `paid` + `payment_id`; **no delivery-status or time limit** | yes (196) for every order of the combined order | all orders become `unpaid` + `cancelled`; alert close; no e-mail; no commission reversal (unverified whether `calculateCommission` wrote `admin_to_pay`) |
| C8 | Seller panel / seller app / delivery boy (`update_delivery_status`) | sellers blocked from `cancelled` (`Seller\OrderController:323`, `Api\V2\Seller\OrderController:50`); delivery-boy API not blocked | seller panel restocks own lines; `OrderService` restocks | `OrderService` wallet refund (35-39) |
| C9 | Admin delete `GET /admin/orders/destroy/{id}` → `OrderController::destroy` 872 (perm `delete_order`) | none | `product_restock` (885; skips delivered/cancelled lines) | `archive_deleted_order` first, `commissionHistory()->delete()`, details deleted, `CombinedOrderCouponUtility::recalculate` |
| C10 | Admin item cancel `POST /admin/order/delete-item` → `deleteItem` | line `cancelled` (kept) | `product_restock`; "cancel and zero" then `StockZeroUtility::zeroForDeletedItem` | coupon/total recalculation without the line |
| C1b | Combined order page `POST /admin/orders/combined/{id}/cancel` → `OrderController::combined_cancel` (perm `edit_order`, same modal and reason as C1) | every live, not delivered order of the checkout, or the ticked `order_ids`; if `CancelGuard` refuses any, none is cancelled | same as C1: both call `cancelOrderByStaff()` | same as C1 |
| C1c | Combined order page "Edit the reason" on a cancelled seller order → `POST /admin/orders/combined/{id}/cancel-reason` → `OrderController::combined_cancel_reason` (perm `edit_order`, JSON; since 2026-11-07) | the given `order_ids` of this checkout that are `cancelled` (returned / live ones ignored, none left = 422), whoever cancelled them (customer or staff); one or every cancelled order of the checkout | rewrites `cancel_reason` (a staff key of `OrderCancelReasons`, or the order's own saved free text unchanged) and `cancel_note` only; status, stock, `cancelled_by` / `cancelled_at` untouched; the `Order` audit keeps the old value | none |
| C11 | Seller account deletion `SellerController::destroy` ~298 | n/a | no | archives and deletes orders **where `orders.user_id` = seller** (orders the seller placed as a customer), not the orders sold by the seller |

`product_restock` (`Helpers.php:3176`) matches `product_stocks` by `product_id` + `variant`, skips details whose `delivery_status` is `delivered|cancelled`, and reverses `num_of_sale`. That is why each path restocks **before** stamping the detail `cancelled`.

**Every stock move of an order line is reported from one point** (since 2026-11-01, for the company warehouse): `OrderStockUtility::apply()` (status path: `moveLines`, `product_restock`, `returnLines`) and `changeQuantity()` (staff quantity edit) fire `App\Events\OrderLineStockMoved` once per line whose `product_stocks.qty` moved (line, order, old / new status, pieces taken > 0 or given back < 0), synchronously inside their transaction; its docblock is the contract (old-rule orders reported too, a variant switch is two reports). A new path that moves an order line's stock without going through these two methods also skips the report, so whatever listens (the warehouse) falls out of step. Order placement's direct `qty -=` runs only for old-rule orders. Pinned in `OrderStockOnConfirmTest` ("line move report"). Its listener today is the company warehouse (`App\Listeners\Warehouse\FollowOrderLineStock` → `WarehouseOrders::lineMoved()`): for a pilot shop, a line that starts holding its stock by a status change takes its pieces from the warehouse first (`order_details.warehouse_qty`), and one that gives its stock back (cancel, back to pending, return) puts the pieces that are in our building into the warehouse; nothing about the order's amounts changes (`WarehouseOrderTakeTest`, `WarehouseOrderGiveBackTest`). A variant switch (`OrderController@combined_change_variant`) wraps its two moves in `WarehouseOrders::switchingVariant()`: it is not a cancel for the warehouse.

### 4.7 Refunds
- Paymera: see C7. The gateway call is `PaymeraService::cancelPayment` (81), used for both void and refund.
- CMS refund addon: `routes/refund_request.php` maps to `App\Http\Controllers\RefundRequestController`, **which does not exist in the repo** (only `Api\V2\RefundRequestController`), so those web routes fail (unverified whether the addon is enabled). `POST /api/v2/refund-request/send` (`Api\V2\RefundRequestController::send` 23) creates a `refund_requests` row for **any** `order_details.id` without checking ownership.

### 4.8 Customer order pages and customer edits (since 2026-10-20)
Design: `docs/codebase/features/purchase-history-redesign.md`, ADR 0001-0004 (`docs/adr/`), terms in `/CONTEXT.md`. Code: `app/Services/CustomerOrders/` (map in `OrderEditor`'s docblock).
1. **List** (`purchase_history.index`, app `GET v2/purchase-history/grouped`): `Purchases::paginate()` groups `orders` by `COALESCE(combined_order_id, -id)` and paginates **checkouts** (10 per page, newest first). Filters `delivery_status` / `payment_status` match a checkout when **any** seller order has that status (`HAVING SUM(CASE …) > 0`, each filter on its own); the checkout is returned whole and `Purchase::matches()` marks the matching seller orders. Seller orders without lines are left out (as the old list did). The older app endpoint `v2/purchase-history` still lists seller orders, unchanged.
2. **Order page** (`purchase_history.details`, URL unchanged: `encrypt(seller order id)`; app `GET v2/purchase-history/combined/{id}`): any seller order of a checkout renders the whole checkout (`Purchases::forOrder()`), scrolled to that seller order; opening it marks **every** seller order viewed. Shared once: address, payment method (per seller order only if they differ), totals from `Purchase::totals()` (= `combined_order_payable_total`; the coupon line is the difference, so the lines add up), rating, invoice, Pay with Paymera. One section per seller order: seller, code, statuses, lines, cancel / return info, Edit / Cancel, the customer's edit history.
3. **Edit** (`purchase_history.order_edit[.preview|.save]`, app `v2/order-edit/{id}[/preview]`): `OrderEditor::options()` → `preview()` (writes nothing) → `apply()` (transaction, `lockForUpdate` on the `orders` row, `OrderEditability::refusal()` again, `BasketFingerprint::matches()` against the fingerprint the form showed). Editable = pending + unpaid + seller not answered (`seller_confirmed_at`, `seller_unavailable_at` empty) + not Paymera + `OrderStockUtility::appliesTo()` + not in Logistic transit (`EditGuard`). Changes: quantity (keeps the line's unit price / tax), variant of the same product (priced by `cart_product_price` / `cart_product_tax` like checkout; refused when `exchange_rate_at(orders.date)` ≠ today's rate), remove (all lines = refused, the customer cancels instead). **A removed line is cancelled, not deleted** (since 2026-10-21, no schema change): `OrderStockUtility::moveLines(..., 'cancelled')` (num_of_sale; no stock, pending holds none); the edit history row marks it `cancelled => true` (who did it). The line stays in `$order->orderDetails` and is shown struck through ("cancelled" pill on the customer page, `lines[].is_cancelled` in the app, a badge on the admin order page). It is charged for nowhere: the edit's own totals and `CombinedOrderCouponUtility::billedLines()` (grand_total / coupon on every recalculation) skip a cancelled line of a live order, `Purchase::lineCancelledAlone()` keeps it out of the customer totals and the admin page subtotal. `OrderStockUtility::movable()` skips the lines the history says the customer cancelled (`CustomerOrderEdits::cancelledLineIds()`), so confirming the order never confirms them and moving a cancelled order back to pending never revives them. Raising / changing needs the product published + approved and the stock; digital / auction / wholesale lines are remove-only. Money: `CombinedOrderCouponUtility::customerEditShare()` decides the coupon again over the whole checkout with the edited basket (percentage follows, fixed amount stays) and the edit writes it on `combined_orders.coupon_discount` (the seller orders carry none since 2026-10-20) and takes it off `combined_orders.grand_total`; the **other seller orders' rows are untouched**; an edit that would drop the coupon is refused (its usage is only released on cancel). Fixed 2026-10-24: it used to read the coupon off the seller orders' shares (all 0), so it never re-decided it and dropped it from the checkout's `grand_total`. Shipping: `EditShipping::after()` = current + (S1 − S0) of the checkout rule on the edited vs original basket (same rounding as `order_from`), never below 0, and 0 when S1 = 0 (the edited basket ships free); unchanged when S1 = S0 or the address's city is unknown (since 2026-10-24; before, max(0, S1 − S0) was added and shipping never went down). Staff edits on the admin combined order page follow the same rule since 2026-11-02 (`EditShipping::liveLines()` before, `followStaffEdit()` before the coupon recalculation): add product (`combined_add_product`), quantity (`updateQty`), option (`combined_change_variant`) and cancelling a line (`deleteItem`, also "unavailable": falling below the limit adds the shipping back, decided 2026-11-03); before, an order staff raised past the free-shipping limit kept its shipping. **Cancelling a whole seller order follows it too** (since 2026-11-05, checkout #5473: a seller order cancelled as out of stock took it from $21.56 to $12.95, below Deir ez-Zor's $20, and the shipping stayed 0): every cancel path takes `EditShipping::linesBefore($order)` before writing the status and passes it to `CombinedOrderCouponUtility::afterOrderStatusChange($order, $before)`, which runs `followStaffEdit()` before the coupon - staff cancel (`admin_cancel` / `combined_cancel` → `cancelOrderByStaff`), the status list (`applyDeliveryStatus`, also bringing a cancelled order back, which takes the shipping off again above the limit), the customer's cancels (web `PurchaseHistoryController@order_cancel[2]`, app `Api/V2/OrderController@order_cancel[2]`) and the delivery app (cancelled only). A new cancel path must do the same. Returns do not (the goods were delivered). Nothing moves when the whole checkout is cancelled or brought back from that (`followStaffEdit()` leaves the shipping as it was when nothing stood before or nothing is left). Per line: `purchase_price` scaled (quantity) or re-read with `SellerPricing::orderLineCost()` (variant), `OrderLinePricesUtility::stamp()` on a variant change, `products.num_of_sale` moved by the delta. No stock moves (a pending order holds none). Logistic hears of it through `QueueOrderEdit` (Eloquent events). After commit: one `audits` row `event = customer_edit` (`CustomerOrderEdits::record()`, written directly, so also in console/tests), and a seller push `type = order_changed` (`SellerPushUtility::send`).
4. **Seller confirms what they saw**: `Seller\OrderController::confirm` takes an optional `basket` and checks it under the same row lock; the seller order page sends it (`#seller-confirm-btn data-basket`), and the WhatsApp "Accept" page (`SellerOrderActionController::submit`) checks it, records the answer and confirms inside one locked transaction - a changed basket records nothing and shows the new products with no auto-submit. Staff see "Changed by the customer" on the orders-list timeline (`OrderTimelineUtility::steps(..., $customerEdits)`, one query per page via `CustomerOrderEdits::lastEditedAt()`).
5. **Look**: the list, the order page and the edit dialog share `frontend/user/purchase_history/styles` (`ko-*` classes, `@once`; the Kapitano card style of the rating card: navy ink, brand yellow `#ffc519` accent - **not** `--secondary-base`, a setting that is navy on some installs - 12-16px radii, pill badges) plus the partials `badges`, `progress` (4-step delivery tracker), `thumbs` (product pictures, preloaded with `preload_uploads()` in the controller), `cancel_modal`, `edit_modal`, `edit_script`. `.aiz-user-panel:has(.ko) { min-width: 0 }` keeps the scrolling tab strip from widening the panel past a phone screen. Pagination uses `frontend.partials.pagination`. Wording: strings in `database/translations/translations.json` (no translation migrations, `NoNewTranslationMigrationsTest`). Shared CMS words whose Arabic reads wrong here have their own keys on these pages (`Payment received`, `Order confirmed`, `Not now`, `Refund Payment`); the shared rows themselves are fixed by staff in Admin > Languages.
6. **Re-order**: `re_order` now checks ownership (it accepted any order id); `re_order_all` (encrypted combined order id) adds every seller order of a checkout.

**A line cancelled on its own (decided 2026-10-21).** The admin "remove product" (order page and combined order page, `deleteItem`) no longer deletes the line: it sets `order_details.delivery_status = cancelled` and keeps it on the order, struck through. "Customer cancelled" gives its stock back; "Cancel and set its stock to zero" (the seller does not have it) zeroes it and records it in the stock zeroing report. Who it was cancelled for is `order_details.line_cancelled_by` (`customer` / `seller`, migration `2026_10_21_000001`), shown as "الزبون: تم الإلغاء" / "التاجر: تم الإلغاء" by `order_line_cancel_label()` on the admin order and checkout pages, the seller panel order page (no availability button on it) and the seller app (`cancel_label` on order items). The last live line of an order is refused (cancel the order instead); a cancelled / returned line takes no quantity change. Such a line counts in **no amount**: `order_line_is_cancelled($detail, $order)` / `order_counted_lines($order)` (`Helpers.php`) leave it out of `CombinedOrderCouponUtility` (grand_total, minimum, percentage, covered products), the order pages' subtotal / tax, invoices (`InvoiceAmountsUtility`, marked void), the customer's purchase pages and app history, `OrdersExport` and the Kapitano Logistic items; its shipping stays. Seller figures and balance already skip cancelled lines. It stays cancelled: `OrderStockUtility::movable()` skips it while its order is live, so a later confirm does not revive it (the lines of a whole-cancelled order still come back with it). A void order keeps the total it had (`allocate()` no longer rebuilds a void order's `grand_total` from its lines). The customer's edit (`OrderEditor`) cancels what it removes the same way and writes `line_cancelled_by = customer`; lines it cancelled before the column are read from its edit history (`CustomerOrderEdits::cancelledLineIds()`). A line cancelled on its own stays cancelled even when its whole order is cancelled and brought back (`movable()`). Reports that sum `order_details` in SQL still include such lines (as they include partial returns). Pinned by `CombinedOrderCouponTest::test_6e_*`, `StockZeroingTest`.

### 4.9 Customer return requests (since 2026-10-21)
Code: `App\Services\ReturnRequests\ReturnRequests` (its docblock is the map), tables `return_requests` + `return_request_items` (migration `2026_10_21_000002`, permissions `2026_10_21_000003`). App contract: `docs/api/return-requests.md`.
1. **Ask** (web: "Request a return" opens the dialog `purchase_history/return_modal` on the order list and on the order page - its returns card is under the seller orders - `POST purchase_history.return_request`; app `POST v2/return-requests/{order_id}`): 1 .. all pieces of lines of one checkout that are `delivered` and whose order's `delivered_date` is within `return_request_window_hours` (24, Admin → Return requests). Reasons = `CUSTOMER_REASONS` (`damaged`, `wrong_item`, `not_as_described`, `other`: keys of `OrderStockUtility::RETURN_REASONS`; no "changed my mind"); a photo is required for `damaged` / `wrong_item`, up to 3, stored like the media library (`uploads`). A line named by any non-withdrawn request is never asked again. Nothing on the order changes.
2. **Staff** (`admin.return_requests.*`, `view_return_requests` / `manage_return_requests`; banner on the admin order and combined order pages, pending count in the menu): a decision per product (`review()`: accepted / refused, a refusal needs a reason; all refused = rejected, nothing moves: the goods are still with the customer), goods received (`→ completed`, accepted products only; fewer pieces than the line split it first, `splitLine()`: `OrderStockUtility::returnLines()`, the same path as `admin_return` / `combined_return`, so stock, line status, the order's `returned` status, seller balance and coupon follow; reason = the customer's, note = "Customer return request #id - note"), reject (with a reason the customer sees). Each answer pushes the customer (`item_type = return_request`). **No refund is made** (decided 2026-10-21); the returned lines come off the order's amounts (decided 2026-10-22, `order_line_is_closed`).
3. `delivered_date` is now written by `Order::saving` on every path that sets `delivered` (it used to be only `applyDeliveryStatus`); an order without it cannot be returned by the customer (never `updated_at`: opening the order page saves the order).

## 5. Status reference

**`orders.delivery_status` / `order_details.delivery_status`** (the ranking in `combined_delivery_status`, `Helpers.php:5575`):

| Value | Set by | Side effects |
|---|---|---|
| `pending` | DB default on create (web/app/app-Paymera); `OrderFollowUpController::reopen`; admin dropdown | follow-up back to pending if it was `ready` (`syncWithDeliveryStatus`) |
| `confirmed` | Confirm Orders (`OrderFollowUpController::confirm`); admin dropdown; seller panel/app (`fromDeliveryStatus` = seller answer) | seller path: `checkout_confirmed_notices` sends once, when the whole checkout is confirmed (Cache guard, 7 days); admin path: `order_email`, SMS, `sendNotification` immediately; follow-up forced `ready` |
| `picked_up` | admin/seller dropdown (CMS) | same generic notifications |
| `on_the_way` | Deliver Orders; dropdowns | notifications |
| `delivered` | Deliver Orders; dropdowns; delivery-boy API | `delivered_date`; affiliate stats; delivery-boy API marks COD **paid** plus commission, bypassing `can_settle_order_payment` |
| `cancelled` | §4.6 | restock, alert close, maybe wallet refund |

No path whitelists the incoming `status` string (`OrderController:1001`, `Seller\OrderController:328`, `OrderService:24`, `DeliveryBoyController:292`).

**`payment_status`** (`orders` and `order_details`): `unpaid` (default) or `paid`.
- Set to `paid` by `PaymeraUtility::markCombinedOrderPaid`, `CheckoutController::checkout_done`, `orderRePaymentDone`, `Helpers::checkout_done` (3050, legacy gateways), `order_re_payment_done` (3087), `OrderController::update_payment_status` / `Seller\...` / `OrderService::handle_payment_status` (all gated by `can_settle_order_payment`), `Api\V2\OrderController::store($set_paid=true)` (wallet), and `DeliveryBoyController::change_delivery_status`.
- Set back to `unpaid` by the Paymera refund and by the accountant ("unpaid" clears `payment_settled_*`).
- Order-level status is derived: paid only if every detail is paid (`OrderController:1164-1170`).

**`order_follow_ups.status`**: `pending` (awaiting), `handed_over` (legacy, treated as awaiting), `ready` (confirmed, which puts it on Deliver Orders), `not_ready` (seller "not available"; the staff button has been removed). **Delivery stage** (derived): `ready` / `on_the_way` / `delivered`.

**`paymera_app_checkouts.status`**: `pending → completed | cancelled | failed | expired`. A payment accepted after cancel or expiry still becomes an order (`PaymeraAppCheckoutUtility.php:127-131`).

**Paymera gateway `Data.status`**: `A` accepted, `P` pending, `C` cancelled, `F` failed.

**`seller_order_notifications`**:
- `send_status`: `pending|accepted|failed|skipped_no_phone|skipped_disabled`
- `response`: `accepted|out_of_stock`
- `response_source`: `whatsapp|panel|app`
- `closed_reason`: `cancelled|staff_on_behalf|order_deleted`

**Notifications per event** (no order e-mail since 2026-11-09, ADR 0011; the customer's push and inbox entry come from `CustomerOrderStatusPush` on `Order::updated`, whatever the path):
- **Placed:**
  - Web: the seller alert in `order_confirmed`.
  - App: `NotificationUtility::sendOrderPlacedNotification`: SMS if OTP addon, and the seller alert.
- **Paid (Paymera / eCash, `PrepaidOrders::markPaid`):** `sendNotification('paid')` and the seller's new-order alert, each in its own `try` (one failing never costs the other); works with nobody signed in since `DbNotification` no longer reads `Auth::user()`.
- **Status change:**
  - Admin: SMS + `NotificationUtility::sendNotification` (seller / admin panel entries). A missing customer account no longer stops it (`findOrFail` + `dd()` removed 2026-11-09).
  - Cancel / return on any path: the seller's panel entry from `SellerOrderChanges`.
- Pinned through the real controllers by `OrderStatusNotificationPathsTest`.

## 6. Change together (duplicated logic)

1. **Order creation.** There are three copies, and they must stay in step:
   - `OrderController::store` 509-742 (web).
   - `Api\V2\OrderController::store` 38-306 (app COD/wallet/manual).
   - `PaymeraAppCheckoutUtility::buildSnapshot` 44 + `createOrders` 210 (app Paymera).

   The dead commented `store` at `Api\V2\OrderController:307-625` is not a live copy, but it can mislead a reader.

   How they differ today:
   - Transaction/lock exists only in the app COD path.
   - `order_from`: `web` / `app`.
   - `additional_info`, `shipping_type`, `pickup_point_id`, `carrier_id`, affiliate stats and seller `num_of_sale` are written only on web.
   - Shipping sits on the combined order on web and app COD, but inside `orders.grand_total` and `order_details.shipping_cost` for app Paymera.
   - Cart cleanup: web deletes active rows, app COD deletes all rows, app Paymera deletes the snapshotted ids.
   - A short stock quantity aborts web and app COD but only logs an oversell for app Paymera.
   - The minimum order check exists only on web.
   - Loyalty points are granted only on web (`order_confirmed`).
   - Order-placed notifications: web sends `order_email`, the app sends `sendOrderPlacedNotification`.
2. **Shipping cost.**
   - `getShippingCost` (`Helpers.php:2209`), `cart_shipping_cost` (2152), `cart_shipping_base_total` (2042), `first_shippable_cart_index` (2070) and `free_shipping_limit_for` (2097).
   - Callers: `CheckoutController::index` 429-446, `store_delivery_info` 927-929, `updateDeliveryAddress` 1270-1272, `updateDeliveryInfo` 1326-1334, `OrderController::store` 737 (**rounded to 1000**), `Api\V2\OrderController::store` 140/225 (rounded to 5), `Api\V2\CheckoutController::createPayment` 584/615, `PaymeraAppCheckoutUtility::buildSnapshot` 69, `Api\V2\ShippingController::shipping_cost` 23-64, `Api\V2\AddressController` 202-208, `Api\V2\CartController::summary` 65, `carrier_base_price` (Helpers 2412) and `resources/views/frontend/partials/cart/cart_summary.blade.php:358`.
   - Coupon code uses shipping too: web `apply_coupon_code` 1006 uses `shipping_cost`, while the app `apply_coupon_code` 105/188 uses the non-existent `$cartItem['shipping']` × quantity.
   - **Editing shipping after the sale** (perm `edit_order_shipping_cost`, "Orders" section, granted to nobody; migration `2026_10_08_000001` / `sqlupdates/kapitano_edit_order_shipping_cost_2026-10-08.sql`, then `cache:clear`): the admin order page shows a "Shipping (SYP)" field (`show.blade.php`, `@can('edit_order_shipping_cost')`) that posts to `OrderController::update_shipping_cost` (route `orders.update_shipping_cost`, **admin only** — the combined order may span sellers). It converts the typed SYP to BASE at the order's rate (`$syp / order_exchange_rate($combinedOrder)`), **zeroes `order_details.shipping_cost` on every child order** (so the `combined_order_payable_total` fallback at Helpers:1767-1768 and `allocate()` cannot resurrect app-Paymera per-line shipping), writes `combined_orders.shipping_cost`, then `CombinedOrderCouponUtility::recalculate`. 0 = free shipping. Pinned by `CombinedOrderCouponTest::test_6d`.
3. **Order totals and payable.** (Since 2026-10-20 `orders.grand_total` carries no coupon: see 02-pricing-coupons §4.4.) `combined_order_payable_total` / `order_payable_total` (`Helpers.php:1253-1299`), `CombinedOrderCouponUtility::recalculate`/`allocate` (56, 369; order total = price + tax + detail shipping − discount), `OrderController::combined_invoice` 236, `combined_order_details` 319, `resources/views/backend/sales/show.blade.php:79`, `show_combined.blade.php`, `backend/invoices/*.blade.php`, `frontend/user/order_details_customer.blade.php`, `purchase_history.blade.php` and `Http/Resources/V2/PurchaseHistoryMiniCollection.php:29`. **The customer order-details page (`order_details_customer.blade.php`), the invoice PDF (`backend/invoices/invoice.blade.php`) and the app order-details resource (`PurchaseHistoryCollection.php`) now read the shipping line from `combinedOrder->shipping_cost` and the total from `combined_order_payable_total` (they used per-order `order_details.shipping_cost` / `order_payable_total` before 2026-10-08), so an admin shipping edit shows to the customer on the website and in the app. The app order-items resource (`PurchaseHistoryItemsCollection`) still reports the per-line `order_details.shipping_cost`, now 0 — shipping is an order-level, not line-level, figure.**
4. **Cancel + restock.** C1-C8 above, spread over `OrderController` (784, 995), `Seller\OrderController` 310, `OrderService` 20, `PurchaseHistoryController` 91/124, `Api\V2\OrderController` 627/655, `PaymeraUtility::markCombinedOrderRefunded` 186 and `DeliveryBoyController` 289. Each one repeats "restock before stamping cancelled", `SellerOrderResponseUtility::close`, and the `cancelled_*` stamps (only some do this). A new cancel side-effect (for example coupon release or a customer notification) must be added to **all** of them.
5. **Delivery-status change.** `OrderController::update_delivery_status` 995, `Seller\OrderController::update_delivery_status` 310, `OrderService::handle_delivery_status` 20, `Api\V2\DeliveryBoyController::change_delivery_status` 289 and `DeliveryBoyController` (web addon, `routes/delivery_boy.php:53`). Wallet-refund and commission-reversal blocks are copy-pasted in them, with different bugs.
6. **Payment-status change.** `OrderController::update_payment_status` 1142, `Seller\OrderController::update_payment_status` 410 and `OrderService::handle_payment_status` 110 (all three gated by `can_settle_order_payment` plus `record_payment_settlement`). Paymera writes the status itself (`markCombinedOrderPaid`). Other mark-paid copies: `CheckoutController::checkout_done`/`checkout_done1`/`orderRePaymentDone`, Helpers `checkout_done`/`order_re_payment_done`, and the delivery-boy API.
7. **Paymera create-payment amount.** `CheckoutController::checkout` 578 (`(int)`, truncates), `PaymeraController::rePayment` 181 (`(int) round`), `Api\V2\CheckoutController::rePay` 437 and `createPayment` 618 (`(int)`). All use `order_exchange_rate($combinedOrder)`, and none divide by 100. The zero-total bypass (`nothingToCharge` → `completeWithoutGateway`) exists at checkout 570, rePayment 171, rePay 421 and createPayment 624.
8. **Paymera notification handlers.** `PaymeraController::callback`/`trigger` (web routes `/payment/callback`, `/payment/trigger`, `/payment/paymera/return|trigger/{id}`) and `Api\V2\CheckoutController::callback`/`trigger` (`/paymera/callback|trigger` in web.php, `/api/paymera/callback|trigger` in api.php, plus `check-payment`). All must call `PaymeraUtility::syncPaymentStatus`, and the trigger URLs must stay in `VerifyCsrfToken::$except` (41-44).
9. **Paymera refund.** `PurchaseHistoryController::refundPayment` 255 and `Api\V2\CheckoutController::refundPayment` 277 (same checks, same helper).
10. **Coupon apply/remove.** Web `CheckoutController::apply_coupon_code` 943 / `remove_coupon_code` 1079 and app `Api\V2\CheckoutController::apply_coupon_code` 45 / `remove_coupon_code` 871 (also `Api\V2\CouponController`). Re-validation at order time is copied four times (web store 534-562, app store 60-96, createPayment 523-559).
11. **Cancel reasons — one list since 2026-10-15.** `App\Utility\OrderCancelReasons` (its docblock is the map) holds every reason: key, label, group (customer / contact / seller and stock / delivery / technical and administrative), whether the customer is offered it, whether a note is required (`other`). The staff cancel modal (`_cancel_order_modal`, grouped `<optgroup>`s, validated `in:` the keys in `admin_cancel`, note required for `other`), the customer's web form (`purchase_history.blade.php`), the app list (`GET /api/v2/cancel-reason`, no sign-in, and the older web `GET /cancel-reason`; `cancelReasons()` in `Api\V2` / web `PurchaseHistoryController`, `[{key, title}]` in Arabic; the app posts the key to `POST /api/v2/order/cancel2/{id}`) and every label (`OrderTimelineUtility::cancelReasonLabel`, the order page, the sales profit report chart and table) read it. `orders.cancel_reason` stores the **key**; reports group by it, so a label may be reworded but a key is never renamed or reused (`out_of_stock` also drives stock zeroing and the seller orders report, which still shows it as "نفد من المخزون"). Customer paths store `normalize()`d input: a known key, a known Arabic text (old pages / app builds) mapped to its key, else the free text, never refused. `customer_request` is legacy (labelled, not offered). Labels are their own English strings with Arabic in `database/translations/translations.json` (synced add-only on `migrate`). Pinned by `tests/Feature/OrderCancelReasonsTest.php`. **The attached picture** (`cancel_image`) goes through `App\Utility\OrderCancelAttachment` on all three paths: saved like every other upload (`local` disk = `public/`, `uploads/cancel_orders/<hash>`) and shown on the admin order page with `url()` → `my_asset()`, so it needs no `storage:link`; rows saved before 2026-10-15 on the `public` disk (`order-cancellations/…`, `uploads/order-cancellations/…`) still resolve to `/storage/…`. Pinned by `tests/Feature/OrderCancelAttachmentTest.php`.
12. **Order-placed notification.** `NotificationUtility::sendOrderPlacedNotification` 21 and its trait copy `FirebaseNotificationTrait::sendOrderPlacedNotification` 511. The same goes for `sendNotification` (84 / 580).
13. **Invoice.** Web `InvoiceController` and `Api\V2\InvoiceController` (identical font selection; both check ownership), plus `Seller\InvoiceController` (seller route `seller.php:124`). **The invoice number is the combined order id** (decided 2026-10-15): `invoice_number($order)` (`Helpers.php`; `orders.code` for an order without a checkout) is printed on every invoice ("Invoice No", next to the order code) and names the downloaded file (`invoice-<n>.pdf`, web, app and seller). The admin and seller order lists find it: a search that is a bare number (`#` optional, `combined_order_id_from_search()`) also matches `orders.combined_order_id`; the orders list also has its own **combined order number** box (`order_number`, placeholder "رقم الطلب المجمع"), an exact match only (a number = that combined order, else one whole order code), because the general box finds the digits anywhere (code, phone, address). **Customer name/phone in every order search goes through `users_matching_search()`** (an `IN` subquery, `Helpers.php`) - never `orWhereHas('user')`, which MySQL ran once per order: 17 s per query, 85-95 s per orders page; now ~0.3 s (orders list, Confirm/Deliver Orders, accountant box, sales profit report, cart logs). Every invoice (`invoice`, `partials/invoice_compact`, `combined_invoice`) shows the new dark-text logo through `invoice_logo_url()` (`public/assets/img/invoice-logo.png`, a 600px copy of `Dark Logo.png` - the 4760px original runs mPDF out of memory), never the old `header_logo` upload. **The buyer gets the admin print page's look** (decided 2026-10-15): the website and app downloads render `partials/invoice_compact` for the buyer and staff (`InvoiceController::wholeCheckout()`), i.e. the whole checkout on one page with amounts that add up (the old per-order PDF showed this order's subtotal next to the whole checkout's total); `$customerView` gives the buyer a plain "Invoice" heading and the customer-service number 0992876936. A seller (their own route, or the customer route as the order's seller) keeps the per-order `invoice.blade.php` (their share, in Syrian pounds at the order's rate like every invoice - it was dollars until 2026-10-15), restyled in the same navy look. Pinned by `tests/Feature/InvoiceNumberTest.php`.
14. **Re-order.** `PurchaseHistoryController::re_order` / `re_order_all` (both through its private `reorder()`) and `Api\V2\PurchaseHistoryController::re_order` 83.
15. **Seller "confirm" semantics.** `Seller\OrderController::confirm` (seller_confirmed_at only) versus the seller dropdown and app `confirmed` (moves `delivery_status`), `SellerOrderActionController::confirmAsSeller` (reuses panel confirm) and `OrderFollowUp::confirmForSeller` (staff on behalf).
16. **Staff-screen filters.** `OrderFollowUpController::filteredOrders` 199, `OrderDeliveryController::filteredOrders` 148 and `AccountantOrderController::myOrders` 181 share the same search and date code (they filter by `orders.date`).
17. **What the seller sees of the customer** (decided 2026-10-21): the name only - phone, email and address stay with us, because our captain delivers. One source, `App\Utility\SellerCustomerPrivacy` (`name()`, `shippingAddressForApi()`), read by the seller order page and list (`seller/orders/show`, `index`), the seller invoice PDF (`backend/invoices/invoice` with `sellerView`), the "order placed" invoice e-mail the seller receives (`emails/invoice`; `$array['sellerView']` set in `NotificationUtility::sendOrderPlacedNotification` and `FirebaseNotificationTrait`, passed by `InvoiceEmailManager`) and the seller app (`Api\V2\Seller\OrderDetailResource`: `shipping_address` keeps its keys, every value but `name` is `''`). The seller push and the WhatsApp alert already carried the name only. A new seller-facing order surface must read the customer through it.
18. **Seller order page answers without a reload** (2026-10-21): the page posts `panel=1` to `seller.orders.confirm` / `unavailable` / `item_availability`, and `Seller\OrderController::panelHtml()` adds `html.actions` / `html.products` / `html.unavailable_items` (the partials under `seller/orders/partials/`) for the page to swap in. Callers that do not send `panel` (the WhatsApp action page, old open pages) get the plain answer. Anything new that these answers change on the page belongs in one of those partials.
19. **Changing an order's basket after the sale.** Admin `OrderController::updateQty` / `deleteItem` (recalculate the checkout coupon with `recalculateForOrder`, no shipping change) and the customer's `App\Services\CustomerOrders\OrderEditor` (siblings frozen, shipping difference, costs and stamps follow, §4.8). Since the order follow-up/profit feature (2026-10-25), `OrderPurchaseCostUtility::updateSnapshot()` scales the existing `purchase_price` on quantity edits and refreshes its applicable source on a variant change. Partial returns split this whole-line cost. A new path that changes `order_details` quantity / variation / price must decide which coupon/shipping rule it follows and keep the existing saved cost in step.
20. **Seller confirmation.** Panel `Seller\OrderController::confirm` and the WhatsApp page both check the basket fingerprint (§4.8 step 4); a new place that lets a seller confirm must send `basket` too.
21. **The sale line "حسم" (2026-10-24).** What the products' own sale (discount, flash deal, fake discount) took off is shown right after the coupon ("القسيمة") on every customer summary, with the subtotal row shown **before** the sale so the rows add up to the unchanged total. Cart: `cart_sale_discount($carts)` (`Helpers.php`; per piece the variant's stored price minus `cart_product_price()`, both rounded alike) in `frontend/partials/cart/cart_summary` (cart page + `/checkout`) and `Api\V2\CartController::summary` (new keys `sale_discount`, `sale_discount_value`, `sub_total_before_sale`; `sub_total` unchanged). Order: `order_line_sale_discount($detail)` (`order_details.price_before_discount` × quantity − `price`, 0 on lines before 2026-10-10, never guessed) in `Purchase::totals()['sale']` (order page + app `purchase-history/combined`, keys `sale_discount`, `subtotal_before_sale`) and `Api\V2\PurchaseHistoryCollection` (`purchase-history-details`). The invoices (`InvoiceAmountsUtility`: the customer's PDF `invoice_compact` and the admin print page `combined_invoice`) use the same `order_line_sale_discount()` and the same rows since 2026-10-24 (subtotal before the sale, shipping, tax, القسيمة, حسم; coupon and حسم only when above 0); before, they guessed old lines from today's product price and showed 2-decimal leftovers. Not shown on the legacy multi-step `frontend/partials/cart_summary` (it lists each line at its sold price). A new customer summary must use the two helpers. App contract: `docs/api/sale-discount-mobile.md`. Pinned by `SaleDiscountLineTest`, `ShippingEngineCharacterizationTest`, `CustomerOrderEditTest`.

## 7. Permissions & routes

**Internal checkout follow-up and product profit (2026-10-25):** private notes and a
follow-up staff owner belong to the whole `CombinedOrder`, stored separately in
`order_staff_details` (`OrderStaffDetail`, `OrderStaffUtility`), distinct from the
child-order delivery/accountant assignments. Admin list/detail access governs reading;
`view_order_details` governs the CSRF-protected internal note save; assignment
changes additionally require `edit_order` and the CX role/current-owner rules
(`orders.staff_details.update`). Saves lock the checkout, check a revision, and record
old/new notes and names in `audits` as `order_staff_update`, on the internal model type.
Notes are excluded from customer/seller serialization and Excel. Owner filters share
the page/export query. `OrderProfitUtility` calculates whole-checkout product revenue
after coupon minus live-line purchase costs, excluding tax/shipping/fees, at the order
rate; rows, filtered totals and export share its expected/estimated/missing-cost rules.
The feature reuses existing saved purchase costs and the current-product fallback;
no additional cost snapshot is required. Migration:
`2026_10_25_000001_add_order_staff_details_and_cost_snapshots`. Full decisions, rollout
and internal request contract: [feature design](features/order-staff-notes-profit.md).
Profit/loss visibility on the list, details, totals and export additionally requires
`view_order_profit_loss` (Staff Roles > Orders); a follow-up migration grants it to
existing CEO, Product Manager, Marketing Manager, admin, Accountant, Ecommerce and
Operation Manager roles, and `2026_10_27_000002` to full access (the roles edit page lists only permissions the editing account holds). Assignment changes additionally require `CX` or `CX Manager`, retaining
the Super Admin bypass. CX may assign an unassigned order; existing assignments may
only be changed/cleared by CX Manager or the current CX assignee, enforced again
under the save lock. Any staff/admin with `view_order_details` may add private notes;
only a note's writer or CX Manager may edit/delete it, retaining the Super Admin
override. `edit_order` is not required for notes. Individual notes live in
`order_staff_notes`, with author snapshots, revisions, soft deletion and immutable
change audits. The migration preserves the legacy saved note entries and their authors.
Staff missing order view permission see read-only notes. The locked save rechecks permissions and
revision, checks current ownership for assignment changes, and preserves omitted
notes during assignment-only saves.
SQLite coverage: `OrderStaffAndProfitTest` and the admin quantity regression in
`CombinedOrderCouponTest`.

**Assignment notifications and Save feedback (2026-10-26):** a changed non-null owner
gets `OrderFollowUpAssignedNotification` through the database channel, atomically
with the assignment/audit. Notes-only/unchanged/unassigned/stale saves produce no
assignment alert. Payload is checkout ID plus assignment revision, never private
notes. The staff bell shows personal assignment alerts even without the general
notifications menu permission; a private feed (`orders.staff_notifications`) refreshes
quietly every 30 seconds while visible. Recipient alerts do not toast. Save flashes
a translated success toast for the saving session after reload; the feed preserves
pending flashes. Opening an alert checks its recipient and existing order permissions,
marks it read, and links to the combined details (or an allowed filtered list for
list-only staff). Both notification components handle the new type. The orders filter
modal scrolls its fields within viewport height while keeping its action footer visible.

**New-note notifications (2026-11-05):** adding a private note
(`OrderStaffNoteUtility::create()`) sends `OrderStaffNoteAddedNotification` (database
channel, in the note's transaction) to every active staff/admin in the `CX` or
`CX Manager` role (name compared without case, spaces, `_`, `-`) who holds
`view_order_details`, except the writer (`cxRecipients()`). Editing or deleting a
note alerts nobody. Payload: checkout ID, note ID, writer's name - never the note's
text. It is one of `NotificationUtility::STAFF_ASSIGNMENT_TYPES`, so it shows in the
bell's follow-up block and opens the order's follow-up modal like an assignment alert.
Pinned by `OrderStaffNotesTest`.

**"للمتابعة" - an order marked for CX follow-up (2026-11-07, button since 2026-11-08):**
anyone who may use the internal follow-up modal (`OrderStaffUtility::canEditNotes()`:
staff / admin with `view_order_details`) presses "للمتابعة" in it
(`orders.staff_follow_up.raise`, `OrderStaffController@followUpRaise`); a second press
while it is marked answers 409. A `CX` / `CX Manager` (or Super Admin) closes it with
"تمت المتابعة" (`orders.staff_follow_up.done`, `@followUpDone`, 409 when nothing is
open), after which it may be marked again. Each press is one `audits` row on
`OrderStaffDetail` (`order_follow_up_raised` / `order_follow_up_done`, shown in the
internal history; the banner names who marked it from the latest raise). Notes raise or
clear nothing: from 2026-11-07 to 2026-11-08 a note by the `Delivery Manger` role raised
it; those notes keep `order_staff_notes.raises_follow_up = 1` and their pill, and closing
stamps them (`follow_up_done_at` / `_by` / `_name`). Everything is in
`App\Utility\OrderFollowUpFlag`. The state is `order_staff_details.needs_follow_up_since`
(NULL = not marked, indexed), which the lists read. Shown as a red pill on the modal
trigger (combined order page and single order page, refreshed by the modal's AJAX through
`data-follow-up`), the button or a banner (with "تمت المتابعة" for CX) at the top of the
modal, and a badge under the checkout code on every `all_orders` list variant, which links
to the modal; the list filter "Follow-up status" → "للمتابعة" (`needs_follow_up=1`)
keeps only marked checkouts (also in the Excel export, which shares
`filteredOrdersQuery()`). No alert is sent on marking. Off until migration
`2026_11_07_000001` runs. Pinned by `OrderFollowUpFlagTest`.

**Permissions (Spatie, staff roles)**

| Permission | Where it applies |
|---|---|
| `view_all_orders` / `view_inhouse_orders` / `view_seller_orders` / `view_pickup_point_orders` / `view_all_offline_payment_orders` | `OrderController::all_orders`/`index` |
| `view_order_details` | `show`, `order_details`, `combined_order_details`, `combined_invoice` |
| `edit_order` | `store`, `updateQty`, `deleteItem`, `admin_cancel`, `combined_cancel`, `combined_cancel_reason` |
| `delete_order` | `destroy`, `bulk_order_delete` |
| `export_order` | order exports |
| `update_order_delivery_status` | route middleware on `/admin/orders/update_delivery_status` (`admin.php:585`); ctor for `update_tracking_code`, `combined_update_delivery_status`, `bulk_order_status` (**method does not exist**) |
| `update_order_payment_status` or `act_as_accountant` | ctor on `update_payment_status` and `combined_update_payment_status`, which is then narrowed by `can_settle_order_payment`. The order details page shows the paid/unpaid dropdown to whoever passes that gate, in both directions (a paid order can be set back to unpaid) |
| `assign_delivery_boy_for_orders`, `unpaid_order_payment_notification_send` | their respective actions |
| `view_order_follow_up`, `decide_order_ready_to_ship` | Confirm Orders index/show and confirm/reopen |
| `view_order_delivery`, `update_order_delivery` | Deliver Orders |
| `act_as_accountant` | Accountant box and `User::isAccountant`; Super Admin role for settings |
| `manage_delivery_agents` | `DeliveryAgentController` |
| `generate_compensation_coupon` | `orders.compensation_coupon` |
| `edit_order_shipping_cost` | route middleware on `/admin/orders/update_shipping_cost`; "Shipping (SYP)" field on the admin order page (`update_shipping_cost`) |
| `seller_whatsapp_settings`, `seller_response_report`, `stock_zeroing_report` | the matching settings and report pages |

`mark_order_paid` and `check_order_follow_up_items` are seeded by migrations but never checked, so they are dead.

The staff screens call `OrderController::update_delivery_status` / `update_payment_status` **in-process**, so those methods' own route permissions are not enforced there. The caller's permission (`update_order_delivery`, `act_as_accountant`, `decide_order_ready_to_ship`) is what gates them.

**Routes**

- **Web** (`routes/web.php`):
  - Checkout: `checkout.check.phone` 113, `checkout.verify.phone` 120 (neither has throttle), `/checkout/*` 546-560 (no auth; the debug `GET /checkout/in` was removed 2026-10-21).
  - Cart: `/cart/*` 466-473.
  - Customer area: `purchase_history.*` 565-580 (customer/verified/unbanned), `order.re_payment` 619, `invoice.download` 637 (auth), `paymera.repayment` 334 and `purchase_history.refund_payment` 339 (auth), `orders.track` 428 (public).
  - Paymera: `/pay`, `/payment/callback|trigger`, `/payment/paymera/return|trigger/{id}`, `/payment/status/{id}`, `/payment/cancel/{id}` (238-254, **public**); `/paymera/callback|trigger` 183-187.
  - Seller order action: `/so/*` 768-775 (throttle 30/min, token-authorised).
- **Admin** (`routes/admin.php`, group `auth, admin` from 167): orders 561-616, deleted orders 555-557, follow-up/delivery/accountant/delivery-agents 814-831, seller WhatsApp 676, seller-response report 748.
- **Seller** (`routes/seller.php:111-125`): orders resource, `update_delivery_status`, `update_payment_status`, `confirm`, `unavailable`, `item-availability`, invoice.
- **API** (`routes/api.php`, prefix `v2`):
  - Carts 263-273 (**no auth**), `coupon-apply`/`coupon-remove` 281-282 (sanctum), `delivery-info`/`shipping_cost` 292-293.
  - Payments: `payments/pay/wallet|cod|manual` 385-387, `order/store` 388, `order/cancel/{id}` 390, `order/cancel2/{id}` 391.
  - History: `purchase-history*` 323-327, `invoice/download/{id}` 330.
  - Paymera: `paymera/create|re-pay|refund` 467-470 (sanctum), `paymera/check-payment` 471 and `paymera/cancel-payment` 473 (**no auth**); `EnsureSystemKey` exempts `api/v2/paymera/*` and `api/v2/order/cancel2/*`.
- **Seller app** (`routes/api_seller.php:13-17`, sanctum only): `orders/update-delivery-status`, `orders/update-payment-status`.

## 8. Gotchas, known bugs, security issues, dead code

**Security (highest first):**
1. `GET /payment/cancel/{paymentId}` → `PaymeraController::cancel` (261) is **public**. It voids or refunds any Paymera payment whose id is known, and does not update orders. `GET /payment/status/{paymentId}` (254) leaks payment data, and `GET /pay` (18) creates a real 50000-SYP payment unauthenticated. All three look like leftover test endpoints.
2. `Api\V2\WalletController::processPayment` (28): the balance check and debit use **request `user_id` and `amount`**, while the order (created `paid` via `store($request, true)`) belongs to the token user. With `amount=0` you get free paid orders, or you can debit someone else's wallet. The wallet payment type may be disabled in production **(unverified)**.
3. App cart endpoints (`routes/api.php:263-273`) have no auth and trust `user_id`. `changeQuantity`/`destroy`/`process` take raw cart ids. The same applies to `apply_coupon_code`/`remove_coupon_code` (they take `user_id` from the body even though sanctum is required). On web, `CartController::removeFromCart` (287) and `updateQuantity` (303) accept any cart id.
4. ~~`Api\V2\InvoiceController::invoice_download` had no ownership check~~ - fixed 2026-10-15 (buyer, seller or staff only, like the web version).
5. `Api\V2\Seller\OrderController::update_delivery_status`/`update_payment_status` (48/59) → `OrderService` has **no `seller_id` ownership check**, and the route has only `auth:sanctum`. Payment is protected by `can_settle_order_payment`; delivery status is not. `Api\V2\DeliveryBoyController::change_delivery_status` (289) has the same problem and also trusts `delivery_boy_id`.
6. Checkout OTP: `checkPhone`/`verifyPhone` have no throttle (the `web` group has none, `Kernel.php:42`) and use a 4-digit code with no attempt counter, so brute-force login as any customer phone is possible. Staff and sellers are excluded (`can_login_at_checkout` 71).
7. `GET /track-your-order?order_code=…` (`HomeController:1551`) is public and shows name, address and items. Order codes are `Ymd-His` plus 2 random digits (`OrderController:614`, `Api\V2\OrderController:175`), which makes them enumerable.
8. `CheckoutController::orderRePayment` (1362) uses `Order::findOrFail($request->order_id)` with no ownership check. With wallet it would debit the caller to pay someone else's order. Customer cancel is a CSRF-able **GET** (`purchase_history.destroy`).
9. There is a hardcoded Ishaar API key `Lr4A***` and a phone number in `OrderController.php:958-966` and `Api\V2\OrderController.php:727-734, 766-773` (dead methods, but the secret is committed). **Rotate it.**
10. `Seller\OrderController::show` (296) does not check that the order belongs to the seller (it relies on `encrypt()`ed ids). `Api\V2\RefundRequestController::send` (23) does not check ownership (`PurchaseHistoryController::re_order` does since 2026-10-20). `Api\V2\PurchaseHistoryController::re_order` still takes any order id.
11. ~~`GET /checkout/in` (`CheckoutController::in` 334) is public and triggers a low-stock e-mail.~~ Route removed 2026-10-21.

**Shipping address (fixed 2026-10-03, `fix/log-2026-10-02-crashes-2026-10-03`):**
- A cart pointing at a deleted address used to be ordered anyway and saved with `shipping_address = '[]'` (web `OrderController::store`, `Api\V2\OrderController::store`, `Api\V2\CheckoutController::createPayment`). All three now refuse it ("Please select a shipping address before placing the order": web flashes and redirects to the cart, the app gets `result=false`). Older rows with `'[]'` remain: `json_decode('[]')` is an empty **array**, so never read `json_decode($order->shipping_address)->field` - use `order_shipping_address($order)` (object or null), `order_shipping_field($order, 'phone')`, `order_shipping_city($order)` (`Helpers.php`), as every reader now does: admin/seller `show`, `combined_order_details`, the status-change SMS calls (web + seller + `OrderService`), and the Blade pages `backend/sales/show`, `seller/orders/show`, `backend/invoices/invoice`, `emails/invoice`, `frontend/user/order_details_customer`, `track_order`, `order_confirmed` (`order_shipping_address($order)?->field`). Left as they were: the unused gateways under `Controllers/Payment/` and `Api/V2/PurchaseHistoryCollection`, which still hands the app `json_decode()` as is (an API contract). New strings get their Arabic from migration `2026_10_03_000001` (`cache:clear` after deploy: `translations-sa` is cached forever).
- The orders list date filter (`all_orders`) goes through `admin_date_range_bounds()`: picker shape `dd-mm-yyyy to dd-mm-yyyy`, plus `Y-m-d`, `m/d/Y`, `d/m/Y`, `d.m.Y` and the separators ` - ` / ` / `; anything else is ignored with a flash instead of a 500. Reversed bounds are swapped.
- Pinned by `tests/Feature/OrderPageNullSafetyTest.php` (helpers) and `CartUnavailableVariantCheckoutTest` (the refusal, the stored address).

**Admin orders list: seller filter and totals (2026-10-09, `feat/orders-seller-filter-totals-2026-10-09`):**
- Every filter of the list lives in `OrderController::filteredOrdersQuery()`, shared by the page and Bulk Action → Export, so a new filter there reaches both. `sellers[]` keeps `orders.seller_id` in the picked ids (the in-house store is the admin's id); the options are `Order::sellerFilterOptions()` (everyone with an order, shop name, in-house first). The in-house list hides it, the seller list drops the in-house option.
- The totals under the table are `Order::listTotals($orders)` over the whole filtered list (every page), in SQL: the rule of `combined_order_payable_total()` (void orders count for nothing, products floored at zero per checkout, shipping once per checkout with a live order: `combined_orders.shipping_cost`, else the lines' shipping), each order at its date's rate. Under a seller filter the shipping is the whole checkout's (it is not split between sellers). The counts are live orders and live combined orders (one live order is enough), with the cancelled / returned ones shown under them (`void`, `void_checkouts`) and, since 2026-10-28, the total of both ("Total"). The coupon card (`coupon`, `coupon_checkouts`, `coupons` = distinct `orders.coupon_id`, which orders from before that column leave out, `coupon_unused`) is what the live orders really got: per checkout `capped_coupon_discount()` over its live orders, i.e. the stored `orders.coupon_discount` but never more than their products (`order_details.price + tax`) and never the shipping, so "products total" + "coupon" = the products before the coupon; cancelled / returned orders' coupons count for nothing, and the part of a coupon larger than the products (orders placed before the coupon was capped at checkout) is shown apart as unused. The coupon filter (a multi-select since 2026-10-28: `coupon[]`, any picked coupon matches, `Order::couponFilterValues()` / `scopeWhereCoupon()`; a single `?coupon=` from older links still works; the Coupon Usage report takes `coupon[]` the same way, `CouponUsageReportService::couponValues()`) has a box above the table that takes its two amounts from here, and leaves returned orders out like cancelled ones. Pinned by `OrdersListSellerFilterTotalsTest` and, on live data against a PHP recomputation, `OrdersExportRuntimeTest`. The "this page" line adds up what the rows show.
- **Purchase price and shipping columns** (list rows, totals card, Excel export): one rule in `App\Utility\OrderPurchaseCostUtility` (PHP, per row) and `Order::listTotals()['purchase']` (SQL via `order_line_cost_sql()`). Purchase = the live lines' (not cancelled / returned) `order_details.purchase_price` (frozen, whole line), else `order_details.line_cost` (frozen at the same moment for products outside seller pricing - partner stores, our own - profit only, never the seller's balance; since 2026-11-09, older lines backfilled from the audit log), else the product's current `purchase_price` (RAW → BASE) × qty; lines with neither are counted as "without a purchase price". Shipping = `combined_orders.shipping_cost`, else the lines' shipping; on a seller-order row (and in the export) the checkout's shipping sits on its **first live order**, 0 on the others, so the column adds up to the checkout's.
- **Timeline per seller order** (the "Status & timeline" column, `partials/_order_timeline`): `App\Utility\OrderTimelineUtility::steps()` reads placed (`orders.date`), seller (`seller_confirmed_at` / `seller_unavailable_at`, not for in-house), staff (`order_follow_ups.decided_at` + `decided_by_name`; a decision with no `decided_by_id` - the rows backfilled when Confirm Orders shipped, 2026-09-29 - is left out, and so is the "not ready" a seller files in their own name when reporting the order not available, which is the seller step), confirmed (`orders.confirmed_at`, folded into the staff step within 5 minutes, since the staff decision sets the status), delivered (`delivered_date`, written only by the admin status change), **edited by staff** (since 2026-10-22: one step per change staff made - coupon, shipping cost, product added, option, quantity, product removed - with who and what, from `audits` rows of event `staff_edit` written by `App\Utility\StaffOrderEdits::record()` in the six `OrderController` paths, the checkout-wide ones (coupon, shipping) on every seller order of it; the latest 5 per order, `forOrders()` two queries per page; a new admin path that changes an order's products or amounts must call `record()`), then either the one step it waits on or how it was closed: `cancelled_at`, who (`cancelled_by`: the customer or the staff name, `closerNames()` one query per page) and the reason (`cancelReasonLabel()`: keys from the staff modal and the customer web/app, older rows hold the Arabic text itself; returns use `OrderStockUtility::returnReasonLabel()`). The checkout row shows when it was placed. Payment method and status are one "Payment" column, with who recorded the payment and when under a paid order (`payment_settled_by` / `payment_settled_at`, `record_payment_settlement()`; nothing for paid online or before 2026-09-21); a seller row has no print button (the checkout row prints the combined invoice: on screen in the Kapitano look (RTL by the language): header, a shipping label (address + customer), every product of the checkout in one table (no per-store split; a void order's lines struck through; the price before discount struck above the paid one), payment method, and price before discount / product discount / coupon (with its codes) / tax / shipping / final amount, all from `App\Utility\InvoiceAmountsUtility::forCheckout()`; **on paper it is an 80mm thermal receipt** (since 2026-10-29, the office prints on an XPrinter: `@media print` only - one column at most 72mm wide, black on white with no backgrounds, 3mm above the logo, each product name on one line cut with an ellipsis and its variant / qty / price / total under it, the price before discount left to the totals); the PDF downloads are not affected; staff who download a seller order's PDF from its page (`InvoiceController`, admin/staff) - and, since 2026-10-15, the buyer on the website and in the app - get the same one page for the whole checkout, numbered by the checkout (`backend/invoices/partials/invoice_compact`), while the seller keeps the per-order PDF of their share); an order line's variation is drawn by `partials/_variation` (order page, combined order page, the list's checkout peek): a value that is one of our colours (`colors.name`, any case) gets a swatch of its `colors.code` and reads in the page language through `translate()` (the storefront colour picker's keys; the saved English name and the code are in the tooltip) (`App\Utility\VariationUtility`, one query per request, only `#hex` codes), a variation with no colour stays the saved text (partner colours are text); there is no products-count column and no list of the checkout's order codes (the checkout row's badges and the seller rows already show both).
- **`orders.confirmed_at`** (migration `2026_10_09_000001` or `sqlupdates/kapitano_order_confirmed_at_2026-10-09.sql`): the first time the status reached confirmed or later, stamped by `Order::saving` from any screen and cleared back to pending (only through Eloquent saves; guarded by `Order::hasConfirmedAt()`, so the code may ship before the column). Backfilled from the audit log (since 2026-09-19), else a staff "ready" decision made by someone; older orders show "date not recorded". Pinned by `tests/Feature/OrderTimelineTest.php`.
- **Status times** (`picked_up_at`, `on_the_way_at`, migration `2026_10_28_000001`; CONTEXT.md "Status time", ADR 0006): one time per status on `orders`, no history table. `Order::saving` (`stampStatusTimes()`) stamps picked up / on the way / `delivered_date` each time the status reaches them, clears the later ones on a move back (`delivered_date` too, since 2026-10-28), and stamps `cancelled_at` on cancelled / returned unless the path wrote it - so every cancel path has a time now (C2 and C6 had none). `confirmed_at` keeps its own rule above. Only Eloquent saves stamp (a query-builder status write does not). The migration copied the times from the audit log (picked up, on the way, and the empty `delivered_date` / `cancelled_at`), keeping only times the order's status today has passed. Read by Reports → Order Status Times (`docs/codebase/06-admin-reports-marketing.md`). `payment_settled_at` is stamped the same way (`stampPaymentTime()`) on every path that marks the order paid without `record_payment_settlement()` (Paymera, the wallet at checkout, the delivery-boy API...), `payment_settled_by` left empty, and cleared on unpaid; migration `2026_10_28_000003` filled the old ones from the audit log. Pinned by `tests/Feature/OrderStatusTimesTest.php`.
- Pinned by `tests/Feature/OrdersListSellerFilterTotalsTest.php` (SQLite) and a case in the opt-in `OrdersExportRuntimeTest` (SQL totals = `combined_order_payable_total()` over every real checkout; page and export follow `sellers[]`).

**Bugs / behaviour traps:**
- **App-Paymera shipping double count (unverified on data).** Its orders carry shipping in `orders.grand_total` and `order_details.shipping_cost`, *and* `combined_orders.shipping_cost` is set (`createPayment` 615). `combined_order_payable_total` (1298) and `CombinedOrderCouponUtility::recalculate` (79-82, after `allocate` adds `orderShipping`) both add the combined shipping on top. So after any admin edit, the combined total and invoice for such checkouts include shipping twice. Web and app COD keep detail shipping at 0, so they are unaffected.
- Web shipping is rounded to **1000 SYP** (`OrderController:737`, `round_system_price(..., 1000)`) while the app rounds to 5. The same cart can be charged different shipping on web and app. The 1000 might have been an intentional business rule **(unverified)**.
- `OrderController::update_delivery_status`:
  - `dd()` in the catch block (1128) returns a debug dump to staff.
  - The wallet refund (1025) runs again on every `cancelled` save.
  - The commission reversal checks the *customer's* `user_type` (1032).
  - Setting `cancelled → pending` does not re-deduct stock.
  - The SMS line reads `SmsTemplate::...->first()->status` without a null check (1087), so it throws if the OTP addon is enabled but the template is missing.
- `NotificationUtility::sendOrderPlacedNotification:47` calls `Mail::to(...)->to(new InvoiceEmailManager(...))`, which never sends. **App order-placed e-mails are silently dropped.** The trait copy is untested.
- `Api\V2\CheckoutController::apply_coupon_code`, `welcome_base` branch (208-232): `$couponDetails` is undefined and `$this->isCouponAlreadyApplied` does not exist in that class, so it is a fatal error. The discount is written to **all** cart rows but divided by the count of *active* rows (246-251).
- `CheckoutController::order_confirmed`: FB CAPI and GA4 fire **once per order line** because their braces sit inside the nested loop (1128-1159). `$earned_points` is undefined when `settings.id=1` is missing (1188/1198). Loyalty is granted at placement, even for unpaid, COD or later-cancelled orders, and never reversed.
- Web Paymera orders are created before payment (stock taken, cart deleted) and never expire. `createUser` sets `email_verified_at = date('Y-m-d H:m:s')` (662; `m` in the minutes slot is month).
- `OrderController::store` has no transaction. The inner short-stock branch (634-637) deletes only the current `Order`, leaving the `CombinedOrder` and earlier sellers' orders with stock taken (mitigated by the pre-check at 524).
- `admin_cancel` does not refund wallet, reverse commission or e-mail the customer (854 commented out). The dropdown cancel does all three. The two paths disagree.
- `OrderService` (namespace `App\Services`) references `AffiliateController` and `DeliveryBoyController` without imports (68, 104). It fatals if those addons are on.
- `SellerOrderActionController::formatTotal` (239) uses `single_price()` for `grand_total`, which breaks the "order amounts at the order's rate" rule. It should be `single_order_price`.
- `CartController::updateQuantity` (303-352) writes the RAW `product_stocks.price` (minus discount) into `carts.price`, which should hold BASE **(unverified whether `cart_product_price` reads it)**.
- `OrderFollowUp::syncWithDeliveryStatus` uses `auth()->user()`, which is null in cron/API contexts. The actor is then logged as null (handled).
- Web `index`/`updateDeliveryAddress`: `Country::...->first()->zone_id` can be null in the carrier path (409 guards with `??`; `getShippingCost:2339` does not).

**Dead code:**
- `CheckoutController::checkout_done1` 721, the `get_shipping_info`/`store_shipping_info`/`store_delivery_info` "will not use after single page checkout" block (761-941), and `in()` 334.
- `PurchaseHistoryController::order_cancel_reason` 201.
- `OrderController::create`/`edit`/`update`/`sendOrderStatusForWhatsApp`, and `Api\V2\OrderController` 307-625 (commented) plus `sendCodeForWhatsApp`/`sendOrderStatusForWhatsApp`.
- `PaymeraController::create`.
- Route `bulk-order-status` (`admin.php:577`) points to a missing method.
- `order_follow_up_items` table and legacy `accountable_employee_id`; permissions `mark_order_paid` and `check_order_follow_up_items`.
- The web `RefundRequestController` routes, whose class is missing.
- Most gateway controllers under `app/Http/Controllers/Payment/` and `Api/V2/*Controller` (Stripe, PayPal and so on): the store uses COD and Paymera only.

## 9. Tests covering this domain (`tests/Feature/`)

| File | Tests | Covers |
|---|---|---|
| `CheckoutOtpVerificationTest.php` | 2 | `verifyPhone` |
| `CartUnavailableVariantCheckoutTest.php` | 16 | `orderable_items`, `first_short_product`, web and app store with removed variants; unpublished / unapproved products hidden from the cart and back when published (`CartProductOnSaleScope`) |
| `CombinedOrderCouponTest.php` | 20 | `CombinedOrderCouponUtility`, `updateQty`/`deleteItem` totals |
| `CouponSingleUseTest.php` | 21 | re-validation at order time, `recordUsage` |
| `FreeShippingThresholdTest.php` | 10 | `getShippingCost`, `cart_shipping_cost` free-shipping limit |
| `CancelledOrderTotalsTest.php` / `CancelledOrderTotalsRuntimeTest.php` | 6 / 4 | `order_is_cancelled`, `combined_order_payable_total` |
| `OrderExchangeRateSnapshotTest.php` | 14 | `order_exchange_rate`, `convert_order_price` |
| `PaymeraAppCheckoutTest.php` | 13 | snapshot, `complete`, cancel/expire, zero total |
| `PaymeraRefundCancelsOrderTest.php` | 4 | `markCombinedOrderRefunded` |
| `DeletedOrderArchiveTest.php` | 11 | `archive_deleted_order`, `Order::deleting` |
| `StockZeroingTest.php` | 10 | `StockZeroUtility`, `admin_cancel`, `deleteItem` |
| `OrderFollowUpTest.php` / `OrderFollowUpRuntimeTest.php` | 20 / 1 | confirm, deliver, accountant rules (`can_settle_order_payment`) |
| `SellerOrderConfirmationTest.php`, `SellerOrderUnavailableTest.php`, `SellerConfirmationRuntimeTest.php` | 15, 21, 3 | seller confirm and unavailable (incl. the stock zeroing and its undo), the redrawn page parts (`panel=1`), the seller seeing the customer's name only |
| `SellerOrderActionLinkTest.php` | 12 | `/so/*` token flow |
| `SellerWhatsAppAlertTest.php`, `SellerWhatsAppScheduleTest.php`, `SellerWhatsAppSettingsTest.php`, `SellerResponseReportTest.php` | 16, …, … | WhatsApp alert |
| `SellerPushTest.php`, `SellerPushNotificationTest.php` | 14, … | push alert |
| `SellerWorkflowRuntimeTest.php` | 29 | seller workflow (runtime) |
| `CompensationCouponTest.php`, `CouponUsageReportTest.php` | — | order-adjacent coupon behaviour |
| `CustomerOrderEditTest.php` / `CustomerOrdersRuntimeTest.php` | 19 / 6 | §4.8: editable rule, quantity / variant pricing, stock, rate guard, remove, coupon with siblings frozen, shipping difference, stale basket, seller confirm refusal, grouped list + ANY filters, totals, history, app endpoints / live web + app routes, views, admin list, WhatsApp page |

| `CustomerReturnRequestTest.php` / `CustomerReturnRequestRuntimeTest.php` | 21 / 2 | §4.9: window, switch, reasons and photos, one request per line, multi-seller checkout, approve / receive / reject, list states, app endpoints / live web + admin + app routes and views |

Runtime tests (`*RuntimeTest`) need `KAPITANO_RUNTIME_DB_TESTS=1` plus MySQL.

**Not covered:** web `CheckoutController::checkout`/`OrderController::store` end to end, `PaymeraController` web callback/trigger, `update_delivery_status` side effects (wallet refund, commission reversal), customer cancel paths C3-C6, app `WalletController::processPayment`, invoice access control, and cart endpoints.

## 10. Open questions

1. Are `orders.cancel_*`, `orders.cancelled_*`, `orders.notified`, `combined_orders.payment_id` and `combined_orders.shipping_cost` created by any script outside the repo? A migration is needed for fresh environments.
2. Is the 1000-SYP shipping rounding on web (`OrderController:737`) intentional, given that the app rounds to 5?
3. Does production data show the app-Paymera shipping double count in `combined_order_payable_total` or invoices?
4. Are the wallet, delivery-boy, refund-request, affiliate and club-point addons active in production? If so, several §8 issues become live.
5. Should the customer Paymera refund be allowed after `confirmed`/`delivered`, and should it reverse commission (`commission_histories`, `shops.admin_to_pay`)?
6. Should abandoned **web** Paymera orders expire and restock, as app checkouts do?
7. Should `admin_cancel` send the customer notification and wallet refund that the dropdown cancel sends?
8. Which payment option names can the web checkout actually submit, and is manual/offline payment still reachable?
9. Is `SellerWhatsAppUtility` still pilot-only (`seller_whatsapp_alert_sellers`) in production?
