# Courier Driver System — Phase 1 + Phase 6
### `DeliveryApp/Delivery` module · Multi-Tenant Laravel + Inertia/React

> **Scope:** Extend the existing Delivery module with courier drivers, encrypted credentials, status
> normalization, and settings UI.
> **Depends on:** Nothing — implement this first.
> **Next:** `03_courier_order_plan.md` (depends on CourierManager from here)

---

## Architecture — Courier Integration Layer

```
Admin configures credentials (Phase 6 Settings UI)
        ↓
deliveries table — provider_key + encrypted credentials
        ↓
CourierManager::driver(Delivery $provider)
  ├── SteadfastDriver  (API Key, polling only)
  ├── PathaoDriver     (OAuth2, stores token in credentials column)
  └── DhlDriver        (Basic Auth, PDF label)
        ↓
CourierOrderService::dispatch() — see 03_courier_order_plan.md
```

---

## Adaptation from WooCommerce

| WooCommerce Concept | Our Implementation |
|--------------------|--------------------|
| Courier/Shipping plugin | `DeliveryApp/Delivery` with encrypted `credentials` per provider |
| Tracking timeline | `courier_order_tracking_events` (append-only) — see file 03 |
| Settings API | `Setting` model (`key`, `value`, `module_id='delivery'`) — no new table |
| Manual `when()`/`where()` filters | `EloquentFilter + ModelFilter` (CLAUDE.md hard rule) |
| `SettingsRepository` singleton | `CourierSettingService` using existing `Setting` model |

### Why No `.env` for Credentials?
Multi-tenant: every tenant has their own courier accounts. `.env` is global. All credentials live
in the `credentials` column of `deliveries`, cast as `encrypted:array` (AES-256-CBC via APP_KEY).
Per-tenant naturally because each tenant has a separate database.

---

## Courier Support Matrix

| Courier   | Region         | Auth       | Webhook | COD | Label  |
|-----------|----------------|------------|---------|-----|--------|
| Steadfast | Bangladesh 🇧🇩 | API Key    | ❌ Poll | ✅  | ❌     |
| Pathao    | Bangladesh 🇧🇩 | OAuth2     | ✅ Yes  | ✅  | ❌     |
| DHL       | International  | Basic Auth | ✅ Yes  | ❌  | ✅ PDF |

---

## Database Schema

**`deliveries` table — existing columns extended**
```
Existing: id, uid, title, description, status (tinyint, StatusEnum), soft_deletes, timestamps

New columns (migration):
  provider_key     string(50) nullable unique — 'steadfast' | 'pathao' | 'dhl'
  supports_cod     boolean default false
  supports_webhook boolean default false
  credentials      text nullable — cast encrypted:array; stores per-provider API keys/tokens
  is_test_mode     boolean default true

Index: provider_key
```

**`settings` table — existing, no schema changes**
```
id, key (string unique), value (text nullable), module_id (unsignedBigInteger nullable),
soft_deletes, timestamps

Delivery-related keys (module_id = 'delivery'):
  delivery.default_provider_id
  delivery.polling_interval_minutes
  delivery.webhook_secret_pathao
  delivery.webhook_secret_dhl
  delivery.auto_assign_courier
```

---

## Module Structure (Delivery subtree only)

```
DeliveryApp/Delivery/
├── app/
│   ├── Http/Controllers/
│   │   ├── DeliveryController.php          (existing CRUD — no changes needed)
│   │   └── DeliverySettingsController.php  (NEW — Phase 6)
│   ├── Models/
│   │   └── Delivery.php                    (extend: add fillable + casts)
│   ├── Services/
│   │   ├── DeliveryService.php             (existing — no changes)
│   │   ├── CourierSettingService.php       (NEW)
│   │   ├── CourierManager.php              (NEW — singleton driver factory)
│   │   ├── StatusNormalizer.php            (NEW)
│   │   └── Couriers/
│   │       ├── CourierDriverInterface.php  (NEW)
│   │       ├── SteadfastDriver.php         (NEW)
│   │       ├── PathaoDriver.php            (NEW)
│   │       └── DhlDriver.php               (NEW)
│   └── Providers/
│       └── DeliveryServiceProvider.php    (register CourierManager singleton)
└── database/
    ├── migrations/
    │   └── XXXX_alter_deliveries_add_courier_columns.php
    └── seeders/
        └── CourierProviderSeeder.php

app/
├── Support/
│   └── SettingKeys.php      (NEW — delivery setting key constants)
└── Exceptions/
    └── CourierApiException.php (NEW)
```

