# Feature 08 — External store order integration
# استقبال الطلبات من متجر خارجي

**Status:** **built and green** — T8.1–T8.6, T8.8, T8.9 and most of T8.10 are implemented and
tested end to end. Still open: `PATCH /orders/{uuid}` (gated on ask **S12**), the rating endpoint
(T8.11), cash reconciliation (T8.12), geofence arrival (T8.13) and the reconciliation pull
(blocked on **S18**). **Not yet connected to a real store**: asks **S1**, **S2**
and **S5** in §3.2 are unanswered, and S1 in particular has to land before the callback half can
be exercised against anything but our own sink.
**Audience:** this project's engineers (Part II), and the external store's integration team
(§3.1–3.2, which is written to be sent to them as-is).
**Sources:** `Captain App.pdf` (Work Plan v2.0, FR-01…FR-23 + the NFR *Integration* row and the
risk row on the main store system), the existing `orders` / `order_pickups` / dispatch code, and
`docs/{analysis,task-list,order-notifications-analysis,feature-04-manual-order-assignment}.md`.

---

# القسم الأول — الشرح بالعربية

## ١. لماذا هذا العمل، وأين نقف اليوم

اليوم لا يدخل الطلب إلى النظام إلا من باب واحد: موظّف في لوحة التحكم يكتبه يدويًا في
`POST /api/dashboard/orders`. والتعليق المكتوب داخل `StoreOrderRequest` نفسه يصف هذه الطلبات
بأنها *"طلبات وهمية إلى أن يبدأ تكامل الطرف الثالث في تغذية النظام"*، ووثيقة
`docs/feature-04-manual-order-assignment.md` تَعِد بأن التحويل *"مجرّد توجيه التغذية إلى نفس
الكاتب"*. هذه الخطة هي تلك التغذية.

**من هو «المتجر الخارجي»؟** خطة العمل (النسخة ٢.٠، الفصل ٢) تقول بوضوح إن تطبيق الكابتن
**تُشغّله شركة منفصلة عن كابيتانو**. إذن هذا الكود هو نظام شركة التوصيل، و«المتجر الخارجي» هو
نظام إدارة الطلبات الخاص بمنصّة كابيتانو نفسها. نحن المورّد، وهم العميل. ويترتّب على ذلك أمر
عملي مهم: **كل حقل نحتاجه يجب التفاوض عليه معهم، لا افتراضه.**

والمطلب مذكور في الوثيقة مرّتين: في المتطلبات غير الوظيفية تحت بند **التكامل** («تكامل مباشر
وسلس مع نظام إدارة الطلبات في المتجر»)، وفي جدول المخاطر («تعارضات أو تأخير في التكامل مع نظام
المتجر الرئيسي») وعلاجها المقترح: **«تحديد نقاط التكامل وجهة اتصال مخصّصة من فريق المتجر منذ
البداية»**. وهذا العلاج تحديدًا هو أثمن ما في هذه الوثيقة — القسم ٣ أدناه قائمة جاهزة للإرسال
إلى فريقهم.

النطاق الآن: **متجر واحد**، لكن بتصميم يجعل المتجر الثاني صفًّا في جدول لا إعادة بناء.

## ٢. كيف سيعمل التكامل — القرار

**نموذج هجين: الدفع أولًا، والسحب للتحقّق.**

- **يدفع المتجر إلينا (Push):** يُرسل المتجر الطلب إلينا لحظة جاهزيته للتوصيل. الإسناد يدوي
  دائمًا (FR-06/07)، أي أن إنسانًا يجب أن يرى الطلب خلال ثوانٍ — والاستقصاء الدوري (Polling)
  يضيف فترة انتظار كاملة إلى كل طلب بلا مقابل.
- **ونسحب منهم دوريًا (Pull):** أمر مجدول كل ٥ دقائق يسأل المتجر: «أي طلبات وسمتَها جاهزة منذ
  الوقت الفلاني ولا نملكها؟» ويعوّض النقص.

لماذا الاثنان معًا؟ لأن هذا المشروع **لا يملك أي بنية Webhook اليوم** — لا توقيعات، ولا سجلّ
تسليم، ولا Horizon لمراقبة الطوابير. في غياب هذه الخبرة التشغيلية، الدفع وحده يعني أن طلبًا
ضائعًا يبقى غير موجود إلى الأبد دون أن ينتبه أحد. السحب الدوري هو التأمين الرخيص الذي يحوّل
«تعطّلنا أو تعطّلوا» من حادثة إلى تأخير خمس دقائق.

وفي الاتجاه المعاكس نرسل لهم **Webhooks موقّعة** عند كل تغيّر في حالة الطلب (أُسند، تم الاستلام،
في الطريق، سُلّم، فشل، أُلغي).

## ٣. ما يجب أن نطلبه من المتجر

هذا هو جوهر سؤالك. القسمان أدناه مكتوبان ليُرسَلا إلى فريق المتجر كما هما. **اطلب كل شيء الآن
حتى لو بنينا على مراحل** — فمدّة استجابة فريق خارجي هي أطول مسمار في الجدول، وهي نفس الحجّة
التي تسوقها `docs/order-notifications-analysis.md` بشأن قوالب ISHAAR.

### ٣.١ بيانات يجب أن تصلنا مع كل طلب

| الحقل | إلزامي؟ | لماذا، وماذا ينكسر بدونه |
|---|---|---|
| `external_order_id` | **نعم** | رقم الطلب عندهم. هو مفتاح منع التكرار ومقبض كل نداء لاحق. بدونه تُنشئ إعادةُ الإرسال طلبًا مكرّرًا ويُرسَل كابتنان |
| `customer.name` / `.phone` | **نعم** | FR-09، و FR-19 (الاتصال المقنّع) لا يجد رقمًا يطلبه |
| `customer.external_id` | **نعم** | **FR-15.** معرّف العميل الثابت عندهم. بدونه لا يمكن أبدًا ربط «ملف عنوان العميل»، و FR-15 يصبح غير قابل للبناء لاحقًا. رخيص الآن، مكلف جدًا بأثر رجعي |
| `pickup.branch_ref` / `.name` / `.address` | **نعم** | من أي فرع نستلم؛ يُعرض للكابتن. يُكتب في `order_pickups.store_ref` الموجود أصلًا |
| **`pickup.lat` / `pickup.lng`** | **نعم** | **أخطر حقل في القائمة.** بدونه تُرجع `pickupPointsFor()` مصفوفة فارغة، فيردّ `GET /orders/{uuid}/captains` بـ **422**، أي **لا يمكن إسناد الطلب لأي كابتن إطلاقًا**. نرفض عند الباب ولا نقبل طلبًا عالقًا |
| `dropoff.lat` / `.lng` | **نعم** | الوقت المتوقّع للوصول والمسار (FR-13) والسياج الجغرافي (FR-14) مستحيلة بدونهما |
| `payment.method` | **نعم** | نقدًا عند الاستلام أو مدفوع مسبقًا. FR-16/17، ويحدّد أيضًا هامش التسليم في خوارزمية الإسناد (٣ دقائق مقابل ٥) |
| `payment.status` | **نعم** | **أخطر مخاطرة بيانات في المشروع كله.** الدالة `OrderService::withPaymentTerms()` تجعل الطريقة الافتراضية «نقدًا عند الاستلام» بصمت. فلو وصل طلب مدفوع مسبقًا بلا حالة دفع، سيطلب النظام من الكابتن **تحصيل مبلغ مدفوع أصلًا** — استرداد وحادثة ثقة في كل مرّة |
| `payment.amount_to_collect` | إن كان نقديًا | FR-16 و FR-18 |
| `items[]` | **نعم، واحد على الأقل** | FR-09، وبدونها يصبح عدّ الأصناف المتوقّع صفرًا فلا تعمل راية `items_mismatch` أبدًا — ميزة مشحونة ومعطّلة بصمت |
| `promised_at` | لا | وعد التسليم. تقرأه قاعدة تجميع الطلبات؛ بدونه تمرّ القاعدة دائمًا فتُكدَّس طلبات على مسار سيتأخّر |
| `pickup.ready_at` | لا | متى يجهز الطلب — يسمح بإرسال الكابتن ليصل مع التجهيز بدل الانتظار |
| `delivery_fee` / `currency` / `customer_note` | لا | رسوم التوصيل بأرقامهم، والعملة، وملاحظة العميل المعروضة للكابتن |

