# Kapitano Logistic — Work Changelog

- **Branch:** `Captain-Approval-And-Authentication`
- **Date range:** Feature 02 + Feature 03 + Feature 04 + Feature 05 + Feature 06 + Feature 07 sessions
- **Purpose:** What was built, endpoint by endpoint, task by task — written so each item can be copied into the Zoho project board as a parent task with its sub-tasks.

---

## Overview

The sessions added the **Admin back-office captain-approval API** (Feature 02) on top of the existing review queue, guaranteed **captain authentication** is restricted to approved captains and hardened the OTP flow (Feature 03), and included supporting work already in the tree: device-token registration, driver presence (availability/location), and the order-domain scaffolding. Everything follows the project's existing conventions (thin controllers, services, repositories, DTOs, enums, Sanctum, uniform JSON envelope). Nothing existing was replaced.

---

## Feature 02 — Dashboard (Admin) Captain Approval API

**Parent task: Build the back-office review API that decides on captain applications.**

- **02.01 — Review queue (list + filters).**
  - Sub: Add dashboard route `GET /api/dashboard/drivers` with server-side filters (search, status, employment type, area/ownership type, activation state, pagination).
  - Sub: Fix the empty `is_active=` filter that returned zero rows when the field was sent empty — it now falls back to "no filter" instead of filtering for inactive-only.
- **02.02 — Driver inspection.**
  - Sub: Add `GET /api/dashboard/drivers/{uuid}` returning the full application (personal data, documents, vehicle, images, review fields).
- **02.03 — Decision endpoints (state machine).**
  - Sub: Add `PATCH .../approve` — personal vehicles optional; company-owned vehicles must be handed over (`vehicle` block required, else 422).
  - Sub: Add `PATCH .../reject` + `PATCH .../request-documents`, both requiring `review_note`.
  - Sub: Enforce transitions in `DriverStateMachine` + `DriverStatus::allowedTransitions()`; a repeated decision answers 409, an illegal move answers 422.
  - Sub: Make an **approval final** — an approved captain can no longer be rejected (their only exit is the activation switch); a rejected captain may still be reconsidered (approve / request-documents) and re-requested documents may still be approved or rejected.
- **02.04 — Notify the captain.**
  - Sub: Dispatch `DriverApplicationReviewed` on every decision; a queued listener sends an FCM push with the outcome and the reviewer's note (approval / rejection / documents requested).
- **02.05 — Deactivate / reactivate.**
  - Sub: `PATCH .../toggle-activation` turns the account on or off and **revokes all open sessions** when turned off.
- **02.06 — Admin identity for the dashboard.**
  - Sub: Reuse the existing admin-email/password login (`POST /api/dashboard/auth/login`) with Sanctum tokens; dashboards routes are protected by `auth:admin` + a `ReviewDrivers`/`ViewDrivers` permission.
- **02.07 — Postman collection.**
  - Sub: Extend `Kapitano-Logistic-Mobile.postman_collection.json` with a **Dashboard (Admin) — Captain approval** folder covering login, queue, inspection, the three decisions, activation, and error cases.
  - Sub: Repair collection JSON (12 saved responses were missing a closing brace) and add the `/api` prefix to every mobile URL so the collection works against this project's route layout.
- **02.08 — Tests.**
  - Sub: Feature tests for queue/filters, inspect, approve/reject/request-documents (happy + 409/422 paths, approval-final rule), notifications, deactivation/revocation.
  - Sub: Unit tests for the status enum's allowed transitions.

---

## Feature 03 — Captain Authentication

**Parent task: Approved captains sign in with a phone number + WhatsApp OTP; everyone else is blocked.**

- **03.01 — Reuse the existing mechanism.**
  - Sub: Confirm captains authenticate via **phone + OTP** (WhatsApp) exchanging for a **Laravel Sanctum** bearer token. No password column exists, so no password login was added; no second authentication mechanism was created.
- **03.02 — Enforce approval status server-side.**
  - Sub: On every `login`/`resend`/`verify`, check the account before any code is handled: unknown number → 404, still reviewing (`pending`/`documents_required`) → 423, rejected/deactivated → 403; the reason also returns in `MessageDebug.reason`.
  - Sub: Protected captain routes (`profile`, `device-token`, `availability`, `location`) are guarded by `auth:driver`; deactivating a captain revokes their tokens.
- **03.03 — OTP security hardening.**
  - Sub: Add a per-code **wrong-try limit** (`otp.attempts`, default 5) — after too many mismatches the code is invalidated and the captain must request a new one; response answers 422 with an "exhausted" message (EN + AR).
  - Sub: Keep existing protections: 15-minute expiry, single-use consumption, hash-only storage + `hash_equals`, resend replaces the old code, `throttle:otp` (4 requests/min per phone and per IP) on login/verify/resend.
- **03.04 — Session lifecycle.**
  - Sub: `verify` issues the token and returns the full profile; `logout` revokes all tokens; revoked tokens are refused 401 on protected routes.
- **03.05 — Documentation.**
  - Sub: Write `docs/feature-03-captain-authentication.md` (endpoints, requests, validation, status codes, error table, security analysis, testing, config).
