# Courier Order + Webhook + Notifications — Phase 3, 4, 5
### `DeliveryApp/CourierOrder` + `DeliveryApp/CourierWebhook` modules

> **Scope:** CourierOrder lifecycle (create → dispatch → track), webhook ingestion, polling, public
> tracking page, and status change notifications.
>
> **Prerequisites:**
> - Phase 1 COMPLETE: `CourierManager`, `StatusNormalizer`, `CourierDriverInterface`,
>   `Delivery` model with `encrypted:array` credentials
> - Phase 2 COMPLETE (optional): Shipping zones resolve at order creation if auto-assign is on
>
> **Previous file:** `01_courier_driver_plan.md` · `02_shipping_zone_plan.md`
> **Next:** `04_delivery_setup_plan.md` (registration, sidebar, full checklist)

---

## Architecture

```
POST /courier-orders/{id}/dispatch
    → CourierOrderController::dispatch()
    → CourierOrderService::dispatch()
        → CourierManager::driver($delivery)   (from Phase 1)
        → $driver->createShipment($payload)
        → Update courier_orders row
        → Update orders columns (delivery_method, delivery_charge, shipped_at)
        → Append CourierOrderTrackingEvent (source='manual')
        → Fire CourierOrderDispatchedEvent

Pathao/DHL Webhook POST /webhooks/courier/{provider}
    → HMAC verify (sync — security gate)
    → ProcessCourierWebhookJob → queue courier-webhooks
        → tenancy()->initialize($tenantId)
        → $driver->parseWebhookPayload()
        → CourierOrderService::updateStatus()
        → Append CourierOrderTrackingEvent (source='webhook')

Scheduled every 30 min (Steadfast + fallback)
    → PollCourierStatusJob
        → CourierOrder::nonTerminal() last polled >28 min ago
        → SyncSingleCourierTrackingJob per order
            → $driver->trackShipment()
            → CourierOrderService::syncTracking()
```

---

## Database Schema (Tables 8–9)

**`courier_orders` table**
```
id, uid (string unique),
order_id          (FK orders nullable nullOnDelete),
delivery_id       (FK deliveries nullable restrictOnDelete),
consignment_id    (string nullable),
tracking_code     (string nullable),
provider_key      (string 50),
status            (string default 'pending') — DeliveryOrderStatusEnum values,
payment_status    (string default 'unpaid') — DeliveryProviderPaymentStatusEnum values,
cod_amount        (decimal 10,2 default 0),
delivery_charge   (decimal 10,2 default 0),
is_cod            (bool default false),
recipient_name    (string),
recipient_phone   (string),
recipient_address (text),
recipient_city    (string nullable),
recipient_zone    (string nullable),
weight            (decimal 8,3 nullable),
parcel_description(text nullable),
dispatched_at     (timestamp nullable),
delivered_at      (timestamp nullable),
failed_at         (timestamp nullable),
last_polled_at    (timestamp nullable),
raw_response      (json nullable),
label_url         (string nullable),
soft_deletes, timestamps

Indexes: [order_id, status], [delivery_id, status]
Unique:  consignment_id
Index:   tracking_code
```

> **Why reuse `orders` columns?** `orders.delivery_method`, `orders.delivery_charge`,
> `orders.delivery_type`, `orders.shipped_at` already exist. Courier dispatch writes these
> — no duplicate snapshot table needed.

**`courier_order_tracking_events` table (append-only — no soft_deletes)**
```
id,
courier_order_id (FK courier_orders cascade),
event_type       (string),
description      (text nullable),
location         (string nullable),
occurred_at      (timestamp),
source           (enum: webhook | polling | manual),
raw_data         (json nullable),
timestamps

Index: [courier_order_id, occurred_at]
```

---

## Module Structure