### ٣.٢ واجهات وبيانات نحتاجها منهم (القائمة القابلة للإرسال)

**أولًا — اعتمادات ومعلومات تشغيلية:**

| # | المطلوب | ماذا ينكسر بدونه |
|---|---|---|
| A1 | **جهة اتصال تقنية مُسمّاة** ومسار تصعيد وقناة مشتركة | هذا نصّ علاج المخاطرة في خطة العمل. بدونه يتحوّل كل غموض إلى أسبوع |
| A2 | **عناوين IP ثابتة** لخوادمهم (إنتاج + اختبار) | تبقى قائمة السماح معطّلة، وتصبح الحماية رمزًا واحدًا فقط |
| A3 | **بيئة اختبار (Sandbox)** بعنوان واعتمادات، وطريقة لتشغيل كل حدث عندهم يدويًا | نختبر على الإنتاج، أو نكتشف الشكل الحقيقي بعد الإطلاق |
| A4 | **المفاتيح**: رمزهم لنا، ومفتاح توقيعهم، ومفتاحنا لهم، **وإجراء تدوير المفاتيح ونافذة التداخل** | أول عملية تدوير مفاتيح = انقطاع خدمة |
| A5 | **حجم العمل**: طلبات/يوم، وذروة الطلبات/دقيقة | محدّد المعدّل الحالي ٣٠ طلبًا/دقيقة سيرفض نصف ذروة الغداء بصمت |
| A6 | سياسة المهلات وإعادة المحاولة عندهم، ومفتاح منع التكرار لديهم | إمّا نُغرقهم أو نُسقط أحداثًا |
| A7 | صيغة الهاتف (E.164؟)، العملة، المنطقة الزمنية، **ولغة كل عميل** | الاتصال المقنّع يفشل، والعميل العربي يصله إشعار بالإنجليزية |
| A8 | صيغة رقم الطلب عندهم وضمان تفرّده | طلبان بنفس الرقم على شاشة الكابتن |

**ثانيًا — واجهات برمجية على جانبهم نناديها نحن:**

| # | الواجهة المطلوبة | المتطلّب | ماذا ينكسر بدونها |
|---|---|---|---|
| **S1** | **مستقبِل Webhook** لأحداثنا + مفتاح التوقيع + أي الأحداث يستهلكون | NFR-التكامل | نظامهم ونظامنا يفترقان؛ تطبيق العميل لا يرى تقدّم الطلب أبدًا |
| **S2** | **دليل الفروع**: `GET /branches` ← معرّف، اسم، عنوان، **إحداثيات**, هاتف, ساعات العمل | FR-09 والإسناد | **أكثر بند معطِّل.** إن عجزوا عن وضع الإحداثيات في الطلب فعلينا تخزين هذا الدليل وترجمة `branch_ref` إلى نقطة |
| **S3** | **كتابة ملف عنوان العميل**: `PUT /customers/{id}/addresses/{addressId}` يقبل المبنى والدور والشقة وأقرب معلم | **FR-15** | التفاصيل الدقيقة التي يلتقطها الكابتن تموت في قاعدتنا ولا تصل إلى نماذجهم — نصف FR-15 مفقود |
| **S4** | **قراءة ملف العنوان** ومعرّف عنوان ثابت | **FR-15** | لا مفتاح نكتب تحته، فـ S3 بلا فائدة |
| **S5** | **حالة الدفع**: `GET /orders/{id}/payment` **و** دفعة `payment.updated` إلينا | **FR-17** | «التحقّق الآلي من الدفع الإلكتروني» مستحيل؛ والافتراضي عندنا نقدي، فيسلّم الكابتن بضاعة على طلب مسترَد |
| **S6** | **التسوية النقدية**: `POST /settlements` أو صيغة تقرير متّفق عليها | **FR-18** | التسليم اليومي يبقى ورقيًا وأي نزاع بلا سجلّ مشترك |
| **S7** | **الاتصال المقنّع**: من يملك حساب الاتصالات وأي مزوّد وواجهة جلسة الوسيط | **FR-19** | إمّا يرى الكابتن رقم العميل الخام (تعرّض لبيانات شخصية) أو لا يستطيع الاتصال |
| **S8** | **التقييمات**: يدفعون إلينا `order.rated`، أو يوفّرون قراءتها | **FR-21** | جودة الكابتن لا تصل أبدًا إلى شركة التوصيل التي توظّفه |
| **S9** | **استهلاك التتبّع**: أيريدون Webhook موقع، أم نقطة قراءة، أم رابط تتبّع نستضيفه؟ ومعدّل التحديث | **FR-12** | لا كابتن على خريطة العميل. القرار يحدّد ما نبنيه |
| **S10** | **تنبيه الوصول** + **نصف قطر السياج بالأمتار** | **FR-14** | لا يوجد أي كود سياج جغرافي في المشروع اليوم؛ ونصف القطر قرار منتج لا هندسة |
| **S11** | تأكيد أنهم سينادون واجهة الإلغاء عندنا + **رموز أسباب الإلغاء** | — | طلب ملغى يُبقي كابتنًا يقود إلى متجر ليس فيه شيء له |
| **S12** | هل يسمح نظامهم بتعديل الطلب بعد الإرسال، **وأي الحقول**؟ | — | عنوان مصحّح لا يصل إلى كابتن يقود نحو القديم. الجواب يقرّر إن كنّا نبني `PATCH` أصلًا |
| **S13** | من يحسب رسوم التوصيل والمبلغ المُحصّل، وهل الضريبة مضمّنة | — | تقاريرنا المالية خيال |
| **S14** | شكل الأصناف: اسم (عربي/إنجليزي)، كمية، سعر، SKU | FR-09 | راية عدم تطابق الأصناف تبقى معطّلة |
| **S15** | معرّف عميل ثابت يصمد أمام تعديل الملف | FR-15/19 | لا مفتاح لكتابة العناوين |
| **S16** | `promised_at` لكل طلب، أو قاعدة الـ SLA لنحسبه | — | قاعدة تجميع الطلبات بلا شيء تختبره فتقبل كل تجميعة |
| **S17** | هل يحتاجون إثبات التسليم (صورة/توقيع) وبأي صيغة؟ | — | إثبات التسليم حبيس مكتبتنا والنزاعات بلا جواب |
| **S18** | **قائمة المطابقة**: `GET /orders?ready_since=…&status=ready_for_delivery` | — | ينهار التصميم الهجين إلى دفع خالص ولا نكتشف طلبًا ضائعًا |

