# Integrating kapitano_web with Kapitano Logistic — recommended approach

**Status:** proposal, nothing implemented. **Written:** 2026-10-03, from the local environment
(`kapitano_web` imported dump, `kapitano_logistic` local DB).
**Decision needed from you:** approve the approach in §2, answer the open decisions in §8, then say
"implement" — and the work proceeds **one API at a time** (§5, §6).

---

## 1. Goal and ground rules

- Orders placed in kapitano_web reach Kapitano Logistic through the **published Store Integration
  API** (`/api/integration/v1`), the same way any other store will.
- **One API at a time.** An API is finished only when its gate (§6) has passed with evidence. No
  work on the next API starts before that.
- **No changes to kapitano_web's existing code or behaviour.** It has no events, no observers and no
  outbound hooks, and about eight controllers change `delivery_status` inline; touching them risks
  every flow that writes those tables.
- Logistic stays **store-agnostic**: no kapitano_web column names or rules inside it. It is built to
  serve "any store".

## 2. Recommendation: a separate store-adapter, talking to the real API over HTTP

```
kapitano_web DB ──read-only──►  kapitano-store-adapter  ──signed HTTP──►  Kapitano Logistic
 (orders, shops, users)         (own small DB + queue)   (token + HMAC     /api/integration/v1
                                         ▲                + Idempotency)          │
                                         └───── signed webhooks (order.*) ◄───────┘
```

A small standalone Laravel app (new folder, e.g. `C:\xampp\htdocs\kapitano_store_adapter`) that:

1. **Reads** kapitano_web's database with a read-only MySQL user. It never writes to it in phase 1.
2. **Maps** a web order to the Store API payload (§4).
3. **Sends** it through an outbox with retries, using the real token, signature and
   `Idempotency-Key`.
4. **Receives** logistic's webhooks on its own endpoint, verifies them, and stores the status in its
   own link table.

### Why this, and not the alternatives

| Option | Verdict |
|---|---|
| **A. Module inside logistic** reading the web DB | ❌ Puts a store's internals (shop table, `shipping_address` JSON, USD rate) inside a multi-tenant system, and bypasses the very API we want to prove. |
| **B. Standalone adapter over HTTP** *(recommended)* | ✅ Zero web changes. Exercises the real contract exactly as a production store will, so it proves the docs. Failure of either side is isolated. It can later move into the web codebase unchanged if you choose. |
| **C. New files inside kapitano_web** (outbox, command, webhook route) | ⚠️ The "textbook merchant" shape, but needs edits to `Kernel.php` / route registration and deployment of web code. Hold this as the eventual home, not the first step. |
| **D. Hook model events in web** | ❌ Affects every code path, including Paymera callbacks and the sibling tools that write the same tables. |

Trade-off accepted with B: it needs Redis, a queue worker and a reachable webhook endpoint locally
(§4.2), and a second small codebase to maintain.

## 3. What the local data says (facts, not assumptions)

| # | Finding | Evidence | Consequence |
|---|---|---|---|
| F1 | **No shop has pickup coordinates.** | `shops.delivery_pickup_latitude/longitude`: 0 of 108 shops, 0 of the 67 that had orders since Sep. | Logistic **requires** `pickup.lat/lng`. A shop→branch mapping must be supplied before any order can be created. **Blocker.** |
| F2 | **Drop-off coordinates are rare.** | `lat_lang` present on 104 of 1,298 orders since 1 Sep (~8%). Web `cities` has no coordinates; logistic has 4 areas locally. | `dropoff.lat/lng` is also required and drives area pricing and ETA. A fallback is needed (§8, D2). |
| F3 | **One checkout often means several orders.** | 234 combined orders since Sep have more than one seller order. | One logistic order per web order for now; shipping belongs to the combined order, not each order (§8, D4). |
| F4 | **Cancellation is huge.** | Since Sep: 408 cancelled vs 507 delivered (≈ 31% of all orders). | Cancel sync is not an edge case; it ships right after create/read/webhooks. |
| F5 | **Totals are in USD base units, net of coupon.** | e.g. line sum 8.50 − coupon 4.00 = `grand_total` 4.50; SYP rate is a static `150`; `no_of_decimals = 0`. Combined shipping is 0.00 on 1,050 combined orders since Sep (the next most common value, 1.00, appears on only 14). | COD amount = `round(grand_total × 150)` SYP (+ shipping share when non-zero) — **to be confirmed** (§8, D1). |
| F6 | **Status changes do bump `updated_at`** in practice. | 224/224 recent delivered and 120/120 cancelled rows have `updated_at ≥` their event time, though the column has no `ON UPDATE`. | Change detection by `updated_at` cursor works. No index, but the table is small (≈3.5k rows). Add a periodic full reconcile anyway. |
| F7 | **Almost all orders are COD.** | Only 8 `paymera` orders since Sep. | The payment API is low-volume; sequence it late. |
| F8 | **Redis is not running here.** | Nothing on :6379. The signed API stores a replay nonce in Redis and **fails closed with 503** `replay_check_unavailable`. | Redis (or Memurai/WSL/Docker) must be up before API 1. (The 21 Sep harness run had one.) |
| F9 | **Webhooks need https on port 443.** | The portal rule checks the port *before* the "allow private URL" dev flag. | Local receiver needs a tunnel or a local TLS vhost on 443 (§4.2). Validate at gate 4. |
| F10 | **Local data is a copy of real customers.** | `users` 6,471 with real phones. | Run in **safe mode** (§4.3) so no real customer is ever contacted. |

