# kapitano_web → Kapitano Logistic: event-listener integration plan

**Status:** plan only, nothing implemented. **Date:** 2026-10-05.
**Approach chosen by you:** event listeners inside kapitano_web (not a separate poller).
**Supersedes** the "read-only store-adapter" approach for this phase. It keeps that plan's client
design, safe mode and one-API-at-a-time gates, but moves the trigger into the web app. That means
**small, additive, flag-guarded changes to kapitano_web** (listed exactly in §5) — the earlier "no web
code changes" rule no longer holds, by your choice.

Evidence labels: **[V]** verified here (code or the local DB copy), **[R]** read but not exercised,
**[D]** a decision for you.

---

## 1. Summary

1. **The event-listener approach fits this codebase well.** `Order::boot()` already hooks
   `static::updated(...)` and tests `wasChanged('delivery_status')` to drive the follow-up screens and
   the rating prompt **[V]**. We add one more reaction in the same place.
2. **Every real status change goes through Eloquent, so events fire.** The audit log shows ~1,500
   status changes since 1 Sep, all from `admin/orders/update_delivery_status` (≈1,412),
   `seller/orders/confirm` (68), `seller/orders/update_delivery_status` (10), `admin/order-follow-up/confirm`
   (2) and the cancel routes **[V]**. The code has **no mass updates** on order status **[V]**. (279
   older orders from 4–18 Sep have no audit row; that is before auditing was reliable, not a bypass —
   every day from 20 Sep is covered.)
3. **Production has no queue worker** (stated in `EventServiceProvider`) **[V]**, but the scheduler runs
   every minute (`seller-whatsapp:process`, `notifications:send-scheduled`, …) **[V]**. So listeners must
   not rely on `ShouldQueue`. The plan uses a **database outbox**: the listener writes a row (cheap,
   in-request), a **scheduled command sends it** every minute, and an **after-response attempt** gives
   near-instant delivery.
4. **Four data gaps block the first real order** (§7) and need your input; none is a code problem.
5. **One logistic-side defect must be fixed first:** store tokens expire after 30 days **[V]**
   (`integration-production-readiness.md`, V1).

## 2. What the analysis found

### kapitano_web (the source)

| Fact | Evidence | Consequence |
|---|---|---|
| `Order` already reacts to `delivery_status` changes inside `boot()` (follow-up sync, rating) and wraps them in `try/catch` so bookkeeping never breaks a status change | `app/Models/Order.php` lines ~23–50 | Same place, same pattern, same safety rule for us |
| Custom events/listeners exist and are deliberately **not queued**; slow work is deferred with `AfterResponse::run()` (`app()->terminating`) | `EventServiceProvider`, `Services/OrderRating/AfterResponse.php` | Reuse `AfterResponse` for the instant attempt |
| `QUEUE_CONNECTION=sync`, no worker, no `failed_jobs` table | `.env`, DB | No queued jobs; outbox + scheduler instead |
| Scheduler is live every minute | `app/Console/Kernel.php` | A `logistic:send` command every minute is safe |
| "Confirm Orders → Deliver Orders" follow-up workflow: any non-pending open status makes the follow-up `ready` (`syncWithDeliveryStatus`) | `Models/OrderFollowUp.php:243` | `confirmed` is the natural hand-over point to delivery |
| Status change has heavy side effects: stock movement (`OrderStockUtility::moveLines`, can **refuse** a transition), details sync, wallet refund, seller earnings, delivered date, affiliate, email/SMS/push, delivery history | `OrderController::update_delivery_status` (~1085–1190) | **Writing status back into web must reuse that logic**; raw updates are out (Phase 2, §8) |
| One checkout → several orders (234 multi-seller checkouts since Sep); shipping lives on `combined_orders` | DB **[V]** | One logistic order per web order; shipping allocated once |
| Almost all COD (only 8 `paymera` orders since Sep); 408 cancelled vs 507 delivered | DB **[V]** | Cancel sync is core; payment sync is low priority |

### Kapitano Logistic (the target)

| Fact | Consequence |
|---|---|
| Store Integration API `/api/integration/v1`: bearer token + `X-Kapitano-Signature` HMAC + `Idempotency-Key`; create returns **201 with a `uuid`** synchronously; `external_order_id` is unique per store | Deterministic keys make retries safe |
| Required: pickup `lat/lng`, dropoff `lat/lng`, customer phone (`+digits`, no country code added), `payment.method/status`, `amount_to_collect` for COD, ≥ 1 item | Mapping rules in §6 |
| Redis is required for the replay check and fails closed (503) | Redis must run locally and in production |
| Webhooks go back to the store (https:443) and need a queue worker **on the logistic side** | Phase 2 prerequisite |
| Known defects: **tokens expire after 30 days**, same-second signed reads rejected, first webhook retry ≈ 60 s | Fix the first before go-live; client spaces reads |

