# Layer 5: Order Fulfillment & Shipping Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Wire EasyPost label purchase, buyer tracking UI, ship-by reminder jobs, cancellation flows, and Stripe Transfer-on-ship into the Alqove marketplace.

**Architecture:** New `LabelProvider` contract with `EasyPostProvider` production adapter and `FakeLabelProvider` test adapter. Orders module gains `OrderFulfillmentService` and `CancellationService`. Layer 4 webhook stops creating Transfers and dispatches `OrderPaid`; a listener schedules three per-Order delayed jobs for reminder/delay/auto-cancel and another listener creates the Stripe Transfer when `OrderShipped` fires. EasyPost webhook drives delivery confirmation. Stripe dispute webhook flags the Purchase and blocks future Transfers. Buyer tracking UI reads a computed `is_delayed` attribute and gains a cancel action.

**Tech Stack:** Laravel 11, `easypost/easypost-php`, Stripe PHP SDK (already installed), Redis, Next.js 15 App Router, TanStack Query, shadcn/ui.

**Spec:** `docs/superpowers/specs/2026-04-16-layer-5-fulfillment-shipping-design.md`

**Prerequisites:** Layer 4 complete. Existing `orders` table has `stripe_transfer_id`, `tracking_number`, `shipping_label_url`, `ship_by`, `shipped_at`, `delivered_at`, `cancelled_by`, `cancellation_reason`, `cancelled_at`. Existing `CancellationReason` enum has `SoldLocally`, `ItemDamaged`, `BuyerRequested`, `ShipDeadlineExceeded`, `StoreSuspended`.

---

## Task 1: Install EasyPost SDK and configure

**Files:**
- Modify: `api/composer.json` (via composer require)
- Modify: `api/config/services.php`
- Modify: `api/.env.example`

- [ ] **Step 1: Install `easypost/easypost-php`**

```bash
cd Alqove && docker compose exec laravel.test composer require easypost/easypost-php
```

Expected: `composer.json` and `composer.lock` updated, autoload regenerated.

- [ ] **Step 2: Add EasyPost config to `api/config/services.php`**

Append inside the `return [...]` array, after the `stripe` block:

```php
'easypost' => [
    'api_key' => env('EASYPOST_API_KEY'),
    'webhook_secret' => env('EASYPOST_WEBHOOK_SECRET'),
    'environment' => env('EASYPOST_ENVIRONMENT', 'test'),
],
```

- [ ] **Step 3: Append to `api/.env.example`**

```
EASYPOST_API_KEY=
EASYPOST_WEBHOOK_SECRET=
EASYPOST_ENVIRONMENT=test
```

- [ ] **Step 4: Commit**

```bash
git add Alqove/api/composer.json Alqove/api/composer.lock Alqove/api/config/services.php Alqove/api/.env.example
git commit -m "chore(shipping): install easypost-php and add config"
```

---

## Task 2: Extend `orders` migration fields

**Files:**
- Create: `api/database/migrations/2026_04_16_000001_add_fulfillment_fields_to_orders_table.php`
- Create: `api/database/migrations/2026_04_16_000002_add_disputed_to_purchases_table.php`

- [ ] **Step 1: Write orders migration**

Content of `add_fulfillment_fields_to_orders_table.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->string('tracker_id')->nullable()->after('tracking_number');
            $table->string('tracking_url')->nullable()->after('tracker_id');
            $table->string('carrier')->nullable()->after('tracking_url');
            $table->string('service')->nullable()->after('carrier');
            $table->timestamp('label_purchased_at')->nullable()->after('shipping_label_url');
            $table->timestamp('transferred_at')->nullable()->after('stripe_transfer_id');
            $table->index('tracker_id');
        });
    }

    public function down(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->dropIndex(['tracker_id']);
            $table->dropColumn([
                'tracker_id',
                'tracking_url',
                'carrier',
                'service',
                'label_purchased_at',
                'transferred_at',
            ]);
        });
    }
};
```

- [ ] **Step 2: Write purchases migration**

Content of `add_disputed_to_purchases_table.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('purchases', function (Blueprint $table) {
            $table->boolean('disputed')->default(false)->after('status');
            $table->index('disputed');
        });
    }

    public function down(): void
    {
        Schema::table('purchases', function (Blueprint $table) {
            $table->dropIndex(['disputed']);
            $table->dropColumn('disputed');
        });
    }
};
```

- [ ] **Step 3: Run migrations and verify**

```bash
cd Alqove && docker compose exec laravel.test php artisan migrate
```

Expected: both migrations listed as `[done]`.

- [ ] **Step 4: Update `Order` model fillable + casts**

In `api/app/Models/Order.php`, add to `$fillable`:

```php
'tracker_id',
'tracking_url',
'carrier',
'service',
'label_purchased_at',
'transferred_at',
```

Add to `casts()`:

```php
'label_purchased_at' => 'datetime',
'transferred_at' => 'datetime',
```

- [ ] **Step 5: Update `Purchase` model fillable + casts**

In `api/app/Models/Purchase.php`, add `'disputed'` to `$fillable` and `'disputed' => 'boolean'` to `casts()`.

- [ ] **Step 6: Commit**

```bash
git add Alqove/api/database/migrations/2026_04_16_000001_add_fulfillment_fields_to_orders_table.php \
        Alqove/api/database/migrations/2026_04_16_000002_add_disputed_to_purchases_table.php \
        Alqove/api/app/Models/Order.php \
        Alqove/api/app/Models/Purchase.php
git commit -m "feat(orders): add tracker/carrier/label/transfer fields and Purchase.disputed flag"
```

---

## Task 3: Add ship-from address to stores

**Files:**
- Create: `api/database/migrations/2026_04_16_000003_add_ship_from_address_to_stores_table.php`
- Modify: `api/app/Models/Store.php`
- Modify: `api/app/Modules/Stores/Requests/UpdateStoreRequest.php` (or equivalent)
- Modify: `api/app/Modules/Stores/Resources/StoreResource.php`

- [ ] **Step 1: Write migration**

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('stores', function (Blueprint $table) {
            $table->string('street1')->nullable()->after('state');
            $table->string('street2')->nullable()->after('street1');
            $table->string('zip')->nullable()->after('street2');
            $table->string('country', 2)->default('US')->after('zip');
        });
    }

    public function down(): void
    {
        Schema::table('stores', function (Blueprint $table) {
            $table->dropColumn(['street1', 'street2', 'zip', 'country']);
        });
    }
};
```

Run: `docker compose exec laravel.test php artisan migrate`.

- [ ] **Step 2: Update `Store` model**

Add to `$fillable` in `api/app/Models/Store.php`: `'street1', 'street2', 'zip', 'country'`. Add an accessor:

```php
public function hasCompleteShipFromAddress(): bool
{
    return filled($this->street1) && filled($this->city) && filled($this->state) && filled($this->zip);
}
```

- [ ] **Step 3: Update `UpdateStoreRequest` rules**

Open the request class used by `PUT /stores/{store}` (check `api/app/Modules/Stores/Requests/` or grep for the controller's `authorize/rules`). Add to the rules array:

```php
'street1' => ['sometimes', 'nullable', 'string', 'max:255'],
'street2' => ['sometimes', 'nullable', 'string', 'max:255'],
'zip' => ['sometimes', 'nullable', 'string', 'max:20'],
'country' => ['sometimes', 'nullable', 'string', 'size:2'],
```

- [ ] **Step 4: Update `StoreResource`**

Add `'street1' => $this->street1, 'street2' => $this->street2, 'zip' => $this->zip, 'country' => $this->country` to the returned array.

- [ ] **Step 5: Write a feature test asserting the address persists**

Create `api/tests/Feature/Stores/UpdateStoreAddressTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Stores;

use App\Models\Store;
use App\Models\User;
use Tests\TestCase;

class UpdateStoreAddressTest extends TestCase
{
    public function test_owner_can_update_ship_from_address(): void
    {
        $user = User::factory()->create();
        $store = Store::factory()->create(['owner_user_id' => $user->id]);

        $response = $this->actingAs($user)
            ->putJson("/v1/stores/{$store->id}", [
                'street1' => '123 Main St',
                'zip' => '97201',
                'country' => 'US',
            ]);

        $response->assertOk();
        $this->assertSame('123 Main St', $store->fresh()->street1);
        $this->assertSame('97201', $store->fresh()->zip);
    }
}
```

- [ ] **Step 6: Run, verify green, commit**

```bash
docker compose exec laravel.test php artisan test --filter=UpdateStoreAddressTest
git add Alqove/api/database/migrations/2026_04_16_000003_add_ship_from_address_to_stores_table.php \
        Alqove/api/app/Models/Store.php \
        Alqove/api/app/Modules/Stores/Requests/ \
        Alqove/api/app/Modules/Stores/Resources/StoreResource.php \
        Alqove/api/tests/Feature/Stores/UpdateStoreAddressTest.php
git commit -m "feat(stores): add ship-from address fields"
```

---

## Task 4: Create `store_parcel_presets` table and model

**Files:**
- Create: `api/database/migrations/2026_04_16_000004_create_store_parcel_presets_table.php`
- Create: `api/app/Models/StoreParcelPreset.php`
- Create: `api/database/factories/StoreParcelPresetFactory.php`

- [ ] **Step 1: Write migration**

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('store_parcel_presets', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->foreignUuid('store_id')->constrained('stores')->cascadeOnDelete();
            $table->string('name');
            $table->unsignedSmallInteger('weight_oz');
            $table->unsignedSmallInteger('length_in');
            $table->unsignedSmallInteger('width_in');
            $table->unsignedSmallInteger('height_in');
            $table->boolean('is_default')->default(false);
            $table->timestamps();

            $table->index('store_id');
        });

        DB::statement('CREATE UNIQUE INDEX store_parcel_presets_default_unique ON store_parcel_presets (store_id) WHERE is_default = true');
    }

    public function down(): void
    {
        Schema::dropIfExists('store_parcel_presets');
    }
};
```

- [ ] **Step 2: Write `StoreParcelPreset` model**

```php
<?php

declare(strict_types=1);

namespace App\Models;

use App\Support\Traits\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class StoreParcelPreset extends Model
{
    use HasFactory;
    use HasUuid;

    protected $fillable = [
        'store_id',
        'name',
        'weight_oz',
        'length_in',
        'width_in',
        'height_in',
        'is_default',
    ];

    protected function casts(): array
    {
        return [
            'weight_oz' => 'integer',
            'length_in' => 'integer',
            'width_in' => 'integer',
            'height_in' => 'integer',
            'is_default' => 'boolean',
        ];
    }

    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }
}
```

- [ ] **Step 3: Write factory**

```php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\Store;
use App\Models\StoreParcelPreset;
use Illuminate\Database\Eloquent\Factories\Factory;

class StoreParcelPresetFactory extends Factory
{
    protected $model = StoreParcelPreset::class;

    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'name' => 'Small poly mailer',
            'weight_oz' => 10,
            'length_in' => 12,
            'width_in' => 9,
            'height_in' => 1,
            'is_default' => false,
        ];
    }

    public function asDefault(): self
    {
        return $this->state(fn () => ['is_default' => true]);
    }
}
```

- [ ] **Step 4: Add relation to `Store` model**

In `api/app/Models/Store.php`:

```php
public function parcelPresets(): HasMany
{
    return $this->hasMany(StoreParcelPreset::class);
}
```

Import `use Illuminate\Database\Eloquent\Relations\HasMany;` if not already.

- [ ] **Step 5: Migrate and commit**

```bash
docker compose exec laravel.test php artisan migrate
git add Alqove/api/database/migrations/2026_04_16_000004_create_store_parcel_presets_table.php \
        Alqove/api/app/Models/StoreParcelPreset.php \
        Alqove/api/app/Models/Store.php \
        Alqove/api/database/factories/StoreParcelPresetFactory.php
git commit -m "feat(stores): add store_parcel_presets table and model"
```

---

## Task 5: Parcel preset CRUD endpoints

**Files:**
- Modify: `api/contracts/openapi.yaml`
- Create: `api/app/Modules/Stores/Controllers/ParcelPresetController.php`
- Create: `api/app/Modules/Stores/Requests/StoreParcelPresetRequest.php`
- Create: `api/app/Modules/Stores/Requests/UpdateParcelPresetRequest.php`
- Create: `api/app/Modules/Stores/Resources/ParcelPresetResource.php`
- Modify: `api/app/Modules/Stores/routes.php`
- Create: `api/tests/Feature/Stores/ParcelPresetCrudTest.php`

- [ ] **Step 1: Write failing feature test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Stores;

use App\Models\Store;
use App\Models\StoreParcelPreset;
use App\Models\User;
use Tests\TestCase;

class ParcelPresetCrudTest extends TestCase
{
    private User $owner;
    private Store $store;

    protected function setUp(): void
    {
        parent::setUp();
        $this->owner = User::factory()->create();
        $this->store = Store::factory()->create(['owner_user_id' => $this->owner->id]);
    }