```
DeliveryApp/
├── CourierOrder/
│   ├── app/
│   │   ├── Console/ (none)
│   │   ├── Events/
│   │   │   ├── CourierOrderDispatchedEvent.php
│   │   │   ├── CourierOrderStatusChangedEvent.php
│   │   │   └── CourierOrderDeliveredEvent.php
│   │   ├── Http/Controllers/
│   │   │   └── CourierOrderController.php
│   │   ├── Jobs/ (none — jobs are in CourierWebhook)
│   │   ├── Listeners/
│   │   │   ├── SendDispatchNotificationListener.php
│   │   │   ├── SendStatusChangeNotificationListener.php
│   │   │   └── UpdateOrderShippingTimestampsListener.php
│   │   ├── Mail/
│   │   │   ├── CourierDispatchedMail.php
│   │   │   └── CourierDeliveredMail.php
│   │   ├── ModelFilters/
│   │   │   └── CourierOrderFilter.php
│   │   ├── Models/
│   │   │   ├── CourierOrder.php
│   │   │   └── CourierOrderTrackingEvent.php
│   │   ├── Providers/
│   │   │   └── EventServiceProvider.php
│   │   ├── Resources/
│   │   │   └── CourierOrderResource.php
│   │   └── Services/
│   │       └── CourierOrderService.php
│   ├── database/migrations/
│   │   ├── XXXX_create_courier_orders_table.php
│   │   └── XXXX_create_courier_order_tracking_events_table.php
│   ├── resources/assets/js/pages/CourierOrder/
│   │   ├── Index.tsx
│   │   ├── Show.tsx
│   │   └── Create.tsx
│   └── routes/tenant.php
│
└── CourierWebhook/
    ├── app/
    │   ├── Console/Commands/
    │   │   └── SyncCourierStatuses.php
    │   ├── Http/Controllers/
    │   │   ├── CourierWebhookController.php
    │   │   └── TrackingController.php
    │   ├── Jobs/
    │   │   ├── ProcessCourierWebhookJob.php
    │   │   ├── PollCourierStatusJob.php
    │   │   └── SyncSingleCourierTrackingJob.php
    │   └── Providers/
    │       └── CourierWebhookServiceProvider.php
    ├── resources/assets/js/pages/Tracking/
    │   └── Show.tsx
    └── routes/tenant.php
```

---

## Phase 3 — `CourierOrder` Module

### Step 3.1 · Scaffold

```bash
php artisan app:create-module CourierOrder DeliveryApp
php artisan app:generate-crud CourierOrder CourierOrder DeliveryApp --with-statics --with-export
```

---

### Step 3.2 · Migrations

> 🤖 **Agent Prompt:**
> Create 2 migration files in `DeliveryApp/CourierOrder/database/migrations/`.
>
> **Migration 1 — `courier_orders`:** all columns from schema above.
> `delivery_id` → `->constrained('deliveries')->restrictOnDelete()`.
> `order_id` → `->constrained('orders')->nullOnDelete()`.
>
> **Migration 2 — `courier_order_tracking_events`:** append-only, no soft_deletes.
> `courier_order_id` → `->constrained('courier_orders')->cascadeOnDelete()`.
> `source` column: `$table->enum('source', ['webhook','polling','manual'])`.
>
> All `uid` columns: unique string 26-char, generated in `boot()`.

---

### Step 3.3 · Models

> 🤖 **Agent Prompt:**
> Create 2 Eloquent models in `DeliveryApp/CourierOrder/app/Models/`.
>
> **`CourierOrder`:**
> - Traits: `HasFactory`, `SoftDeletes`, `Filterable`
> - `modelFilter()` → `CourierOrderFilter::class`
> - Relationships:
>   - `belongsTo(\SalesApp\OrderProduct\Models\Order::class)` — cross-module
>   - `belongsTo(\DeliveryApp\Delivery\Models\Delivery::class)`
>   - `hasMany(CourierOrderTrackingEvent::class)->orderByDesc('occurred_at')`
> - Casts: `raw_response → array`, `dispatched_at/delivered_at/failed_at/last_polled_at → datetime`
> - Scope `nonTerminal()`: `whereNotIn('status', ['delivered', 'returned', 'failed'])`
> - Method `isTerminal(): bool`: return `in_array($this->status, ['delivered', 'returned', 'failed'])`
>
> **`CourierOrderTrackingEvent` (append-only — no SoftDeletes):**
> - `belongsTo(CourierOrder::class)`
> - Casts: `raw_data → array`, `occurred_at → datetime`
> - Default ordering: `protected $orderBy = [['occurred_at', 'desc']]`