- **03.06 — Tests.**
  - Sub: Endpoint tests for approved sign-in, pending/rejected/disabled/unknown, wrong/empty/reused/exhausted codes, resend, logout + revocation, throttling, header middleware.
  - Sub: Service tests for OTP lifecycle (single-use, no-clear-text, expiry, resend, attempt limit).
- **03.07 — End-to-end verification (live).**
  - Sub: Against the real database: register → 423 while pending → admin approves → OTP → token → protected endpoint 200 → logout → same token 401. Throwaway records cleaned up afterwards.

---

## Feature 04 — Manual Order Assignment & Nearest-First Captain Suggestion (MVP)

**Parent task: the dispatcher creates an order and picks the nearest idle captain to carry it — no auto-dispatch in the MVP.**

- **04.01 — Orders permission group.**
  - Sub: Add `orders.view`, `orders.create`, `orders.assign` to `AdminPermission` (auto-seeded by `PermissionSeeder`), with EN + AR labels; grant all three to `operations-manager` and `orders.view` to `support` in `RoleSeeder::DEFAULTS`.
- **04.02 — Orders list + detail.**
  - Sub: GET `/api/dashboard/orders` — paginated, filterable by `status` and `search` (order number / customer / phone), shows status + carrying captain + items.
  - Sub: GET `/api/dashboard/orders/{uuid}` — detail with items, captain, and full status timeline (`OrderRepository::findDetail`).
- **04.03 — Create fake orders (until the third-party integration).**
  - Sub: POST `/api/dashboard/orders` creates a pending order (`ORD-xxxxxx` sequential number, `created_by` = admin), optional `items[]` written in the same transaction.
  - Sub: Pickup/dropoff coordinates optional but each pair stays intact (lat without lng → 422); the create endpoint writes the same `orders` table the future third-party feed will write.
- **04.04 — Idle-captain suggestion query.**
  - Sub: `DriverRepository::suggestableCaptains()` — approved + active + `is_online` + has a reported location + holds no in-progress order (`OrderStatus::inProgress()` = assigned/picked_up/on_the_way).
- **04.05 — Nearest-first ranking.**
  - Sub: `DistanceService` computes straight-line (haversine) distance in PHP round-trip identical on MySQL and SQLite; suggestions sorted ascending, ties broken by name, each entry emits `distance_km`.
  - Sub: GET `/api/dashboard/orders/{uuid}/captains` returns the ranked list as captain + `distance_km`; order without pickup coordinates → 422.
- **04.06 — Assignment.**
  - Sub: PATCH `/api/dashboard/orders/{uuid}/assign` with `driver_uuid` — eligibility re-checked inside `OrderService::assign()` (same rule as suggestions) so no second door bypasses it; busy/offline/inactive/unapproved captain → 422, non-pending order → 422, unknown captain → 404.
  - Sub: Pending → Assigned via the existing state machine in one transaction; sets `driver_id`, appends an `order_status_history` row naming the acting admin.
- **04.07 — Notify the captain.**
  - Sub: `OrderAssigned` event → queued `NotifyCaptainOfAssignment` → `NewOrderAssignedNotification` (FCM, `type=order_assigned`, `order_uuid`), delivered only when the captain has a registered device token.
- **04.08 — Demo data.**
  - Sub: Order factory + `OrderSeeder` gain pickup/dropoff coordinates (Riyadh); `DriverSeeder` puts every approved+active sample captain online with a reported position; add a fourth approved+active sample captain (`+966500000008`) who is free right after seeding.
- **04.09 — Documentation.**
  - Sub: Write `docs/feature-04-manual-order-assignment.md` (endpoints, eligibility rule, ranking, flow, permissions, seeds, tests, deliberate MVP exclusions).
- **04.10 — Tests.**
  - Sub: Unit tests for `DistanceService` (known distances, symmetry, rounding).
  - Sub: Feature tests for ranking order + distances, exclusions (offline/unlocated/inactive/not-approved/busy), missing-pickup 422, assignment + timeline + notification, busy/non-pending refusal, 404/401/403, create-with-items and coordinate-pair validation, list filters, detail.
  - Sub: `OrderServiceTest` adjusted for the new eligibility guard (offline + busy captains cannot be assigned).

---

## Feature 05 — Vehicle Management (Dashboard)

**Parent task: the back office manages the vehicles captains drive (one captain, one vehicle; deactivate, never delete).**

- **05.01 — Vehicle details & documents.**
  - Sub: Migration adds `vehicle_type` (motorcycle / car / van / pickup_truck / truck), `registration_number` + `registration_expires_at`, `insurance_policy_number` + `insurance_expires_at`, and a unique index on `vehicles.driver_id`.
  - Sub: `VehicleType` + `VehicleDocumentStatus` enums (EN + AR labels); each response carries `registration_status` / `insurance_status` (missing / expired / expiring_soon ≤ 30 days / valid), computed per request.
- **05.02 — Vehicle list + detail.**
  - Sub: GET `/api/dashboard/vehicles`, paginated, with filters: search (plate / brand / model / papers / captain name or phone), ownership_type, vehicle_type, document_status, driver_uuid, is_active, awaiting_assignment.
  - Sub: GET `/api/dashboard/vehicles/{uuid}`: vehicle with captain, photos and document statuses.