    public function test_owner_can_list_presets(): void
    {
        StoreParcelPreset::factory()->count(2)->create(['store_id' => $this->store->id]);

        $response = $this->actingAs($this->owner)
            ->getJson("/v1/stores/{$this->store->id}/parcel-presets");

        $response->assertOk()->assertJsonCount(2, 'data');
    }

    public function test_owner_can_create_preset(): void
    {
        $response = $this->actingAs($this->owner)
            ->postJson("/v1/stores/{$this->store->id}/parcel-presets", [
                'name' => 'Medium box',
                'weight_oz' => 32,
                'length_in' => 12,
                'width_in' => 9,
                'height_in' => 6,
                'is_default' => true,
            ]);

        $response->assertCreated()->assertJsonPath('data.name', 'Medium box');
        $this->assertDatabaseCount('store_parcel_presets', 1);
    }

    public function test_default_toggle_is_exclusive(): void
    {
        $first = StoreParcelPreset::factory()->asDefault()->create(['store_id' => $this->store->id]);
        $second = StoreParcelPreset::factory()->create(['store_id' => $this->store->id]);

        $this->actingAs($this->owner)
            ->patchJson("/v1/stores/{$this->store->id}/parcel-presets/{$second->id}", ['is_default' => true])
            ->assertOk();

        $this->assertFalse($first->fresh()->is_default);
        $this->assertTrue($second->fresh()->is_default);
    }

    public function test_cannot_delete_last_preset(): void
    {
        $preset = StoreParcelPreset::factory()->create(['store_id' => $this->store->id]);

        $this->actingAs($this->owner)
            ->deleteJson("/v1/stores/{$this->store->id}/parcel-presets/{$preset->id}")
            ->assertStatus(409);
    }

    public function test_non_owner_cannot_touch_presets(): void
    {
        $other = User::factory()->create();
        $this->actingAs($other)
            ->getJson("/v1/stores/{$this->store->id}/parcel-presets")
            ->assertForbidden();
    }
}
```

- [ ] **Step 2: Run test, verify all 5 fail (routes don't exist → 404)**

```bash
docker compose exec laravel.test php artisan test --filter=ParcelPresetCrudTest
```

Expected: 5 failures.

- [ ] **Step 3: Write `ParcelPresetResource`**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Stores\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class ParcelPresetResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'store_id' => $this->store_id,
            'name' => $this->name,
            'weight_oz' => $this->weight_oz,
            'length_in' => $this->length_in,
            'width_in' => $this->width_in,
            'height_in' => $this->height_in,
            'is_default' => $this->is_default,
        ];
    }
}
```

- [ ] **Step 4: Write `StoreParcelPresetRequest`**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Stores\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreParcelPresetRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:100'],
            'weight_oz' => ['required', 'integer', 'min:1', 'max:1120'],
            'length_in' => ['required', 'integer', 'min:1', 'max:108'],
            'width_in' => ['required', 'integer', 'min:1', 'max:108'],
            'height_in' => ['required', 'integer', 'min:1', 'max:108'],
            'is_default' => ['sometimes', 'boolean'],
        ];
    }
}
```

- [ ] **Step 5: Write `UpdateParcelPresetRequest`**

Same as `StoreParcelPresetRequest` but all rules become `'sometimes'` instead of `'required'`.

- [ ] **Step 6: Write `ParcelPresetController`**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Stores\Controllers;

use App\Models\Store;
use App\Models\StoreParcelPreset;
use App\Modules\Stores\Requests\StoreParcelPresetRequest;
use App\Modules\Stores\Requests\UpdateParcelPresetRequest;
use App\Modules\Stores\Resources\ParcelPresetResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;

class ParcelPresetController
{
    public function index(Store $store): AnonymousResourceCollection
    {
        return ParcelPresetResource::collection($store->parcelPresets()->get());
    }

    public function store(StoreParcelPresetRequest $request, Store $store): JsonResponse
    {
        $data = $request->validated();
        $isDefault = (bool) ($data['is_default'] ?? false);

        $preset = DB::transaction(function () use ($store, $data, $isDefault) {
            if ($isDefault) {
                $store->parcelPresets()->update(['is_default' => false]);
            }
            return $store->parcelPresets()->create($data);
        });

        return (new ParcelPresetResource($preset))->response()->setStatusCode(201);
    }

    public function update(UpdateParcelPresetRequest $request, Store $store, StoreParcelPreset $preset): ParcelPresetResource
    {
        abort_unless($preset->store_id === $store->id, 404);

        $data = $request->validated();

        DB::transaction(function () use ($store, $preset, $data) {
            if (($data['is_default'] ?? false) === true) {
                $store->parcelPresets()->where('id', '!=', $preset->id)->update(['is_default' => false]);
            }
            $preset->update($data);
        });

        return new ParcelPresetResource($preset->fresh());
    }

    public function destroy(Store $store, StoreParcelPreset $preset): JsonResponse
    {
        abort_unless($preset->store_id === $store->id, 404);

        if ($store->parcelPresets()->count() <= 1) {
            return response()->json(['message' => 'Store must have at least one parcel preset.'], 409);
        }

        $preset->delete();
        return response()->json(null, 204);
    }
}
```

- [ ] **Step 7: Register routes in `api/app/Modules/Stores/routes.php`**

Inside the existing `Route::middleware('store.owner')->group(function () { ... })` block, add:

```php
Route::get('/stores/{store}/parcel-presets', [ParcelPresetController::class, 'index']);
Route::post('/stores/{store}/parcel-presets', [ParcelPresetController::class, 'store']);
Route::patch('/stores/{store}/parcel-presets/{preset}', [ParcelPresetController::class, 'update']);
Route::delete('/stores/{store}/parcel-presets/{preset}', [ParcelPresetController::class, 'destroy']);
```

Add `use App\Modules\Stores\Controllers\ParcelPresetController;` at top.

- [ ] **Step 8: Bind `{preset}` route param**

In `api/app/Providers/RouteServiceProvider.php` (or existing route model binding), add:

```php
Route::model('preset', \App\Models\StoreParcelPreset::class);
```

If route model binding is automatic (by type-hint), skip this step — verify by running the test.

- [ ] **Step 9: Add OpenAPI paths**

In `api/contracts/openapi.yaml`, add under `paths:`:

```yaml
/v1/stores/{storeId}/parcel-presets:
  get:
    operationId: listParcelPresets
    tags: [Stores]
    parameters:
      - $ref: '#/components/parameters/StoreId'
    responses:
      '200':
        description: Preset list
        content:
          application/json:
            schema:
              type: object
              properties:
                data:
                  type: array
                  items: { $ref: '#/components/schemas/ParcelPreset' }
  post:
    operationId: createParcelPreset
    tags: [Stores]
    parameters:
      - $ref: '#/components/parameters/StoreId'
    requestBody:
      required: true
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ParcelPresetInput' }
    responses:
      '201':
        description: Created
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/ParcelPreset' }

/v1/stores/{storeId}/parcel-presets/{presetId}:
  patch:
    operationId: updateParcelPreset
    tags: [Stores]
    parameters:
      - $ref: '#/components/parameters/StoreId'
      - in: path
        name: presetId
        required: true
        schema: { type: string, format: uuid }
    requestBody:
      required: true
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ParcelPresetInput' }
    responses:
      '200':
        description: Updated
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/ParcelPreset' }
  delete:
    operationId: deleteParcelPreset
    tags: [Stores]
    parameters:
      - $ref: '#/components/parameters/StoreId'
      - in: path
        name: presetId
        required: true
        schema: { type: string, format: uuid }
    responses:
      '204': { description: Deleted }
      '409': { description: Cannot delete last preset }
```

Add schemas under `components.schemas`:

```yaml
ParcelPreset:
  type: object
  required: [id, store_id, name, weight_oz, length_in, width_in, height_in, is_default]
  properties:
    id: { type: string, format: uuid }
    store_id: { type: string, format: uuid }
    name: { type: string }
    weight_oz: { type: integer }
    length_in: { type: integer }
    width_in: { type: integer }
    height_in: { type: integer }
    is_default: { type: boolean }
ParcelPresetInput:
  type: object
  properties:
    name: { type: string }
    weight_oz: { type: integer }
    length_in: { type: integer }
    width_in: { type: integer }
    height_in: { type: integer }
    is_default: { type: boolean }
```

- [ ] **Step 10: Run tests, verify green**

```bash
docker compose exec laravel.test php artisan test --filter=ParcelPresetCrudTest
```

Expected: all 5 pass.

- [ ] **Step 11: Regenerate TS types**

```bash
cd Alqove && npm run build:types
```

- [ ] **Step 12: Commit**

```bash
git add Alqove/api/app/Modules/Stores/ Alqove/api/contracts/openapi.yaml Alqove/api/tests/Feature/Stores/ParcelPresetCrudTest.php Alqove/packages/types/
git commit -m "feat(stores): parcel preset CRUD endpoints"
```

---

## Task 6: `LabelProvider` contract and `FakeLabelProvider`

**Files:**
- Create: `api/app/Modules/Shipping/Contracts/LabelProvider.php`
- Create: `api/app/Modules/Shipping/DTOs/ShipmentRequest.php`
- Create: `api/app/Modules/Shipping/DTOs/PurchasedLabel.php`
- Create: `api/app/Modules/Shipping/Services/FakeLabelProvider.php`

- [ ] **Step 1: Write `ShipmentRequest` DTO**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Shipping\DTOs;

class ShipmentRequest
{
    /**
     * @param array{name:string,street1:string,street2?:string|null,city:string,state:string,zip:string,country:string} $fromAddress
     * @param array{name:string,street1:string,street2?:string|null,city:string,state:string,zip:string,country:string} $toAddress
     * @param array{weight_oz:int,length_in:int,width_in:int,height_in:int} $parcel
     */
    public function __construct(
        public readonly array $fromAddress,
        public readonly array $toAddress,
        public readonly array $parcel,
        public readonly string $reference,
    ) {}
}
```

- [ ] **Step 2: Write `PurchasedLabel` DTO**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Shipping\DTOs;

class PurchasedLabel
{
    public function __construct(
        public readonly string $trackerId,
        public readonly string $trackingNumber,
        public readonly string $trackingUrl,
        public readonly string $labelUrl,
        public readonly string $carrier,
        public readonly string $service,
        public readonly int $rateCents,
    ) {}
}
```

- [ ] **Step 3: Write `LabelProvider` contract**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Shipping\Contracts;

use App\Modules\Shipping\DTOs\PurchasedLabel;
use App\Modules\Shipping\DTOs\ShipmentRequest;

interface LabelProvider
{
    /**
     * Buy the cheapest available label for this shipment.
     *
     * @throws \App\Modules\Shipping\Exceptions\LabelProviderException
     */
    public function buyCheapestLabel(ShipmentRequest $request): PurchasedLabel;

    /**
     * Parse a tracker webhook payload into a canonical status.
     *
     * @param array<string,mixed> $payload
     * @return array{tracker_id:string,status:string,event_id:string}
     */
    public function parseTrackerEvent(array $payload): array;

    /**
     * Verify the signature on a webhook request.
     */
    public function verifyWebhookSignature(string $rawBody, string $signatureHeader): bool;
}
```

- [ ] **Step 4: Write `LabelProviderException`**

Create `api/app/Modules/Shipping/Exceptions/LabelProviderException.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Shipping\Exceptions;

class LabelProviderException extends \RuntimeException {}
```

- [ ] **Step 5: Write `FakeLabelProvider`**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Shipping\Services;

use App\Modules\Shipping\Contracts\LabelProvider;
use App\Modules\Shipping\DTOs\PurchasedLabel;
use App\Modules\Shipping\DTOs\ShipmentRequest;
use Illuminate\Support\Str;

class FakeLabelProvider implements LabelProvider
{
    /** @var array<int, PurchasedLabel> */
    public array $purchased = [];
    public bool $shouldFail = false;

    public function buyCheapestLabel(ShipmentRequest $request): PurchasedLabel
    {
        if ($this->shouldFail) {
            throw new \App\Modules\Shipping\Exceptions\LabelProviderException('Fake provider forced failure');
        }

        $label = new PurchasedLabel(
            trackerId: 'trk_'.Str::random(16),
            trackingNumber: '9400'.random_int(10000000, 99999999),
            trackingUrl: 'https://example.com/track/'.$request->reference,
            labelUrl: 'https://example.com/label/'.$request->reference.'.pdf',
            carrier: 'USPS',
            service: 'Priority',
            rateCents: 899,
        );
        $this->purchased[] = $label;
        return $label;
    }

    public function parseTrackerEvent(array $payload): array
    {
        return [
            'tracker_id' => $payload['result']['id'] ?? '',
            'status' => $payload['result']['status'] ?? 'unknown',
            'event_id' => $payload['id'] ?? '',
        ];
    }

    public function verifyWebhookSignature(string $rawBody, string $signatureHeader): bool
    {
        return $signatureHeader === 'fake-signature-ok';
    }
}
```

- [ ] **Step 6: Commit**