---

### Step 3.4 · `CourierOrderFilter`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/CourierOrder/app/ModelFilters/CourierOrderFilter.php`.
> Extend `ModelFilter`, `use CommonFilter`.
>
> Methods:
> - `search(string $v)` — uid, consignment_id, tracking_code, recipient_name, recipient_phone `LIKE %v%`
> - `providerKey(string $v)` — `whereIn('provider_key', explode(',', $v))`
> - `deliveryId(int $v)` — where delivery_id = $v
> - `orderId(int $v)` — where order_id = $v
> - `dispatchedAtStart(string $d)` — whereDate dispatched_at >=
> - `dispatchedAtEnd(string $d)` — whereDate dispatched_at <=

---

### Step 3.5 · `CourierOrderService`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/CourierOrder/app/Services/CourierOrderService.php`.
> Follow existing service conventions (CRUD, paginate, baseQuery).
>
> `baseQuery(array $params)`: `CourierOrder::filter($params)->with(['delivery', 'order'])`
>
> **`createFromOrder(Order $order, Delivery $provider, array $opts = []): CourierOrder`**
> Snapshot recipient from order's shipping address. Required fields:
> `order_id`, `delivery_id`, `provider_key=$provider->provider_key`,
> `recipient_name`, `recipient_phone`, `recipient_address`, `recipient_city` (from address),
> `cod_amount=$opts['cod_amount']??0`, `is_cod=$opts['is_cod']??false`,
> `weight=$opts['weight']??0.5`, `parcel_description=$opts['description']??null`, `status='pending'`.
>
> **`dispatch(CourierOrder $co): CourierOrder`**
> 1. `$driver = app(CourierManager::class)->driver($co->delivery)`
> 2. `$result = $driver->createShipment($this->buildPayload($co))`
>    — Catch `CourierApiException`: `$co->update(['status'=>'failed']); throw $e;`
> 3. Update co: `consignment_id`, `tracking_code`, `status='assigned'`, `dispatched_at=now()`, `raw_response`, `delivery_charge=$result['charge']??0`, `label_url=$result['label_url']??null`
> 4. Update Order: `$co->order->update(['delivery_method'=>$co->delivery->title, 'delivery_charge'=>$co->delivery_charge, 'delivery_type'=>'external_provider', 'shipped_at'=>now()])`
> 5. Append `CourierOrderTrackingEvent`: `['event_type'=>'dispatched','source'=>'manual','occurred_at'=>now()]`
> 6. `event(new CourierOrderDispatchedEvent($co))`
> 7. Return fresh `$co->refresh()`
>
> **`updateStatus(CourierOrder $co, string $rawStatus, string $source = 'polling', ?array $raw = null): CourierOrder`**
> - `$oldStatus = $co->status`
> - `$normalized = StatusNormalizer::normalize($co->provider_key, $rawStatus)`
> - `$co->update(['status' => $normalized])` + conditionals: if delivered → `delivered_at=now()`; if failed → `failed_at=now()`
> - Append `CourierOrderTrackingEvent`: `['event_type'=>$rawStatus,'source'=>$source,'raw_data'=>$raw,'occurred_at'=>now()]`
> - `event(new CourierOrderStatusChangedEvent($co, $normalized, $oldStatus))`
> - Return `$co->refresh()`
>
> **`syncTracking(CourierOrder $co): void`**
> - `$events = app(CourierManager::class)->driver($co->delivery)->trackShipment($co->tracking_code ?? $co->consignment_id)['events']`
> - For each event: check `CourierOrderTrackingEvent::where('courier_order_id',$co->id)->whereDate('occurred_at',$event['occurred_at'])->where('event_type',$event['status'])->exists()`. If not: call `updateStatus()`.
> - `$co->update(['last_polled_at'=>now()])`
>
> **`buildPayload(CourierOrder $co): array`**
> Return common payload shape:
> ```php
> [
>     'invoice'          => $co->order?->uid ?? $co->uid,
>     'recipient_name'   => $co->recipient_name,
>     'recipient_phone'  => $co->recipient_phone,
>     'recipient_address'=> $co->recipient_address,
>     'recipient_city'   => $co->recipient_city,
>     'recipient_zone'   => $co->recipient_zone,
>     'cod_amount'       => $co->cod_amount,
>     'weight'           => $co->weight,
>     'note'             => $co->parcel_description,
>     'is_cod'           => $co->is_cod,
>     'merchant_order_id'=> $co->uid,
>     'store_id'         => $co->delivery->credentials['store_id'] ?? null,
> ]
> ```