## 3. Architecture

```
 Admin/Seller screen or app ── $order->save() ──► Order::updated (existing hook)
                                                      │ wasChanged('delivery_status')
                                                      ▼
                                        dispatch domain event (try/catch, flag-guarded)
                                                      ▼
                          Listener ── writes ──►  logistic_outbox  (same request, 1 INSERT)
                                                      │
              ┌───────────────────────────────────────┴────────────────────────────┐
              ▼                                                                    ▼
   AfterResponse::run(sendNow)                                    schedule: logistic:send (every minute)
   best-effort, right after the page is served                    claims due rows, retries with backoff
              └───────────────► LogisticClient (sign, Idempotency-Key, timeouts) ◄───┘
                                                      │ HTTPS
                                                      ▼
                                       Kapitano Logistic /api/integration/v1
                                                      │ 201 {uuid}
                                                      ▼
                                   logistic_order_links  (order_id ↔ uuid, state, last error)

 Safety net (still event-driven): `logistic:reconcile` re-dispatches the same event for orders that
 are `confirmed`+ but have no link/outbox row (covers an outage, a deploy gap, or a rare missed save).
```

Why this shape:
- **Never call logistic inside the admin's request.** A slow or down logistic must not slow or break
  "update delivery status" (the same reason the existing hook swallows its own exceptions).
- **Outbox, not queued listeners:** no worker in production; the scheduler already runs.
- **Domain events, not logic in the model:** `Order::boot` only raises `OrderReadyForDelivery` /
  `OrderCancelledAfterHandoff`; listeners hold the logic, so the model hook stays two lines.
- **A feature flag and an allow-list** wrap everything (§9).

## 4. Events and what each one does

| Web event (raised from `Order::updated`) | Condition | Listener action | Logistic API |
|---|---|---|---|
| `OrderReadyForDelivery` | `delivery_status` changes **to `confirmed` for the first time**, order has no link, passes the allow-list **[D1]** | Enqueue `create` | `POST /orders` |
| `OrderCancelledAfterHandoff` | status → `cancelled` and the order has a link in a live state | Enqueue `cancel` (reason from `cancel_reason`) | `POST /orders/{uuid}/cancel` |
| `OrderPaid` (later) | `payment_status` → `paid` on a linked prepaid order | Enqueue `payment` | `POST /orders/{uuid}/payment` |
| `OrderEditedAfterHandoff` (later) | address / items / total changed on a linked, not-yet-picked-up order | Enqueue `update` | `PATCH /orders/{uuid}` |
| `returned` after delivery | — | Log only; logistic cannot reverse `delivered` | none |

Echo-loop guard (needed from Phase 2): any status written by the logistic webhook sets a request-scoped
flag so the same `updated` hook does **not** try to cancel or re-push it.

## 5. What is added to kapitano_web (and exactly where it touches existing code)

**New files only (no behaviour change until the flag is on):**

```
config/logistic.php                          # flag, allow-list, base URL, rate, safe mode, timeouts
database/migrations/…logistic_order_links    # order_id, logistic_uuid, state, last_error, timestamps
database/migrations/…logistic_outbox         # operation, order_id, idempotency_key, payload, status, attempts, next_attempt_at
database/migrations/…logistic_branches       # shop_id → branch_ref, name, address, lat, lng
database/migrations/…logistic_area_centroids# state/city name → lat, lng (drop-off fallback)
app/Events/Logistic/{OrderReadyForDelivery,OrderCancelledAfterHandoff,…}.php
app/Listeners/Logistic/{QueueCreate,QueueCancel,…}.php
app/Services/Logistic/{LogisticClient,Signer,OrderPayloadMapper,MoneyConverter,
                       PhoneNormalizer,ShopBranchResolver,DropoffLocator,OutboxSender,Reconciler}.php
app/Console/Commands/Logistic/{Send,Reconcile,Replay,Status}.php
tests/Feature/Logistic/*, tests/Unit/Logistic/*
```

**Touch points in existing files (the only edits):**

1. `app/Models/Order.php` — inside the existing `static::updated` hook, one guarded `try/catch` line
   that raises the domain event. No change to the rest of the hook.
2. `app/Providers/EventServiceProvider.php` — add the event → listener map.
3. `app/Console/Kernel.php` — schedule `logistic:send` (every minute, `withoutOverlapping`) and
   `logistic:reconcile` (every 5 minutes).
4. `.env` — `LOGISTIC_*` settings (token and keys live here only, never in git).

Blast radius is therefore two small edits in two existing PHP files, plus config. Everything is
disabled while `LOGISTIC_ENABLED=false`.