**عاملهم كمعطِّلَين: S2 و S5.** الباقي له بديل مؤقّت أو تأجيل معلن.

## ٤. ما الذي نغيّره في هذا المشروع

| المجال | التغيير | السبب |
|---|---|---|
| **هوية العميل** | جدول `store_clients` جديد (اسم، رابط Webhook، مفتاحان مشفّران، قائمة IP، مفعّل) | نحتاج كيانًا يحمل رموز Sanctum والمفاتيح، ونَنسِب إليه كل طلب. **ملاحظة مهمة: هذا لا ينقض القرار رقم ٧ في `docs/analysis.md`** الذي أجّل جدول `stores` — ذاك كان عن *دليل الفروع* الذي ترتّب عليه الخوارزمية، وما زال الصواب أن تحمل نقاط الاستلام إحداثياتها. أمّا هذا فهو *هوية تكامل*، شيء آخر تمامًا. ولذلك سُمّي `store_clients` لا `stores` حتى لا يخلط أحد بينهما لاحقًا |
| **المصادقة** | حارس `store` جديد برموز Sanctum **+** توقيع HMAC على كل طلب في الاتجاهين | الرمز وحده يعني أن تسريبه من سجلّ أو وسيط = حقن طلبات بلا حدّ وإلى الأبد. والتوقيع وحده لا يعطينا `$request->user()` الذي نحتاجه للمعدّل والعزل والتدقيق. لكلٍّ وظيفته |
| **جدول `orders`** | `store_client_id`, `source`, `external_order_id`, `external_customer_id`, `external_address_ref`, `payment_status`, `cancelled_at`, `cancel_reason`, `address_details`, وفهرس فريد على `(store_client_id, external_order_id)` | النسب، ومنع التكرار على مستوى قاعدة البيانات، ومفاتيح FR-15/17 |
| **حالة `cancelled`** | حالة جديدة في `OrderStatus` + توسيع عمود ENUM | **مطلوبة قطعًا.** بدونها لا يمكن تمثيل «العميل ألغى»، ويظلّ الكابتن يقود إلى متجر لا شيء فيه له. وهي الثغرة K8 المرصودة في `docs/order-notifications-analysis.md` |
| **حدث `OrderCreated`** | لا يوجد أي حدث عند الإنشاء اليوم | نحتاجه لصفّ Webhook الإشعار بالاستلام ولصفّ تنبيه لوحة التحكم |
| **سجلّ Webhook** | جدول `webhook_deliveries` على نفس نمط `push_deliveries` القائم | أول خلاف مع شريك خارجي دائمًا «نحن أرسلنا»؛ بلا السجلّ الخام لا حسم |
| **سجلّ منع التكرار** | جدول `integration_requests` (المفتاح، بصمة الحمولة، الردّ المخزّن) | إعادة الإرسال المشروعة يجب أن تكون مملّة: نفس المفتاح ونفس الحمولة ← نفس الردّ السابق حرفيًا، ٢٠٠ |
| **توليد رقم الطلب** | جدول تسلسل مقفول بدل قراءة `MAX(id)` | الطريقة الحالية تتسابق: دفعتان متزامنتان تقرآن نفس الرقم، فيرمي الفهرس الفريد خطأ يتحوّل إلى **٥٠٠** يعيد المتجر المحاولة عليه فيُنشئ مكرّرًا |
| **محدّد المعدّل** | تعديل في `RateLimiterProvider` | فخّ حقيقي: `throttle:api` مضاف على **كل** مسارات `api/*` عالميًا، فإضافة محدّد أكرم على مجموعة المتجر **لن تفعل شيئًا** — الاثنان يُطبَّقان والأشدّ يفوز. والأسوأ أن `$request->user()` بلا وسيط يحلّ الحارس الافتراضي (`driver`)، فيعود فارغًا لرمز المتجر، فيُحسب المتجر بعنوان IP عند **٣٠/دقيقة** |
| **الوسطاء الموثوقون** | إضافة `trustProxies` في `bootstrap/app.php` | غير مضبوط اليوم. خلف nginx يصبح `$request->ip()` عنوان الوسيط، فتمرّ قائمة السماح على الجميع وينهار مفتاح المعدّل إلى دلو واحد. **هذا خلل قائم يمسّ محدّد OTP أيضًا** |
| **التوثيق** | مجموعة l5-swagger ثالثة باسم `integration` | يحصل مهندسو المتجر على مواصفة حيّة بدل مرفق Word — وهذا هو العلاج الملموس لمخاطرة التكامل |

**طمأنة بخصوص عدّاد الكباتن:** ترصد `docs/task-list.md` في المهمة T6.1 قلقًا من أن تغذية خارجية
تكتب طلبات قيد التنفيذ مباشرةً فتُفسد `drivers.active_orders`. لا ينطبق هنا: `OrderService::create()`
تفرض حالة `Pending` دائمًا، فالتغذية لا تستطيع إنشاء طلب مُسند. المسار الجديد الوحيد الذي يمسّ
العدّاد هو إلغاء طلب مُسند، ويُعالَج بجعل `Cancelled` حالة نهائية فتعمل آلية إرجاع السعة القائمة
من تلقاء نفسها.

## ٥. أخطاء ستقع لو لم يُنتبه لها — وقد انتُبه لها

ثلاث منها ليست نواقص بل **أعطال وقت تشغيل**:

1. **`deliverySteps()` سترمي استثناء.** دالة `match ($current)` داخلها تعدّ الحالات الست بلا
   `default`، فأول طلب ملغى يُفتح في تطبيق الكابتن يرفع `UnhandledMatchError`. إضافة الحالة
   ليست تنظيفًا اختياريًا.
2. **`order_status_history.status` عمود ENUM أيضًا**، لا `orders.order_status` وحده. هجرة
   التوسيع يجب أن تعدّل الجدولين — والهجرة `2026_09_09_140007` تفعل ذلك بالضبط وهي القالب.
3. **`requiresReason()` مكتوبة `$this === self::DeliveryFailed`** ويجب أن تصير `in_array` على
   الحالتين النهائيتين اللتين تتطلّبان سببًا.