---

### Step 3.6 · `CourierOrderController`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/CourierOrder/app/Http/Controllers/CourierOrderController.php`.
> Inject `CourierOrderService $service`.
>
> Standard methods: `index`, `create`, `store`, `show`, `edit`, `update`, `destroy`, `bulkAction`, `export`.
>
> `index(Request $request)`:
> - if `wantsJson()` → return JSON (fetchDatatable pattern)
> - else → `Inertia::render('CourierOrder/Index', ['courierOrderData' => [...]])`
> - Inertia props: `data`, `meta`, `links`, `queryParams`, `summary`, `statuses`, `couriers`
>
> **Extra methods (all return redirect — NEVER json):**
>
> `dispatch(int $id)` POST:
> ```php
> try {
>     $this->service->dispatch(CourierOrder::findOrFail($id));
>     return redirect()->route('courier-orders.show', $id)->with('success', 'Dispatched');
> } catch (CourierApiException $e) {
>     return redirect()->back()->with('error', $e->getMessage());
> }
> ```
>
> `updateStatus(Request $request, int $id)` PUT:
> Validate `status` against `DeliveryOrderStatusEnum` values.
> Call `$this->service->updateStatus($co, $request->status, 'manual')`.
> Return `redirect()->route('courier-orders.show', $id)->with('success', 'Status updated.')`.
>
> `sync(int $id)` POST:
> Call `$this->service->syncTracking($co)`.
> Return `redirect()->route('courier-orders.show', $id)->with('success', 'Tracking synced.')`.

---