```bash
git add Alqove/api/app/Modules/Shipping/
git commit -m "feat(shipping): add LabelProvider contract and FakeLabelProvider"
```

---

## Task 7: `EasyPostProvider` adapter

**Files:**
- Create: `api/app/Modules/Shipping/Services/EasyPostProvider.php`
- Create: `api/app/Providers/ShippingServiceProvider.php`
- Modify: `api/bootstrap/providers.php` (or `config/app.php` depending on Laravel version)
- Create: `api/tests/Unit/Shipping/EasyPostProviderTest.php`

- [ ] **Step 1: Write `EasyPostProvider`**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Shipping\Services;

use App\Modules\Shipping\Contracts\LabelProvider;
use App\Modules\Shipping\DTOs\PurchasedLabel;
use App\Modules\Shipping\DTOs\ShipmentRequest;
use App\Modules\Shipping\Exceptions\LabelProviderException;
use EasyPost\EasyPostClient;
use EasyPost\Exception\General\EasyPostException;
use Illuminate\Support\Facades\Log;

class EasyPostProvider implements LabelProvider
{
    public function __construct(
        private readonly EasyPostClient $client,
        private readonly string $webhookSecret,
    ) {}

    public function buyCheapestLabel(ShipmentRequest $request): PurchasedLabel
    {
        try {
            $shipment = $this->client->shipment->create([
                'from_address' => $request->fromAddress,
                'to_address' => $request->toAddress,
                'parcel' => [
                    'weight' => $request->parcel['weight_oz'],
                    'length' => $request->parcel['length_in'],
                    'width' => $request->parcel['width_in'],
                    'height' => $request->parcel['height_in'],
                ],
                'reference' => $request->reference,
            ]);

            if (empty($shipment->rates)) {
                throw new LabelProviderException('No rates returned for shipment');
            }

            $cheapest = collect($shipment->rates)->sortBy(fn ($r) => (float) $r->rate)->first();
            $bought = $this->client->shipment->buy($shipment->id, ['rate' => ['id' => $cheapest->id]]);

            return new PurchasedLabel(
                trackerId: $bought->tracker->id,
                trackingNumber: $bought->tracking_code,
                trackingUrl: $bought->tracker->public_url ?? '',
                labelUrl: $bought->postage_label->label_url,
                carrier: $cheapest->carrier,
                service: $cheapest->service,
                rateCents: (int) round(((float) $cheapest->rate) * 100),
            );
        } catch (EasyPostException $e) {
            Log::error('EasyPost error during label purchase', ['message' => $e->getMessage()]);
            throw new LabelProviderException('EasyPost error: '.$e->getMessage(), 0, $e);
        }
    }

    public function parseTrackerEvent(array $payload): array
    {
        return [
            'tracker_id' => $payload['result']['id'] ?? '',
            'status' => $payload['result']['status'] ?? 'unknown',
            'event_id' => $payload['id'] ?? '',
        ];
    }

    public function verifyWebhookSignature(string $rawBody, string $signatureHeader): bool
    {
        if ($this->webhookSecret === '' || $signatureHeader === '') {
            return false;
        }
        $computed = hash_hmac('sha256', $rawBody, $this->webhookSecret);
        return hash_equals($computed, $signatureHeader);
    }
}
```

- [ ] **Step 2: Write `ShippingServiceProvider` with environment-based binding**

```php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Modules\Shipping\Contracts\LabelProvider;
use App\Modules\Shipping\Services\EasyPostProvider;
use App\Modules\Shipping\Services\FakeLabelProvider;
use EasyPost\EasyPostClient;
use Illuminate\Support\ServiceProvider;

class ShippingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(LabelProvider::class, function ($app) {
            if (app()->environment('testing') || config('services.easypost.api_key') === null) {
                return new FakeLabelProvider();
            }
            $client = new EasyPostClient(config('services.easypost.api_key'));
            return new EasyPostProvider($client, config('services.easypost.webhook_secret') ?? '');
        });
    }
}
```

- [ ] **Step 3: Register provider**

In `api/bootstrap/providers.php`, add `App\Providers\ShippingServiceProvider::class,` to the array.

- [ ] **Step 4: Write unit test for signature verification**

`api/tests/Unit/Shipping/EasyPostProviderTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Shipping;

use App\Modules\Shipping\Services\EasyPostProvider;
use EasyPost\EasyPostClient;
use Tests\TestCase;

class EasyPostProviderTest extends TestCase
{
    public function test_verify_webhook_signature_matches_hmac(): void
    {
        $client = new EasyPostClient('fake');
        $provider = new EasyPostProvider($client, 'secret');
        $body = '{"ok":true}';
        $sig = hash_hmac('sha256', $body, 'secret');

        $this->assertTrue($provider->verifyWebhookSignature($body, $sig));
        $this->assertFalse($provider->verifyWebhookSignature($body, 'wrong'));
        $this->assertFalse($provider->verifyWebhookSignature($body, ''));
    }
}
```

- [ ] **Step 5: Run tests, verify green**

```bash
docker compose exec laravel.test php artisan test --filter=EasyPostProviderTest
```

- [ ] **Step 6: Commit**

```bash
git add Alqove/api/app/Modules/Shipping/Services/EasyPostProvider.php \
        Alqove/api/app/Providers/ShippingServiceProvider.php \
        Alqove/api/bootstrap/providers.php \
        Alqove/api/tests/Unit/Shipping/EasyPostProviderTest.php
git commit -m "feat(shipping): add EasyPostProvider and environment-driven binding"
```

---

## Task 8: `OrderPaid` event and refactor Layer 4 webhook to stop creating Transfers

**Files:**
- Create: `api/app/Modules/Orders/Events/OrderPaid.php`
- Modify: `api/app/Modules/Checkout/Services/CheckoutService.php`
- Modify: `api/tests/Feature/Checkout/WebhookFulfillmentTest.php`

- [ ] **Step 1: Write `OrderPaid` event**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;

class OrderPaid
{
    use Dispatchable;

    public function __construct(public readonly Order $order) {}
}
```

- [ ] **Step 2: Update webhook test to reflect new behavior**

Open `api/tests/Feature/Checkout/WebhookFulfillmentTest.php`. Find any assertion that expects `stripe_transfer_id` to be set on Order after webhook — remove/adjust it. Add a new assertion using `Event::fake`:

```php
public function test_webhook_dispatches_order_paid_and_does_not_create_transfer(): void
{
    Event::fake([\App\Modules\Orders\Events\OrderPaid::class]);

    // ... existing setup that triggers fulfillPayment ...

    Event::assertDispatchedTimes(\App\Modules\Orders\Events\OrderPaid::class);

    $order = Order::first();
    $this->assertNull($order->stripe_transfer_id, 'Transfer must NOT be created at payment time');
    $this->assertNull($order->transferred_at);
}
```

Delete any existing test that asserted `stripe_transfer_id` was populated by the webhook.

- [ ] **Step 3: Run test, verify the new assertion fails (transfer still being created)**

```bash
docker compose exec laravel.test php artisan test --filter=WebhookFulfillmentTest
```

- [ ] **Step 4: Refactor `CheckoutService::fulfillPayment`**

In `api/app/Modules/Checkout/Services/CheckoutService.php`, inside `fulfillPayment`, find the block starting with `// Create Stripe transfer to store` (around line 268-277) and REMOVE it entirely:

Delete:
```php
// Create Stripe transfer to store
$store = \App\Models\Store::find($storeData['store']['id']);
if ($store?->stripe_connect_id && $sellerPayout > 0) {
    $transfer = $this->stripeService->createTransfer(
        $sellerPayout,
        $store->stripe_connect_id,
        $transferGroup,
    );
    $order->update(['stripe_transfer_id' => $transfer->id]);
}
```

After the `foreach ($storeData['items'] as $itemData) { ... }` inner loop, dispatch `OrderPaid`:

```php
\App\Modules\Orders\Events\OrderPaid::dispatch($order);
```

Note: the variable `$transferGroup` may become unused — remove that line too (`$transferGroup = "checkout_{$checkout->id}";`).

- [ ] **Step 5: Run test, verify green**

```bash
docker compose exec laravel.test php artisan test --filter=WebhookFulfillmentTest
```

- [ ] **Step 6: Commit**

```bash
git add Alqove/api/app/Modules/Orders/Events/OrderPaid.php \
        Alqove/api/app/Modules/Checkout/Services/CheckoutService.php \
        Alqove/api/tests/Feature/Checkout/WebhookFulfillmentTest.php
git commit -m "refactor(checkout): defer Stripe Transfer to ship event, dispatch OrderPaid"
```

---

## Task 9: Apply `store.settings.processing_days` to `ship_by`

**Files:**
- Modify: `api/app/Modules/Checkout/Services/CheckoutService.php`
- Create: `api/tests/Feature/Checkout/ShipByFromSettingsTest.php`

- [ ] **Step 1: Write test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Checkout;

use App\Models\Order;
use App\Models\Store;
use App\Models\StoreSettings;
use Tests\TestCase;

class ShipByFromSettingsTest extends TestCase
{
    public function test_ship_by_uses_store_processing_days(): void
    {
        $store = Store::factory()->create();
        StoreSettings::factory()->create(['store_id' => $store->id, 'processing_days' => 5]);

        // Trigger the webhook fulfillment path with a single-store checkout.
        // (Use whatever helper existing Checkout tests use; see WebhookFulfillmentTest)
        $order = $this->simulatePaidOrder($store);

        $this->assertTrue(
            $order->ship_by->greaterThanOrEqualTo(now()->addDays(5)->subMinute())
                && $order->ship_by->lessThanOrEqualTo(now()->addDays(5)->addMinute()),
            'ship_by must equal now + processing_days',
        );
    }
}
```

(If `simulatePaidOrder` doesn't exist, replicate the arrangement from `WebhookFulfillmentTest` — stub checkout data → call `fulfillPayment`.)

- [ ] **Step 2: Run, expect failure (currently hardcoded to 3 days)**

```bash
docker compose exec laravel.test php artisan test --filter=ShipByFromSettingsTest
```

- [ ] **Step 3: Update `CheckoutService::fulfillPayment`**

Replace `'ship_by' => now()->addDays(3),` with:

```php
'ship_by' => now()->addDays(
    (int) optional(\App\Models\StoreSettings::where('store_id', $storeData['store']['id'])->value('processing_days')) ?: 3
),
```

(The `?: 3` fallback guards stores without settings seeded yet.)

- [ ] **Step 4: Run tests, verify green**

```bash
docker compose exec laravel.test php artisan test --filter=ShipByFromSettingsTest
docker compose exec laravel.test php artisan test --filter=WebhookFulfillmentTest
```

- [ ] **Step 5: Commit**

```bash
git add Alqove/api/app/Modules/Checkout/Services/CheckoutService.php \
        Alqove/api/tests/Feature/Checkout/ShipByFromSettingsTest.php
git commit -m "fix(checkout): compute ship_by from store processing_days"
```

---

## Task 10: Ship-by delayed jobs + `ScheduleShipByJobsOnPaid` listener

**Files:**
- Create: `api/app/Modules/Orders/Jobs/SendShipByReminderJob.php`
- Create: `api/app/Modules/Orders/Jobs/NotifyOrderDelayedJob.php`
- Create: `api/app/Modules/Orders/Jobs/AutoCancelOverdueOrderJob.php`
- Create: `api/app/Modules/Orders/Events/ShipByReminderDue.php`
- Create: `api/app/Modules/Orders/Events/OrderDelayed.php`
- Create: `api/app/Modules/Orders/Events/OrderAutoCancelled.php`
- Create: `api/app/Modules/Orders/Listeners/ScheduleShipByJobsOnPaid.php`
- Modify: `api/app/Providers/EventServiceProvider.php` (or equivalent)
- Create: `api/tests/Feature/Orders/ShipByJobSchedulingTest.php`

- [ ] **Step 1: Write the three event classes**

Each event follows the `OrderPaid` shape. Example `ShipByReminderDue.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;

class ShipByReminderDue
{
    use Dispatchable;

    public function __construct(public readonly Order $order) {}
}
```

Replicate for `OrderDelayed` and `OrderAutoCancelled`.

- [ ] **Step 2: Write `SendShipByReminderJob`**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Jobs;

use App\Models\Order;
use App\Modules\Orders\Events\ShipByReminderDue;
use App\Support\Enums\OrderStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class SendShipByReminderJob implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public function __construct(public readonly string $orderId) {}

    public function handle(): void
    {
        $order = Order::find($this->orderId);
        if (! $order) {
            return;
        }
        if (! in_array($order->status, [OrderStatus::Pending, OrderStatus::Processing], true)) {
            return;
        }
        ShipByReminderDue::dispatch($order);
    }
}
```

- [ ] **Step 3: Write `NotifyOrderDelayedJob`** (same shape, dispatches `OrderDelayed`)

- [ ] **Step 4: Write `AutoCancelOverdueOrderJob`**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Jobs;