## ٦. المراحل والتقدير

**المرحلة الأولى (≈ ١٢ يوم عمل مع الاختبارات):** هوية العميل والمصادقة ← منع التكرار ونقطة
الاستقبال ← الإلغاء ← Webhooks الصادرة ← القراءة والتعديل ← لوحة التحكم ← المطابقة ← التوثيق.

**المرحلة الثانية (معطّلة على أجوبتهم):** التتبّع والسياج الجغرافي (FR-12/14)، وملف عنوان العميل
(FR-15)، والتحقّق من الدفع (FR-17)، والتقييمات (FR-21)، والتسوية النقدية (FR-18).

التفاصيل الكاملة — الحقول والمسارات وأسماء الملفات والاختبارات — في القسم الإنجليزي أدناه.

---
---

# Part II — the detailed plan (English)

## 1. Context

This project is the delivery company's system; the "external store" is the Kapitano platform's
order management system (Work Plan §2 — the Captain App is *"operated by a separate company"*).
We are the supplier. Scope: one store, modelled so the second costs a row.

**Feature folder: `Integration`.** Everything new lives in `app/*/Integration/`,
`config/integration.php`, `routes/store-integration.php`, `tests/{Unit,Feature}/Integration/`,
`resources/lang/{en,ar}/integration.php` — the convention `Dispatch` established.

## 2. What exists, and the seam

| Fact | Consequence |
|---|---|
| `OrderService::create(array $attributes, ?Admin $creator = null, array $items = [])` is the **only** production write path. Forces `Pending`, applies payment terms, writes one `OrderPickup`, one transaction | The intake service delegates to it. `$creator` is already nullable, so a store order has `created_by = null`. The one write path stays the one write path |
| `order_pickups.store_ref` exists, is plumbed through `attachPickups()`, and **nothing writes it** | The branch id lands here. No schema change needed for it |
| `withPaymentTerms()` silently defaults a missing method to **cash on delivery** | Never rely on that default here — see §4's required `payment_status` |
| `OrderRepository::expectedItemCount()` returns 0 with no items, disabling `items_mismatch` | `items` is `required min:1` |
| No `OrderStatus::Cancelled`, no reassign, no edit (K7/K8/K9) | Cancellation required; reassign deferred; edit gated on ask S12 |
| No webhooks, HMAC, idempotency or integration log anywhere | All new. `push_deliveries` + `SendPushBroadcastChunk` are the precedents |
| `CheckApiHeaderMiddleware` 401s any `api/*` without **both** `Accept: application/json` and `Accept-Language`, before auth | Contract, not exemption — §5.3 |
| `nextSequentialNumber()` reads `MAX(id)`'s number, `preg_replace`s non-digits | Racy *and* corruptible — §9 |
| Dispatch 422s without pickup coordinates; reads `promised_at` and `payment_method` | Coordinates **required**, unlike the dashboard form |

## 3. Integration contract

**Hybrid, push-primary.** Store pushes on ready (sub-second to the dispatcher, which manual
dispatch demands); a 5-minute `integration:reconcile-orders --since=` pull back-fills anything
lost. Pure push is unsafe in a codebase with no webhook operations maturity; pure pull adds an
interval of latency to every order.

### 3.1 Endpoints we expose — `/api/integration/v1`, guard `auth:store`

Route file `routes/store-integration.php`, required from `routes/api.php`, name `api.integration.`.

| Method | Path | Ability | Purpose |
|---|---|---|---|
| GET | `/health` | `orders:read` | credential + clock-skew check |
| POST | `/orders` | `orders:write` | **ingest** |
| GET | `/orders/{order}` | `orders:read` | their repair path |
| GET | `/orders?external_order_id=` | `orders:read` | recovery when they lost our uuid |
| PATCH | `/orders/{order}` | `orders:write` | edit — gated on S12 |
| POST | `/orders/{order}/cancel` | `orders:write` | |
| POST | `/orders/{order}/payment` | `orders:write` | FR-17 |
| POST | `/orders/{order}/rating` | `orders:write` | FR-21 — in v1, implemented later |

`{order}` is **our** UUID with `->whereUuid('order')` — the `uuid-routes` rule in
`scripts/check-architecture.php` errors on `{id}`. Rating is published in v1 so the store
integrates once; until T8.11 it persists to `integration_requests` and returns 202.

### 3.2 Ingest payload

Headers: `Accept: application/json`, `Accept-Language: en`, `Content-Type`,
`Authorization: Bearer <PAT>`, `Idempotency-Key`, `X-Kapitano-Signature: t=<unix>,v1=<hex>`.

Fields and rules: see §3.1 of the Arabic table for the business reasoning; the mechanical mapping
is `external_order_id|external_order_number` → `orders.*`; `customer.{external_id,name,phone,
locale,note}` → `orders.{external_customer_id,customer_name,customer_phone,…,customer_note}`;
`pickup.{branch_ref,name,address,lat,lng,ready_at}` → `order_pickups.*` plus the mirrored
`orders.pickup_*`; `dropoff.{address,lat,lng,details,address_ref}`;
`payment.{method,status,amount_to_collect,reference}`; `currency`, `delivery_fee`, `promised_at`,
`note`, `items[].{name,quantity,unit_price,sku,note}`.

**Five fields are `required` here that are nullable on the dashboard form, deliberately:**
`pickup_lat/lng` (no coordinates → no dispatch, ever), `dropoff_lat/lng` (no ETA, no geofence),
`customer_phone` (FR-19 has nothing to dial), `payment_method` **+ `payment_status`** (the COD
default would make a captain collect money already paid), and `items` (min 1, or `items_mismatch`
is inert).

**201** on create. **200** on replay with the stored response verbatim plus
`Idempotency-Replayed: true` — a retry is not an error. Body is the project envelope.

### 3.3 Errors

401 `accept_header` / Sanctum / `signature_{missing,malformed,mismatch,expired,replayed}` ·
403 `ip_not_allowed` / `client_inactive` / `missing_ability` · 404 unknown **or another client's**
order (404 not 403 — never confirm existence) · 409 `duplicate_external_order` /
`order_in_transit` / `invalid_transition` · 422 field errors incl. `pickup_coordinates_required`,
`unknown_branch` · 429 · 503 `integration_paused` (kill switch).

Five distinct signature keys so a clock-skew incident is never misdiagnosed as a wrong secret.

### 3.4 Outbound webhook

`POST {store_clients.webhook_url}` with `X-Kapitano-Event`, `X-Kapitano-Event-Id` (their dedupe
key), `X-Kapitano-Delivery-Attempt`, and the **same** `t=…,v1=…` signature scheme we verify on
inbound — so their engineers write one verifier for both directions.

Events: `order.received`, `order.assigned`, `order.picked_up`, `order.on_the_way`,
`order.delivered`, `order.delivery_failed`, `order.cancelled`, `order.address_details` (FR-15),
`captain.arrived` (FR-14), `captain.location` (FR-12, throttled, opt-in).