### Step 3.7 · Routes

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/CourierOrder/routes/tenant.php` using standard tenant middleware.
>
> Declare action routes BEFORE `Route::resource` to prevent shadowing:
> ```php
> Route::post('courier-orders/bulk-action', [CourierOrderController::class, 'bulkAction'])->name('courier-orders.bulk-action');
> Route::post('courier-orders/export', [CourierOrderController::class, 'export'])->name('courier-orders.export');
> Route::post('courier-orders/{id}/dispatch', [CourierOrderController::class, 'dispatch'])->name('courier-orders.dispatch');
> Route::put('courier-orders/{id}/status', [CourierOrderController::class, 'updateStatus'])->name('courier-orders.status');
> Route::post('courier-orders/{id}/sync', [CourierOrderController::class, 'sync'])->name('courier-orders.sync');
> Route::resource('courier-orders', CourierOrderController::class)->names('courier-orders');
> ```

---

### Step 3.8 · React Pages

> 🤖 **Agent Prompt:**
> Create 3 Inertia pages at `DeliveryApp/CourierOrder/resources/assets/js/pages/CourierOrder/`.
> All follow CLAUDE.md rules strictly.
>
> **`Index.tsx`:**
> - `Index.layout`: AppLayout, breadcrumbs: Home → Delivery → Courier Orders
> - Outer wrapper: `<div className="no-scrollbar rounded-xl bg-gray-100/55 p-2 sm:p-4">`
> - `StatisticsCard` row (2 cols → 4 cols): Total, Pending, Dispatched, Delivered, Failed (5 cards → use `lg:grid-cols-5`)
> - `DataTable` columns: UID (link to show), Order# (linked), Recipient Name, Provider badge, Consignment ID, Status badge, Charge, Dispatched At, Actions
> - Status badge color map:
>   - pending → yellow, assigned → blue, picked_up → purple, on_the_way → orange,
>     delivered → green, failed → red, returned → gray
> - Filters: status (multiselect), provider_key (dropdown), dispatched date range, search input
> - Bulk actions: status update, export
>
> **`Show.tsx`:**
> - Follow CLAUDE.md §Show/Detail pattern exactly (gradient header, 4 stat cards, Card sections)
> - Header: back button → courier-orders.index, title UID + consignment_id, status badge, action buttons:
>   - "Dispatch" (POST courier-orders.dispatch) — shown if status='pending'
>   - "Sync Tracking" (POST courier-orders.sync) — shown if status not terminal
>   - "Update Status" dropdown — shown always
> - 4 stat cards: COD Amount | Delivery Charge | Dispatched At | Delivered At
> - Card 1 — Recipient Details: name, phone, address, city, zone, weight, parcel description
> - Card 2 — Tracking Timeline: `courierOrder.trackingEvents` ordered ASC, each row has:
>   occurred_at (formatted), event_type badge, description, location,
>   source badge (webhook=blue, polling=gray, manual=green)
> - Card 3 — Raw API Response (collapsible `<pre>` with JSON.stringify)
> - If `label_url` present: "Download Label" button (a href download)
>
> **`Create.tsx`:**
> - Form fields: Order (searchable select — shows order uid + recipient name), Courier Provider (select from `couriers` prop), COD Amount, Is COD toggle, Weight (kg), Parcel Description
> - On order selection: AJAX fetch to prefill Recipient Name/Phone/Address/City from order's shipping address
> - Breadcrumbs: Home → Delivery → Courier Orders → New Courier Order

---

## Phase 4 — `CourierWebhook` Module

### Step 4.1 · Scaffold

```bash
php artisan app:create-module CourierWebhook DeliveryApp
```

No CRUD generation — this module only contains jobs, controllers, and commands.

---

### Step 4.2 · CSRF Exclusion

> 🤖 **Agent Prompt:**
> Edit `app/Http/Middleware/VerifyCsrfToken.php`. Add to the `$except` array:
> ```php
> 'webhooks/courier/pathao',
> 'webhooks/courier/dhl',
> ```

---

### Step 4.3 · `CourierWebhookController`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/CourierWebhook/app/Http/Controllers/CourierWebhookController.php`.
> Two public methods: `pathao(Request $request)` and `dhl(Request $request)`.
>
> ⚠️ These routes have NO auth, NO CSRF. Follow this exact order:
>
> 1. Retrieve webhook secret via `app(CourierSettingService::class)->getWebhookSecret('pathao')` (or 'dhl').
> 2. Verify HMAC synchronously (SECURITY GATE — must happen before queuing):
>    - Pathao: `hash_hmac('sha256', $request->getContent(), $secret)` vs header `X-Pathao-Signature`
>    - DHL: same, header `DHL-Signature`
>    - Compare with `hash_equals()` (timing-safe)
>    - Invalid → return `response()->json(['error' => 'invalid_signature'], 401)`
>      *(401 is the deliberate exception to "always 200" — stops retry storms from bad actors)*
> 3. Dispatch `ProcessCourierWebhookJob::dispatch($providerKey, $request->all(), tenant()->id)` → queue `courier-webhooks`
> 4. Return `response()->json(['received' => true], 200)`
>
> Wrap all in try-catch. On exception: `Log::warning(...)`, return 200.
>
> **Why verify synchronously?** If we queued unverified and checked in the job, a malicious flood
> of invalid webhooks would fill the queue. Verify first, reject fast.
>
> **Why 401 on bad signature?** Courier partners interpret 200 as "received and OK." Returning 200
> for forged payloads confirms to the attacker their forgery was accepted. Real couriers (Pathao,
> DHL) will not retry 401 — they will alert the developer.

---

### Step 4.4 · `ProcessCourierWebhookJob`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/CourierWebhook/app/Jobs/ProcessCourierWebhookJob.php`.
> Implements `ShouldQueue`. Queue: `courier-webhooks`.
>
> Constructor: `string $providerKey, array $payload, string $tenantId`
>
> `handle(CourierOrderService $service, CourierManager $manager)`:
> 1. `tenancy()->initialize(\Stancl\Tenancy\Database\Models\Tenant::find($this->tenantId))`
> 2. Find `Delivery::where('provider_key', $this->providerKey)->first()` — if null, return silently
> 3. `$driver = $manager->driver($delivery)`
> 4. `$normalized = $driver->parseWebhookPayload($this->payload)` → `['consignment_id', 'raw_status', 'events'[]]`
> 5. `$co = CourierOrder::where('consignment_id', $normalized['consignment_id'])->first()` — if null, return (not our order)
> 6. `$service->updateStatus($co, $normalized['raw_status'], 'webhook', $this->payload)`
> 7. For each event in `$normalized['events']`:
>    Dedup: `CourierOrderTrackingEvent::where('courier_order_id',$co->id)->where('occurred_at',$event['occurred_at'])->where('event_type',$event['status'])->exists()`.
>    If not exists: `CourierOrderTrackingEvent::create([..., 'source'=>'webhook'])`.
>
> Catch `CourierApiException`: `Log::warning("Webhook processing failed: {$e->getMessage()}")`. Do NOT rethrow.