use App\Models\Order;
use App\Modules\Orders\Events\OrderAutoCancelled;
use App\Modules\Orders\Services\CancellationService;
use App\Support\Enums\OrderStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class AutoCancelOverdueOrderJob implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public function __construct(public readonly string $orderId) {}

    public function handle(CancellationService $cancellationService): void
    {
        $order = Order::find($this->orderId);
        if (! $order) {
            return;
        }
        if (! in_array($order->status, [OrderStatus::Pending, OrderStatus::Processing], true)) {
            return;
        }

        $cancellationService->systemCancel($order, \App\Support\Enums\CancellationReason::ShipDeadlineExceeded);
        OrderAutoCancelled::dispatch($order->fresh());
    }
}
```

(`CancellationService` is introduced in Task 12. For this task, `systemCancel` may not exist yet — comment this line out temporarily or skip `AutoCancelOverdueOrderJob::handle` body for now and return to it in Task 12. Prefer: write all three jobs in this task, leave `AutoCancelOverdueOrderJob::handle` with only the state guard + event dispatch; then in Task 12, add the systemCancel call and re-run tests.)

Simplified `handle` for now:

```php
public function handle(): void
{
    $order = Order::find($this->orderId);
    if (! $order) return;
    if (! in_array($order->status, [OrderStatus::Pending, OrderStatus::Processing], true)) return;
    OrderAutoCancelled::dispatch($order);
}
```

- [ ] **Step 5: Write `ScheduleShipByJobsOnPaid` listener**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Listeners;

use App\Modules\Orders\Events\OrderPaid;
use App\Modules\Orders\Jobs\AutoCancelOverdueOrderJob;
use App\Modules\Orders\Jobs\NotifyOrderDelayedJob;
use App\Modules\Orders\Jobs\SendShipByReminderJob;
use Carbon\Carbon;

class ScheduleShipByJobsOnPaid
{
    public function handle(OrderPaid $event): void
    {
        $order = $event->order;
        if ($order->ship_by === null) {
            return;
        }

        $shipBy = Carbon::parse($order->ship_by);

        SendShipByReminderJob::dispatch($order->id)->delay($shipBy);
        NotifyOrderDelayedJob::dispatch($order->id)->delay($shipBy->copy()->addDays(2));
        AutoCancelOverdueOrderJob::dispatch($order->id)->delay($shipBy->copy()->addBusinessDays(7));
    }
}
```

- [ ] **Step 6: Register listener**

In `api/app/Providers/EventServiceProvider.php` `$listen`:

```php
\App\Modules\Orders\Events\OrderPaid::class => [
    \App\Modules\Orders\Listeners\ScheduleShipByJobsOnPaid::class,
],
```

If the project uses auto-discovery, this step is unnecessary — verify with a test.

- [ ] **Step 7: Write scheduling test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Orders;

use App\Models\Order;
use App\Modules\Orders\Events\OrderPaid;
use App\Modules\Orders\Jobs\AutoCancelOverdueOrderJob;
use App\Modules\Orders\Jobs\NotifyOrderDelayedJob;
use App\Modules\Orders\Jobs\SendShipByReminderJob;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;

class ShipByJobSchedulingTest extends TestCase
{
    public function test_three_delayed_jobs_are_dispatched_on_order_paid(): void
    {
        Queue::fake();

        $order = Order::factory()->create(['ship_by' => now()->addDays(3)]);

        OrderPaid::dispatch($order);

        Queue::assertPushed(SendShipByReminderJob::class);
        Queue::assertPushed(NotifyOrderDelayedJob::class);
        Queue::assertPushed(AutoCancelOverdueOrderJob::class);
    }

    public function test_reminder_job_noops_when_order_already_shipped(): void
    {
        $order = Order::factory()->create(['status' => 'shipped']);
        \Illuminate\Support\Facades\Event::fake();

        (new SendShipByReminderJob($order->id))->handle();

        \Illuminate\Support\Facades\Event::assertNotDispatched(\App\Modules\Orders\Events\ShipByReminderDue::class);
    }
}
```

- [ ] **Step 8: Run and commit**

```bash
docker compose exec laravel.test php artisan test --filter=ShipByJobSchedulingTest
git add Alqove/api/app/Modules/Orders/Jobs/ \
        Alqove/api/app/Modules/Orders/Events/ \
        Alqove/api/app/Modules/Orders/Listeners/ScheduleShipByJobsOnPaid.php \
        Alqove/api/app/Providers/EventServiceProvider.php \
        Alqove/api/tests/Feature/Orders/ShipByJobSchedulingTest.php
git commit -m "feat(orders): ship-by reminder/delay/auto-cancel delayed jobs"
```

---

## Task 11: `OrderFulfillmentService` + label purchase endpoint

**Files:**
- Create: `api/app/Modules/Orders/Services/OrderFulfillmentService.php`
- Create: `api/app/Modules/Orders/Events/OrderShipped.php`
- Create: `api/app/Modules/Orders/Requests/BuyLabelRequest.php`
- Create: `api/app/Modules/Orders/Controllers/OrderFulfillmentController.php`
- Modify: `api/app/Modules/Orders/routes.php`
- Modify: `api/contracts/openapi.yaml`
- Create: `api/tests/Feature/Orders/BuyLabelTest.php`

- [ ] **Step 1: Write `OrderShipped` event** (same shape as `OrderPaid`)

- [ ] **Step 2: Write failing feature test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Orders;

use App\Models\Order;
use App\Models\Store;
use App\Models\StoreParcelPreset;
use App\Models\User;
use App\Modules\Orders\Events\OrderShipped;
use App\Modules\Shipping\Contracts\LabelProvider;
use App\Modules\Shipping\Services\FakeLabelProvider;
use App\Support\Enums\OrderStatus;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

class BuyLabelTest extends TestCase
{
    public function test_owner_can_buy_label_which_marks_order_shipped(): void
    {
        Event::fake([OrderShipped::class]);
        $fake = new FakeLabelProvider();
        $this->app->instance(LabelProvider::class, $fake);

        $owner = User::factory()->create();
        $store = Store::factory()->create([
            'owner_user_id' => $owner->id,
            'street1' => '10 Ship St', 'zip' => '97201',
        ]);
        $preset = StoreParcelPreset::factory()->asDefault()->create(['store_id' => $store->id]);
        $order = Order::factory()->forStore($store)->pending()->create();

        $response = $this->actingAs($owner)
            ->postJson("/v1/stores/{$store->id}/orders/{$order->id}/labels", [
                'parcel_preset_id' => $preset->id,
            ]);

        $response->assertOk()
            ->assertJsonPath('data.status', OrderStatus::Shipped->value)
            ->assertJsonPath('data.carrier', 'USPS');

        $order->refresh();
        $this->assertSame(OrderStatus::Shipped, $order->status);
        $this->assertNotNull($order->shipped_at);
        $this->assertNotNull($order->label_url);
        $this->assertNotNull($order->tracker_id);

        Event::assertDispatched(OrderShipped::class);
    }

    public function test_cannot_buy_label_on_already_shipped_order(): void
    {
        $fake = new FakeLabelProvider();
        $this->app->instance(LabelProvider::class, $fake);

        $owner = User::factory()->create();
        $store = Store::factory()->create(['owner_user_id' => $owner->id, 'street1' => '10 Ship St', 'zip' => '97201']);
        $preset = StoreParcelPreset::factory()->create(['store_id' => $store->id]);
        $order = Order::factory()->forStore($store)->create(['status' => OrderStatus::Shipped]);

        $this->actingAs($owner)
            ->postJson("/v1/stores/{$store->id}/orders/{$order->id}/labels", ['parcel_preset_id' => $preset->id])
            ->assertStatus(409);
    }
}
```

(Add `forStore` + `pending` factory states to `OrderFactory` if not present. Example: `public function pending(): self { return $this->state(['status' => OrderStatus::Pending]); }`.)

- [ ] **Step 3: Run test, expect 404 (route missing)**

- [ ] **Step 4: Write `BuyLabelRequest`**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Requests;

use Illuminate\Foundation\Http\FormRequest;

class BuyLabelRequest extends FormRequest
{
    public function authorize(): bool { return true; }

    public function rules(): array
    {
        return [
            'parcel_preset_id' => ['required', 'uuid', 'exists:store_parcel_presets,id'],
        ];
    }
}
```

- [ ] **Step 5: Write `OrderFulfillmentService`**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Services;

use App\Models\Order;
use App\Models\StoreParcelPreset;
use App\Modules\Orders\Events\OrderShipped;
use App\Modules\Shipping\Contracts\LabelProvider;
use App\Modules\Shipping\DTOs\ShipmentRequest;
use App\Support\Enums\OrderStatus;
use Illuminate\Support\Facades\DB;

class OrderFulfillmentService
{
    public function __construct(private readonly LabelProvider $labelProvider) {}

    public function purchaseLabel(Order $order, StoreParcelPreset $preset): Order
    {
        if (! in_array($order->status, [OrderStatus::Pending, OrderStatus::Processing], true)) {
            abort(409, 'Order cannot be shipped from its current status.');
        }
        if ((string) $preset->store_id !== (string) $order->store_id) {
            abort(422, 'Parcel preset does not belong to this store.');
        }

        $store = $order->store;
        if (! $store->hasCompleteShipFromAddress()) {
            abort(422, 'Store ship-from address is incomplete.');
        }

        $shippingAddress = $order->purchase->shipping_address;

        $shipmentRequest = new ShipmentRequest(
            fromAddress: [
                'name' => $store->name,
                'street1' => $store->street1,
                'street2' => $store->street2,
                'city' => $store->city,
                'state' => $store->state,
                'zip' => $store->zip,
                'country' => $store->country ?? 'US',
            ],
            toAddress: [
                'name' => trim(($shippingAddress['first_name'] ?? '').' '.($shippingAddress['last_name'] ?? '')),
                'street1' => $shippingAddress['street'] ?? '',
                'street2' => null,
                'city' => $shippingAddress['city'] ?? '',
                'state' => $shippingAddress['state'] ?? '',
                'zip' => $shippingAddress['zip'] ?? '',
                'country' => 'US',
            ],
            parcel: [
                'weight_oz' => $preset->weight_oz,
                'length_in' => $preset->length_in,
                'width_in' => $preset->width_in,
                'height_in' => $preset->height_in,
            ],
            reference: $order->id,
        );

        $label = $this->labelProvider->buyCheapestLabel($shipmentRequest);

        DB::transaction(function () use ($order, $label) {
            $order->update([
                'tracker_id' => $label->trackerId,
                'tracking_number' => $label->trackingNumber,
                'tracking_url' => $label->trackingUrl,
                'carrier' => $label->carrier,
                'service' => $label->service,
                'shipping_label_url' => $label->labelUrl,
                'label_purchased_at' => now(),
                'shipped_at' => now(),
                'status' => OrderStatus::Shipped,
            ]);
        });

        OrderShipped::dispatch($order->fresh());

        return $order->fresh();
    }
}
```

- [ ] **Step 6: Write `OrderFulfillmentController`**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Controllers;

use App\Models\Order;
use App\Models\Store;
use App\Models\StoreParcelPreset;
use App\Modules\Orders\Requests\BuyLabelRequest;
use App\Modules\Orders\Resources\OrderResource;
use App\Modules\Orders\Services\OrderFulfillmentService;

class OrderFulfillmentController
{
    public function __construct(private readonly OrderFulfillmentService $service) {}

    public function buyLabel(BuyLabelRequest $request, Store $store, Order $order): OrderResource
    {
        abort_unless((string) $order->store_id === (string) $store->id, 404);

        $preset = StoreParcelPreset::findOrFail($request->validated('parcel_preset_id'));
        $updated = $this->service->purchaseLabel($order, $preset);

        return new OrderResource($updated);
    }
}
```

- [ ] **Step 7: Register route**

In `api/app/Modules/Orders/routes.php`, inside the `store.owner` group:

```php
Route::post('/stores/{store}/orders/{order}/labels',
    [\App\Modules\Orders\Controllers\OrderFulfillmentController::class, 'buyLabel']);
```

- [ ] **Step 8: Ensure `OrderResource` exposes new fields**

Open `api/app/Modules/Orders/Resources/OrderResource.php`. Ensure it returns: `id, status, store_id, subtotal, shipping_cost, shipped_at, delivered_at, tracking_number, tracking_url, carrier, service, shipping_label_url, cancelled_by, cancellation_reason, cancelled_at, ship_by, is_delayed`. Add any missing fields. For `is_delayed`, use the accessor added in Task 13; for now, add a temporary `$this->is_delayed ?? false` — it'll light up in Task 13.

- [ ] **Step 9: Add OpenAPI path**

In `api/contracts/openapi.yaml`, add under `paths:`:

```yaml
/v1/stores/{storeId}/orders/{orderId}/labels:
  post:
    operationId: buyOrderLabel
    tags: [Orders]
    parameters:
      - $ref: '#/components/parameters/StoreId'
      - in: path
        name: orderId
        required: true
        schema: { type: string, format: uuid }
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [parcel_preset_id]
            properties:
              parcel_preset_id: { type: string, format: uuid }
    responses:
      '200':
        description: Label purchased, order marked shipped
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/Order' }
      '409': { description: Order cannot be shipped from current status }
      '422': { description: Validation failure }
      '502': { description: Shipping provider error }
