# Shipping Module

Label purchase and tracking webhook integration for fulfilled orders.

## Responsibilities

- Label generation through a pluggable `LabelProvider` contract (`EasyPostProvider` in prod, `FakeLabelProvider` in dev/test)
- Tracker webhook ingestion (`POST /v1/webhooks/easypost`) with HMAC verification and Redis-keyed idempotency
- Status mapping from carrier events → internal `Order.status` transitions (shipped → delivered / failed)
- Shipping cost calculation (flat rate per store, set in `store_settings.flat_shipping_rate`)

## Components

- `Contracts/LabelProvider.php` — interface (`buyCheapestLabel`, `parseTrackerEvent`, `verifyWebhookSignature`)
- `Services/EasyPostProvider.php` — production adapter, signs EasyPost HMAC, buys cheapest rate
- `Services/FakeLabelProvider.php` — deterministic fixture for tests and local dev (signature `fake-signature-ok`)
- `Services/TrackingService.php` — consumes parsed events, delegates to `OrderFulfillmentService::markDelivered` or dispatches `OrderDeliveryFailed`
- `Controllers/ShippingWebhookController.php` — verifies signature, dedupes by EasyPost `event.id` (24h TTL)
- DTOs: `ShipmentRequest`, `PurchasedLabel`

## Environment

```
EASYPOST_API_KEY=                # empty in local → FakeLabelProvider
EASYPOST_WEBHOOK_SECRET=
EASYPOST_ENVIRONMENT=test        # test | production
```

Binding is in `App\Providers\ShippingServiceProvider`: when `app()->environment('testing')` or `EASYPOST_API_KEY` is null, `FakeLabelProvider` is bound. Otherwise the real `EasyPostProvider` is bound with an `EasyPostClient`.

## Local webhook testing

EasyPost needs a public URL. Use `ngrok` or Cloudflare Tunnel:

```bash
ngrok http 8000
# then register https://<id>.ngrok.app/v1/webhooks/easypost in the EasyPost dashboard
```

## Tracker statuses handled

| EasyPost status     | Action                                          |
|---------------------|-------------------------------------------------|
| `delivered`         | `markDelivered` → `OrderDelivered` event        |
| `return_to_sender`  | log + `OrderDeliveryFailed`                     |
| `failure` / `error` | log + `OrderDeliveryFailed`                     |
| others              | no-op (pre_transit, in_transit, out_for_delivery) |

## Idempotency

The webhook uses a Redis key `easypost:event:{event_id}` with 24h TTL. A second delivery of the same `event.id` short-circuits with `{ "status": "duplicate" }`.