## 4. Prerequisites

### 4.1 Setup (once)

1. Register + approve a store in logistic (e.g. "Kapitano Shop (local)"); generate token, signing
   key and webhook key from the new portal **Integration** page. They live in the adapter's `.env`.
2. Create the adapter app and its own small database; create the read-only MySQL user for
   `kapitano_web`.
3. Start Redis; run `php artisan serve` and `php artisan queue:work` for logistic.

### 4.2 Receiving webhooks locally

| Option | Use for |
|---|---|
| `integration:webhook-sink <slug>` (port 8129) | Contract checks (signature, sequence) — but it may not satisfy the portal's port-443 rule; confirm at gate 4. |
| **Tunnel** (cloudflared / ngrok) → public `https://…` on 443 → adapter | End-to-end with no logistic code change. **Recommended** for the real run. |
| Local Apache SSL vhost on 443 + `INTEGRATION_ALLOW_PRIVATE_WEBHOOK_URLS=true` | Offline, but logistic's HTTP client must trust the self-signed cert. |

### 4.3 Safe mode (default while developing)

- The adapter only pushes web orders on an **allow-list of order ids** you give it.
- A flag swaps the customer phone/name for test values, so nothing downstream can reach a real person.
- No write to the web database, ever, in phase 1.

## 5. The APIs, in order

Each row is one "one-at-a-time" unit. Detail per API follows.

| # | API | Why in this position |
|---|---|---|
| 0 | *Foundation* (not an API) | Config, signing client, link/outbox tables, safe mode — nothing works without it. |
| 1 | `GET /health` | Proves token + HMAC + headers + Redis + clock before any real data moves. |
| 2 | `POST /orders` | The core. Everything else depends on a created order and its `uuid`. |
| 3 | `GET /orders/{uuid}` and `GET /orders?external_order_id=` | Reconciliation and the safe way out of a 409. |
| 4 | **Webhooks** (logistic → adapter) | Status back from logistic. |
| 5 | `POST /orders/{uuid}/cancel` | 31% of orders cancel; must not leave a live logistic order for a dead web order. |
| 6 | `POST /orders/{uuid}/payment` | Only for online-paid orders; low volume. |
| 7 | `PATCH /orders/{uuid}` | Edits after dispatch are rare and locked by state; last. |

### API 0 — Foundation
Signing client (raw-bytes body, `t=…,v1=…`, `Accept` + `Accept-Language`), config, `integration_outbox`
and `order_links` tables, the safe-mode switch, structured logging that **never** prints tokens or
secrets. **Done when:** unit tests for the signature match the published algorithm byte-for-byte.

### API 1 — `GET /health`
**Scope:** signed read, parse the envelope. **Gate:** 200 with a valid signature; deliberately
wrong signature → 401 `signature_mismatch`; stale timestamp → 401 `signature_expired`; same
signature twice → 401 `signature_replayed`; Redis stopped → 503. **Note:** two signed GETs in the
same second collide (known issue) — the client spaces reads > 1 s.

### API 2 — `POST /orders` (create)
**Trigger:** web `delivery_status = confirmed` (seller accepted), only allow-listed ids in safe mode.
**Mapping:** `external_order_id = orders.id`, `external_order_number = orders.code`; customer from the
shipping-address snapshot, phone normalised to `+9639…`; pickup from the shop→branch map (F1);
dropoff coordinates per D2; `payment.method` COD → `cash_on_delivery`, Paymera → `prepaid`;
`amount_to_collect` per D1; items from `order_details` (`price ÷ quantity` for unit price).
**Idempotency:** key `create-{orders.id}`; on 409 `duplicate_external_order` adopt the existing
`uuid` via lookup, never loop. **Retries:** only network / 429 / 5xx, with a fresh signature each
time. **Unmappable orders** (no coordinates, bad phone) go to a visible "needs attention" list with
the reason — never silently dropped. **Gate:** a real confirmed order appears in logistic with
correct items, address, amount; re-running creates nothing new (`Idempotency-Replayed`); a payload
with a bad phone yields a clean 422 recorded against the order; the logistic order carries
`source=store_api` and our `external_order_id`.