---

## Phase 1 — Extend `Delivery` Module

### Step 1.1 · Scaffold Commands

No new module. Extend the existing `DeliveryApp/Delivery` module.

---

### Step 1.2 · Migration — Extend `deliveries` Table

> 🤖 **Agent Prompt:**
> Create a Laravel migration at `DeliveryApp/Delivery/database/migrations/` to alter the `deliveries` table.
>
> Add these columns:
> - `provider_key` string(50) nullable — values: 'steadfast' | 'pathao' | 'dhl'
> - `supports_cod` boolean default false
> - `supports_webhook` boolean default false
> - `credentials` text nullable — stores encrypted JSON (model cast handles encryption)
> - `is_test_mode` boolean default true
>
> Add unique index on `provider_key`.
>
> Namespace: `DeliveryApp\Delivery\Database\Migrations`

---

### Step 1.3 · Update `Delivery` Model

> 🤖 **Agent Prompt:**
> Update `DeliveryApp/Delivery/app/Models/Delivery.php`.
>
> Add to `$fillable`: `provider_key`, `supports_cod`, `supports_webhook`, `credentials`, `is_test_mode`
>
> Add to `$casts`:
> ```php
> 'credentials'      => 'encrypted:array',
> 'supports_cod'     => 'boolean',
> 'supports_webhook' => 'boolean',
> 'is_test_mode'     => 'boolean',
> ```
>
> Keep all existing traits (HasFactory, LogsActivity, SoftDeletes, Filterable) and uid auto-generate boot logic.
>
> Add scope: `scopeActive($q)` — where status = StatusEnum::ACTIVE->value

---

### Step 1.4 · `SettingKeys` Constants Class

> 🤖 **Agent Prompt:**
> Create `app/Support/SettingKeys.php` as a final class (no instantiation).
>
> ```php
> final class SettingKeys
> {
>     const DELIVERY_DEFAULT_PROVIDER = 'delivery.default_provider_id';
>     const DELIVERY_POLLING_INTERVAL = 'delivery.polling_interval_minutes';
>     const PATHAO_WEBHOOK_SECRET     = 'delivery.webhook_secret_pathao';
>     const DHL_WEBHOOK_SECRET        = 'delivery.webhook_secret_dhl';
>     const DELIVERY_AUTO_ASSIGN      = 'delivery.auto_assign_courier';
>
>     public static function deliveryGroup(): string { return 'delivery'; }
> }
> ```

---

### Step 1.5 · `CourierSettingService`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/Delivery/app/Services/CourierSettingService.php`.
>
> Inject `App\Models\Setting`.
>
> Methods:
> - `getGlobalSetting(string $key, mixed $default = null): mixed`
>   — `Setting::where('key', $key)->where('module_id', 'delivery')->value('value') ?? $default`
> - `setGlobalSetting(string $key, mixed $value): void`
>   — `Setting::updateOrCreate(['key' => $key], ['value' => $value, 'module_id' => 'delivery'])`
> - `getGlobalSettings(): array`
>   — fetch all settings where module_id='delivery' as key→value array using `SettingKeys` constants
> - `getCredentials(Delivery $provider): array`
>   — returns `$provider->credentials ?? []` (cast decrypts automatically)
> - `setCredentials(Delivery $provider, array $raw): void`
>   — `$provider->update(['credentials' => $raw])` (cast encrypts automatically)
> - `getWebhookSecret(string $providerKey): ?string`
>   — reads PATHAO_WEBHOOK_SECRET or DHL_WEBHOOK_SECRET from settings
>
> This is the ONLY class that reads/writes delivery-related settings rows.

---

### Step 1.6 · `CourierApiException`

> 🤖 **Agent Prompt:**
> Create `app/Exceptions/CourierApiException.php`.
>
> Extends `\RuntimeException`. Constructor: `string $message, string $providerKey, int $httpStatus = 0, ?\Throwable $previous = null`.
> Store `$providerKey` and `$httpStatus` as public readonly properties.

---