- **05.03 — Add / correct a vehicle.**
  - Sub: POST `/api/dashboard/vehicles` puts a vehicle on record for a captain who has none (409 if they already have one, 404 for an unknown captain), with optional photos.
  - Sub: PUT `/api/dashboard/vehicles/{uuid}` updates only the fields sent; papers can be cleared with null; filling in a waiting fleet car lets its captain be approved without a vehicle block.
  - Sub: POST `/api/dashboard/vehicles/{uuid}/images` replaces the vehicle photo and/or mechanic's report.
  - Sub: PATCH `/api/dashboard/vehicles/{uuid}/activation` toggles the vehicle on or off.
- **05.04 — Plate numbers unique on every write path.**
  - Sub: Unique-plate validation on admin create and update, captain registration, and the approval's fleet-car block (a captain's own plate is not a clash).
- **05.05 — Permissions.**
  - Sub: Routes use the existing `vehicles.view` / `vehicles.create` / `vehicles.update` permissions; no re-seed needed.
- **05.06 — Documentation.**
  - Sub: Write `docs/feature-05-vehicle-management.md`; add the `Dashboard — Vehicles` OpenAPI operations and extend the `Vehicle` schema.
- **05.07 — Demo data.**
  - Sub: `VehicleFactory` and `VehicleSeeder` fill in category and papers (one expired registration, one missing).
- **05.08 — Tests.**
  - Sub: `VehicleManagementApiTest` (list and filters, document classification, detail, create / 409 / 404 / duplicate plate / required fields, partial update, fleet-car-then-approve, photos, activation, 403 / 401), plus duplicate-plate tests in registration and approval.
- **05.09 — Captain app: the captain's own vehicle.**
  - Sub: GET `/api/driver/vehicle` shows the signed-in captain's vehicle with photos and document statuses (404 if none is on record).
  - Sub: POST `/api/driver/vehicle` lets a captain with their own car update vehicle type, plate / brand / model / year / color, registration and insurance, and photos; saved immediately, plate kept unique.
  - Sub: A captain with a company car can view it only (update → 403); the captain can never change the ownership type.
  - Sub: `CaptainVehicleApiTest` (view, full and partial update, plate rules, ownership ignored, company car 403, no vehicle 404, admin token / anonymous / missing headers 401).

---

## Feature 06 — Full Order Details & the Captain's Orders

**Parent task: the captain is notified of an assignment and can open and deliver the order; every order shows customer, phone, delivery address, products, customer notes and payment method.**

- **06.01 — Order payment & customer notes.**
  - Sub: Migration adds `customer_note`, `payment_method` (`cash_on_delivery` / `prepaid`, default cash on delivery) and `amount_to_collect` to `orders`; `note` becomes the internal operations note.
  - Sub: `OrderPaymentMethod` enum (EN + AR labels); `OrderService::create()` settles the payment terms (prepaid never carries an amount to collect).
  - Sub: `POST /api/dashboard/orders` requires `payment_method`, plus `amount_to_collect` for cash on delivery (breaking for clients that omit them).
- **06.02 — Full order details on the dashboard.**
  - Sub: `OrderResource` adds `customer_note`, `payment_method` (+ label) and `amount_to_collect`; items move to the shared `OrderItemResource`.
- **06.03 — Captain app: my orders.**
  - Sub: GET `/api/driver/orders` (own orders only, status filter) and GET `/api/driver/orders/{uuid}` (full detail via `CaptainOrderResource`, internal note hidden, `next_statuses` for the buttons); another captain's order → 403.
- **06.04 — Captain app: delivery steps.**
  - Sub: PATCH `/api/driver/orders/{uuid}/picked-up`, `/on-the-way`, `/delivered` (optional note), `/failed` (reason required), routed through `OrderService::advanceForCaptain()` onto the existing lifecycle methods (ownership guard, state machine, timeline).
- **06.05 — New-order notification loop.**
  - Sub: Verified `OrderAssigned` → queued `NotifyCaptainOfAssignment` → FCM push with `order_uuid`, which the captain app now opens.
  - Sub: Documented the delivery prerequisites: running queue worker, `FIREBASE_CREDENTIALS`, registered device token; noted 23 stale jobs in the development `jobs` table.
- **06.06 — Documentation.**
  - Sub: Write `docs/feature-06-captain-orders.md`; add `Captain App — Orders` OpenAPI operations, the `CaptainOrder` schema, and the new order fields.
- **06.07 — Tests.**
  - Sub: `CaptainOrderApiTest` (list, full detail, prepaid, 403, full delivery with timeline, 422 out-of-order, failed reason, 404, 401s) and `OrderAssignmentApiTest` additions (payment rules, full dashboard detail, assign → push → captain opens order).

---

## Feature 07 — Order Status Updates (Captain App)

**Parent task: one-tap pickup with an optional item count, and a clear Picked Up → On the Way → Delivered / Delivery Failed sequence.**

- **07.01 — Pickup item confirmation.**
  - Sub: Migration adds `items_collected` (nullable) and `items_mismatch` (boolean, indexed) to `orders`.
  - Sub: `PATCH /api/driver/orders/{uuid}/picked-up` accepts an optional `items_collected` (whole number 1–9999) through `PickUpOrderRequest`; an empty body is still a one-tap pickup.
  - Sub: `OrderService::markPickedUp()` compares the count with `OrderRepository::expectedItemCount()` (sum of product quantities); a different count still picks up, sets `items_mismatch`, and answers with a "flagged" message; orders without product lines are never flagged.