```

Update/add `Order` schema to include the new fields (tracking_url, carrier, service, label_purchased_at, transferred_at, is_delayed, cancelled_by, cancellation_reason, cancelled_at).

- [ ] **Step 10: Run test, verify green, commit**

```bash
docker compose exec laravel.test php artisan test --filter=BuyLabelTest
cd Alqove && npm run build:types
git add Alqove/api/app/Modules/Orders/Services/OrderFulfillmentService.php \
        Alqove/api/app/Modules/Orders/Events/OrderShipped.php \
        Alqove/api/app/Modules/Orders/Requests/BuyLabelRequest.php \
        Alqove/api/app/Modules/Orders/Controllers/OrderFulfillmentController.php \
        Alqove/api/app/Modules/Orders/Resources/OrderResource.php \
        Alqove/api/app/Modules/Orders/routes.php \
        Alqove/api/contracts/openapi.yaml \
        Alqove/api/tests/Feature/Orders/BuyLabelTest.php \
        Alqove/packages/types/
git commit -m "feat(orders): POST /stores/{store}/orders/{order}/labels buys EasyPost label"
```

---

## Task 12: `CancellationService` + buyer & seller cancel endpoints

**Files:**
- Create: `api/app/Modules/Orders/Services/CancellationService.php`
- Create: `api/app/Modules/Orders/Events/OrderCancelled.php`
- Create: `api/app/Modules/Orders/Requests/BuyerCancelOrderRequest.php`
- Create: `api/app/Modules/Orders/Requests/SellerCancelOrderRequest.php`
- Modify: `api/app/Support/Enums/CancellationReason.php` (add `Other`)
- Modify: `api/app/Modules/Orders/Controllers/OrderController.php`
- Modify: `api/app/Modules/Orders/routes.php`
- Modify: `api/app/Modules/Checkout/Services/StripeService.php` (add `refund`)
- Modify: `api/app/Modules/Orders/Jobs/AutoCancelOverdueOrderJob.php`
- Modify: `api/contracts/openapi.yaml`
- Create: `api/tests/Feature/Orders/CancelOrderTest.php`

- [ ] **Step 1: Add `Other` to CancellationReason enum**

```php
case Other = 'other';
```

- [ ] **Step 2: Write `OrderCancelled` event**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;

class OrderCancelled
{
    use Dispatchable;

    public function __construct(public readonly Order $order) {}
}
```

- [ ] **Step 3: Write failing feature test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Orders;

use App\Models\Item;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Modules\Orders\Events\OrderCancelled;
use App\Support\Enums\CancellationReason;
use App\Support\Enums\ItemStatus;
use App\Support\Enums\OrderCancelledBy;
use App\Support\Enums\OrderStatus;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class CancelOrderTest extends TestCase
{
    public function test_buyer_can_cancel_pre_ship_order_and_items_relist(): void
    {
        Event::fake([OrderCancelled::class]);
        Http::fake();

        $buyer = User::factory()->create();
        $store = Store::factory()->create();
        $purchase = Purchase::factory()->for($buyer, 'buyer')->create([
            'stripe_payment_intent_id' => 'pi_test_1',
        ]);
        $order = Order::factory()->create([
            'purchase_id' => $purchase->id,
            'store_id' => $store->id,
            'status' => OrderStatus::Pending,
        ]);
        $item = Item::factory()->sold()->create(['sold_to_user_id' => $buyer->id]);
        OrderItem::factory()->create(['order_id' => $order->id, 'item_id' => $item->id]);

        $response = $this->actingAs($buyer)
            ->postJson("/v1/orders/{$order->id}/cancel");

        $response->assertOk();
        $order->refresh();
        $this->assertSame(OrderStatus::Cancelled, $order->status);
        $this->assertSame(OrderCancelledBy::Buyer, $order->cancelled_by);
        $this->assertSame(CancellationReason::BuyerRequested, $order->cancellation_reason);
        $this->assertSame(ItemStatus::Active, $item->fresh()->status);
        $this->assertNull($item->fresh()->sold_at);

        Event::assertDispatched(OrderCancelled::class);
    }

    public function test_buyer_cannot_cancel_shipped_order(): void
    {
        $buyer = User::factory()->create();
        $purchase = Purchase::factory()->for($buyer, 'buyer')->create();
        $order = Order::factory()->create([
            'purchase_id' => $purchase->id,
            'status' => OrderStatus::Shipped,
        ]);

        $this->actingAs($buyer)
            ->postJson("/v1/orders/{$order->id}/cancel")
            ->assertStatus(409);
    }

    public function test_seller_cancel_marks_items_removed_not_relisted(): void
    {
        Http::fake();

        $owner = User::factory()->create();
        $store = Store::factory()->create(['owner_user_id' => $owner->id]);
        $purchase = Purchase::factory()->create(['stripe_payment_intent_id' => 'pi_test_2']);
        $order = Order::factory()->create([
            'purchase_id' => $purchase->id,
            'store_id' => $store->id,
            'status' => OrderStatus::Pending,
        ]);
        $item = Item::factory()->sold()->create();
        OrderItem::factory()->create(['order_id' => $order->id, 'item_id' => $item->id]);

        $this->actingAs($owner)
            ->postJson("/v1/stores/{$store->id}/orders/{$order->id}/cancel", [
                'reason' => 'sold_locally',
                'note' => 'Sold in store this morning',
            ])->assertOk();

        $order->refresh();
        $this->assertSame(OrderStatus::Cancelled, $order->status);
        $this->assertSame(OrderCancelledBy::Seller, $order->cancelled_by);
        $this->assertSame(CancellationReason::SoldLocally, $order->cancellation_reason);
        $this->assertSame(ItemStatus::Removed, $item->fresh()->status);
    }

    public function test_non_owner_buyer_cannot_cancel_anothers_order(): void
    {
        $other = User::factory()->create();
        $purchase = Purchase::factory()->create();
        $order = Order::factory()->create(['purchase_id' => $purchase->id, 'status' => OrderStatus::Pending]);

        $this->actingAs($other)
            ->postJson("/v1/orders/{$order->id}/cancel")
            ->assertStatus(403);
    }
}
```

- [ ] **Step 4: Run test, expect failures (endpoints missing)**

- [ ] **Step 5: Add `refund` method to `StripeService`**

In `api/app/Modules/Checkout/Services/StripeService.php`:

```php
public function refundForOrder(string $paymentIntentId, int $amountCents): \Stripe\Refund
{
    return \Stripe\Refund::create([
        'payment_intent' => $paymentIntentId,
        'amount' => $amountCents,
        'reason' => 'requested_by_customer',
    ], ['idempotency_key' => 'refund_'.$paymentIntentId.'_'.$amountCents]);
}
```

- [ ] **Step 6: Write `CancellationService`**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Services;

use App\Models\Item;
use App\Models\Order;
use App\Modules\Checkout\Services\StripeService;
use App\Modules\Orders\Events\OrderCancelled;
use App\Support\Enums\CancellationReason;
use App\Support\Enums\ItemStatus;
use App\Support\Enums\OrderCancelledBy;
use App\Support\Enums\OrderStatus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class CancellationService
{
    public function __construct(private readonly StripeService $stripeService) {}

    public function buyerCancel(Order $order): Order
    {
        $this->assertCancellable($order);
        $this->applyCancel($order, OrderCancelledBy::Buyer, CancellationReason::BuyerRequested);
        $this->relistItems($order);
        $this->refundOrderPortion($order);
        OrderCancelled::dispatch($order->fresh());
        return $order->fresh();
    }

    public function sellerCancel(Order $order, CancellationReason $reason, ?string $note): Order
    {
        $this->assertCancellable($order);
        $this->applyCancel($order, OrderCancelledBy::Seller, $reason);
        $this->removeItems($order);
        $this->refundOrderPortion($order);

        activity()
            ->performedOn($order)
            ->withProperties(['reason' => $reason->value, 'note' => $note])
            ->log('seller_cancelled_order');

        OrderCancelled::dispatch($order->fresh());
        return $order->fresh();
    }

    public function systemCancel(Order $order, CancellationReason $reason): Order
    {
        $this->assertCancellable($order);
        $this->applyCancel($order, OrderCancelledBy::System, $reason);
        $this->relistItems($order);
        $this->refundOrderPortion($order);
        OrderCancelled::dispatch($order->fresh());
        return $order->fresh();
    }

    private function assertCancellable(Order $order): void
    {
        if (! in_array($order->status, [OrderStatus::Pending, OrderStatus::Processing], true)) {
            abort(409, 'Order cannot be cancelled from its current status.');
        }
    }

    private function applyCancel(Order $order, OrderCancelledBy $by, CancellationReason $reason): void
    {
        DB::transaction(function () use ($order, $by, $reason) {
            $order->update([
                'status' => OrderStatus::Cancelled,
                'cancelled_by' => $by,
                'cancellation_reason' => $reason,
                'cancelled_at' => now(),
            ]);
        });
    }

    private function relistItems(Order $order): void
    {
        DB::transaction(function () use ($order) {
            foreach ($order->orderItems as $orderItem) {
                $item = Item::find($orderItem->item_id);
                if ($item) {
                    $item->update([
                        'status' => ItemStatus::Active,
                        'sold_at' => null,
                        'sold_to_user_id' => null,
                    ]);
                }
            }
        });
    }

    private function removeItems(Order $order): void
    {
        DB::transaction(function () use ($order) {
            foreach ($order->orderItems as $orderItem) {
                $item = Item::find($orderItem->item_id);
                if ($item) {
                    $item->update(['status' => ItemStatus::Removed]);
                }
            }
        });
    }

    private function refundOrderPortion(Order $order): void
    {
        $amount = (int) ($order->subtotal + $order->shipping_cost + $order->tax_amount);
        if ($amount <= 0) return;
        $pi = $order->purchase->stripe_payment_intent_id;
        if (! $pi) return;

        try {
            $this->stripeService->refundForOrder($pi, $amount);
        } catch (\Throwable $e) {
            Log::error('Refund failed; admin reconciliation required', [
                'order_id' => $order->id,
                'amount' => $amount,
                'error' => $e->getMessage(),
            ]);
        }
    }
}
```

- [ ] **Step 7: Add `OrderCancelledBy::System` case**

In `api/app/Support/Enums/OrderCancelledBy.php` add `case System = 'system';` if not present.

- [ ] **Step 8: Write `BuyerCancelOrderRequest`**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Requests;

use Illuminate\Foundation\Http\FormRequest;

class BuyerCancelOrderRequest extends FormRequest
{
    public function authorize(): bool { return true; }
    public function rules(): array { return []; }
}
```

- [ ] **Step 9: Write `SellerCancelOrderRequest`**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class SellerCancelOrderRequest extends FormRequest
{
    public function authorize(): bool { return true; }

    public function rules(): array
    {
        return [
            'reason' => ['required', Rule::in(['sold_locally', 'item_damaged', 'other'])],
            'note' => ['nullable', 'string', 'max:500'],
        ];
    }
}
```

- [ ] **Step 10: Extend `OrderController`**

In `api/app/Modules/Orders/Controllers/OrderController.php`, add:

```php
public function buyerCancel(
    BuyerCancelOrderRequest $request,
    Order $order,
    CancellationService $service,
): OrderResource {
    abort_unless((string) $order->purchase->buyer_id === (string) $request->user()->id, 403);
    $updated = $service->buyerCancel($order);
    return new OrderResource($updated);
}

public function sellerCancel(
    SellerCancelOrderRequest $request,
    Store $store,
    Order $order,
    CancellationService $service,
): OrderResource {
    abort_unless((string) $order->store_id === (string) $store->id, 404);
    $reason = \App\Support\Enums\CancellationReason::from($request->validated('reason'));
    $updated = $service->sellerCancel($order, $reason, $request->validated('note'));
    return new OrderResource($updated);
}
```

Add `use` imports as needed.

- [ ] **Step 11: Register routes**

In `api/app/Modules/Orders/routes.php`:

Outside `store.owner` group, inside `auth:sanctum`:

```php
Route::post('/orders/{order}/cancel', [OrderController::class, 'buyerCancel']);
```

Inside `store.owner` group:

```php
Route::post('/stores/{store}/orders/{order}/cancel', [OrderController::class, 'sellerCancel']);
```

- [ ] **Step 12: Wire `AutoCancelOverdueOrderJob` to use the service**

Restore the `handle(CancellationService $service)` signature from Task 10 Step 4 (full version, which calls `$service->systemCancel(...)`).

- [ ] **Step 13: Add OpenAPI paths** for the two cancel endpoints. Follow the buy-label pattern.

- [ ] **Step 14: Run tests, verify green**

```bash
docker compose exec laravel.test php artisan test --filter=CancelOrderTest
```

- [ ] **Step 15: Commit**