---

### Step 4.5 · `PollCourierStatusJob` + `SyncSingleCourierTrackingJob`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/CourierWebhook/app/Jobs/PollCourierStatusJob.php`. Implements `ShouldQueue`.
>
> `handle()`:
> - Query non-terminal orders due for polling:
>   ```php
>   CourierOrder::nonTerminal()
>       ->where(fn($q) => $q->whereNull('last_polled_at')
>                           ->orWhere('last_polled_at', '<', now()->subMinutes(28)))
>       ->with('delivery')
>       ->limit(50)
>       ->get()
>   ```
> - For each: `SyncSingleCourierTrackingJob::dispatch($co->id)->onQueue('courier-tracking')`
>
> Create `DeliveryApp/CourierWebhook/app/Jobs/SyncSingleCourierTrackingJob.php`:
> - Constructor: `int $courierOrderId`
> - `handle(CourierOrderService $service)`:
>   `$co = CourierOrder::with('delivery')->findOrFail($this->courierOrderId)`
>   `$service->syncTracking($co)`
>   Catch `CourierApiException`: `Log::warning(...)`. Do NOT rethrow — polling failures must NOT fill `failed_jobs`.

---

### Step 4.6 · Artisan Command + Schedule

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/CourierWebhook/app/Console/Commands/SyncCourierStatuses.php`.
>
> Artisan signature: `courier:sync {--tenant=* : Tenant IDs to sync (default: all)}`
>
> `handle()`: dispatch `PollCourierStatusJob`. Output: "Dispatched polling job for non-terminal courier orders."
>
> Register in `CourierWebhookServiceProvider::boot()`:
> ```php
> $this->callAfterResolving(Schedule::class, function (Schedule $schedule) {
>     $schedule->job(new PollCourierStatusJob)->everyThirtyMinutes()->onOneServer();
> });
> ```

---

### Step 4.7 · Public Tracking Page

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/CourierWebhook/app/Http/Controllers/TrackingController.php`.
>
> `show(string $trackingCode)`:
> ```php
> $co = CourierOrder::with('trackingEvents')
>     ->where('tracking_code', $trackingCode)
>     ->orWhere('consignment_id', $trackingCode)
>     ->first();
> return Inertia::render('Tracking/Show', [
>     'courierOrder' => $co ? new CourierOrderResource($co) : null,
>     'notFound'     => $co === null,
> ]);
> ```
>
> Create `DeliveryApp/CourierWebhook/resources/assets/js/pages/Tracking/Show.tsx`:
> - **NO AppLayout** — plain `<div className="min-h-screen bg-gray-50">` wrapper
> - Brand name at top (from usePage props or tenant name)
> - Large tracking number display
> - Current status badge (large, using same color map as CourierOrder/Index)
> - Vertical timeline of events ordered ASC: date + location + description
> - "Not Found" state if `notFound=true`
> - No login required — publicly accessible

---