- **07.02 — Operations visibility.**
  - Sub: `items_expected`, `items_collected`, `items_mismatch` on the dashboard and captain order responses; `GET /api/dashboard/orders?items_mismatch=1` lists flagged pickups.
- **07.03 — Clear sequential progress.**
  - Sub: `OrderStepState` enum (done / next / upcoming / failed, EN + AR) and `OrderStatus::deliverySteps()`; every captain order response carries `steps` (Picked Up → On the Way → Delivered or Delivery Failed) with state and time.
- **07.04 — Documentation.**
  - Sub: Write `docs/feature-07-order-status-updates.md`; OpenAPI pickup body, `OrderDeliveryStep` schema, list filter and item-check fields.
- **07.05 — Tests.**
  - Sub: `CaptainOrderApiTest` (one tap, matching / different / no-lines counts, invalid counts, steps for every status) and `OrderAssignmentApiTest` (mismatch filter and detail).

---

## Supporting work included in this tree

**Parent task: Captains' devices and presence (pre-existing uncommitted work).**

- **S1 — Device tokens.** Register/unregister an FCM device token per captain (`POST`/`DELETE /api/driver/device-token`); tokens drive push delivery for notifications.
- **S2 — Driver presence.** Availability and live location endpoints (`POST /api/driver/availability`, `POST /api/driver/location`) with their DTOs, requests, resources, repositories, services, and factories.
- **S4 — Firebase push wiring and token hygiene.**
  - Sub: `FIREBASE_CREDENTIALS` now points at the service account JSON in `storage/app/firebase/` (gitignored, never committed); `.env.example` documents the setting and `.gitignore` covers the folder explicitly.
  - Sub: `php artisan firebase:test` (`TestFirebasePushCommand` + `FirebasePushTester`) reports which project and service account are loaded, then sends the real `NewFcmNotification` through the SDK — a dry run by default, `--token=<device>` and `--deliver` for a live send. Without a token it uses a simulated one, which Firebase must authenticate the service account to reject.
  - Sub: The command distinguishes "could not reach Firebase" (exit 1, nothing proven), "service account refused" (exit 1) and "service account accepted" (exit 0), and says so. The first version reported all three as success — verified by re-running it through a dead proxy and with a service account that does not exist, both of which it had called a pass. The classification is measured against the live API, not assumed: a refused account and an invalid token both arrive as `InvalidMessage`, separated only by the OAuth wording and `SendReport::messageTargetWasInvalid()`.
  - Sub: Fix the dead `DeleteExpiredNotificationTokens` listener — it guarded on a `notificationTokens()` relation no model has and queried a `push_token` column that does not exist, so it never pruned anything. It now deletes by `fcm_token` through `DeviceTokenService::forget()` → `DeviceTokenRepository::forget()`, and only when Firebase reports the token unknown or malformed; quota errors and outages leave it alone.
  - Sub: `FirebasePushTest` drives the real notifiable → notification → FCM channel → failure event → listener path with the SDK faked at `sendMulticast()`, plus the credential-report cases.
  - Sub: The same listener was registered by hand in `EventServiceProvider` *and* found by Laravel's listener discovery, so it ran twice for every failed send. The manual registration is gone — `php artisan event:list` now shows it once.
  - Sub: `Driver` and `Admin` gained a `deviceTokens()` morph relation; `routeNotificationForFcm()` reads from it instead of an inline `morphMany()`, so the tokens can be queried and eager-loaded like any other relation.
  - Sub: `fcm_token` is unique across `device_tokens`, not merely per owner (the migration first resolves existing duplicates in favour of the newest registration). A token identifies a handset, so `DeviceTokenRepository::register()` takes it from any other captain still holding it — otherwise a captain who signed out kept receiving notifications on a colleague's phone, and that phone could be pushed to twice. Signing out does not release the token: `logout()` only revokes Sanctum tokens, so the transfer happens when the new captain registers.
  - Sub: `FIREBASE_HTTP_CLIENT_TIMEOUT=30` keeps a Firebase call under the queue's `retry_after` of 90 seconds — kreait sets no timeout by default, so a hung request could be re-claimed by a second worker mid-send and deliver the same notification twice. The worker should run with `--tries=1` for the same reason: FCM has no idempotency key, so a message that reached Google but failed afterwards would arrive twice on a retry; a failed job in `failed_jobs` is recoverable, a duplicate notification is not.
- **S5 — Push notification management for the back office.**
  - Sub: `push_deliveries` log — one row per device per notification with the recipient, what they were shown, and Firebase's answer (`sent` + message id, `failed` + error, `no_device`). Written by the `RecordPushDeliveries` listener from the per-token reports Laravel passes to `NotificationSent`; a failure to write the row is logged and never fails the notification.
  - Sub: Dashboard endpoints under `/api/dashboard/push`: `deliveries` (filtered log), `stats` (counts and failure rate), `devices` (registered handsets, fleet or one captain) and `test` (real test push). New permissions `push.view` and `push.send`, labelled in en/ar; `operations-manager` gets both, `support` view only.
  - Sub: The test push reports what Firebase answered, read back from the log — the first version answered "sent" for any captain with a device, which real Firebase exposed when it rejected the token and the endpoint still said success. With the log switched off it returns `null` rather than guess.
  - Sub: `push:prune` (daily, `PUSH_RETENTION_DAYS`) and `push:health` (hourly, exits non-zero past the failure threshold) in `config/push.php`; documented in `docs/runbook.md` §1 and OpenAPI.
  - Sub: `PushDeliveryApiTest` covers the listener through the real FCM channel (sent, failed, no device, log off), every endpoint with its permissions and 401, and both commands.