**`sequence` is `order_status_history.id`** — already monotonic per order and already written in
the same transaction as the status. The contract line is *"discard any event whose `sequence` is
≤ the last you processed for that order."* One field, the whole out-of-order problem, zero cost.
`occurred_at` is advisory: clocks lie, ids do not.

**Retry:** unlike `SendPushBroadcastChunk` (`tries = 1`, because FCM has no idempotency key),
webhooks **may** retry — `event_id` makes duplicates harmless. `tries = 6`,
`backoff = [10, 60, 300, 900, 3600, 3600]`, `timeout = 15` (under the `database` queue's
`retry_after = 90`), `retryUntil()` +24 h; exhausted → `dropped` + an admin inbox row.

## 4. Authentication

**Sanctum PAT authenticates the client; HMAC authenticates the request.** Neither alone is
enough: a PAT in a proxy log is unlimited order injection forever with no replay defence, while
HMAC alone gives no `$request->user()`, so our highest-volume caller falls back to IP-keyed
throttling and we hand-roll revocation.

- `StoreClient` model with `HasApiTokens`; guard `'store' => ['driver' => 'sanctum', 'provider'
  => 'store_clients']`. Abilities `orders:read`, `orders:write`.
- Stripe-format signature over `"{t}.{rawBody}"`, `hash_equals`, ±300 s tolerance
  (`config('integration.signature_tolerance')`). Same scheme outbound.
- **Replay:** `(store_client_id, v1)` written to Redis with `SET NX EX <tolerance>`, reusing the
  shape of `app/Repositories/Dispatch/AssignmentLockStore.php`. Bounded memory, no table. Fails
  **closed** — Redis down returns 503, it does not silently disable replay protection.
- **Idempotency is separate and longer-lived** — a legitimate retry ten minutes later carries a
  new signature. That is the `integration_requests` table.
- IP allowlist as defence in depth; empty array disables it so local and staging work.

### 4.1 The header gate — contract, not exemption

A machine client sending `Accept: */*` **will get 401**. Both headers go in the contract; they are
two lines in their HTTP client and `config/cors.php` already allows them. Two notes that follow
from reading the middleware: `Accept-Language: en` machine-side, because it sets the app locale
and `ar` would make `MessageDebug` unparseable in their logs (the *customer's* language travels
in `customer.locale` in the body); and the header 401 arrives **before** signature verification,
so its body shape differs from the other 401s — document both.

### 4.2 Rate limiting — a required change

`throttle:api` is appended to **every** `api/*` route globally, so `throttle:integration` on the
group would stack, not replace, and the stricter 30/min would win. And `$request->user()` with no
argument resolves the default `driver` guard — null for a store token — so the store is keyed by
**IP at 30/min**. Fix in one place:

```php
RateLimiter::for('api', static function (Request $request) {
    if ($request->is('api/integration/*')) {
        $client = $request->user('store');   // resolved only on this path

        return Limit::perMinute((int) config('integration.rate_limit', 300))
            ->by($client ? 'store:'.$client->getKey() : 'store-ip:'.$request->ip());
    }

    return Limit::perMinute(30)->by($request->user()?->id ?: $request->ip());
});
```

**Also required: `->trustProxies(...)` in `bootstrap/app.php`**, which is unset today. Behind
nginx `$request->ip()` is the proxy, so the allowlist passes everything and the rate-limit key
collapses to one bucket. This is a live bug for the existing OTP limiter too.

### 4.3 Middleware stack

```
[global] throttle:api → HandleCors → CheckApiHeaderMiddleware
[group]  integration.ip → integration.signature → auth:store → integration.active
[route]  abilities:orders:write
```

Signature before auth, so an unsigned request never costs a token lookup. `integration.active` is
a kill switch that does not require revoking tokens. Add the Sanctum `abilities` alias.

## 5. Schema

**`store_clients` — and why this does *not* reverse `analysis.md` decision #7.** That decision
deferred a `stores` table by folding branch identity into `order_pickups`, and it was about the
*vendor directory the algorithm ranks against*. It still stands. What we add is a different
entity: an **integration client identity** — credentials, secrets, webhook URL, allowlist, and a
Sanctum `tokenable`. Named `store_clients`, not `stores`, so the two are never confused.

A `store_branches` directory cache stays deferred with an explicit trigger: **build it the moment
the store answers S2 with "branch_ref but no coordinates."**

| Migration | Contents |
|---|---|
| `*_create_store_clients_table` | `uuid, name, slug unique, webhook_url, webhook_secret (encrypted), signing_secret (encrypted), allowed_ips (json), default_currency, timezone, is_active` |
| `*_add_integration_columns_to_orders_table` | `store_client_id` FK, `source` (enum col, `OrderSource`), `external_order_id`, `external_order_number`, `external_customer_id`, `external_address_ref`, `payment_status` (enum col), `payment_verified_at`, `payment_reference`, `amount_collected`, `cancelled_at`, `cancel_reason`, `address_details` (json), `address_details_at`; **unique `(store_client_id, external_order_id)`**; index `(store_client_id, created_at)` |
| `*_widen_order_status_enum_for_cancelled` | MySQL-only ALTER on `orders.order_status` **and `order_status_history.status`**; no-op on SQLite. Template: `2026_09_09_140007` |
| `*_create_integration_requests_table` | `store_client_id, idempotency_key, endpoint, payload_hash, response_status, response_body (json), order_id`; unique `(store_client_id, idempotency_key)` |
| `*_create_webhook_deliveries_table` | modelled on `push_deliveries`: `store_client_id, order_id, event, event_id unique, sequence, url, payload, status (plain string — a new outcome costs a write not an ALTER), attempts, response_status, response_body, error, next_attempt_at, delivered_at` |
| `*_add_store_ref_index_to_order_pickups_table` | index on `store_ref` |
| `*_create_order_number_sequences_table` | `prefix, period, last_number`; unique `(prefix, period)` |

All new order columns join `#[Fillable]` **except** `store_client_id` and `source`, which only the
intake service sets — the discipline `drivers.active_orders` already follows.

## 6. Domain changes

### `OrderStatus::Cancelled` — strictly required

Today a cancel push hits `assertCanTransition()`, returns 422, the order stays `Assigned`, and the
captain keeps driving. Transitions:

```
Pending → [Assigned, Cancelled] · Assigned → [PickedUp, Cancelled]
PickedUp → [OnTheWay, Cancelled] · OnTheWay → [Delivered, DeliveryFailed, Cancelled]
Cancelled → []
```

Cancelling after `PickedUp` is permitted because refusing to *record* it does not undo the
customer's decision; the goods-in-the-car return is an operations process, not a status. **Needs
product sign-off — §10 Q1.**

- `isTerminal()` must include `Cancelled` — this is what makes `releaseCapacityIfFinished()` free
  the captain's slot for free, and the only thing between cancellation and the T6.1 drift.