```bash
git add Alqove/api/app/Modules/Orders/ \
        Alqove/api/app/Modules/Checkout/Services/StripeService.php \
        Alqove/api/app/Support/Enums/CancellationReason.php \
        Alqove/api/app/Support/Enums/OrderCancelledBy.php \
        Alqove/api/contracts/openapi.yaml \
        Alqove/api/tests/Feature/Orders/CancelOrderTest.php
git commit -m "feat(orders): buyer + seller cancellation with refund and item state transitions"
```

---

## Task 13: `is_delayed` computed attribute + Order/Purchase resource expansion

**Files:**
- Modify: `api/app/Models/Order.php`
- Modify: `api/app/Modules/Orders/Resources/OrderResource.php`
- Modify: `api/app/Modules/Orders/Resources/PurchaseResource.php`
- Create: `api/tests/Unit/Orders/OrderIsDelayedTest.php`

- [ ] **Step 1: Write unit test**

```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Orders;

use App\Models\Order;
use App\Support\Enums\OrderStatus;
use Carbon\Carbon;
use Tests\TestCase;

class OrderIsDelayedTest extends TestCase
{
    public function test_not_delayed_before_grace_period(): void
    {
        Carbon::setTestNow('2026-04-20 12:00:00');
        $order = Order::factory()->make([
            'status' => OrderStatus::Pending,
            'ship_by' => Carbon::parse('2026-04-19 12:00:00'),
        ]);
        $this->assertFalse($order->is_delayed);
    }

    public function test_delayed_past_grace_period(): void
    {
        Carbon::setTestNow('2026-04-22 12:00:00');
        $order = Order::factory()->make([
            'status' => OrderStatus::Pending,
            'ship_by' => Carbon::parse('2026-04-19 12:00:00'),
        ]);
        $this->assertTrue($order->is_delayed);
    }

    public function test_not_delayed_if_shipped(): void
    {
        Carbon::setTestNow('2026-04-22 12:00:00');
        $order = Order::factory()->make([
            'status' => OrderStatus::Shipped,
            'ship_by' => Carbon::parse('2026-04-19 12:00:00'),
        ]);
        $this->assertFalse($order->is_delayed);
    }
}
```

- [ ] **Step 2: Add `is_delayed` accessor to `Order`**

```php
use Illuminate\Database\Eloquent\Casts\Attribute;
// ...

protected function isDelayed(): Attribute
{
    return Attribute::make(
        get: function (): bool {
            if ($this->ship_by === null) return false;
            if (! in_array($this->status, [OrderStatus::Pending, OrderStatus::Processing], true)) return false;
            return now()->greaterThan(Carbon::parse($this->ship_by)->addDays(2));
        }
    )->shouldCache();
}

protected $appends = ['is_delayed'];
```

(Add `use Carbon\Carbon;` and `use App\Support\Enums\OrderStatus;` imports.)

- [ ] **Step 3: Update `OrderResource` to include `is_delayed`** (replace the `?? false` placeholder from Task 11):

```php
'is_delayed' => $this->is_delayed,
```

- [ ] **Step 4: Update `PurchaseResource`** to include a rollup:

```php
'is_delayed' => $this->orders->contains(fn ($o) => $o->is_delayed),
```

- [ ] **Step 5: Run tests, commit**

```bash
docker compose exec laravel.test php artisan test --filter=OrderIsDelayedTest
git add Alqove/api/app/Models/Order.php \
        Alqove/api/app/Modules/Orders/Resources/ \
        Alqove/api/tests/Unit/Orders/OrderIsDelayedTest.php
git commit -m "feat(orders): is_delayed computed attribute and resource rollup"
```

---

## Task 14: `TransferFundsToStore` listener (honours `Purchase.disputed`)

**Files:**
- Create: `api/app/Modules/Orders/Listeners/TransferFundsToStore.php`
- Modify: `api/app/Modules/Checkout/Services/StripeService.php` (ensure `createTransfer` exists; it does from Layer 4)
- Modify: `api/app/Providers/EventServiceProvider.php`
- Create: `api/tests/Feature/Orders/TransferFundsOnShipTest.php`

- [ ] **Step 1: Write test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Orders;

use App\Models\Order;
use App\Models\Purchase;
use App\Models\Store;
use App\Modules\Orders\Events\OrderShipped;
use App\Modules\Orders\Listeners\TransferFundsToStore;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class TransferFundsOnShipTest extends TestCase
{
    public function test_transfer_created_on_order_shipped(): void
    {
        Http::fake([
            'api.stripe.com/*' => Http::response(['id' => 'tr_123'], 200),
        ]);

        $store = Store::factory()->create(['stripe_connect_id' => 'acct_123']);
        $purchase = Purchase::factory()->create(['disputed' => false, 'stripe_payment_intent_id' => 'pi_test']);
        $order = Order::factory()->create([
            'store_id' => $store->id,
            'purchase_id' => $purchase->id,
            'seller_payout' => 8500,
        ]);

        (new TransferFundsToStore(app(\App\Modules\Checkout\Services\StripeService::class)))
            ->handle(new OrderShipped($order));

        $this->assertNotNull($order->fresh()->stripe_transfer_id);
        $this->assertNotNull($order->fresh()->transferred_at);
    }

    public function test_transfer_skipped_when_purchase_disputed(): void
    {
        $store = Store::factory()->create(['stripe_connect_id' => 'acct_123']);
        $purchase = Purchase::factory()->create(['disputed' => true]);
        $order = Order::factory()->create([
            'store_id' => $store->id,
            'purchase_id' => $purchase->id,
            'seller_payout' => 8500,
        ]);

        (new TransferFundsToStore(app(\App\Modules\Checkout\Services\StripeService::class)))
            ->handle(new OrderShipped($order));

        $this->assertNull($order->fresh()->stripe_transfer_id);
    }
}
```

- [ ] **Step 2: Write listener**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Listeners;

use App\Modules\Checkout\Services\StripeService;
use App\Modules\Orders\Events\OrderShipped;
use Illuminate\Support\Facades\Log;

class TransferFundsToStore
{
    public function __construct(private readonly StripeService $stripeService) {}

    public function handle(OrderShipped $event): void
    {
        $order = $event->order;

        if ($order->purchase->disputed) {
            Log::info('Transfer skipped: purchase disputed', ['order_id' => $order->id]);
            return;
        }

        $store = $order->store;
        if (! $store?->stripe_connect_id) {
            Log::warning('Transfer skipped: no Connect account', ['order_id' => $order->id]);
            return;
        }

        if ($order->seller_payout <= 0) {
            return;
        }

        try {
            $transferGroup = 'checkout_'.$order->purchase_id;
            $transfer = $this->stripeService->createTransfer(
                $order->seller_payout,
                $store->stripe_connect_id,
                $transferGroup,
            );
            $order->update([
                'stripe_transfer_id' => $transfer->id,
                'transferred_at' => now(),
            ]);
        } catch (\Throwable $e) {
            Log::error('Stripe transfer failed', [
                'order_id' => $order->id,
                'error' => $e->getMessage(),
            ]);
        }
    }
}
```

- [ ] **Step 3: Register listener** in `EventServiceProvider::$listen`:

```php
\App\Modules\Orders\Events\OrderShipped::class => [
    \App\Modules\Orders\Listeners\TransferFundsToStore::class,
],
```

- [ ] **Step 4: Run tests, commit**

```bash
docker compose exec laravel.test php artisan test --filter=TransferFundsOnShipTest
git add Alqove/api/app/Modules/Orders/Listeners/TransferFundsToStore.php \
        Alqove/api/app/Providers/EventServiceProvider.php \
        Alqove/api/tests/Feature/Orders/TransferFundsOnShipTest.php
git commit -m "feat(orders): TransferFundsToStore listener fires on ship, honours dispute flag"
```

---

## Task 15: `RecomputePurchaseStatusOnOrderChange` listener

**Files:**
- Create: `api/app/Modules/Orders/Listeners/RecomputePurchaseStatusOnOrderChange.php`
- Create: `api/app/Modules/Orders/Services/PurchaseStatusCalculator.php`
- Modify: `api/app/Providers/EventServiceProvider.php`
- Create: `api/tests/Unit/Orders/PurchaseStatusCalculatorTest.php`
- Create: `api/tests/Feature/Orders/PurchaseRollupTest.php`

- [ ] **Step 1: Write `PurchaseStatusCalculator` unit test**

```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Orders;

use App\Modules\Orders\Services\PurchaseStatusCalculator;
use App\Support\Enums\OrderStatus;
use App\Support\Enums\PurchaseStatus;
use Tests\TestCase;

class PurchaseStatusCalculatorTest extends TestCase
{
    public function test_all_pending_stays_pending(): void
    {
        $this->assertSame(PurchaseStatus::Paid, PurchaseStatusCalculator::fromOrderStatuses([
            OrderStatus::Pending, OrderStatus::Pending,
        ]));
    }

    public function test_some_shipped_is_partially_shipped(): void
    {
        $this->assertSame(PurchaseStatus::PartiallyShipped, PurchaseStatusCalculator::fromOrderStatuses([
            OrderStatus::Shipped, OrderStatus::Pending,
        ]));
    }

    public function test_all_shipped(): void
    {
        $this->assertSame(PurchaseStatus::Shipped, PurchaseStatusCalculator::fromOrderStatuses([
            OrderStatus::Shipped, OrderStatus::Shipped,
        ]));
    }

    public function test_all_delivered(): void
    {
        $this->assertSame(PurchaseStatus::Delivered, PurchaseStatusCalculator::fromOrderStatuses([
            OrderStatus::Delivered, OrderStatus::Delivered,
        ]));
    }

    public function test_all_cancelled(): void
    {
        $this->assertSame(PurchaseStatus::Cancelled, PurchaseStatusCalculator::fromOrderStatuses([
            OrderStatus::Cancelled, OrderStatus::Cancelled,
        ]));
    }
}
```

Ensure `PurchaseStatus` enum includes these cases. Add `PartiallyShipped = 'partially_shipped'` and `Cancelled = 'cancelled'` if missing. Check `api/app/Support/Enums/PurchaseStatus.php`.

- [ ] **Step 2: Write `PurchaseStatusCalculator`**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Services;

use App\Support\Enums\OrderStatus;
use App\Support\Enums\PurchaseStatus;

class PurchaseStatusCalculator
{
    /**
     * @param array<int, OrderStatus> $statuses
     */
    public static function fromOrderStatuses(array $statuses): PurchaseStatus
    {
        if (count($statuses) === 0) return PurchaseStatus::Paid;

        $non = array_values(array_filter($statuses, fn ($s) => $s !== OrderStatus::Cancelled));
        if (count($non) === 0) return PurchaseStatus::Cancelled;

        if (self::all($non, [OrderStatus::Delivered])) return PurchaseStatus::Delivered;
        if (self::all($non, [OrderStatus::Delivered, OrderStatus::Shipped])) return PurchaseStatus::Shipped;
        if (self::any($non, [OrderStatus::Shipped, OrderStatus::Delivered])) return PurchaseStatus::PartiallyShipped;
        return PurchaseStatus::Paid;
    }

    private static function all(array $statuses, array $allowed): bool
    {
        foreach ($statuses as $s) {
            if (! in_array($s, $allowed, true)) return false;
        }
        return true;
    }

    private static function any(array $statuses, array $candidates): bool
    {
        foreach ($statuses as $s) {
            if (in_array($s, $candidates, true)) return true;
        }
        return false;
    }
}
```

- [ ] **Step 3: Write listener**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Listeners;

use App\Models\Purchase;
use App\Modules\Orders\Services\PurchaseStatusCalculator;

class RecomputePurchaseStatusOnOrderChange
{
    public function handle(object $event): void
    {
        $order = $event->order ?? null;
        if (! $order) return;

        $purchase = Purchase::find($order->purchase_id);
        if (! $purchase) return;

        $statuses = $purchase->orders()->pluck('status')->map(
            fn ($s) => is_string($s) ? \App\Support\Enums\OrderStatus::from($s) : $s,
        )->all();

        $purchase->update(['status' => PurchaseStatusCalculator::fromOrderStatuses($statuses)]);
    }
}
```

- [ ] **Step 4: Register listener for multiple events**

```php
\App\Modules\Orders\Events\OrderShipped::class => [
    \App\Modules\Orders\Listeners\TransferFundsToStore::class,
    \App\Modules\Orders\Listeners\RecomputePurchaseStatusOnOrderChange::class,
],
\App\Modules\Orders\Events\OrderDelivered::class => [
    \App\Modules\Orders\Listeners\RecomputePurchaseStatusOnOrderChange::class,
],
\App\Modules\Orders\Events\OrderCancelled::class => [
    \App\Modules\Orders\Listeners\RecomputePurchaseStatusOnOrderChange::class,
],
```

(`OrderDelivered` event is created in Task 16. Add the registration now; the test for this listener against `OrderDelivered` runs in Task 16.)

- [ ] **Step 5: Write feature test for the rollup via OrderShipped**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Orders;