### Step 1.7 · `CourierDriverInterface`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/Delivery/app/Services/Couriers/CourierDriverInterface.php`.
>
> ```php
> interface CourierDriverInterface
> {
>     // Returns: ['consignment_id' => string, 'tracking_code' => string, 'charge' => float, 'label_url' => ?string]
>     public function createShipment(array $payload): array;
>
>     public function cancelShipment(string $consignmentId): bool;
>
>     // Returns: ['status' => string (raw), 'events' => [['status','description','location','occurred_at']]]
>     public function trackShipment(string $trackingCode): array;
>
>     // Parses courier webhook payload → ['consignment_id' => string, 'raw_status' => string, 'events' => [...]]
>     public function parseWebhookPayload(array $data): array;
>
>     public function getProviderKey(): string;
> }
> ```
>
> All implementations must wrap Http calls in try-catch and throw `CourierApiException` on non-2xx.
> Use `Http::retry(3, 500)->timeout(15)` on all requests.

---

### Step 1.8 · `SteadfastDriver`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/Delivery/app/Services/Couriers/SteadfastDriver.php` implementing `CourierDriverInterface`.
>
> Constructor: `array $credentials` (from `CourierSettingService::getCredentials()`).
>
> Base URL: `https://portal.steadfast.com.bd/public/api/v1`
> Auth headers: `X-API-Key: $credentials['api_key']`, `X-Secret-Key: $credentials['secret_key']`
>
> `createShipment(array $payload)`: POST `/create_order`.
> Body: `invoice`, `recipient_name`, `recipient_phone`, `recipient_address`, `cod_amount`, `note`.
> Parse response: consignment_id, tracking_code, status, charge.
>
> `trackShipment(string $trackingCode)`: GET `/status_by_trackingcode/{trackingCode}`.
> Return raw `delivery_status` in `events[0]['status']`.
>
> `cancelShipment()`: return false (no API endpoint).
>
> `parseWebhookPayload()`: Steadfast has no webhooks — throw `\LogicException('Steadfast does not support webhooks')`.
>
> `getProviderKey()`: return `'steadfast'`

---

### Step 1.9 · `PathaoDriver`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/Delivery/app/Services/Couriers/PathaoDriver.php` implementing `CourierDriverInterface`.
>
> Constructor: inject `Delivery $provider` and `CourierSettingService $settingService`.
>
> Auth: OAuth2 password grant. POST `https://merchant.pathao.com/aladdin/api/v1/issue-token`
> with `client_id`, `client_secret`, `username`, `password`, `grant_type=password`.
>
> Cache the access token in `$provider->credentials` (re-encrypt via `$settingService->setCredentials()`).
> Store `access_token` and `token_expires_at`. Before each request call `ensureToken()` —
> if `token_expires_at < now() + 60 seconds`, re-fetch and re-store.
>
> `createShipment()`: POST `/orders`.
> Body: `store_id` (from credentials), `merchant_order_id`, `recipient_name`, `recipient_phone`,
> `recipient_address`, `recipient_city`, `recipient_zone`, `delivery_type` (default 48),
> `item_type=2`, `item_quantity=1`, `item_weight`, `amount_to_collect`, `item_description`.
>
> `trackShipment()`: GET `/orders/{consignment_id}/info`. Map log array to events.
>
> `parseWebhookPayload()`: extract `consignment_id`, `order_status` from Pathao payload body.
>
> `getProviderKey()`: return `'pathao'`

---

### Step 1.10 · `DhlDriver`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/Delivery/app/Services/Couriers/DhlDriver.php` implementing `CourierDriverInterface`.
>
> Constructor: `array $credentials`.
>
> Auth: `Http::withBasicAuth($credentials['api_key'], '')`.
> Base URL: `$credentials['is_sandbox']` → `https://api-mock.dhl.com/mydhl`
>             else `https://express.api.dhl.com/mydhlapi`
>
> `createShipment()`: POST `/shipments`. Build payload using `credentials` shipper info
> (shipper_name, shipper_phone, shipper_address, shipper_city, shipper_country) + receiver from `$payload`.
> Set `plannedShippingDateAndTime` = tomorrow 09:00 UTC.
> Parse response: `shipmentTrackingNumber`. Find document with `typeCode="label"`, base64-decode,
> store as `storage/app/public/courier-labels/dhl-{tracking}.pdf`, return `Storage::url()` as `label_url`.
>
> `trackShipment()`: GET `/shipments/{trackingCode}/tracking?trackingView=all-checkpoints`.
> Map `shipments[0].events` array.
>
> `parseWebhookPayload()`: extract `shipmentTrackingNumber` and latest event status code.
>
> `getProviderKey()`: return `'dhl'`

---