- **`deliverySteps()` will throw `UnhandledMatchError`** — see §Arabic 5.1. Add the branch.
- `requiresReason()` becomes an `in_array`.
- `OrderService::cancel(Order, string $reason, ?Model $actor)` must go **through `transition()`**.
  A raw `update(['order_status' => Cancelled])` reintroduces the drift.
- `resources/lang/{en,ar}/order.php` → `status.cancelled` (the `translations` checker rule fails
  CI otherwise).

### `OrderCreated` event — strictly required

Nothing fires on create today. Needed for the `order.received` webhook and the dashboard inbox
row. **Must implement `ShouldDispatchAfterCommit`** — `create()` runs inside `DB::transaction`,
and a listener queueing a webhook for an uncommitted order would announce an order that never
existed. Never registered in `EventServiceProvider`; listeners are auto-discovered here.

### Edit (`PATCH`) — conditional on S12; reassign (K7) — deferred

If they allow post-submission edits, a hard whitelist, with two refusals: dropoff change while
`OnTheWay` → 409 `order_in_transit`; `payment_method` never editable (that is cancel + new order).
If S12 says "no edits", drop the endpoint and save the work.

## 7. Tasks

| # | Task | Days | Key files |
|---|---|---|---|
| **T8.1** | **Contract freeze + send the ask list** (no code; blocks everything) | 0.5 | `docs/feature-08-store-integration.md`, `docs/task-list.md` (Epic 8) |
| **T8.2** ✅ | Client identity + auth + rate limiter + trusted proxies — **done, see below** | 2 | `store_clients` migration, `app/Models/StoreClient.php`, `config/{auth,integration}.php`, `app/Http/Middleware/Integration/{VerifyStoreSignature,AllowStoreIp,EnsureStoreClientIsActive}.php`, `app/Services/Integration/SignatureService.php`, `app/Repositories/Integration/SignatureNonceStore.php`, `app/Exceptions/Integration/IntegrationException.php` (extends `ApiException` — the `instanceof` loop in `ApiExceptionHandler` routes subclasses, no `$handlers` edit), `app/Providers/RateLimiterProvider.php`, `bootstrap/app.php`, `IssueStoreTokenCommand` |
| **T8.3** ✅ | Idempotency + the intake endpoint — **done, see below** | 2.5 | orders/requests/sequence migrations, `OrderSource` + `OrderPaymentStatus` enums, `app/Http/Controllers/Integration/Order/OrderIntakeController.php` (a third audience folder, consistent with the rule), `IngestOrderRequest`, `app/DTOs/Integration/*`, `app/Services/Integration/OrderIntakeService.php`, `OrderService::create()` + `OrderCreated`, `OrderRepository::{nextSequentialNumber,findForClient,findByExternalId}`, `routes/store-integration.php` |
| **T8.4** ✅ | Cancellation + state machine — **done** | 1.5 | `OrderStatus`, widening migration, `OrderService::cancel()`, `CancelOrderRequest`, lang |
| **T8.5** ✅ | Webhook ledger, job, listeners — **done** | 2.5 | `webhook_deliveries`, `StoreWebhookService`, `SendStoreWebhook` job, `QueueStoreWebhookOn{StatusChange,Create}` listeners, `ReplayWebhooksCommand`, `PruneIntegrationLogCommand`, `routes/console.php` |
| **T8.6** ✅ | Read / lookup / cancel / payment, tenant-scoped — **done** (edit deferred, see below) | 1.5 | controller actions, requests, `OrderService::updateFromStore()` |
| **T8.7** | FR-15 capture + write-back (blocked on S3/S4/S15) | 2 | `CaptureAddressDetailsRequest`, `Mobile/Order/OrderController::captureAddressDetails()`, `AddressDetailsCaptured` event, `PushAddressDetailsToStore` listener |
| **T8.8** ✅ | Back-office visibility — **done** | 1.5 | `routes/integration-management.php`, `Dashboard/Integration/IntegrationLogController`, `AdminPermission::{integration.view,integration.replay}` + seeder |
| **T8.9** | Reconciliation pull + health (blocked on S18) | 1.5 | `StorePlatformClient`, `ReconcileOrdersCommand`, `IntegrationHealthCommand` |
| **T8.10** ✅ | OpenAPI group + sandbox harness + E2E — **done** | 1 | `config/l5-swagger.php` third group, annotations, `SimulateStoreCommand`, extend `tests/Feature/Documentation/SwaggerIsolationTest.php` |

Deferred: **T8.11** ratings (FR-21), **T8.12** cash reconciliation (FR-18, blocked on S6),
**T8.13** geofence arrival (FR-14, blocked on S10 — no geofencing code exists today).

### T8.2 — done (2026-09-19)

`store_clients` + `StoreClient` (`HasApiTokens`, encrypted secrets, `#[Hidden]`) + factory; the
`store` guard and provider; `config/integration.php`; `SignatureService` + `SignatureNonceStore`;
`IntegrationException` + `SignatureFailure`; four middlewares; `StoreClientRepository` bound in
`RepositoryServiceProvider`; `GET /api/integration/v1/health`; `integration:issue-token`; en + ar
strings. **33 tests green** (19 feature, 14 unit); full suite **623 passed, 1 skipped**; Pint and
`check-architecture.php` clean.

**Four deviations from the plan above, each forced by something the plan did not know:**

1. **Middleware order is `auth:store → active → ip → signature`, not signature-first.** The plan
   put the signature ahead of the guard to save a token lookup on unsigned traffic. That cannot
   work: the signature is verified against *that client's* secret and the address against *that
   client's* allowlist, so both need to know who is calling, and only the token says so. A
   key-id header would have allowed it; this contract has none. After the guard the order is
   cheapest-refusal-first — a boolean, then an array lookup, then the one check that hashes and
   talks to Redis.

2. **Abilities are enforced by our own `EnsureStoreAbility`, not Sanctum's `abilities`.** Sanctum
   throws `MissingAbilityException`, which extends `AuthorizationException`, and Laravel's
   `prepareException()` rewrites that into a plain `AccessDeniedHttpException` **before** any
   renderable callback runs. An entry in `ApiExceptionHandler::$handlers` is therefore dead code —
   written, tested, observed not to fire, and removed. The store would have received an untyped
   403 with no `missing_ability` key to branch on.

3. **A new 503, `replay_check_unavailable`.** The plan said the replay guard fails closed but did
   not name the answer. It now has its own key rather than borrowing `integration_paused`, because
   "we are pausing intake" and "we cannot currently prove this is not a repeat" call for different
   reactions. Added to `docs/store-api-ar.md` §5.

4. **`trustProxies` is set from `TRUSTED_PROXIES`, empty by default.** Passing an empty env
   through `explode` yields `['']`, which would register a bogus proxy, so the call is skipped
   entirely when nothing is configured. `'*'` is passed through as the wildcard.

### T8.3 — done (2026-09-19)