use App\Models\Order;
use App\Models\Purchase;
use App\Modules\Orders\Events\OrderShipped;
use App\Support\Enums\OrderStatus;
use App\Support\Enums\PurchaseStatus;
use Tests\TestCase;

class PurchaseRollupTest extends TestCase
{
    public function test_one_of_two_orders_shipped_rolls_up_to_partially_shipped(): void
    {
        $purchase = Purchase::factory()->create();
        $shipped = Order::factory()->create(['purchase_id' => $purchase->id, 'status' => OrderStatus::Shipped]);
        Order::factory()->create(['purchase_id' => $purchase->id, 'status' => OrderStatus::Pending]);

        event(new OrderShipped($shipped));

        $this->assertSame(PurchaseStatus::PartiallyShipped, $purchase->fresh()->status);
    }
}
```

- [ ] **Step 6: Run tests, commit**

```bash
docker compose exec laravel.test php artisan test --filter="PurchaseStatusCalculatorTest|PurchaseRollupTest"
git add Alqove/api/app/Modules/Orders/Services/PurchaseStatusCalculator.php \
        Alqove/api/app/Modules/Orders/Listeners/RecomputePurchaseStatusOnOrderChange.php \
        Alqove/api/app/Providers/EventServiceProvider.php \
        Alqove/api/app/Support/Enums/PurchaseStatus.php \
        Alqove/api/tests/Unit/Orders/PurchaseStatusCalculatorTest.php \
        Alqove/api/tests/Feature/Orders/PurchaseRollupTest.php
git commit -m "feat(orders): Purchase status rollup from child Order statuses"
```

---

## Task 16: EasyPost tracker webhook

**Files:**
- Create: `api/app/Modules/Shipping/Controllers/ShippingWebhookController.php`
- Create: `api/app/Modules/Shipping/Services/TrackingService.php`
- Create: `api/app/Modules/Orders/Events/OrderDelivered.php`
- Create: `api/app/Modules/Orders/Events/OrderDeliveryFailed.php`
- Modify: `api/app/Modules/Shipping/routes.php`
- Modify: `api/app/Modules/Orders/Services/OrderFulfillmentService.php` (add `markDelivered`)
- Modify: `api/contracts/openapi.yaml`
- Create: `api/tests/Feature/Shipping/EasyPostWebhookTest.php`

- [ ] **Step 1: Write events** `OrderDelivered` and `OrderDeliveryFailed` (same shape as previous events, constructor takes Order).

- [ ] **Step 2: Write failing feature test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Shipping;

use App\Models\Order;
use App\Modules\Orders\Events\OrderDelivered;
use App\Modules\Shipping\Contracts\LabelProvider;
use App\Modules\Shipping\Services\FakeLabelProvider;
use App\Support\Enums\OrderStatus;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

class EasyPostWebhookTest extends TestCase
{
    public function test_delivered_event_marks_order_delivered(): void
    {
        Event::fake([OrderDelivered::class]);
        $this->app->instance(LabelProvider::class, new FakeLabelProvider());

        $order = Order::factory()->create([
            'tracker_id' => 'trk_abc',
            'status' => OrderStatus::Shipped,
        ]);

        $payload = [
            'id' => 'evt_1',
            'result' => ['id' => 'trk_abc', 'status' => 'delivered'],
        ];

        $response = $this->postJson('/v1/webhooks/easypost', $payload, [
            'X-EasyPost-Signature' => 'fake-signature-ok',
        ]);

        $response->assertOk();
        $this->assertSame(OrderStatus::Delivered, $order->fresh()->status);
        $this->assertNotNull($order->fresh()->delivered_at);
        Event::assertDispatched(OrderDelivered::class);
    }

    public function test_invalid_signature_returns_401(): void
    {
        $this->app->instance(LabelProvider::class, new FakeLabelProvider());

        $this->postJson('/v1/webhooks/easypost', ['id' => 'e'], ['X-EasyPost-Signature' => 'wrong'])
            ->assertStatus(401);
    }

    public function test_duplicate_event_id_is_idempotent(): void
    {
        Event::fake([OrderDelivered::class]);
        $this->app->instance(LabelProvider::class, new FakeLabelProvider());

        $order = Order::factory()->create([
            'tracker_id' => 'trk_abc',
            'status' => OrderStatus::Shipped,
        ]);

        $payload = ['id' => 'evt_dup', 'result' => ['id' => 'trk_abc', 'status' => 'delivered']];
        $headers = ['X-EasyPost-Signature' => 'fake-signature-ok'];

        $this->postJson('/v1/webhooks/easypost', $payload, $headers)->assertOk();
        $this->postJson('/v1/webhooks/easypost', $payload, $headers)->assertOk();

        Event::assertDispatchedTimes(OrderDelivered::class, 1);
    }
}
```

- [ ] **Step 3: Write `TrackingService`**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Shipping\Services;

use App\Models\Order;
use App\Modules\Orders\Events\OrderDeliveryFailed;
use App\Modules\Orders\Services\OrderFulfillmentService;

class TrackingService
{
    public function __construct(private readonly OrderFulfillmentService $fulfillment) {}

    public function applyUpdate(string $trackerId, string $status): void
    {
        $order = Order::where('tracker_id', $trackerId)->first();
        if (! $order) return;

        switch ($status) {
            case 'delivered':
                $this->fulfillment->markDelivered($order);
                break;
            case 'return_to_sender':
            case 'failure':
            case 'error':
                activity()->performedOn($order)->withProperties(['tracker_status' => $status])->log('delivery_failed');
                OrderDeliveryFailed::dispatch($order);
                break;
            default:
                // pre_transit, in_transit, out_for_delivery → no state change
        }
    }
}
```

- [ ] **Step 4: Add `markDelivered` to `OrderFulfillmentService`**

```php
public function markDelivered(\App\Models\Order $order): void
{
    if ($order->status === \App\Support\Enums\OrderStatus::Delivered) return;
    $order->update([
        'status' => \App\Support\Enums\OrderStatus::Delivered,
        'delivered_at' => now(),
    ]);
    \App\Modules\Orders\Events\OrderDelivered::dispatch($order->fresh());
}
```

- [ ] **Step 5: Write `ShippingWebhookController`**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Shipping\Controllers;

use App\Modules\Shipping\Contracts\LabelProvider;
use App\Modules\Shipping\Services\TrackingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;

class ShippingWebhookController
{
    public function __construct(
        private readonly LabelProvider $provider,
        private readonly TrackingService $tracking,
    ) {}

    public function easypost(Request $request): JsonResponse
    {
        $body = $request->getContent();
        $signature = $request->header('X-EasyPost-Signature', '');

        if (! $this->provider->verifyWebhookSignature($body, $signature)) {
            return response()->json(['error' => 'invalid_signature'], 401);
        }

        $parsed = $this->provider->parseTrackerEvent($request->all());
        $eventId = $parsed['event_id'];
        $redisKey = "easypost:event:{$eventId}";

        if ($eventId !== '' && Redis::set($redisKey, '1', 'EX', 86400, 'NX') === null) {
            return response()->json(['status' => 'duplicate']);
        }

        $this->tracking->applyUpdate($parsed['tracker_id'], $parsed['status']);

        return response()->json(['status' => 'ok']);
    }
}
```

- [ ] **Step 6: Register route**

In `api/app/Modules/Shipping/routes.php`:

```php
use App\Modules\Shipping\Controllers\ShippingWebhookController;
use Illuminate\Support\Facades\Route;

Route::post('/webhooks/easypost', [ShippingWebhookController::class, 'easypost']);
```

Ensure `verify-csrf-token` middleware excludes `/v1/webhooks/*` in `app/Http/Middleware/VerifyCsrfToken.php` (check — Stripe webhook already needs this).

- [ ] **Step 7: Add OpenAPI path**

```yaml
/v1/webhooks/easypost:
  post:
    operationId: easypostWebhook
    tags: [Webhooks]
    security: []
    requestBody:
      required: true
      content:
        application/json:
          schema: { type: object }
    responses:
      '200': { description: Accepted }
      '401': { description: Invalid signature }
```

- [ ] **Step 8: Run tests, commit**

```bash
docker compose exec laravel.test php artisan test --filter=EasyPostWebhookTest
git add Alqove/api/app/Modules/Shipping/ \
        Alqove/api/app/Modules/Orders/Events/OrderDelivered.php \
        Alqove/api/app/Modules/Orders/Events/OrderDeliveryFailed.php \
        Alqove/api/app/Modules/Orders/Services/OrderFulfillmentService.php \
        Alqove/api/contracts/openapi.yaml \
        Alqove/api/tests/Feature/Shipping/EasyPostWebhookTest.php
git commit -m "feat(shipping): EasyPost tracker webhook with HMAC verification and idempotency"
```

---

## Task 17: Minimal Stripe dispute handler

**Files:**
- Modify: `api/app/Modules/Checkout/Controllers/CheckoutController.php`
- Create: `api/app/Modules/Orders/Events/PurchaseDisputed.php`
- Create: `api/tests/Feature/Checkout/DisputeWebhookTest.php`

- [ ] **Step 1: Write `PurchaseDisputed` event**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Events;

use App\Models\Purchase;
use Illuminate\Foundation\Events\Dispatchable;

class PurchaseDisputed
{
    use Dispatchable;

    public function __construct(public readonly Purchase $purchase) {}
}
```

- [ ] **Step 2: Write failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Checkout;

use App\Models\Purchase;
use App\Modules\Orders\Events\PurchaseDisputed;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

class DisputeWebhookTest extends TestCase
{
    public function test_charge_dispute_created_marks_purchase_disputed(): void
    {
        Event::fake([PurchaseDisputed::class]);

        $purchase = Purchase::factory()->create([
            'stripe_payment_intent_id' => 'pi_abc',
            'disputed' => false,
        ]);

        // Craft a Stripe webhook body and call the webhook endpoint.
        // Use the same helper WebhookFulfillmentTest uses to build a signed payload.
        $body = json_encode([
            'id' => 'evt_dispute_1',
            'type' => 'charge.dispute.created',
            'data' => ['object' => [
                'id' => 'du_1',
                'payment_intent' => 'pi_abc',
                'amount' => 5000,
                'reason' => 'product_not_received',
                'evidence_details' => ['due_by' => 1714521600],
            ]],
        ]);
        $signature = $this->signStripe($body); // existing test helper

        $this->postJson('/v1/webhooks/stripe', json_decode($body, true), ['Stripe-Signature' => $signature])
            ->assertOk();

        $this->assertTrue($purchase->fresh()->disputed);
        Event::assertDispatched(PurchaseDisputed::class);
    }
}
```