### Step 1.11 · `StatusNormalizer`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/Delivery/app/Services/StatusNormalizer.php`.
>
> Use existing `App\Enums\DeliveryOrderStatusEnum` as target values:
> pending, assigned, picked_up, on_the_way, delivered, failed, returned.
>
> Static lookup maps per provider:
>
> **Steadfast:**
> "Pending"→pending, "Delivered to Courier"→assigned, "Received by Courier"→assigned,
> "In Transit"→on_the_way, "Delivered"→delivered, "Partial Delivered"→delivered,
> "Cancelled"→failed, "Hold"→pending, "Returned"→returned, "Partially Returned"→returned
>
> **Pathao:**
> "Pending"→pending, "Pickup Requested"→pending, "Picked"→picked_up,
> "In Transit"→on_the_way, "Out for Delivery"→on_the_way,
> "Delivered"→delivered, "Return"→returned, "Return In Transit"→returned,
> "Return Received"→returned, "Cancelled"→failed, "Hold"→pending
>
> **DHL event codes:**
> "PU"→picked_up, "PL"→picked_up, "DF"→on_the_way, "AR"→on_the_way,
> "WC"→on_the_way, "OK"→delivered, "BD"→failed, "RT"→returned, "HP"→pending, "CM"→failed
>
> Static methods:
> - `normalize(string $providerKey, string $rawStatus): string` → mapped value or 'pending'
> - `getLabel(string $status): string` → `DeliveryOrderStatusEnum::from($status)->label()`
> - `isTerminal(string $status): bool` → true for delivered, returned, failed

---

### Step 1.12 · `CourierManager`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/Delivery/app/Services/CourierManager.php`.
>
> Constructor: inject `CourierSettingService $settingService`.
>
> ```php
> public function driver(Delivery $provider): CourierDriverInterface
> {
>     $creds = $this->settingService->getCredentials($provider);
>     return match($provider->provider_key) {
>         'steadfast' => new SteadfastDriver($creds),
>         'pathao'    => new PathaoDriver($provider, $this->settingService),
>         'dhl'       => new DhlDriver($creds),
>         default     => throw new \InvalidArgumentException("Unknown provider_key: {$provider->provider_key}"),
>     };
> }
> ```
>
> Register as singleton in `DeliveryServiceProvider::register()`:
> ```php
> $this->app->singleton(CourierManager::class, fn($app) =>
>     new CourierManager($app->make(CourierSettingService::class))
> );
> ```

---

### Step 1.13 · Courier Provider Seeder

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/Delivery/database/seeders/CourierProviderSeeder.php`.
>
> Use `Delivery::updateOrCreate(['provider_key' => ...], [...])` to seed three rows:
>
> - Steadfast: title='Steadfast', provider_key='steadfast', supports_cod=true,
>   supports_webhook=false, is_test_mode=false, status=ACTIVE, credentials=[]
> - Pathao: title='Pathao', provider_key='pathao', supports_cod=true,
>   supports_webhook=true, is_test_mode=false, status=ACTIVE, credentials=[]
> - DHL: title='DHL Express', provider_key='dhl', supports_cod=false,
>   supports_webhook=true, is_test_mode=true, status=ACTIVE, credentials=[]

---

## Phase 6 — Delivery Settings UI
*(Bundled here: both steps touch the Delivery module opened in Phase 1)*

### Step 6.1 · `DeliverySettingsController`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/Delivery/app/Http/Controllers/DeliverySettingsController.php`.
>
> `index()`: return `Inertia::render('DeliverySettings/Index', [
>     'settings'  => $settingService->getGlobalSettings(),
>     'providers' => Delivery::active()->get(),
> ])`.
>
> `update(Request $request)`: validate then call `$settingService->setGlobalSetting()` for each
> **whitelisted key only**:
> - `delivery.default_provider_id`
> - `delivery.polling_interval_minutes`
> - `delivery.auto_assign_courier`
> - `delivery.webhook_secret_pathao`
> - `delivery.webhook_secret_dhl`
>
> Return `redirect()->back()->with('success', 'Settings saved.')`
>
> Add routes to `DeliveryApp/Delivery/routes/tenant.php`:
> ```php
> Route::get('delivery/settings',  [DeliverySettingsController::class, 'index'])->name('delivery.settings.index');
> Route::post('delivery/settings', [DeliverySettingsController::class, 'update'])->name('delivery.settings.update');
> ```

---

### Step 6.2 · Credential Endpoints on `DeliveryController`