`POST /api/integration/v1/orders`. Four migrations (order columns + the unique reference index,
`integration_requests`, the `order_pickups.store_ref` index, `order_number_sequences`);
`OrderSource` and `OrderPaymentStatus` enums; `IntegrationRequest` and `OrderNumberSequence`
models + factories; `StoreOrderData` / `StoreOrderPickupData` / `StoreOrderItemData`;
`IngestOrderRequest`; `OrderIntakeService`; `IntegrationRequestRepository`;
`OrderIntakeController` + `StoreOrderResource`; `RequireIdempotencyKey`; `OrderCreated`; and the
rewrite of `OrderRepository::nextSequentialNumber()`. **19 new tests** (17 feature, 2 parallel);
Pint and `check-architecture.php` clean.

**The scaffolder was not used, and a dry run is why.** `scaffold.php IntegrationRequest` wanted to
create a whole new feature — `/api/integration-requests` as a public CRUD resource, its own
`Mobile/` controller, its own route file and its own `integration_request.php` translations. The
idempotency ledger is an internal table that must never be served, it belongs to the existing
`Integration` feature, and the intake endpoint belongs on the route group T8.2 built. This is the
case `docs/runbook.md` §4 already records — *"the scaffolder produces a CRUD feature; these are
algorithm services. The checker and Pint enforce the same layout"* — so it was hand-written to the
layer contracts and the checker run instead.

**The order-number race is fixed and the fix is mutation-checked.** `ParallelOrderNumberTest`
spawns eight real processes against MariaDB. Against the counter it passes; with the old
`MAX(id)` read put back, **five of the eight die with `1062 Duplicate entry`** — which is the 500
a store client is told to retry, and therefore a duplicate delivery. The second test pins the
other half of the old bug: the number came off the row with the highest **id**, not the highest
number.

**Deviations from the plan above:**

1. **`cancelled_at`, `cancel_reason` and `amount_collected` were left out** of the orders
   migration the plan put them in. They belong to T8.4 and T8.12; adding columns for a feature
   that does not exist yet makes the schema claim more than the code does.
2. **A first attempt that was logged but never completed is retried, not refused.** The plan
   implied a key is spent once. A row with `order_id` null means nothing was written, so refusing
   the retry would strand an order that never existed. The key is spent only on success.
3. **`RequireIdempotencyKey` middleware** is new. The contract demands the header; nothing was
   enforcing it, and defaulting a generated key would make every retry look like a new order —
   precisely the failure the ledger exists to prevent.
4. **`additional_pickups` is accepted but undocumented.** The schema models three stops and the
   writer now handles them; the published contract stays single-pickup until the store says they
   need more (ask S14's neighbour in §3.2).
5. **A SKU is folded into the item note.** `order_items` has no column for one, and dropping it
   silently would cost the captain the only identifier they can check a box against.
6. **`promised_at` is not validated `after:now`.** A promise a few seconds stale from clock skew
   is still the promise the customer was given; refusing the delivery over it is worse than a
   batch rule that declines to stack the order.
7. **`OrderSource` and `OrderPaymentStatus` labels live in `order.php`**, not `integration.php`.
   They are order concepts that the dashboard renders too, and a dashboard screen should not have
   to load the integration's translations to name a payment status.

**Two things that would have shipped broken without being caught:** the migration's `down()` has
to drop the three inline indexes by hand or SQLite refuses to drop the columns, which only showed
up in `DispatchSchemaMigrationTest`'s rollback; and `Order::casts()` referenced the new enums
without importing them, so every ingest 500'd on `undefined cast [App\Models\OrderPaymentStatus]`.

**Also found while building, worth knowing:** `Illuminate\Console\Command::secret()` is a public
method (it prompts for hidden input), so a private `secret()` helper on a command is a fatal
error, not an override — renamed `generateSecret()`. And in tests, `getJson()` puts `[]` in the
body of a GET while Symfony's `Request::create()` always supplies a default `Accept-Language`;
both make a naive signature or missing-header test assert the wrong thing, so the suite builds
its requests with `call()` and an explicitly empty body.


### T8.4 – T8.10 — done (2026-09-19)

**T8.4 cancellation.** `OrderStatus::Cancelled`, reachable from every non-terminal stage; the
enum-widening migration touches `orders.order_status` **and** `order_status_history.status`;
`OrderService::cancel()` goes through `transition()`, which is what releases the captain.
`POST /orders/{uuid}/cancel` with a required reason and an optional `reason_code` kept on the
timeline.

**T8.5 webhooks — the half that makes this an integration rather than a drop box.**
`webhook_deliveries`, `StoreWebhookService`, `SendStoreWebhook`, and two auto-discovered
listeners. Five events now leave on a normal delivery, signed with the same scheme the store
signs inbound with.

**T8.6 read, lookup, payment.** All scoped to the calling client, and a stranger's order answers
**404 rather than 403** — a 403 confirms the order exists to somebody guessing references.

**T8.8 back office.** `GET/POST /api/dashboard/integration/webhooks…` behind two new permissions,
with a status summary that reports zero rather than omitting the key.

**T8.9 / T8.10.** `integration:prune` (scheduled 03:40), `integration:simulate-store`,
`integration:webhook-sink`, and `StoreOrderEndToEndTest`.

**Deviations:**

1. **`tries = 1` and the retry is scheduled by hand**, rather than leaning on the queue's own
   retry. Two accounts of "how many times have we tried" — the queue's and the ledger's — would
   disagree the first time a worker was killed mid-flight, and the ledger is what the dashboard
   shows. This is the opposite reasoning to `SendPushBroadcastChunk`, which is `tries = 1`
   because a retry there means a second buzz on somebody's phone.
2. **Cancellation is allowed after pickup**, which §6 above argued for and which stands:
   refusing to *record* it does not undo the customer's decision, it only leaves the order lying
   about what is happening.
3. **`PATCH /orders/{uuid}` was not built.** It is gated on ask **S12** — if the store's OMS does
   not allow post-submission edits, the endpoint is work nobody needs. The contract in
   `docs/store-api-ar.md` §7.6 already marks it conditional.
4. **The rating endpoint was not built either.** Same reason: T8.11 owns it, and publishing a
   route that returns 202 and does nothing is worse than publishing none.
5. **The third OpenAPI group is still open.** The store has `docs/store-api-ar.md`, which is
   complete; the live spec is a nicety on top of it, not the contract.
6. **`WebhookSinkCommand` runs a PHP built-in server rather than adding a route here.** A route
   in this application would be reached without a request ever leaving the process, and the point
   is to prove a real listener can verify our signature.

**Three bugs found by writing the end-to-end test, all of the same shape** — an optional field
validated as nullable written into a `NOT NULL DEFAULT` column, which defeats the default:

| Column | Sent by a store that… | Was |
|---|---|---|
| `order_items.unit_price` | ships a free gift or an unpriced line | 500 |
| `orders.fee` | does not charge for delivery | 500 |
| `orders.currency` | only ever trades in one currency | 500 |

The first two paths existed before this feature — the dashboard's own create form validates
`unit_price` as nullable too — so this was a live bug that the intake merely made reachable.
`attachItems()` now falls back to the column default, and `StoreOrderData` drops the keys it has
no value for rather than writing nulls over defaults. **None of the unit or feature tests caught
these**, because every hand-written payload happened to include the optional fields; only the
end-to-end test, whose payload was written to look like a real one, did.

### The OpenAPI group (2026-09-19)

`api/documentation/integration` is the third Swagger document, beside `captain` and `dashboard`:
six operations, nine schemas, one security scheme, `storage/api-docs/integration-docs.json`. The
Info page carries the four things that actually block an integration — the two mandatory headers,
the signing algorithm with the **same verifiable test vector** as `docs/store-api-ar.md` §3.1, how
a retry must be sent, and the webhook contract — rather than leaving them to be found one 401 at
a time.

**Two traps, both of which fail silently:**

1. **A schema file's *first* class must match its filename.** `app/OpenApi/Schemas/ApiEnvelope.php`
   holds eight schema classes and works only because PSR-4 can load `ApiEnvelope`, which defines
   the rest as a side effect. A file whose first class does not match is never loaded, and every
   schema in it is skipped — the generator says `Skipping unknown …` and carries on. The store
   schemas are therefore one class per file.
2. **`cleanUnusedComponents` was eating the webhook schema.** It is enabled in
   `l5-swagger.defaults` for a good reason: the other two specs share `app/OpenApi/Schemas`, and
   without it each listed the other's models. But `StoreWebhookPayload` documents a body we
   *send*, so no operation references it and it was pruned — the webhook contract vanished from
   the spec with no warning. `ConfigFactory` merges per-document config recursively, so the
   integration group turns the pruning off for itself; it scans only its own folder, so there is
   nothing there to prune anyway.

`SwaggerIsolationTest` now covers all three documents and additionally asserts that no two groups
share a JSON filename or a route — a copy-pasted group that kept another's `oauth2_callback`
would shadow it, which is invisible until somebody's docs page breaks.

## 8. Risks and failure modes

| Risk | Handling |
|---|---|
| Duplicate pushes | Three layers: Redis nonce (instant replay) → `unique(client, idempotency_key)` with stored-response replay (retries) → `unique(client, external_order_id)` backstop. A caught 23000 becomes a 200-replay or 409, never a 500 |
| Out-of-order webhooks (us → them) | `sequence` = `order_status_history.id`; contract says discard `<= last_seen` |
| Out-of-order pushes (PATCH before POST) | 404 + retry. **No upsert-on-patch** — a blind upsert from a partial payload creates an order with no pickup, which then 422s on suggestion. A stranded order is worse than a retried 404 |
| Store down | Ledger + 6 attempts over 24 h + `dropped` + admin row + `integration:replay-webhooks`; their repair path is `GET /orders/{uuid}` |
| We are down | Contract requires ≥24 h retry with the same key; the 5-minute pull back-fills. This is why the design is hybrid |
| `order_number` race | Sequence table with a locked increment inside the existing transaction. Also fixes the `preg_replace` corruption path |
| `active_orders` drift | Intake only creates `Pending`, driverless orders. Cancellation must go through `transition()`; enforced by T8.4's capacity assertion |
| Missing coordinates | `required` + distinct 422 key. If S2 is coordinate-less, build `store_branches` and reject `unknown_branch` — never accept a null point |
| Wrong payment data | `payment_method` + `payment_status` required; the COD default is never reached. Highest-severity data risk in the feature |
| Clock skew | ±300 s, `signature_expired` distinguished from `signature_mismatch` |
| Rate-limited off the platform | §4.2. Without it the store is IP-keyed at 30/min and 429s before our allowlist even runs, which looks like an outage to them |
| Proxy-masked IP | `trustProxies` (pre-existing bug) |
| Locale poisoning | `Accept-Language: en` machine-side; customer language in the body |
| Secret leakage | `encrypted` casts + `#[Hidden]`, never logged; rotation via a second accepted secret during an overlap window |
| Tenant leakage | Every read/write scopes on `store_client_id` from day one, even with one store |
| Bad deploy | `config('integration.accept_orders')` → 503, isolates without revoking tokens |

## 9. Verification — without the real store

1. **Unit:** `SignatureServiceTest` (round trip, tampered byte, skew, malformed header,
   `hash_equals`); `OrderNumberSequenceTest` (concurrent collision, reusing the harness in
   `tests/Feature/Dispatch/Concurrency/`).
2. **Feature inbound:** the project's definition of done (happy path, validation failure,
   missing-headers 401) plus the full auth matrix, the idempotency matrix, the throttle. All
   assertions through the envelope and `__()` keys; routes via `route('api.integration.orders.store')`.