## 6. Mapping a web order to the Store API payload

| Logistic field | Source | Rule |
|---|---|---|
| `external_order_id` | `orders.id` | `orders.code` is **not unique** — use it only as `external_order_number` |
| `customer.external_id` / `name` | `orders.user_id`, `shipping_address.name` | A guest checkout still creates a real user |
| `customer.phone` | `shipping_address.phone` | Strip non-digits, `00` → `+`, `09…` → `+9639…`, `963…` → `+963…`; reject anything else into "needs attention" (logistic adds no country code) |
| `pickup.*` | the order's seller shop → `logistic_branches` | `branch_ref = shops.id`; **0 of 108 shops have coordinates today [V]** so this table must be filled **[D2]** |
| `dropoff.address` | `shipping_address.address` + city + state | text only in web |
| `dropoff.lat/lng` | 1) `shipping_address.lat_lang` → 2) customer's saved address coordinates → 3) `logistic_area_centroids` by state/city → 4) park for review | only ~8 % of orders have 1) or 2) **[V]**; centroid results are flagged `approximate` in the logistic order note **[D3]** |
| `payment.method` | `payment_type` | `cash_on_delivery` → `cash_on_delivery`; `paymera` → `prepaid` |
| `payment.status` | `payment_status` | `paid` / `unpaid` |
| `payment.amount_to_collect` | `orders.grand_total` (already net of coupon **[V]**) | `round(grand_total × SYP rate)` + the combined shipping (first order of a checkout only) **[D4]**; dropped for prepaid |
| `currency` | — | `SYP` (new pound) |
| `items[]` | `order_details` + `products` | `quantity`, `name`; `unit_price = price ÷ quantity` (`price` is the **line total**); `sku` from `product_stocks` or `product_id` |
| `note` | — | order code, seller name, "approximate location" marker |

Idempotency key = `create-{orders.id}` (and `cancel-{id}`, `payment-{id}`, `update-{id}-{rev}`).

## 7. Open data gaps (need you, not code)