> 🤖 **Agent Prompt:**
> Add two methods to the existing `DeliveryApp/Delivery/app/Http/Controllers/DeliveryController.php`.
>
> `showCredentials(int $id)` GET: load provider, decrypt credentials (cast handles it),
> mask sensitive values (show last 4 chars, rest as ****). Return Inertia component.
>
> `updateCredentials(Request $request, int $id)` POST:
> Validate and whitelist credential keys per provider_key:
> - steadfast: api_key, secret_key
> - pathao: client_id, client_secret, username, password, store_id
> - dhl: api_key, account_number, is_sandbox, shipper_name, shipper_phone,
>         shipper_address, shipper_city, shipper_country
>
> Call `$settingService->setCredentials($provider, $validated)`.
> Return `redirect()->route('delivery.show', $id)->with('success', 'Credentials updated.')`
>
> Add routes (before `Route::resource('delivery', ...)` in tenant.php):
> ```php
> Route::get('delivery/{id}/credentials',  [DeliveryController::class, 'showCredentials'])->name('delivery.credentials');
> Route::post('delivery/{id}/credentials', [DeliveryController::class, 'updateCredentials'])->name('delivery.credentials.update');
> ```

---

## Phase 1 + 6 Checklist

### Phase 1 — Extend Delivery Module
- [ ] Migration: alter `deliveries` (provider_key, credentials, supports_cod, supports_webhook, is_test_mode)
- [ ] `Delivery` model: new fillable + `encrypted:array` cast + `scopeActive()`
- [ ] `SettingKeys` constants class at `app/Support/SettingKeys.php`
- [ ] `CourierApiException` at `app/Exceptions/CourierApiException.php`
- [ ] `CourierSettingService` (wraps existing Setting model)
- [ ] `CourierDriverInterface` contract
- [ ] `SteadfastDriver` (API key auth, poll-only)
- [ ] `PathaoDriver` (OAuth2, token stored in credentials column)
- [ ] `DhlDriver` (Basic auth, PDF label storage)
- [ ] `StatusNormalizer` (all 3 providers → DeliveryOrderStatusEnum)
- [ ] `CourierManager` singleton registered in ServiceProvider
- [ ] `CourierProviderSeeder` (3 rows)

### Phase 6 — Settings UI
- [ ] `DeliverySettingsController` (global settings — whitelisted keys only)
- [ ] Credential endpoints on `DeliveryController` (per-provider whitelist)
- [ ] Settings page route added to tenant.php
- [ ] Credential routes added (before Route::resource)

---

## Tests

### `SteadfastDriverTest`
> `DeliveryApp/Delivery/tests/Unit/SteadfastDriverTest.php` — use `Http::fake()`.
> - `createShipment` returns `['consignment_id', 'tracking_code', 'charge']`
> - `trackShipment` maps "Delivered" → raw status string
> - API returns 422 → throws `CourierApiException`
> - Retry on 503 (`Http::fake` sequence)

### `StatusNormalizerTest`
> `DeliveryApp/Delivery/tests/Unit/StatusNormalizerTest.php`
> - All Steadfast raw statuses → correct DeliveryOrderStatusEnum values
> - All Pathao raw statuses → correct values
> - All DHL event codes → correct values
> - Unknown status → 'pending'

### `CourierSettingsTest`
> `DeliveryApp/Delivery/tests/Feature/CourierSettingsTest.php` — use `RefreshDatabase`.
> - `setCredentials` encrypts + `getCredentials` decrypts correctly
> - POST with arbitrary key → rejected (only whitelisted keys saved)
> - Unauthenticated request → 401

---

## Design Decisions

**Why `encrypted:array` cast on `credentials`?**
Laravel's `encrypted:array` uses AES-256-CBC with the tenant's APP_KEY. Encrypt on write, decrypt on read, automatically. Since each tenant has a separate database, credentials are naturally per-tenant.

**Why `DeliveryOrderStatusEnum` not new constants?**
The project already has `DeliveryOrderStatusEnum` (pending, assigned, picked_up, on_the_way,
delivered, failed, returned) with `label()` methods. `StatusNormalizer` maps all 3 APIs to this
single shared enum.

**Why Pathao token stored in `credentials` column?**
Pathao access tokens expire and are per-tenant. Redis with tenant-scoped keys adds complexity and
risks cold-start misses. Storing in the encrypted `credentials` column is durable, no cache miss,
naturally tenant-scoped.