- **S6 — Push notifications production-ready.**
  - Sub: Notifications are built in the captain's language: a device keeps the `Accept-Language` it registered with and `Driver` implements `HasLocalePreference`. Before, a push built on a queue worker used the default locale, so Arabic phones were told about orders in English.
  - Sub: No broken image: the icon comes from `PUSH_NOTIFICATION_ICON_URL` and is omitted when empty — the old default pointed at an asset that was never built, on the application's local URL.
  - Sub: Broadcasts (`POST /api/dashboard/push/broadcasts`, `push.broadcast`): all approved captains, those on duty, or chosen ones; recorded with the sender; sent as a Laravel job batch of 50-captain chunks, each attempted once with a 60 s timeout so no chunk outlives the queue's `retry_after` and gets pushed twice; 409 on the same broadcast within 120 s; 5 a minute. Progress and counts come from the delivery rows linked to the broadcast.
  - Sub: Resend any logged delivery to the recipient's current devices (`push.send`), reporting Firebase's answer. The read-back now counts rows written after the log's last id instead of within the same second, so another push to the same captain is not mistaken for this one.
  - Sub: Devices get a uuid (existing rows backfilled) and can be removed from the dashboard (`push.manage`).
  - Sub: Failed push jobs listed, retried and forgotten from the dashboard (`push.manage`); only order-assigned, application-decision and broadcast jobs are in reach.
  - Sub: `push:health` also notifies the admins' inbox (group System, once per cooldown day); `stats` shows the thresholds.
  - Sub: Retention and thresholds stay deploy configuration by decision, not dashboard settings.
  - Sub: Verified on the real stack — database queue with a real worker, real Firebase: broadcast queued → chunk run → completed, duplicate refused with 409, the rejected token logged and pruned, resend and device removal. `PushManagementApiTest` covers all of it.