(If `signStripe` doesn't exist, replicate signature construction from `WebhookFulfillmentTest`.)

- [ ] **Step 3: Extend `CheckoutController::webhook`**

Add another branch alongside the existing `payment_intent.succeeded` block:

```php
if ($event->type === 'charge.dispute.created') {
    $paymentIntentId = $event->data->object->payment_intent ?? null;
    $purchase = $paymentIntentId
        ? \App\Models\Purchase::where('stripe_payment_intent_id', $paymentIntentId)->first()
        : null;

    if ($purchase) {
        $purchase->update(['disputed' => true]);
        activity()
            ->performedOn($purchase)
            ->withProperties([
                'dispute_id' => $event->data->object->id ?? null,
                'amount' => $event->data->object->amount ?? null,
                'reason' => $event->data->object->reason ?? null,
            ])
            ->log('purchase_disputed');
        \App\Modules\Orders\Events\PurchaseDisputed::dispatch($purchase);
    }
}
```

- [ ] **Step 4: Run test, commit**

```bash
docker compose exec laravel.test php artisan test --filter=DisputeWebhookTest
git add Alqove/api/app/Modules/Checkout/Controllers/CheckoutController.php \
        Alqove/api/app/Modules/Orders/Events/PurchaseDisputed.php \
        Alqove/api/tests/Feature/Checkout/DisputeWebhookTest.php
git commit -m "feat(checkout): minimal charge.dispute.created handler flags Purchase"
```

---

## Task 18: Extend buyer tracking UI — purchase list

**Files:**
- Modify: `web/app/(buyer)/purchases/page.tsx`
- Modify: `web/components/purchases/purchase-card.tsx` (or create)
- Modify: `packages/shared/hooks/use-purchases.ts` (if exists) — expand types

- [ ] **Step 1: Regenerate types (if not already)**

```bash
cd Alqove && npm run build:types
```

- [ ] **Step 2: Update `PurchaseCard` component**

Add display of `is_delayed` pill and rollup status text. Example JSX:

```tsx
{purchase.is_delayed && (
  <Badge variant="warning">Delayed</Badge>
)}
<p className="text-sm text-muted-foreground">
  {statusLabel(purchase.status)} — {shippedCount(purchase.orders)} of {purchase.orders.length} orders shipped
</p>
```

Helper functions:
```tsx
function statusLabel(status: string) {
  return {
    paid: 'Paid',
    partially_shipped: 'Partially shipped',
    shipped: 'Shipped',
    delivered: 'Delivered',
    cancelled: 'Cancelled',
  }[status] ?? status;
}

function shippedCount(orders: Array<{ status: string }>) {
  return orders.filter(o => ['shipped', 'delivered'].includes(o.status)).length;
}
```

- [ ] **Step 3: Playwright smoke check** (if Playwright is configured in Layer 4): verify the purchases list renders the delayed pill for a seeded delayed purchase.

- [ ] **Step 4: Commit**

```bash
git add Alqove/web/app/\(buyer\)/purchases/ Alqove/web/components/purchases/ Alqove/packages/shared/hooks/
git commit -m "feat(web): delayed pill and rollup status on purchases list"
```

---

## Task 19: Buyer purchase detail — tracking, cancel, delayed banner

**Files:**
- Modify: `web/app/(buyer)/purchases/[id]/page.tsx`
- Create: `web/components/purchases/order-tracking.tsx`
- Create: `web/components/purchases/cancel-order-dialog.tsx`
- Create: `packages/shared/hooks/use-cancel-order.ts`
- Modify: `packages/api-client/endpoints/orders.ts`

- [ ] **Step 1: Add cancel endpoint to API client**

In `packages/api-client/endpoints/orders.ts`:

```ts
export async function cancelOrder(orderId: string): Promise<Order> {
  const res = await apiClient.post<{ data: Order }>(`/v1/orders/${orderId}/cancel`);
  return res.data;
}
```

- [ ] **Step 2: Write `use-cancel-order` hook**

```ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { cancelOrder } from '@alqove/api-client';

export function useCancelOrder() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (orderId: string) => cancelOrder(orderId),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['purchases'] });
    },
  });
}
```

- [ ] **Step 3: Write `CancelOrderDialog`**

```tsx
'use client';

import { useCancelOrder } from '@alqove/shared';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';

export function CancelOrderDialog({ orderId, delayed }: { orderId: string; delayed?: boolean }) {
  const { mutate, isPending } = useCancelOrder();

  return (
    <AlertDialog>
      <AlertDialogTrigger asChild>
        <Button variant={delayed ? 'default' : 'ghost'} size="sm">
          {delayed ? 'Cancel for full refund' : 'Cancel order'}
        </Button>
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>Cancel this order?</AlertDialogTitle>
          <AlertDialogDescription>
            You'll receive a full refund for this store's items and shipping.
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>Keep order</AlertDialogCancel>
          <AlertDialogAction disabled={isPending} onClick={() => mutate(orderId)}>
            {isPending ? 'Cancelling…' : 'Cancel order'}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}
```

- [ ] **Step 4: Write `OrderTracking` component**

```tsx
import type { Order } from '@alqove/types';
import { CancelOrderDialog } from './cancel-order-dialog';

export function OrderTracking({ order }: { order: Order }) {
  return (
    <div className="space-y-3">
      <OrderStatusStepper status={order.status} shippedAt={order.shipped_at} deliveredAt={order.delivered_at} />

      {order.tracking_url && (
        <a href={order.tracking_url} target="_blank" rel="noreferrer" className="text-primary underline text-sm">
          Track with {order.carrier}: {order.tracking_number}
        </a>
      )}

      {order.is_delayed && (
        <div className="rounded-md bg-amber-50 border border-amber-200 p-3 text-sm">
          <p className="font-medium text-amber-900">Your order is delayed.</p>
          <p className="text-amber-800 mt-1">The store hasn't shipped yet. Cancel for a full refund.</p>
          <CancelOrderDialog orderId={order.id} delayed />
        </div>
      )}

      {!order.is_delayed && (order.status === 'pending' || order.status === 'processing') && (
        <CancelOrderDialog orderId={order.id} />
      )}

      {order.status === 'cancelled' && (
        <CancellationMessage reason={order.cancellation_reason} by={order.cancelled_by} />
      )}
    </div>
  );
}

function CancellationMessage({ reason, by }: { reason: string | null; by: string | null }) {
  const messages: Record<string, string> = {
    buyer_requested: "You cancelled this order. Refund processed.",
    sold_locally: "The store marked this item as unavailable. Full refund processed.",
    item_damaged: "The store could not fulfill this order. Full refund processed.",
    ship_deadline_exceeded: "This order was automatically cancelled because it wasn't shipped in time. Full refund processed.",
    other: "This order was cancelled by the store. Full refund processed.",
  };
  return (
    <p className="text-sm text-muted-foreground">
      {reason ? messages[reason] ?? 'Order cancelled. Full refund processed.' : 'Order cancelled.'}
    </p>
  );
}

function OrderStatusStepper(props: { status: string; shippedAt: string | null; deliveredAt: string | null }) {
  const steps = [
    { label: 'Placed', done: true, timestamp: null as string | null },
    { label: 'Shipped', done: !!props.shippedAt, timestamp: props.shippedAt },
    { label: 'Delivered', done: !!props.deliveredAt, timestamp: props.deliveredAt },
  ];
  return (
    <ol className="flex items-center gap-4">
      {steps.map((s, i) => (
        <li key={s.label} className="flex items-center gap-2 text-sm">
          <span className={`inline-block h-2 w-2 rounded-full ${s.done ? 'bg-primary' : 'bg-muted'}`} />
          <span className={s.done ? 'text-foreground' : 'text-muted-foreground'}>
            {s.label}{s.timestamp ? ` · ${new Date(s.timestamp).toLocaleDateString()}` : ''}
          </span>
        </li>
      ))}
    </ol>
  );
}
```

- [ ] **Step 5: Update purchase detail page to render `OrderTracking` per order**

Open `web/app/(buyer)/purchases/[id]/page.tsx`. Inside the map over `purchase.orders`, render `<OrderTracking order={order} />` below the existing store header and items list.

- [ ] **Step 6: Add Vitest test for `CancellationMessage`**

`web/components/purchases/__tests__/cancellation-message.test.tsx`:

```tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { CancellationMessage } from '../order-tracking'; // export it or test via OrderTracking

describe('CancellationMessage', () => {
  it('renders sold_locally copy', () => {
    render(<CancellationMessage reason="sold_locally" by="seller" />);
    expect(screen.getByText(/marked this item as unavailable/)).toBeInTheDocument();
  });
});
```

Export `CancellationMessage` from `order-tracking.tsx` for testability.

- [ ] **Step 7: Commit**

```bash
cd Alqove && npm run typecheck && npm run test -- --run
git add Alqove/web/ Alqove/packages/
git commit -m "feat(web): purchase detail tracking, cancel dialog, delay banner"
```

---

## Task 20: Reconciliation command for failed Transfers & refunds

**Files:**
- Create: `api/app/Modules/Orders/Console/ReconcileFailedMoneyMovements.php`
- Modify: `api/app/Console/Kernel.php` (or `bootstrap/app.php` schedule)
- Create: `api/tests/Feature/Orders/ReconciliationCommandTest.php`

- [ ] **Step 1: Write command**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Console;

use App\Models\Order;
use App\Modules\Checkout\Services\StripeService;
use App\Modules\Orders\Events\OrderShipped;
use App\Support\Enums\OrderStatus;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;

class ReconcileFailedMoneyMovements extends Command
{
    protected $signature = 'orders:reconcile-money';
    protected $description = 'Retry failed Transfers for shipped orders without a Transfer.';

    public function handle(StripeService $stripe): int
    {
        $orders = Order::where('status', OrderStatus::Shipped)
            ->whereNull('stripe_transfer_id')
            ->whereNotNull('shipped_at')
            ->get();

        foreach ($orders as $order) {
            if ($order->purchase->disputed) continue;
            OrderShipped::dispatch($order); // re-triggers TransferFundsToStore listener
            $this->info("Retried transfer for order {$order->id}");
        }
        return self::SUCCESS;
    }
}
```

- [ ] **Step 2: Register schedule**

In `api/app/Console/Kernel.php` (or `bootstrap/app.php` schedule callback):

```php
$schedule->command('orders:reconcile-money')->hourly();
```

- [ ] **Step 3: Write test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Orders;

use App\Models\Order;
use App\Modules\Orders\Events\OrderShipped;
use App\Support\Enums\OrderStatus;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

class ReconciliationCommandTest extends TestCase
{
    public function test_reconcile_redispatches_shipped_orders_without_transfer(): void
    {
        Event::fake([OrderShipped::class]);

        Order::factory()->create([
            'status' => OrderStatus::Shipped,
            'stripe_transfer_id' => null,
            'shipped_at' => now(),
        ]);
        Order::factory()->create([
            'status' => OrderStatus::Shipped,
            'stripe_transfer_id' => 'tr_existing',
            'shipped_at' => now(),
        ]);

        $this->artisan('orders:reconcile-money')->assertSuccessful();

        Event::assertDispatchedTimes(OrderShipped::class, 1);
    }
}
```

- [ ] **Step 4: Commit**

```bash
docker compose exec laravel.test php artisan test --filter=ReconciliationCommandTest
git add Alqove/api/app/Modules/Orders/Console/ \
        Alqove/api/app/Console/Kernel.php \
        Alqove/api/tests/Feature/Orders/ReconciliationCommandTest.php
git commit -m "feat(orders): hourly reconciliation retries failed Transfers"
```

---

## Task 21: Documentation updates

**Files:**
- Modify: `api/app/Modules/Shipping/README.md`
- Modify: `api/app/Modules/Orders/README.md`
- Create: `docs/adr/004-easypost-integration.md`

- [ ] **Step 1: Write Shipping README content**

Document: LabelProvider contract, how to run with FakeLabelProvider in dev, how to set up EasyPost test keys, webhook endpoint URL, how to use ngrok / Cloudflare Tunnel for local webhook testing, and the known statuses we handle.

- [ ] **Step 2: Update Orders README**

Add sections: cancellation matrix (buyer/seller/system × item relist/removal), Transfer-on-ship contract, ship-by job pipeline (when each job fires, no-op semantics).

- [ ] **Step 3: Write ADR 004**

Standard ADR format: Context → Decision → Consequences. Content: why EasyPost over Shippo, why single provider behind interface, FakeLabelProvider for tests, webhook signature choice.

- [ ] **Step 4: Commit**

```bash
git add Alqove/api/app/Modules/Shipping/README.md \
        Alqove/api/app/Modules/Orders/README.md \
        Alqove/docs/adr/004-easypost-integration.md
git commit -m "docs(layer-5): shipping, orders, and ADR 004 for EasyPost"
```

---

## Task 22: Full suite run + final verification

- [ ] **Step 1: Run full test suite**

```bash
docker compose exec laravel.test php artisan test
```

Expected: all green.

- [ ] **Step 2: Run Pint and PHPStan**

```bash
docker compose exec laravel.test ./vendor/bin/pint
docker compose exec laravel.test ./vendor/bin/phpstan analyse
```

Fix any style/static issues.

- [ ] **Step 3: Run web type check and tests**

```bash
cd Alqove && npm run typecheck && npm run lint && npm run test -- --run
```

- [ ] **Step 4: Regenerate types one last time and verify no drift**

```bash
cd Alqove && npm run build:types
git status  # Should show no changes to packages/types — if there are changes, commit them.
```

- [ ] **Step 5: Manual smoke test via HTTP**

With API running:

```bash
# Seeded buyer + seller. Create a Store address + parcel preset via API.
# Simulate a paid checkout via test Stripe webhook.
# Call POST /v1/stores/{store}/orders/{order}/labels with the preset id.
# Assert response contains label_url, tracking_number, carrier.
# Call POST /v1/orders/{order}/cancel as a different pending order's buyer, assert 200.
```

Document any issues and open follow-up tasks.

- [ ] **Step 6: Final commit if any fixes made**

```bash
git add -A
git commit -m "chore(layer-5): final style and typecheck fixes"
```

---

## Self-Review Checklist

- [ ] Every spec section has at least one task implementing it (shipping labels, tracker webhook, cancellation, item relisting, ship-by jobs, dispute handler, buyer UI, Transfer timing migration).
- [ ] No "TBD" or "implement later" placeholders in any task step.
- [ ] Method signatures in later tasks match earlier definitions (`systemCancel`, `buyerCancel`, `markDelivered`, `purchaseLabel`).
- [ ] Tests exist for each new endpoint, listener, and job.
- [ ] OpenAPI is updated for every new endpoint.
- [ ] Types regenerated after OpenAPI changes.
- [ ] Webhook CSRF exemption verified for `/v1/webhooks/easypost`.

## Notes

- Task 10's `AutoCancelOverdueOrderJob` depends on Task 12's `CancellationService`. The plan sidesteps this by making the Task 10 version of the job a stub (event-only) and completing it in Task 12. Execute in order.
- If `Spatie Activity Log` isn't installed, Task 12's `activity()` calls will fail. Layer 0 should have installed it per the master spec — verify with `docker compose exec laravel.test composer show spatie/laravel-activitylog`.
- `StripeService::createTransfer` was used by the Layer 4 webhook pre-refactor. Confirm it still exists after Task 8 — the listener in Task 14 depends on it.