3. **Feature outbound:** `Http::fake()` + `Queue::fake()` — exact URL, headers, a **recomputed**
   signature (assert the algorithm, the way the Google engine's tests assert the exact field
   mask), `event_id` uniqueness, retry ledger transitions, `dropped` + admin notification,
   `sequence` monotonicity, **no** webhook for dashboard orders, **no** webhook after a rolled-back
   transaction.
4. **End to end on one machine:** `php artisan integration:simulate-store --push` signs and POSTs
   a real payload at our own endpoint; `--sink` registers a local-only receiver that verifies
   *our* outbound signature. Point `webhook_url` at it and drive the lifecycle: push → dispatcher
   assigns → captain advances → seven signed events land in sequence. Real middleware, real queue,
   real ledger.
5. **The proof the order is first-class:** `GET /api/dashboard/orders/{uuid}/captains` returns a
   ranked list, **not** 422.
6. **Chaos:** sink returns 500 then 200 — assert backoff progression and eventual `sent`. Stop
   Redis — assert a clear 503, not silently disabled replay protection.
7. **Store-side stub:** record fixtures for S2/S3/S5 and drive `StorePlatformClient` against
   `Http::fake()`, exactly as `GoogleRoutesEngine` is verified until real keys arrive. Switch to
   their sandbox by config, not by code.
8. `php artisan test --compact --filter=Integration`, then the full suite;
   `vendor/bin/pint.bat --dirty --format agent` and `php scripts/check-architecture.php Integration`
   both clean — CI runs them and will block otherwise.

## 10. Open questions for the product owner

1. **Cancellation after pickup.** §6 permits it and treats the return as an operations process.
   Confirm, or forbid it and make the store call operations instead.
2. **Do we ever refuse an order?** Phase 1 accepts everything. If coverage area, weight or opening
   hours should be able to reject one, that is a pre-`pending` state and a rejection callback —
   design it now rather than bolt it on.
3. **Pricing (S13).** We take their `delivery_fee`. Confirm we are not expected to price the
   delivery; a pricing engine is a feature, not a field.
4. **Proof of delivery (S17).** `Order::MEDIA_PROOF_OF_DELIVERY` is registered but nothing uploads
   to it. Does `order.delivered` need to carry a photo from day one?
5. **Who sends the ask list, and when?** §3.1–3.2 is ready to send. T8.1 is blocked until the
   store's team has it and S2 + S5 are answered, so this should go out before any code starts.