- **S7 — The applicant's handset, so the decision reaches them.**
  - Sub: `POST /api/driver/auth/register` accepts an optional `fcm_token` (and `device_id`). Until now the first chance to register a device was at sign-in, and a pending applicant cannot sign in — so the approval, rejection and documents-required pushes had nowhere to go and were logged `no_device`. The applicant's own phone is the only device they have at that point.
  - Sub: Registered through the same `DeviceTokenService::register()` the captain app calls, inside the registration transaction, so the one-token-one-captain rule holds: a handset a previous captain registered moves to the applicant. The push language is taken from the registration request's `Accept-Language`, like any other device.
  - Sub: The field is optional — an app build that sends none still registers, it simply has no device to be told the decision on. The controller is unchanged; the token travels on `DriverRegistrationData` and is excluded from the driver's column payload.
  - Sub: `DriverRegistrationApiTest` covers the three outcomes (stored with the request's locale, nothing stored when absent, moved from a previous owner), each confirmed to fail without the change. The approval and rejection end-to-end tests now take the device from the application itself instead of inserting one by hand — which is what a real applicant does.

- **S3 — Order-domain scaffolding.** Base order model set (orders, order items, status history), status enums, migrations, factories, seeder (ORD-100001..100004), and initial service + state-machine tests — groundwork for a future delivery feature.

---

## Verification

- All feature and unit tests pass when run per file (180+ assertions across the chosen files; full-suite single-process run on this Windows box has one pre-existing hang inside the untouched `DriverRegistrationApiTest` — it passes standalone).
- `php -l` clean on every edited file.
- Live E2E flows verified against the real MySQL database:
  - Approval flow: pending → approved; approve is final (re-approve 409, reject-after-approve 422); reject is final (re-reject 409) but reconsideration (approve a rejected captain) still works.
  - Authentication flow: pending → 423, approved OTP login → 200 token, protected endpoint 200, logout, revoked token 401.
- Thrown-away test captains were removed from the database.

---

## Captain Dispatch Algorithm (v2.1) — Epics 1–9

**Parent task: decide which captain should take each order, and keep their route honest while they drive it.**

The whole feature runs today on a simulated routing engine (`FakeRoutingEngine`) because the
Google keys do not exist yet. Switching to real maps is one environment variable and one API —
see "Switching engines" in `docs/runbook.md`.

### The idea the whole thing rests on

**The closest captain is not the fastest.** A captain standing at the store may still be twenty
minutes from being able to start, because they have a delivery to finish and a parcel to hand
over. So the geo filter only narrows the field by distance, and the ranking is by **Adjusted
ETA** — the drive, plus finishing what they are carrying, plus the handover. `php artisan
dispatch:demo` prints that comparison against seeded data, and says which captain was nearest and
which one won.

### What was built

- **Foundations.** Redis with a geo index of live captain positions, the dispatch settings table,
  the `dispatch` config with every business value env-backed, and an architecture check that fails
  the build if any of those numbers is hardcoded in the algorithm.
- **Eligibility and location.** Approved, on duty, not on a break, with capacity. A captain's
  *effective* position is their phone when idle and the customer they are driving to when busy —
  measuring a busy captain from their phone makes them look closer than they will be. A GPS fix
  past `GPS_STALE_MIN` takes them off the list entirely, because a stale position is worse than
  none: it is believed.
- **Adaptive GPS.** The captain app is told when to ping next — every few seconds while moving,
  less when parked — and history is buffered in Redis and flushed to SQL in batches so a ping
  never costs an insert.
- **The suggestion list.** Geo filter, then one routing matrix for the shortlist, then Adjusted
  ETA, batch compatibility, at-store stacking and a fairness tie-break. Every list is written to
  the Suggestion Log with its timings, its cost and its reasons, so any decision can be explained
  afterwards.
- **Assignment.** Capacity is reserved in one conditional update, so two dispatchers confirming the
  same captain cannot both succeed — the loser gets a 409 they can act on rather than a surprise.
- **Route plans.** Every captain's whole route, versioned: stops in the order they should be
  driven, a line to draw, an arrival per stop. Rebuilt when an order joins, when a stop is
  completed, when the captain leaves the route, and when a dispatcher reorders it by hand.
- **The repaint.** Each new version is announced once, over Reverb to the captain's private
  channel and the dispatch dashboard, and pushed silently to the phone as an FCM data message
  carrying a pointer rather than the plan.
- **Deviation detection.** Off the drawn route by more than 250 m continuously for 30 s and the
  route is rebuilt around where the captain actually is. Distance alone is not deviation — GPS
  bounces off buildings — so the rule is sustained, and it fires once per plan.
- **Routing outages.** A captain already driving a measured route keeps it; it is marked degraded
  rather than replaced with a straight-line guess, and the job retries at 5 s, 30 s and 120 s. A
  captain with no route yet still gets the rough one, because a captain holding an order and no
  route is worse.
- **Observability.** Eleven counters, a JSON log channel of its own, and a KPI endpoint reporting
  latency percentiles, acceptance and batch rates, fallback rate and the maps cost per delivery.
- **Readiness and demo.** `dispatch:redis-check`, `dispatch:broadcast-check`,
  `dispatch:routing-check`, `dispatch:measure-speed`, `dispatch:reconcile-capacity` and
  `dispatch:demo`.

### Tested

Unit and feature suites throughout, plus an end-to-end suite that drives the real HTTP endpoints:
eligibility, location and GPS freshness, ranking in both directions, at-store stacking, routing
failure modes, the batch policy, capacity, the recompute triggers and the manual reorder, the full
order lifecycle, deviation, maps outage, channel authorisation and the KPI screen — and a
**live-server** suite that talks to a running server over real HTTP, opt-in through
`E2E_BASE_URL`.

The fan-out is asserted at the far end of the broadcast layer and the FCM channel rather than with
`Event::fake()`, which stops an event before `ShouldBroadcast` is consulted and would let a
mistake in the payload pass every test while no phone repaints.

### Known limits, stated rather than buried

- **`DISPATCH_ETA_ESTIMATE_SPEED_KMH` is still a placeholder.** It decides which captain wins an
  order, whether a batch is refused, and what the customer is told. `dispatch:measure-speed` reads
  it from the GPS history, but no captain has driven yet, so the default 30 is a guess.
- **The four parallel concurrency tests cannot run on the development machine.** They are what
  prove two dispatchers cannot overfill one captain. A MariaDB hang, documented and unsolved, stops
  them; CI is where they are confirmed.
- **The algorithm has never met a real map.** Everything above is against the simulation.


## Feature 10 — Price Calculation & Captain Settlement

**Parent task: record every movement of money between captains and the company, let an order change hands mid-route, and give the cash desk an owes/owed view it can clear.**

Balance convention used everywhere: **positive = the company owes the captain.** Every figure is per currency and never summed across currencies.

- **10.01 — The captain ledger.**
  - Sub: `captain_ledger_entries` — append-only (no `updated_at`), one row per movement: `supplier_payment` (+), `cash_collected` (−), `cash_paid_out` (−), `cash_handed_in` (+), `adjustment` (±, reason required). Each row keeps `expected_amount`, `variance`, `balance_after`, and the actor (captain or back office).
  - Sub: The sign lives on `CaptainLedgerEntryType`, not at call sites; amounts arrive unsigned and a negative is refused rather than flipped.
  - Sub: No denormalised balance column — balances are `sum(amount)` grouped by currency. `balance_after` is written under `lockForUpdate()` as the audit trail.
  - Sub: `driver_id` is `restrictOnDelete`: deleting a captain cannot delete what the company owes them.
- **10.02 — Money at pickup and delivery.**
  - Sub: `amount_paid` on `PATCH /api/driver/orders/{uuid}/picked-up`; `amount_collected` on `…/delivered`. Both optional; 0 or omitted writes nothing.
  - Sub: `orders.amount_paid` / `orders.amount_collected` record what happened to the order; `expected_goods_cost` (sum of quantity × unit_price, null when unpriced) is exposed so the app pre-fills it. A difference is a variance, not a refusal.
  - Sub: Ledger writes happen inside the status transaction, not in a listener (`OrderStatusChanged` is after-commit). Fixed on the way: `markPickupsCollected()` used to run outside any transaction.
  - Sub: Wired the unused `OrderPaymentStatus::allowsHandover()` — a refunded order can no longer be delivered or have cash taken for it.
- **10.03 — Handover between captains.**
  - Sub: `POST /api/driver/orders/{uuid}/handover` (offer), `GET /api/driver/handovers` (inbox), `POST …/handover/accept`, `POST …/handover/decline`, `DELETE …/handover` (withdraw).
  - Sub: Only from `picked_up` / `on_the_way`; status never changes. The receiver must accept; capacity is reserved for them under the assignment lock, and released from the giver only afterwards (409 when full). Timeline records who took it; both routes recompute.
  - Sub: **The ledger does not move with the parcel** — the captain who paid stays owed.
- **10.04 — The cash desk.**
  - Sub: Permissions `captain_ledger.view` and `captain_ledger.settle`; new seeded **`cashier`** role (those two + `drivers.view`, `orders.view`).
  - Sub: `GET /api/dashboard/captain-ledger` — captains by largest outstanding amount, filter `owed`/`owes`/`clear`, per-currency `Totals` (each captain netted first).
  - Sub: `GET /api/dashboard/captains/{uuid}/ledger` — one captain's balances and statement.
  - Sub: `POST …/ledger/settle` — cash only moves towards zero and never past it (wrong direction / over balance / nothing outstanding → 422), checked under a lock on the captain. `POST …/ledger/adjust` — signed correction, reason required.
  - Sub: `GET /api/driver/ledger` — the captain's own balance and statement.
  - Sub: The dashboard order detail gains a `settlement` block: every money movement on the order and which captain made it.
- **10.05 — Docs.**
  - Sub: Swagger — "Money: paying, collecting, handing over" section on the captain app page, Handover and Ledger tags, the Cash Desk tag, and the new schemas.
- **10.06 — Tests.**
  - Sub: 63 new tests across `CaptainLedgerTest`, `DeliveryMoneyTest`, `OrderHandoverTest`, `CashDeskTest`, and an end-to-end test driving the whole brief through HTTP. Mutation-checked: the sign convention, the handover capacity release, and the desk's direction guard.
  - Sub: Migrations run and rolled back cleanly on MariaDB (found and fixed a foreign-key/index drop order that SQLite cannot detect); a 27-check live run against a running server on MariaDB.

## Feature 13 — Directed work for employee captains

- **13.01 — `PATCH /api/dashboard/orders/{uuid}/assign-enforced`.**
  - Sub: The same hand-over as `assign`, without the captain's answer: the order is assigned and accepted in one move, no `offer_expires_at` is written, and no expiry job is queued — there is no offer to run out.
  - Sub: **Employee captains only**, refused with 422 for a freelance one, and refused before any capacity is taken. A freelancer's arrangement *is* that they may say no; a company that can force work on them is not using freelancers.
  - Sub: The order still passes through `assigned` internally, because that transition is what recomputes the captain's route and puts the order on their phone — jumping it would hand somebody a delivery their app never heard of. The timeline shows both steps, and **the accepted step carries the manager as its actor**, so the audit trail does not claim the captain answered.
  - Sub: Its own permission, `orders.assign_enforced`, seeded to `operations-manager` only. A route of its own rather than a flag on `assign`, following the codebase's rule that a permission boundary must not be a value in a request body.
  - Sub: Six tests: an employee lands at `accepted` with nothing queued, the timeline names the manager, a freelancer is refused with the order and the captain untouched, the ordinary assignment still offers and waits, `orders.assign` alone is not enough, and the missing-headers 401.
- **13.02 — The suggestion list says which arrangement each captain is on.**
  - Sub: `captain.employment_type` and its label on every candidate, so the dashboard offers "assign without asking" only where it can work. One extra column on a query that already reads the row.
  - Sub: `CandidateData` gained the field as a required argument rather than a defaulted one, and the three test fixtures that build candidates by hand now pass the captain's real type — a default would let the next caller forget it silently.
- **13.03 — The dashboard.**
  - Sub: The confirm step of the assign dialog offers a second button, not a tick box: offering an order and directing it are two decisions, and a checkbox that changes what the confirm button means is read wrong at the end of a shift. It appears only for an employee captain and only for an operator holding the grant.
  - Sub: The candidate row carries the arrangement as a badge, so it is known before the choice rather than after it.

## Feature 12 — The offer window is an operations setting

- **12.01 — `offer_timeout_s` joins the dashboard's editable dispatch settings.**
  - Sub: It was config-only on purpose, and the comment saying so named the two risks: a short window takes orders off captains who were about to accept, a long one leaves customers waiting on somebody who never will. Operations asked for control of it; **bounds are what contain both risks**, so the key is editable within a floor and a ceiling rather than free.
  - Sub: `editable_bounds.offer_timeout_s = 30..900`. Under 30 seconds the window is shorter than getting a phone out of a pocket, and each offer taken back is recorded as a refusal — pushing that captain down the ranking for work they never declined. Over 900 the order should have been offered to somebody else a quarter of an hour ago.
  - Sub: `DispatchSettings::offerTimeoutSeconds()` now reads the override, the value joins `effective()`, and the comment that said the opposite was rewritten rather than left to mislead.
  - Sub: The settings screen needed no change — it renders whatever `editable` carries, so the fourth row with its own bounds appeared on its own.
  - Sub: Found on the way: `DispatchSettingsUpdateData` lists its keys explicitly, so a new key silently never reaches the write. The test that caught it asserts the *assignment* honours the override, not that the value was stored — storing it and using it are different claims.
  - Sub: Four tests: the screen lists it with its bounds, a wider window is accepted and read back, values outside the bounds are refused, and an assignment stamps its deadline from the override (clock frozen, compared at whole seconds — the precision the column keeps).

## Fixes — the captain app could not read its own state

- **F.04 — `GET /api/driver/availability`.**
  - Sub: The app could set availability and never read it back: `POST` was the only door on that prefix, and neither the profile nor any other read carried `is_online` / `on_break`. An app restarting, reinstalled, or opened on a second handset had to guess — or write a value the captain had not asked for, which is how somebody ends up online with their phone in a drawer.
  - Sub: A captain with no row answers `is_online: false`, `on_break: false`, `last_seen_at: null` with a 200, and **the read does not create the row** — answering the question must not be what puts a captain on the dispatch map.
  - Sub: Reading is allowed for a captain still under review, while setting stays approved-only: "you are offline" is the truth, and a 403 there would read as a fault in the app.
  - Sub: Four tests in `DriverPresenceApiTest` — reading back what was set, the never-online default with no row written, the captain under review, and the missing-headers 401. Swagger operation added.

## Feature 11 — Every movement of money, in one place

**Parent task: make the financial record readable, per order and across the fleet.**

- **11.01 — The money feed (`GET /api/dashboard/captain-ledger/entries`).**
  - Sub: Every ledger entry, newest first, across captains — the audit trail behind the balances. The desk's existing list answers "where is the money sitting"; it cannot answer "what moved", and a captain who took 5,000 and handed 5,000 back before closing has a balance of zero.
  - Sub: Filters `type`, `currency`, `captain_uuid` (ULID), `order_uuid`, and a `from`/`to` window that covers the whole of both days it names. A filter naming a captain or an order that no longer exists answers with an empty page, never with every row — being shown everything when you asked for one captain is worse than nothing, because nothing tells you the filter was dropped.
  - Sub: `Totals` per currency reports money **in and out separately** rather than netted, computed from the same filters as the page so the headline can never describe a different set of rows than the one on screen.
  - Sub: `CaptainLedgerEntryResource` gained `captain`, loaded only where rows belong to different people — a statement still names its captain once, beside the page.
  - Sub: Reads carry `captain_ledger.view`. Seeing what moved is not the authority to move it.
  - Sub: `LedgerFeedTest` — 10 tests: ordering, each filter, the whole-day window, the empty answer for a captain that does not exist, in/out totals on a day that nets to zero, the permission boundary, and the missing-headers 401.
- **11.02 — The dashboard reads what the API already answered.**
  - Sub: The order detail page shows the money on the order: expected goods cost against what was paid, cash to collect against what was collected, the difference where there is one, and every movement with the captain behind it. The API had returned all of it since Feature 10 and no screen read it — a field-level gap that a route-level audit does not catch.
  - Sub: The cash desk gained a **Movements** tab over the new feed: the in/out cards per currency, filters for kind and window, and rows linking to the captain and the order. The movement badge, the "recorded by" wording and the variance line are the statement's own, so one vocabulary covers both screens.

## Fixes — the integration log

**Parent task: two defects the back office integration screen made visible.**

- **F.01 — Every webhook event read as its own translation key.**
  - Sub: `resources/lang/{en,ar}/integration.php` wrote the labels as `'order.received' => …`. `__()` reads a dot as a path into the file's array, so the lookup walked `webhook_event` → `order` → `received`, found nothing, and returned the key — `integration.webhook_event.order.received` on screen, in both locales, for all ten events. The keys are nested now; `StoreWebhookEvent::label()` is unchanged.
  - Sub: `scripts/check-architecture.php` gained a rule for the whole class: a translation key containing a dot is an error, reported at its line. The existing rule could not see this, because the call that exposed it builds its key from an enum value and the rule only resolves literals.
  - Sub: `StoreWebhookEventTest` asserts every event's label is not its own key and that the two locales differ. Note that asserting a label is merely *not empty* passes against the bug — a missing key is returned verbatim, and a key is not empty.
- **F.02 — The delivery list named neither the store nor the order.**
  - Sub: `WebhookDeliveryRepository::paginateWithFilters()` ran `$this->query()` with no relations while the resource exposes both as `whenLoaded`, so every row answered without them and the two columns that say which shop is failing and which order it was about showed a dash. It eager-loads `storeClient` and `order` now.
  - Sub: The store portal's own list eager-loads `order` for the same reason. Not the store: every row belongs to the caller, so repeating their own name per row is payload that tells them nothing.
  - Sub: Guarded by two assertions on the back office list and one on the store portal's end-to-end run.
- **F.03 — `ADMIN_ALLOWED_ORIGINS` replaces the allowed origins rather than adding to them.**
  - Sub: A developer adding a local dev origin to `.env` silently dropped the production dashboard, and `CorsTest` went red. The variable is now pinned in `phpunit.xml`, so the suite tests the configured contract instead of whatever is in the developer's `.env`.

## Notes

- Webserver artifacts were intentionally left out of the commit: `.htaccess`, `run_composer_install.cmd`, and the local `public/index.php` subfolder patch (XAMPP workaround). They remain on disk.