### API 3 — read / lookup
**Scope:** `GET /orders/{uuid}` and `?external_order_id=`; used by reconciliation (non-terminal
orders older than N minutes) and by the 409 path. **Gate:** reconcile corrects a deliberately
stale link record; adopting an existing order after a simulated lost response works.

### API 4 — webhooks (status back)
**Scope:** a receiver that verifies the signature over the **raw** body (iterating every `v1`),
dedupes on `event_id`, orders by per-order `sequence`, treats each event as "replace my copy",
returns 2xx fast and processes on a queue. **Phase 1 stores status in the adapter's link table only**
(no web write-back). **Gate:** bad signature rejected; duplicate `event_id` ignored; out-of-order
`sequence` ignored; secret-rotation header (two `v1`) accepted; receiver down → retries arrive →
nothing lost; and the full path works with a real order moving assigned → delivered.

### API 5 — cancel
**Trigger:** a web order that was already pushed becomes `cancelled`. **Rules:** `reason` required;
409 `invalid_transition` on a terminal order is **success-equivalent** (log it, don't retry).
Key `cancel-{orders.id}`. **Gate:** cancelling in web cancels in logistic; cancelling twice is
harmless; a delivered order cannot be "un-delivered".

### API 6 — payment
**Trigger:** an online (Paymera) order becomes paid after creation. **Gate:** logistic shows
`paid`; COD is unaffected.

### API 7 — edit (PATCH)
**Scope:** only changes the lock table allows (address/items/amount before pickup, etc.); respect the
409 keys; never retry a lock. **Gate:** each lock state returns the documented 409 and the adapter
reports it instead of looping.

## 6. The "done" protocol (how we avoid half-integrations)

For every API, in order:

1. Implement **only** that API's slice (plus tests).
2. Run its **gate** (the list above) against the local logistic.
3. Record evidence in a short `evidence/<api>.md` (requests, responses, what was observed).
4. **Stop and report.** The next API starts only after you say it is accepted.

A failure found at a later API that traces back to an earlier one reopens the earlier gate.

## 7. Phase 2 (deferred, needs your decision): writing status back into kapitano_web

Showing "on the way / delivered" inside the web store means changing `orders.delivery_status`, and
the web controllers do much more than set a column: restock on cancel, wallet refund, commission and
club points, customer notifications, delivery history (and COD → paid on delivery). Options when we
get there: (a) a new additive route in the web app that calls the existing logic, (b) replicate that
logic in the adapter, (c) keep logistic as the source of truth and only display it elsewhere.
**Raw SQL updates of `delivery_status` are ruled out.**

## 8. Open decisions (my default in bold)

| # | Decision | Options |
|---|---|---|
| D1 | **COD amount** | **`round(grand_total × 150)` new-pound SYP, plus the combined shipping for the first order of a checkout** / other rate / different shipping rule. Confirm the 150 rate is the real collection rate. |
| D2 | **Missing drop-off coordinates (~92%)** | (a) **city/governorate centroid table + mark "approximate location" in the note** — free, coarse; (b) geocode the address (needs a key, costs money); (c) hold the order until staff pin the location — most accurate, slowest. |
| D3 | **Shop pickup coordinates** | **A mapping file (shop id → branch name, address, lat, lng) you or operations provide** / geocode shop addresses. Needed for the 67 active shops before go-live; for dev, a handful. |
| D4 | **Multi-seller checkouts** | **One logistic order per web order** / merge into one delivery with `additional_pickups`. |
| D5 | **Push trigger** | **`confirmed`** / earlier (`pending`) / after payment. |
| D6 | **Where the adapter lives** | **New folder `C:\xampp\htdocs\kapitano_store_adapter`** / inside logistic's repo / later in the web repo. |
| D7 | **Webhook receiver locally** | **Tunnel** / local TLS vhost / sink only. |
| D8 | **Redis locally** | Install Memurai / WSL / Docker — whichever you already use. |

## 9. Risks

- **Wrong amounts** (D1) are the costliest error; mitigated by safe mode and by checking the first
  real orders by hand against the web admin.
- **Wrong drop-off location** misprices and misroutes (F2); mitigated by marking approximate
  locations and by the review list.
- **Silent loss**: an order that cannot be mapped must be visible, never skipped.
- **Dev data is real customers** (F10): safe mode is on until you turn it off deliberately.
- **Known contract issues in logistic** (same-second signed reads, ~60 s first webhook retry, the
  stale Arabic doc) are accommodated in the adapter, not fixed here.

## 10. What happens when you say "implement"

I start at **API 0 (foundation)** then **API 1 (health)**, stop at its gate with evidence, and wait.
Nothing touches kapitano_web's code or database writes.