### Step 4.8 · Webhook + Tracking Routes

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/CourierWebhook/routes/tenant.php`.
>
> Two groups — public only (no auth routes here):
> ```php
> Route::middleware([
>     'web',
>     InitializeTenancyByDomain::class,
>     PreventAccessFromCentralDomains::class,
> ])->group(function () {
>     // Public webhook endpoints (CSRF excluded in VerifyCsrfToken)
>     Route::post('webhooks/courier/pathao', [CourierWebhookController::class, 'pathao'])->name('webhook.courier.pathao');
>     Route::post('webhooks/courier/dhl',    [CourierWebhookController::class, 'dhl'])->name('webhook.courier.dhl');
>
>     // Public tracking page (no auth required)
>     Route::get('track/{trackingCode}', [TrackingController::class, 'show'])->name('courier.track');
> });
> ```

---

## Phase 5 — Notifications

### Step 5.1 · Events

> 🤖 **Agent Prompt:**
> Create 3 Event classes in `DeliveryApp/CourierOrder/app/Events/`:
>
> ```php
> class CourierOrderDispatchedEvent
> {
>     public function __construct(public CourierOrder $courierOrder) {}
> }
>
> class CourierOrderStatusChangedEvent
> {
>     public function __construct(
>         public CourierOrder $co,
>         public string $newStatus,
>         public string $oldStatus
>     ) {}
> }
>
> class CourierOrderDeliveredEvent
> {
>     public function __construct(public CourierOrder $co) {}
> }
> ```

---

### Step 5.2 · Listeners

> 🤖 **Agent Prompt:**
> Create 3 Listener classes in `DeliveryApp/CourierOrder/app/Listeners/`.
>
> **`SendDispatchNotificationListener`** (implements `ShouldQueue`):
> Listens `CourierOrderDispatchedEvent`.
> Fetch contact phone from `$event->courierOrder->order->contact->phone`.
> Send SMS: "Your order #{order_uid} has been dispatched via {courier_title}. Track at: {route('courier.track', $co->tracking_code)}"
> Queue mail: `CourierDispatchedMail::to($order->contact->email)->queue()`
>
> **`SendStatusChangeNotificationListener`** (implements `ShouldQueue`):
> Listens `CourierOrderStatusChangedEvent`.
> Notify ONLY for: `on_the_way`, `delivered`, `failed`.
> Use `StatusNormalizer::getLabel($event->newStatus)` for the human-readable message.
> SMS + email (same pattern as dispatch listener).
>
> **`UpdateOrderShippingTimestampsListener`** (NOT queued — synchronous):
> Listens `CourierOrderStatusChangedEvent`.
> If `$event->newStatus === 'delivered'`:
> ```php
> Order::where('id', $event->co->order_id)->update(['delivered_at' => now()]);
> ```
> Must be synchronous so the timestamp is written before any redirect response.
>
> Register all in `DeliveryApp/CourierOrder/app/Providers/EventServiceProvider.php`:
> ```php
> protected $listen = [
>     CourierOrderDispatchedEvent::class => [SendDispatchNotificationListener::class],
>     CourierOrderStatusChangedEvent::class => [
>         SendStatusChangeNotificationListener::class,
>         UpdateOrderShippingTimestampsListener::class,
>     ],
>     CourierOrderDeliveredEvent::class => [],
> ];
> ```

---

## Phase 3 + 4 + 5 Checklist

### Phase 3 — CourierOrder Module
- [ ] Module `DeliveryApp/CourierOrder` scaffolded + registered
- [ ] 2 migrations (`courier_orders`, `courier_order_tracking_events`)
- [ ] `CourierOrder` model: Filterable, SoftDeletes, nonTerminal scope, cross-module relations
- [ ] `CourierOrderTrackingEvent` model: append-only, no SoftDeletes
- [ ] `CourierOrderFilter`: search, providerKey, deliveryId, orderId, date ranges + CommonFilter
- [ ] `CourierOrderService`: createFromOrder, dispatch, updateStatus, syncTracking, buildPayload
- [ ] `CourierOrderController`: all standard + dispatch/updateStatus/sync (all redirect)
- [ ] Routes: action routes declared before `Route::resource`
- [ ] `CourierOrder/Index.tsx`: 5 stat cards, DataTable, filters, bulk actions
- [ ] `CourierOrder/Show.tsx`: gradient header, conditional action buttons, tracking timeline, raw response
- [ ] `CourierOrder/Create.tsx`: order AJAX select, courier select, form fields
- [ ] 3 Events declared
- [ ] 3 Listeners registered in EventServiceProvider
- [ ] `CourierDispatchedMail`, `CourierDeliveredMail` mail classes

### Phase 4 — CourierWebhook Module
- [ ] Module `DeliveryApp/CourierWebhook` scaffolded + registered
- [ ] `VerifyCsrfToken`: webhook paths added to `$except`
- [ ] `CourierWebhookController`: HMAC verify sync → queue → 200 (401 on bad sig only)
- [ ] `ProcessCourierWebhookJob`: queue `courier-webhooks`, tenant init, dedup events
- [ ] `PollCourierStatusJob`: non-terminal query, batch dispatch to `courier-tracking` queue
- [ ] `SyncSingleCourierTrackingJob`: silent on CourierApiException (no failed_jobs fill)
- [ ] `SyncCourierStatuses` Artisan command (`courier:sync`)
- [ ] Schedule: `everyThirtyMinutes()->onOneServer()` in ServiceProvider
- [ ] `TrackingController`: public, no auth, works by tracking_code or consignment_id
- [ ] `Tracking/Show.tsx`: no AppLayout, public page, not-found state
- [ ] Public routes in tenant.php (no auth group)

### Phase 5 — Notifications
- [ ] `CourierOrderDispatchedEvent` + `CourierOrderStatusChangedEvent` + `CourierOrderDeliveredEvent`
- [ ] `SendDispatchNotificationListener` (queued)
- [ ] `SendStatusChangeNotificationListener` (queued — only for on_the_way/delivered/failed)
- [ ] `UpdateOrderShippingTimestampsListener` (synchronous — writes delivered_at)
- [ ] All registered in EventServiceProvider

---

## Tests

### `WebhookTest`
> `DeliveryApp/CourierWebhook/tests/Feature/WebhookTest.php` — use `RefreshDatabase`.
>
> - Valid HMAC + valid consignment → `ProcessCourierWebhookJob` dispatched, returns 200
> - Invalid HMAC → returns 401, no job dispatched
> - Missing signature header → returns 401
> - Valid payload but consignment_id not in our DB → job runs, returns early silently
> - Duplicate event (same occurred_at + event_type) → no new `CourierOrderTrackingEvent` row

### `CourierOrderTest`
> `DeliveryApp/CourierOrder/tests/Feature/CourierOrderTest.php` — use `RefreshDatabase`.
>
> - `dispatch()` on pending order: creates consignment, sets status='assigned', appends tracking event
> - `dispatch()` on CourierApiException: status set to 'failed', exception rethrown
> - `updateStatus()` with 'delivered': `delivered_at` set, event appended, `CourierOrderStatusChangedEvent` fired
> - `syncTracking()` dedup: processing same event twice → only one TrackingEvent row
> - `nonTerminal()` scope: delivered/returned/failed orders excluded
> - POST `/courier-orders/{id}/dispatch` → redirect (NEVER json)
> - `UpdateOrderShippingTimestampsListener` sync: order `delivered_at` written before redirect

---

## Design Decisions

**Why `orders.delivery_method/charge/type/shipped_at` and not a separate snapshot table?**
These columns already exist on `orders`. Writing them at courier dispatch provides the same
immutable snapshot semantics without an additional table. `courier_orders` stores the full
raw API response for audit purposes.

**Why verify HMAC synchronously before queuing?**
The HMAC check is a security gate. If we queued first and checked in the job, malicious actors
could flood the queue with invalid payloads. Verify at the controller, reject with 401 on failure,
dispatch to queue only on success.

**Why 401 on bad webhook signature (not 200)?**
The general rule "always return 200" exists so legitimate couriers don't stop retrying on transient
errors. A bad HMAC is NOT a transient error — it is either a misconfiguration or an attack. 401
signals the courier to stop retrying with the same (invalid) payload and alerts the developer.

**Why `SyncSingleCourierTrackingJob` catches `CourierApiException` silently?**
Polling failures are common (API timeouts, rate limits). If we let them throw, every polling
failure fills `failed_jobs`. Logging a warning and moving on is correct — the next poll cycle
will retry naturally.

**Why `UpdateOrderShippingTimestampsListener` is NOT queued?**
If queued, there's a race condition between the redirect response and the queue worker writing
`delivered_at`. Any immediately-following order load would show the old value. Synchronous
execution ensures the timestamp is written before the HTTP response.