| # | Gap | Evidence | What I need |
|---|---|---|---|
| G1 | **Shop pickup coordinates** | `shops.delivery_pickup_latitude/longitude` empty for 108/108 (67 active) | A list: shop id → branch name, address, lat, lng (a handful is enough to start; the 67 active before go-live) |
| G2 | **Drop-off coordinates** | 104 of 1,298 orders (~8 %) have them; saved addresses are the same 8 % | Approve the centroid fallback (and give me the governorate/city centre list or let me derive it from the 14 governorates + logistic's `cities`) |
| G3 | **COD amount rule** | Totals are USD base units; SYP rate is a static `150`; `no_of_decimals = 0`; combined shipping is 0.00 on 1,050 checkouts | Confirm the 150 rate and that shipping is added once per checkout |
| G4 | **Multi-seller checkouts** | 234 since Sep | Confirm "one logistic order per web order" |

## 8. Delivery order — one API at a time, each with a gate

| Step | Scope | Gate (evidence recorded) |
|---|---|---|
| **0** | Logistic prerequisites: fix the 30-day token expiry, run Redis, register + approve a store, generate token/keys | Token aged 200 days still authenticates; signed `GET /health` returns 200 |
| **1** | Web foundation: config, flag, signer, `LogisticClient`, outbox + link tables, safe mode, `logistic:status` | Signature unit test matches the published algorithm; signed `health` works from the web app |
| **2** | **Create** (`OrderReadyForDelivery` → outbox → send) | Confirm a test order in the admin screen → outbox row in the same request → logistic order appears with correct items/address/amount; confirm twice → one order; logistic stopped → the **confirmation still succeeds** and the row is retried later; added latency to the admin action < 20 ms |
| **3** | **Lookup / reconcile** (`logistic:reconcile`, 409 → adopt existing uuid) | A deliberately lost response is recovered without a duplicate |
| **4** | **Cancel** (`OrderCancelledAfterHandoff`) | Cancel in web → cancelled in logistic; cancel twice harmless; delivered order untouched |
| **5** | **Webhook receiver, display only** (verify signature, dedupe `event_id`, order by `sequence`, store status in `logistic_order_links`; show it on the admin order page) | Bad signature rejected; duplicate/out-of-order ignored; full path assigned → delivered visible |
| **6** | **Status write-back into web** *(needs a small refactor: extract the controller's status logic into a service so webhooks reuse stock movement, notifications and history)* | Delivered in logistic → web order delivered with stock/notifications/delivery history identical to a manual change; no echo loop |
| **7** | Payment (`OrderPaid`) | Online-paid order shows paid in logistic |
| **8** | Edit (`OrderEditedAfterHandoff`) | Each lock returns its documented 409 and is recorded, not retried |

Each step ends with a short evidence note and **waits for your acceptance** before the next starts.

## 9. Safety, rollout and kill switches

- **`LOGISTIC_ENABLED`** (global) and **`LOGISTIC_ALLOW_SELLERS` / `LOGISTIC_ALLOW_ORDER_IDS`** (allow-list). Start with one test seller; widen deliberately.
- **Safe mode** (default on locally): customer name/phone replaced by test values so no real customer can ever be contacted from the local copy of production data.
- **Failure isolation:** every listener is wrapped in `try/catch` + `report()`; a logistic outage can never fail an admin action. A hard rule: **no HTTP call inside the request** except the after-response attempt.
- **Instant stop:** flip the flag; pending outbox rows stay and resume when it is turned back on.
- **Observability:** `logistic:status` (counts by state, oldest pending, last error), a dashboard widget later, and an alert when an outbox row is older than 10 minutes or any row is `needs_attention`.

## 10. Reliability rules for the sender

| Response | Action |
|---|---|
| network / 429 / 5xx / 503 | retry with exponential backoff + jitter (30 s, 2 m, 10 m, 30 m, 1 h, 3 h), then `needs_attention` |
| 201 / 200 (`Idempotency-Replayed`) | link `uuid`, mark done |
| 409 `duplicate_external_order` | look up by `external_order_id`, adopt the `uuid`, mark done — never loop |
| 422 (bad phone, missing coordinates…) | park as `needs_attention` with the field errors; visible, never silent |
| 401 (`token_expired` / bad token) | stop sending, raise an alert; do not burn retries |
| 403 `client_inactive` / 503 `integration_paused` | pause the sender, alert |

Sender claims rows atomically (`UPDATE … WHERE status='pending' AND next_attempt_at <= now()` with a
claim token) so the after-response attempt and the scheduler never send the same row twice.
Signed reads are spaced > 1 s apart (same-second signature collision).

## 11. Testing plan

- **Unit:** phone normalisation (all web formats), money conversion, mapper (single, multi-line,
  multi-seller), drop-off fallback chain, backoff schedule, signer vs the published algorithm.
- **Feature (web):** `$order->save()` with `delivery_status` → `confirmed` raises the event exactly
  once; saving other fields does not; flag off = nothing written; listener failure does not fail the save.
- **Contract:** payload validated against logistic's `integration-docs.json`.
- **Integration (local logistic + Redis):** create, retry same key, duplicate id, cancel, 422 and
  401 paths, logistic down/up.
- **E2E via the real admin screen:** confirm → logistic order → cancel → cancelled.
- **Performance:** the admin "update delivery status" action gains < 20 ms (one INSERT).
- **Safety:** with safe mode on, no payload contains a real name or phone.

## 12. Risks

| Risk | Mitigation |
|---|---|
| Editing `Order` (a core model) | Two-line guarded edit inside an existing hook; flag off by default; covered by a feature test; easy to revert |
| An event is missed (deploy gap, exception) | `logistic:reconcile` replays the same event for unlinked confirmed orders |
| No queue worker | Outbox + scheduler (already running every minute) |
| Wrong amount or location | Safe mode first; allow-list; hand-check the first real orders; "approximate" marker |
| Status write-back bypasses stock/notification logic | Deferred to step 6 with a proper service extraction; never raw updates |
| Token expiry / Redis down at logistic | Step 0 fix; sender pauses and alerts instead of failing orders |
| Duplicate orders | Deterministic idempotency keys + unique `(order_id, operation)` in the outbox + 409 adoption |

## 13. Decisions for you

| # | Decision | Default |
|---|---|---|
| D1 | Which web orders are pushed, and when | **First time `delivery_status` becomes `confirmed`**, only for an allow-listed seller at first |
| D2 | Pickup coordinates source (G1) | You provide the shop → location list |
| D3 | Drop-off fallback (G2) | **City/governorate centroid, flagged "approximate"**; later geocoding |
| D4 | COD amount rule (G3) | **`round(grand_total × 150)` + shipping once per checkout** |
| D5 | Multi-seller (G4) | **One logistic order per web order** |
| D6 | Status back into web | **Display-only first (step 5)**, real write-back after a service extraction (step 6) |
| D7 | Local webhook receiver | Tunnel (cloudflared/ngrok) to the web app's receiver route |

## 14. What happens when you say "start"

I begin at **Step 0** (the logistic token-expiry fix, Redis, register/approve a test store) and then
**Step 1** (web foundation + signed health), stop at its gate with evidence, and wait for your
acceptance. Nothing in kapitano_web changes until Step 1, and even then only behind
`LOGISTIC_ENABLED=false`.
