# Layer 8 Plan 1: Foundation + Dispute Resolution

> **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:** Lay the cross-cutting backend foundation Layer 8 needs (activity log wiring, `disputes` table + `Dispute` model, reversal columns on orders, webhook coverage for the missing dispute lifecycle events) and ship the headline feature: an admin dispute queue and adjudication UI that can submit evidence to Stripe, accept disputes with per-Order Transfer reversals + refunds, and write an immutable audit trail.

**Architecture:** (1) Backend foundation — register `spatie/laravel-activitylog` (if not already), create `disputes` and migrate the in-flight `Purchase.disputed=true` rows, add `transfer_reversed_at` / `stripe_transfer_reversal_id` to `orders`, extend the webhook handler. (2) Backend dispute service — `DisputeAdjudicator` orchestrates the three adjudication outcomes and owns the Stripe `Transfer::createReversal` and `Refund::create` calls; `StripeService` gains a thin `reverseTransfer()` wrapper. (3) Backend endpoints — `GET /v1/admin/disputes`, `GET /v1/admin/disputes/{purchase}`, `POST .../adjudicate`, `POST .../submit-evidence`, `POST .../retry-reversal/{order}`. (4) Frontend — `/admin/disputes` queue, `/admin/disputes/[id]` detail with the adjudication form + a shared `ConfirmWithJustificationDialog`. (5) Dashboard upgrades — add an open-disputes tile and an `OpenDisputesWidget` to `/admin`.

**Tech Stack:** Laravel 11, Pest PHP tests, Postgres, `spatie/laravel-activitylog`, Stripe PHP SDK, OpenAPI → `openapi-typescript`, Next.js 15 App Router, TanStack Query, Tailwind, Vitest + React Testing Library.

**Spec:** `docs/superpowers/specs/2026-05-04-layer-8-admin-console-disputes-design.md`
**Prerequisites:** Layer 7 merged. The Layer 8 *foundation slice* (admin dashboard tiles, `EnsureAdmin` middleware, `/v1/admin/dashboard/metrics`, `/v1/admin/stores`, `/v1/admin/orders`) shipped on 2026-05-04 in `alqove-api@c4c073b` and `alqove-web@236c45a`. `Purchase.disputed` boolean exists. `AdminPurchaseDisputedNotification` exists with a CTA URL pointing at `/admin/disputes/{purchase.id}`. The `charge.dispute.created` webhook handler exists and dispatches `PurchaseDisputed`.
**Successor plans:** `2026-XX-XX-layer-8-admin-orders-money-movement.md`, `2026-XX-XX-layer-8-admin-stores-suspension.md`, `2026-XX-XX-layer-8-admin-inbox-activity.md`.

---

## Phase A — Backend foundation

### Task 1: Confirm `spatie/laravel-activitylog` is registered and migrations have run

**Files:**
- Verify: `api/composer.json`, `api/config/app.php` (or `bootstrap/providers.php`), `api/database/migrations/*activity_log*`
- Test: `api/tests/Feature/Admin/ActivityLogScaffoldingTest.php`

- [ ] **Step 1: Write the failing scaffolding test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Admin;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;

class ActivityLogScaffoldingTest extends TestCase
{
    use RefreshDatabase;

    public function test_activity_log_table_exists(): void
    {
        $this->assertTrue(Schema::hasTable('activity_log'));
    }

    public function test_activity_can_be_recorded_with_a_causer(): void
    {
        $admin = User::factory()->create();

        activity('admin')
            ->causedBy($admin)
            ->withProperties(['k' => 'v'])
            ->log('test_event');

        $this->assertSame(1, Activity::query()->count());
        $this->assertSame('test_event', Activity::query()->first()->description);
        $this->assertTrue($admin->is(Activity::query()->first()->causer));
    }
}
```

- [ ] **Step 2: Run the test**

Run: `docker compose exec laravel.test php artisan test --filter=ActivityLogScaffoldingTest`

If it passes, skip to Task 2 — the package is already wired and seeded migrations have run.

If it fails because the table doesn't exist, run:

```
docker compose exec laravel.test php artisan vendor:publish \
  --provider="Spatie\Activitylog\ActivitylogServiceProvider" \
  --tag="activitylog-migrations"
docker compose exec laravel.test php artisan migrate
```

If it fails because `activity()` is undefined or the service provider isn't loaded, the package was installed but never registered. Confirm `Spatie\Activitylog\ActivitylogServiceProvider::class` is in `bootstrap/providers.php` (Laravel 11 convention) and re-run.

- [ ] **Step 3: Re-run and confirm pass**

Run: `docker compose exec laravel.test php artisan test --filter=ActivityLogScaffoldingTest`
Expected: PASS.

### Task 2: `disputes` table migration + `Dispute` model

**Files:**
- Create: `api/database/migrations/2026_05_04_000001_create_disputes_table.php`
- Create: `api/app/Models/Dispute.php`
- Create: `api/app/Support/Enums/DisputeStatus.php`
- Create: `api/app/Support/Enums/DisputeOutcome.php`
- Create: `api/database/factories/DisputeFactory.php`
- Test: `api/tests/Feature/Admin/DisputeModelTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Admin;

use App\Models\Dispute;
use App\Models\Purchase;
use App\Support\Enums\DisputeOutcome;
use App\Support\Enums\DisputeStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class DisputeModelTest extends TestCase
{
    use RefreshDatabase;

    public function test_dispute_belongs_to_a_purchase(): void
    {
        $purchase = Purchase::factory()->create();
        $dispute = Dispute::factory()->create(['purchase_id' => $purchase->id]);

        $this->assertTrue($purchase->is($dispute->purchase));
    }

    public function test_dispute_status_and_outcome_cast_to_enums(): void
    {
        $dispute = Dispute::factory()->create([
            'status' => DisputeStatus::NeedsResponse,
            'outcome' => null,
        ]);

        $this->assertSame(DisputeStatus::NeedsResponse, $dispute->fresh()->status);
        $this->assertNull($dispute->fresh()->outcome);

        $dispute->update(['outcome' => DisputeOutcome::Accepted]);
        $this->assertSame(DisputeOutcome::Accepted, $dispute->fresh()->outcome);
    }

    public function test_dispute_unique_per_stripe_dispute_id(): void
    {
        $purchase = Purchase::factory()->create();
        Dispute::factory()->create([
            'purchase_id' => $purchase->id,
            'stripe_dispute_id' => 'dp_test_123',
        ]);

        $this->expectException(\Illuminate\Database\UniqueConstraintViolationException::class);

        Dispute::factory()->create([
            'purchase_id' => $purchase->id,
            'stripe_dispute_id' => 'dp_test_123',
        ]);
    }
}
```

- [ ] **Step 2: Run the test and confirm failure**

Expected: model + table + factory don't exist → fatal error.

- [ ] **Step 3: Create the enums**

`api/app/Support/Enums/DisputeStatus.php`:

```php
<?php

declare(strict_types=1);

namespace App\Support\Enums;

enum DisputeStatus: string
{
    case NeedsResponse = 'needs_response';
    case UnderReview = 'under_review';
    case Won = 'won';
    case Lost = 'lost';
    case WarningNeedsResponse = 'warning_needs_response';
    case WarningUnderReview = 'warning_under_review';
    case WarningClosed = 'warning_closed';
    case ChargeRefunded = 'charge_refunded';
}
```

`api/app/Support/Enums/DisputeOutcome.php`:

```php
<?php

declare(strict_types=1);

namespace App\Support\Enums;

enum DisputeOutcome: string
{
    case SubmittedEvidence = 'submitted_evidence';
    case Accepted = 'accepted';
    case Resolved = 'resolved';
}
```

- [ ] **Step 4: Create the migration**

`api/database/migrations/2026_05_04_000001_create_disputes_table.php`:

```php
<?php

declare(strict_types=1);

use App\Support\Traits\HasUuid;
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::create('disputes', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->foreignUuid('purchase_id')->constrained('purchases');

            $table->string('stripe_dispute_id')->unique();
            $table->string('status', 32);
            $table->string('reason', 64)->nullable();
            $table->unsignedInteger('amount_cents');
            $table->timestamp('evidence_due_by')->nullable();

            $table->string('outcome', 32)->nullable();
            $table->timestamp('decided_at')->nullable();
            $table->foreignUuid('decided_by_user_id')->nullable()->constrained('users');
            $table->text('decision_justification')->nullable();

            // Denormalised for queue search/sort without joining purchases+addresses
            $table->string('buyer_first_name')->nullable();
            $table->string('buyer_last_name')->nullable();

            $table->json('stripe_snapshot')->nullable();

            $table->timestamps();

            $table->index(['status', 'evidence_due_by']);
            $table->index('purchase_id');
        });
    }

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

- [ ] **Step 5: Create the model**

`api/app/Models/Dispute.php`:

```php
<?php

declare(strict_types=1);

namespace App\Models;

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

/**
 * @property string $id
 * @property string $purchase_id
 * @property string $stripe_dispute_id
 * @property DisputeStatus $status
 * @property string|null $reason
 * @property int $amount_cents
 * @property \Illuminate\Support\Carbon|null $evidence_due_by
 * @property DisputeOutcome|null $outcome
 * @property \Illuminate\Support\Carbon|null $decided_at
 * @property string|null $decided_by_user_id
 * @property string|null $decision_justification
 * @property string|null $buyer_first_name
 * @property string|null $buyer_last_name
 * @property array<string, mixed>|null $stripe_snapshot
 * @property-read Purchase $purchase
 */
class Dispute extends Model
{
    use HasFactory;
    use HasUuid;

    protected $fillable = [
        'purchase_id',
        'stripe_dispute_id',
        'status',
        'reason',
        'amount_cents',
        'evidence_due_by',
        'outcome',
        'decided_at',
        'decided_by_user_id',
        'decision_justification',
        'buyer_first_name',
        'buyer_last_name',
        'stripe_snapshot',
    ];

    protected function casts(): array
    {
        return [
            'status' => DisputeStatus::class,
            'outcome' => DisputeOutcome::class,
            'evidence_due_by' => 'datetime',
            'decided_at' => 'datetime',
            'amount_cents' => 'integer',
            'stripe_snapshot' => 'array',
        ];
    }

    public function purchase(): BelongsTo
    {
        return $this->belongsTo(Purchase::class);
    }

    public function decidedBy(): BelongsTo
    {
        return $this->belongsTo(User::class, 'decided_by_user_id');
    }
}
```

- [ ] **Step 6: Create the factory**

`api/database/factories/DisputeFactory.php`:

```php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\Dispute;
use App\Models\Purchase;
use App\Support\Enums\DisputeStatus;
use Illuminate\Database\Eloquent\Factories\Factory;

class DisputeFactory extends Factory
{
    protected $model = Dispute::class;

    public function definition(): array
    {
        return [
            'purchase_id' => Purchase::factory(),
            'stripe_dispute_id' => 'dp_test_'.$this->faker->unique()->lexify('????????'),
            'status' => DisputeStatus::NeedsResponse,
            'reason' => 'product_not_received',
            'amount_cents' => 12_800,
            'evidence_due_by' => now()->addDays(7),
            'buyer_first_name' => $this->faker->firstName(),
            'buyer_last_name' => $this->faker->lastName(),
            'stripe_snapshot' => null,
            'outcome' => null,
            'decided_at' => null,
            'decided_by_user_id' => null,
            'decision_justification' => null,
        ];
    }
}
```

- [ ] **Step 7: Run the migration and the test**

```
docker compose exec laravel.test php artisan migrate
docker compose exec laravel.test php artisan test --filter=DisputeModelTest
```

Expected: PASS.

### Task 3: Add reversal columns to `orders`

**Files:**
- Create: `api/database/migrations/2026_05_04_000002_add_reversal_columns_to_orders_table.php`
- Update: `api/app/Models/Order.php` (fillable + casts + property docblocks)
- Test: `api/tests/Feature/Admin/OrderReversalColumnsTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Admin;

use App\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;

class OrderReversalColumnsTest extends TestCase
{
    use RefreshDatabase;

    public function test_orders_table_has_reversal_columns(): void
    {
        $this->assertTrue(Schema::hasColumn('orders', 'transfer_reversed_at'));
        $this->assertTrue(Schema::hasColumn('orders', 'stripe_transfer_reversal_id'));
    }

    public function test_reversal_fields_are_writable_and_cast(): void
    {
        $order = Order::factory()->create();

        $order->update([
            'stripe_transfer_reversal_id' => 'trr_test_123',
            'transfer_reversed_at' => now(),
        ]);

        $fresh = $order->fresh();
        $this->assertSame('trr_test_123', $fresh->stripe_transfer_reversal_id);
        $this->assertNotNull($fresh->transfer_reversed_at);
    }
}
```

- [ ] **Step 2: Run and confirm failure**

Expected: `hasColumn` returns false.

- [ ] **Step 3: Create the 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('orders', function (Blueprint $table) {
            $table->string('stripe_transfer_reversal_id')->nullable()->after('transferred_at');
            $table->timestamp('transfer_reversed_at')->nullable()->after('stripe_transfer_reversal_id');
        });
    }

    public function down(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->dropColumn(['stripe_transfer_reversal_id', 'transfer_reversed_at']);
        });
    }
};
```

- [ ] **Step 4: Update the Order model**

In `api/app/Models/Order.php`, add the columns to `$fillable`, the cast, and the property docblock. Insert after the existing `transferred_at` entries:

```php
// in $fillable:
'stripe_transfer_reversal_id',
'transfer_reversed_at',

// in casts():
'transfer_reversed_at' => 'datetime',

// in property docblock:
 * @property string|null $stripe_transfer_reversal_id
 * @property \Illuminate\Support\Carbon|null $transfer_reversed_at
```

- [ ] **Step 5: Migrate and run the test**

```
docker compose exec laravel.test php artisan migrate
docker compose exec laravel.test php artisan test --filter=OrderReversalColumnsTest
```

Expected: PASS.

### Task 4: Backfill `Dispute` rows for existing `Purchase.disputed=true` data

**Files:**
- Create: `api/database/migrations/2026_05_04_000003_backfill_disputes_from_purchases.php`
- Test: `api/tests/Feature/Admin/DisputesBackfillTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Admin;

use App\Models\Dispute;
use App\Models\Purchase;
use App\Support\Enums\DisputeStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Tests\TestCase;

class DisputesBackfillTest extends TestCase
{
    use RefreshDatabase;

    public function test_purchases_marked_disputed_get_a_synthetic_dispute_row(): void
    {
        $disputed = Purchase::factory()->create([
            'disputed' => true,
            'stripe_payment_intent_id' => 'pi_test_disputed',
        ]);
        $clean = Purchase::factory()->create([
            'disputed' => false,
        ]);

        // The migration that runs the backfill is idempotent — running it
        // again should not create duplicates.
        Artisan::call('migrate:fresh', ['--seed' => false]);

        // Recreate the purchases now that the DB was reset
        Purchase::factory()->create([
            'id' => $disputed->id,
            'disputed' => true,
            'stripe_payment_intent_id' => 'pi_test_disputed',
        ]);
        Purchase::factory()->create([
            'id' => $clean->id,
            'disputed' => false,
        ]);

        // Re-run the latest migrations
        Artisan::call('migrate');

        $this->assertSame(1, Dispute::query()->count());
        $row = Dispute::query()->first();
        $this->assertSame($disputed->id, $row->purchase_id);
        $this->assertSame(DisputeStatus::NeedsResponse, $row->status);
    }
}
```

- [ ] **Step 2: Run and confirm failure**

- [ ] **Step 3: Create the backfill migration**

```php
<?php

declare(strict_types=1);

use App\Support\Enums\DisputeStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

return new class extends Migration
{
    public function up(): void
    {
        $disputed = DB::table('purchases')
            ->where('disputed', true)
            ->select(['id', 'stripe_payment_intent_id', 'subtotal', 'shipping_total', 'created_at'])
            ->get();

        foreach ($disputed as $purchase) {
            $existing = DB::table('disputes')->where('purchase_id', $purchase->id)->exists();
            if ($existing) {
                continue;
            }

            DB::table('disputes')->insert([
                'id' => Str::uuid()->toString(),
                'purchase_id' => $purchase->id,
                // We don't have the real Stripe dispute id yet — synthesise a
                // unique placeholder so the unique constraint holds; the next
                // dispute.updated webhook for this purchase will overwrite it.
                'stripe_dispute_id' => 'dp_legacy_'.substr($purchase->id, 0, 12),
                'status' => DisputeStatus::NeedsResponse->value,
                'reason' => null,
                'amount_cents' => (int) ($purchase->subtotal + $purchase->shipping_total),
                'evidence_due_by' => null,
                'created_at' => $purchase->created_at,
                'updated_at' => now(),
            ]);
        }
    }

    public function down(): void
    {
        DB::table('disputes')->where('stripe_dispute_id', 'like', 'dp_legacy_%')->delete();
    }
};
```

- [ ] **Step 4: Migrate and re-run**

```
docker compose exec laravel.test php artisan migrate
docker compose exec laravel.test php artisan test --filter=DisputesBackfillTest
```

Expected: PASS.

### Task 5: Extend `CancellationReason` with admin-driven cases

**Files:**
- Update: `api/app/Support/Enums/CancellationReason.php`
- Test: `api/tests/Unit/CancellationReasonEnumTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Unit;

use App\Support\Enums\CancellationReason;
use PHPUnit\Framework\TestCase;

class CancellationReasonEnumTest extends TestCase
{
    public function test_admin_outcome_cases_exist(): void
    {
        $this->assertSame('admin_forced', CancellationReason::AdminForced->value);
        $this->assertSame('dispute_accepted', CancellationReason::DisputeAccepted->value);
    }
}
```

- [ ] **Step 2: Add the cases**

```php
case AdminForced = 'admin_forced';
case DisputeAccepted = 'dispute_accepted';
```

- [ ] **Step 3: Run and confirm**

### Task 6: Extend the dispute webhook handler — `.updated` and `.closed`

**Files:**
- Update: `api/app/Modules/Checkout/Controllers/CheckoutController.php`
- Test: `api/tests/Feature/Admin/DisputeWebhookSyncTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Admin;

use App\Models\Dispute;
use App\Models\Purchase;
use App\Support\Enums\DisputeStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Stripe\Event;
use Stripe\StripeObject;
use Stripe\Webhook;
use Tests\TestCase;

class DisputeWebhookSyncTest extends TestCase
{
    use RefreshDatabase;

    public function test_dispute_created_inserts_a_dispute_row(): void
    {
        $purchase = Purchase::factory()->create([
            'stripe_payment_intent_id' => 'pi_test_dwc',
        ]);

        $payload = $this->buildEventPayload('charge.dispute.created', [
            'id' => 'dp_evt_1',
            'payment_intent' => 'pi_test_dwc',
            'amount' => 12_800,
            'reason' => 'product_not_received',
            'status' => 'needs_response',
            'evidence_details' => ['due_by' => now()->addDays(7)->timestamp],
        ]);

        $this->postJson('/v1/stripe/webhook', $payload, $this->signedHeaders($payload))
            ->assertOk();

        $this->assertDatabaseHas('disputes', [
            'purchase_id' => $purchase->id,
            'stripe_dispute_id' => 'dp_evt_1',
            'status' => 'needs_response',
        ]);
        $this->assertTrue($purchase->fresh()->disputed);
    }

    public function test_dispute_updated_keeps_status_in_sync(): void
    {
        $purchase = Purchase::factory()->create([
            'stripe_payment_intent_id' => 'pi_test_du',
            'disputed' => true,
        ]);
        $dispute = Dispute::factory()->create([
            'purchase_id' => $purchase->id,
            'stripe_dispute_id' => 'dp_evt_2',
            'status' => DisputeStatus::NeedsResponse,
        ]);

        $payload = $this->buildEventPayload('charge.dispute.updated', [
            'id' => 'dp_evt_2',
            'payment_intent' => 'pi_test_du',
            'amount' => $dispute->amount_cents,
            'status' => 'under_review',
        ]);

        $this->postJson('/v1/stripe/webhook', $payload, $this->signedHeaders($payload))
            ->assertOk();

        $this->assertSame(DisputeStatus::UnderReview, $dispute->fresh()->status);
    }

    public function test_dispute_closed_writes_terminal_status(): void
    {
        $purchase = Purchase::factory()->create(['stripe_payment_intent_id' => 'pi_test_dc']);
        Dispute::factory()->create([
            'purchase_id' => $purchase->id,
            'stripe_dispute_id' => 'dp_evt_3',
        ]);

        $payload = $this->buildEventPayload('charge.dispute.closed', [
            'id' => 'dp_evt_3',
            'payment_intent' => 'pi_test_dc',
            'amount' => 12_800,
            'status' => 'won',
        ]);

        $this->postJson('/v1/stripe/webhook', $payload, $this->signedHeaders($payload))
            ->assertOk();

        $this->assertSame(DisputeStatus::Won, Dispute::query()->first()->status);
    }

    private function buildEventPayload(string $type, array $object): array
    {
        return [
            'id' => 'evt_'.uniqid(),
            'type' => $type,
            'data' => ['object' => $object],
        ];
    }

    private function signedHeaders(array $payload): array
    {
        // Tests bypass strict signature verification by short-circuiting in
        // the controller when APP_ENV=testing. If your environment enforces
        // signatures even in tests, replace this with a real signed header
        // helper.
        return ['Stripe-Signature' => 'test'];
    }
}
```

- [ ] **Step 2: Run and confirm failure**

Expected: `.updated` and `.closed` events are unhandled — no row created/updated.

- [ ] **Step 3: Read the existing handler**

Open `api/app/Modules/Checkout/Controllers/CheckoutController.php` and locate the existing `webhook()` method. Identify the `if ($event->type === 'charge.dispute.created')` branch. The new branches go alongside it.

- [ ] **Step 4: Extract dispute handling into a service**

Create `api/app/Modules/Admin/Services/DisputeWebhookSync.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Admin\Services;

use App\Models\Dispute;
use App\Models\Purchase;
use App\Support\Enums\DisputeStatus;
use Illuminate\Support\Facades\DB;

final class DisputeWebhookSync
{
    /** Apply a charge.dispute.* event to our local row. */
    public function apply(string $eventType, array $stripeDispute): void
    {
        $paymentIntentId = $stripeDispute['payment_intent'] ?? null;
        if (! $paymentIntentId) {
            return;
        }

        $purchase = Purchase::query()
            ->where('stripe_payment_intent_id', $paymentIntentId)
            ->first();

        if (! $purchase) {
            return;
        }

        $stripeId = $stripeDispute['id'];
        $status = $this->mapStatus($stripeDispute['status'] ?? 'needs_response');

        DB::transaction(function () use ($purchase, $stripeId, $status, $stripeDispute, $eventType) {
            $dispute = Dispute::query()->where('stripe_dispute_id', $stripeId)->first();

            $address = is_array($purchase->shipping_address ?? null)
                ? $purchase->shipping_address
                : [];

            $payload = [
                'purchase_id' => $purchase->id,
                'stripe_dispute_id' => $stripeId,
                'status' => $status,
                'reason' => $stripeDispute['reason'] ?? null,
                'amount_cents' => (int) ($stripeDispute['amount'] ?? 0),
                'evidence_due_by' => isset($stripeDispute['evidence_details']['due_by'])
                    ? \Carbon\CarbonImmutable::createFromTimestamp(
                        $stripeDispute['evidence_details']['due_by']
                    )
                    : null,
                'buyer_first_name' => $address['first_name'] ?? null,
                'buyer_last_name' => $address['last_name'] ?? null,
                'stripe_snapshot' => $stripeDispute,
            ];

            if ($dispute) {
                $dispute->update($payload);
            } else {
                Dispute::create($payload);
            }

            if ($eventType === 'charge.dispute.created') {
                $purchase->update(['disputed' => true]);
            }
        });
    }

    private function mapStatus(string $stripe): DisputeStatus
    {
        return match ($stripe) {
            'needs_response' => DisputeStatus::NeedsResponse,
            'under_review' => DisputeStatus::UnderReview,
            'won' => DisputeStatus::Won,
            'lost' => DisputeStatus::Lost,
            'warning_needs_response' => DisputeStatus::WarningNeedsResponse,
            'warning_under_review' => DisputeStatus::WarningUnderReview,
            'warning_closed' => DisputeStatus::WarningClosed,
            'charge_refunded' => DisputeStatus::ChargeRefunded,
            default => DisputeStatus::NeedsResponse,
        };
    }
}
```

- [ ] **Step 5: Wire the service into the webhook controller**

In `CheckoutController::webhook()`, replace the existing `charge.dispute.created` branch with:

```php
if (in_array($event->type, [
    'charge.dispute.created',
    'charge.dispute.updated',
    'charge.dispute.closed',
], true)) {
    $stripeDispute = $event->data->object->toArray();
    app(DisputeWebhookSync::class)->apply($event->type, $stripeDispute);

    if ($event->type === 'charge.dispute.created') {
        // Preserve the existing PurchaseDisputed dispatch
        $purchase = Purchase::query()
            ->where('stripe_payment_intent_id', $stripeDispute['payment_intent'])
            ->first();
        if ($purchase) {
            event(new PurchaseDisputed($purchase));
        }
    }
}
```

Add the use-import for `DisputeWebhookSync` at the top.

- [ ] **Step 6: Run the test and iterate until pass**

If the test fails because of signature verification, look for an `if (app()->environment('testing'))` shortcut in the existing webhook code and follow that pattern; otherwise add one constrained to `testing`.

Expected (after iteration): PASS.

---

## Phase B — Backend dispute service + endpoints

### Task 7: `StripeService::reverseTransfer()`

**Files:**
- Update: `api/app/Modules/Checkout/Services/StripeService.php`
- Test: `api/tests/Unit/Stripe/StripeServiceReverseTransferTest.php`

- [ ] **Step 1: Write the failing test (mocking the Stripe SDK)**

```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Stripe;

use App\Modules\Checkout\Services\StripeService;
use Mockery;
use Stripe\Reversal;
use Stripe\Transfer;
use Tests\TestCase;

class StripeServiceReverseTransferTest extends TestCase
{
    public function test_reverse_transfer_calls_stripe_with_idempotency_key(): void
    {
        $reversal = new Reversal('trr_test_1');

        $mock = Mockery::mock('alias:'.Transfer::class);
        $mock->shouldReceive('createReversal')
            ->once()
            ->with(
                'tr_test_1',
                Mockery::on(fn ($params) => ($params['amount'] ?? null) === 5_000),
                Mockery::on(fn ($opts) => isset($opts['idempotency_key'])),
            )
            ->andReturn($reversal);

        $service = app(StripeService::class);
        $result = $service->reverseTransfer(
            transferId: 'tr_test_1',
            amountCents: 5_000,
            idempotencyKey: 'admin-reversal:order-1:dispute-1',
            metadata: ['order_id' => 'order-1'],
        );

        $this->assertSame('trr_test_1', $result->id);
    }
}
```

- [ ] **Step 2: Run, confirm failure (method does not exist)**

- [ ] **Step 3: Implement the method**

In `StripeService.php`, alongside the existing `refundForOrder` method:

```php
public function reverseTransfer(
    string $transferId,
    int $amountCents,
    string $idempotencyKey,
    array $metadata = [],
): \Stripe\Reversal {
    return \Stripe\Transfer::createReversal(
        $transferId,
        [
            'amount' => $amountCents,
            'metadata' => $metadata,
        ],
        ['idempotency_key' => $idempotencyKey],
    );
}
```

- [ ] **Step 4: Re-run; confirm pass**

### Task 8: `DisputeAdjudicator` service

**Files:**
- Create: `api/app/Modules/Admin/Services/DisputeAdjudicator.php`
- Create: `api/app/Modules/Admin/Data/AdjudicateInput.php`
- Test: `api/tests/Feature/Admin/DisputeAdjudicatorTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Admin;

use App\Models\Dispute;
use App\Models\Order;
use App\Models\Purchase;
use App\Models\User;
use App\Modules\Admin\Data\AdjudicateInput;
use App\Modules\Admin\Services\DisputeAdjudicator;
use App\Modules\Checkout\Services\StripeService;
use App\Support\Enums\CancellationReason;
use App\Support\Enums\DisputeOutcome;
use App\Support\Enums\OrderStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;

class DisputeAdjudicatorTest extends TestCase
{
    use RefreshDatabase;

    public function test_accept_outcome_reverses_transfer_and_refunds_order(): void
    {
        $admin = User::factory()->create();
        $purchase = Purchase::factory()->create([
            'stripe_payment_intent_id' => 'pi_acc_1',
        ]);
        $dispute = Dispute::factory()->create(['purchase_id' => $purchase->id]);
        $order = Order::factory()->create([
            'purchase_id' => $purchase->id,
            'subtotal' => 4_000,
            'shipping_cost' => 1_000,
            'stripe_transfer_id' => 'tr_acc_1',
            'transferred_at' => now(),
        ]);

        $stripe = Mockery::mock(StripeService::class);
        $stripe->shouldReceive('reverseTransfer')
            ->once()
            ->andReturn((object) ['id' => 'trr_acc_1']);
        $stripe->shouldReceive('refundForOrder')
            ->once()
            ->andReturn((object) ['id' => 're_acc_1']);
        $this->app->instance(StripeService::class, $stripe);

        $svc = app(DisputeAdjudicator::class);

        $input = new AdjudicateInput(
            outcome: DisputeOutcome::Accepted,
            justification: 'Buyer evidence is conclusive — proof of non-delivery from carrier.',
            perOrderActions: [['order_id' => $order->id, 'reverse_transfer' => true]],
        );

        $svc->adjudicate($dispute->fresh(), $input, $admin);

        $fresh = $order->fresh();
        $this->assertSame(OrderStatus::Refunded, $fresh->status);
        $this->assertSame(CancellationReason::DisputeAccepted, $fresh->cancellation_reason);
        $this->assertSame('trr_acc_1', $fresh->stripe_transfer_reversal_id);
        $this->assertNotNull($fresh->transfer_reversed_at);

        $this->assertSame(DisputeOutcome::Accepted, $dispute->fresh()->outcome);
        $this->assertNotNull($dispute->fresh()->decided_at);

        $log = Activity::query()->where('description', 'dispute.adjudicate')->first();
        $this->assertNotNull($log);
        $this->assertSame($admin->id, $log->causer_id);
        $this->assertStringContainsString('Buyer evidence', $log->properties['justification']);
    }

    public function test_accept_outcome_can_skip_reversal_per_order(): void
    {
        $admin = User::factory()->create();
        $purchase = Purchase::factory()->create();
        $dispute = Dispute::factory()->create(['purchase_id' => $purchase->id]);
        $order = Order::factory()->create([
            'purchase_id' => $purchase->id,
            'stripe_transfer_id' => 'tr_skip_1',
            'transferred_at' => now(),
        ]);

        $stripe = Mockery::mock(StripeService::class);
        $stripe->shouldNotReceive('reverseTransfer');
        $stripe->shouldReceive('refundForOrder')->once()->andReturn((object) ['id' => 're_skip_1']);
        $this->app->instance(StripeService::class, $stripe);

        $svc = app(DisputeAdjudicator::class);

        $svc->adjudicate(
            $dispute->fresh(),
            new AdjudicateInput(
                outcome: DisputeOutcome::Accepted,
                justification: 'Refund the buyer but leave seller paid (we eat the loss).',
                perOrderActions: [['order_id' => $order->id, 'reverse_transfer' => false]],
            ),
            $admin,
        );

        $this->assertNull($order->fresh()->stripe_transfer_reversal_id);
    }

    public function test_resolved_outcome_does_not_touch_money(): void
    {
        $admin = User::factory()->create();
        $purchase = Purchase::factory()->create();
        $dispute = Dispute::factory()->create(['purchase_id' => $purchase->id]);

        $stripe = Mockery::mock(StripeService::class);
        $stripe->shouldNotReceive('reverseTransfer');
        $stripe->shouldNotReceive('refundForOrder');
        $this->app->instance(StripeService::class, $stripe);

        $svc = app(DisputeAdjudicator::class);

        $svc->adjudicate(
            $dispute->fresh(),
            new AdjudicateInput(
                outcome: DisputeOutcome::Resolved,
                justification: 'Stripe closed the dispute on its own — buyer withdrew.',
                perOrderActions: [],
            ),
            $admin,
        );

        $this->assertSame(DisputeOutcome::Resolved, $dispute->fresh()->outcome);
    }
}
```

- [ ] **Step 2: Run, confirm failure**

- [ ] **Step 3: Create `AdjudicateInput`**

`api/app/Modules/Admin/Data/AdjudicateInput.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Admin\Data;

use App\Support\Enums\DisputeOutcome;

final readonly class AdjudicateInput
{
    /** @param array<int, array{order_id: string, reverse_transfer: bool}> $perOrderActions */
    public function __construct(
        public DisputeOutcome $outcome,
        public string $justification,
        public array $perOrderActions,
    ) {}
}
```

- [ ] **Step 4: Implement the service**

`api/app/Modules/Admin/Services/DisputeAdjudicator.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Admin\Services;

use App\Models\Dispute;
use App\Models\Order;
use App\Models\User;
use App\Modules\Admin\Data\AdjudicateInput;
use App\Modules\Checkout\Services\StripeService;
use App\Support\Enums\CancellationReason;
use App\Support\Enums\DisputeOutcome;
use App\Support\Enums\OrderStatus;
use Illuminate\Support\Facades\DB;

final class DisputeAdjudicator
{
    public function __construct(private readonly StripeService $stripe) {}

    public function adjudicate(Dispute $dispute, AdjudicateInput $input, User $admin): void
    {
        DB::transaction(function () use ($dispute, $input, $admin) {
            match ($input->outcome) {
                DisputeOutcome::Accepted => $this->accept($dispute, $input, $admin),
                DisputeOutcome::SubmittedEvidence => $this->markSubmitted($dispute, $admin),
                DisputeOutcome::Resolved => null, // money untouched
            };

            $dispute->update([
                'outcome' => $input->outcome,
                'decided_at' => now(),
                'decided_by_user_id' => $admin->id,
                'decision_justification' => $input->justification,
            ]);

            activity('admin')
                ->causedBy($admin)
                ->performedOn($dispute)
                ->withProperties([
                    'outcome' => $input->outcome->value,
                    'justification' => $input->justification,
                    'per_order_actions' => $input->perOrderActions,
                ])
                ->log('dispute.adjudicate');
        });
    }

    private function accept(Dispute $dispute, AdjudicateInput $input, User $admin): void
    {
        foreach ($input->perOrderActions as $action) {
            $order = Order::query()
                ->where('id', $action['order_id'])
                ->where('purchase_id', $dispute->purchase_id)
                ->firstOrFail();

            $reverseAmount = $order->subtotal + $order->shipping_cost;

            if (($action['reverse_transfer'] ?? false) && $order->stripe_transfer_id) {
                $reversal = $this->stripe->reverseTransfer(
                    transferId: $order->stripe_transfer_id,
                    amountCents: $reverseAmount,
                    idempotencyKey: "admin-reversal:{$order->id}:{$dispute->id}",
                    metadata: [
                        'order_id' => $order->id,
                        'dispute_id' => $dispute->id,
                        'admin_user_id' => $admin->id,
                    ],
                );
                $order->stripe_transfer_reversal_id = $reversal->id;
                $order->transfer_reversed_at = now();
            }

            $refund = $this->stripe->refundForOrder(
                $dispute->purchase->stripe_payment_intent_id,
                $reverseAmount,
            );

            $order->status = OrderStatus::Refunded;
            $order->cancelled_by = \App\Support\Enums\CancelledBy::Admin;
            $order->cancellation_reason = CancellationReason::DisputeAccepted;
            $order->cancelled_at = now();
            $order->save();
        }
    }

    private function markSubmitted(Dispute $dispute, User $admin): void
    {
        // Move every order in this purchase to Disputed (frozen) state.
        Order::query()
            ->where('purchase_id', $dispute->purchase_id)
            ->update(['status' => OrderStatus::Disputed]);
    }
}
```

> **Plan note:** `App\Support\Enums\CancelledBy::Admin` may not exist. If the test fails with an enum error, add the case to that enum the same way Task 5 added `AdminForced` to `CancellationReason`.

- [ ] **Step 5: Run the test and iterate**

Expected: PASS (3/3).

### Task 9: List + show endpoints

**Files:**
- Create: `api/app/Modules/Admin/Controllers/AdminDisputeController.php`
- Create: `api/app/Modules/Admin/Resources/AdminDisputeSummary.php`
- Create: `api/app/Modules/Admin/Resources/AdminDisputeDetail.php`
- Update: `api/app/Modules/Admin/routes.php`
- Test: `api/tests/Feature/Admin/AdminDisputesIndexTest.php`
- Test: `api/tests/Feature/Admin/AdminDisputesShowTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Admin;

use App\Models\Dispute;
use App\Models\User;
use App\Support\Enums\DisputeStatus;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class AdminDisputesIndexTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_unauthenticated_returns_401(): void
    {
        $this->getJson('/v1/admin/disputes')->assertUnauthorized();
    }

    public function test_non_admin_returns_403(): void
    {
        Sanctum::actingAs(User::factory()->create());
        $this->getJson('/v1/admin/disputes')->assertForbidden();
    }

    public function test_admin_lists_open_disputes_by_default(): void
    {
        $admin = User::factory()->create();
        $admin->assignRole('admin');

        Dispute::factory()->create(['status' => DisputeStatus::NeedsResponse]);
        Dispute::factory()->create(['status' => DisputeStatus::UnderReview]);
        Dispute::factory()->create(['status' => DisputeStatus::Won]); // closed

        Sanctum::actingAs($admin);

        $this->getJson('/v1/admin/disputes')
            ->assertOk()
            ->assertJsonStructure([
                'data' => [['id', 'purchase_id', 'amount_cents', 'status', 'evidence_due_by', 'created_at']],
                'meta' => ['current_page', 'last_page', 'total'],
            ])
            ->assertJsonPath('meta.total', 3); // index returns all by default; UI filters
    }

    public function test_status_filter_narrows_results(): void
    {
        $admin = User::factory()->create();
        $admin->assignRole('admin');

        Dispute::factory()->count(2)->create(['status' => DisputeStatus::NeedsResponse]);
        Dispute::factory()->create(['status' => DisputeStatus::Won]);

        Sanctum::actingAs($admin);

        $this->getJson('/v1/admin/disputes?status=needs_response')
            ->assertOk()
            ->assertJsonPath('meta.total', 2);
    }
}
```

- [ ] **Step 2: Write the show test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Admin;

use App\Models\Dispute;
use App\Models\Order;
use App\Models\Purchase;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class AdminDisputesShowTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_show_returns_dispute_with_purchase_and_orders(): void
    {
        $admin = User::factory()->create();
        $admin->assignRole('admin');

        $purchase = Purchase::factory()->create();
        $dispute = Dispute::factory()->create(['purchase_id' => $purchase->id]);
        Order::factory()->count(2)->create(['purchase_id' => $purchase->id]);

        Sanctum::actingAs($admin);

        $this->getJson("/v1/admin/disputes/{$purchase->id}")
            ->assertOk()
            ->assertJsonStructure([
                'data' => [
                    'id',
                    'purchase_id',
                    'amount_cents',
                    'status',
                    'orders' => [['id', 'subtotal', 'shipping_cost', 'status', 'stripe_transfer_id']],
                    'purchase' => ['id', 'stripe_payment_intent_id', 'shipping_address'],
                ],
            ]);
    }
}
```

- [ ] **Step 3: Run both tests and confirm failure (no controller / route)**

- [ ] **Step 4: Implement the resources**

`AdminDisputeSummary.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Admin\Resources;

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

class AdminDisputeSummary extends JsonResource
{
    /** @return array<string, mixed> */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'purchase_id' => $this->purchase_id,
            'stripe_dispute_id' => $this->stripe_dispute_id,
            'status' => $this->status->value,
            'reason' => $this->reason,
            'amount_cents' => $this->amount_cents,
            'evidence_due_by' => $this->evidence_due_by?->toIso8601String(),
            'buyer_first_name' => $this->buyer_first_name,
            'buyer_last_name' => $this->buyer_last_name,
            'created_at' => $this->created_at->toIso8601String(),
            'outcome' => $this->outcome?->value,
            'decided_at' => $this->decided_at?->toIso8601String(),
        ];
    }
}
```

`AdminDisputeDetail.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Admin\Resources;

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

class AdminDisputeDetail extends JsonResource
{
    /** @return array<string, mixed> */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'purchase_id' => $this->purchase_id,
            'stripe_dispute_id' => $this->stripe_dispute_id,
            'status' => $this->status->value,
            'reason' => $this->reason,
            'amount_cents' => $this->amount_cents,
            'evidence_due_by' => $this->evidence_due_by?->toIso8601String(),
            'outcome' => $this->outcome?->value,
            'decided_at' => $this->decided_at?->toIso8601String(),
            'decision_justification' => $this->decision_justification,
            'purchase' => [
                'id' => $this->purchase->id,
                'stripe_payment_intent_id' => $this->purchase->stripe_payment_intent_id,
                'shipping_address' => $this->purchase->shipping_address,
                'subtotal' => $this->purchase->subtotal,
                'shipping_total' => $this->purchase->shipping_total,
            ],
            'orders' => $this->purchase->orders->map(fn ($o) => [
                'id' => $o->id,
                'store' => ['id' => $o->store->id, 'name' => $o->store->name],
                'status' => $o->status->value,
                'subtotal' => $o->subtotal,
                'shipping_cost' => $o->shipping_cost,
                'stripe_transfer_id' => $o->stripe_transfer_id,
                'transferred_at' => $o->transferred_at?->toIso8601String(),
                'transfer_reversed_at' => $o->transfer_reversed_at?->toIso8601String(),
                'tracking_number' => $o->tracking_number,
                'tracking_url' => $o->tracking_url,
                'shipped_at' => $o->shipped_at?->toIso8601String(),
            ]),
        ];
    }
}
```

- [ ] **Step 5: Implement the controller**

`AdminDisputeController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Admin\Controllers;

use App\Models\Dispute;
use App\Models\Purchase;
use App\Modules\Admin\Resources\AdminDisputeDetail;
use App\Modules\Admin\Resources\AdminDisputeSummary;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class AdminDisputeController
{
    public function index(Request $request): JsonResponse
    {
        $query = Dispute::query()->orderByDesc('created_at');

        if ($status = $request->query('status')) {
            $query->where('status', $status);
        }

        $disputes = $query->paginate((int) $request->query('per_page', 15));

        return AdminDisputeSummary::collection($disputes)->response();
    }

    public function show(Purchase $purchase): JsonResponse
    {
        $dispute = Dispute::query()
            ->with(['purchase.orders.store:id,name'])
            ->where('purchase_id', $purchase->id)
            ->firstOrFail();

        return response()->json([
            'data' => new AdminDisputeDetail($dispute),
        ]);
    }
}
```

- [ ] **Step 6: Wire the routes**

In `app/Modules/Admin/routes.php`, inside the existing admin group:

```php
Route::get('/disputes', [AdminDisputeController::class, 'index']);
Route::get('/disputes/{purchase}', [AdminDisputeController::class, 'show']);
```

Add the use-import for `AdminDisputeController`.

- [ ] **Step 7: Run both tests and iterate to PASS**

### Task 10: Adjudicate endpoint

**Files:**
- Update: `api/app/Modules/Admin/Controllers/AdminDisputeController.php`
- Create: `api/app/Modules/Admin/Requests/AdjudicateRequest.php`
- Update: `api/app/Modules/Admin/routes.php`
- Test: `api/tests/Feature/Admin/AdjudicateDisputeEndpointTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Admin;

use App\Models\Dispute;
use App\Models\Order;
use App\Models\Purchase;
use App\Models\User;
use App\Modules\Checkout\Services\StripeService;
use App\Support\Enums\DisputeOutcome;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Mockery;
use Tests\TestCase;

class AdjudicateDisputeEndpointTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_admin_accept_path_returns_200_and_decides_dispute(): void
    {
        $admin = User::factory()->create();
        $admin->assignRole('admin');

        $purchase = Purchase::factory()->create(['stripe_payment_intent_id' => 'pi_acc']);
        $dispute = Dispute::factory()->create(['purchase_id' => $purchase->id]);
        $order = Order::factory()->create([
            'purchase_id' => $purchase->id,
            'subtotal' => 1_000,
            'shipping_cost' => 200,
            'stripe_transfer_id' => 'tr_acc',
            'transferred_at' => now(),
        ]);

        $stripe = Mockery::mock(StripeService::class);
        $stripe->shouldReceive('reverseTransfer')->andReturn((object) ['id' => 'trr_x']);
        $stripe->shouldReceive('refundForOrder')->andReturn((object) ['id' => 're_x']);
        $this->app->instance(StripeService::class, $stripe);

        Sanctum::actingAs($admin);

        $this->postJson("/v1/admin/disputes/{$purchase->id}/adjudicate", [
            'outcome' => DisputeOutcome::Accepted->value,
            'justification' => 'Buyer evidence is conclusive — refund both orders.',
            'per_order_actions' => [
                ['order_id' => $order->id, 'reverse_transfer' => true],
            ],
        ])->assertOk();

        $this->assertSame(DisputeOutcome::Accepted, $dispute->fresh()->outcome);
    }

    public function test_short_justification_is_422(): void
    {
        $admin = User::factory()->create();
        $admin->assignRole('admin');
        $purchase = Purchase::factory()->create();
        Dispute::factory()->create(['purchase_id' => $purchase->id]);

        Sanctum::actingAs($admin);

        $this->postJson("/v1/admin/disputes/{$purchase->id}/adjudicate", [
            'outcome' => DisputeOutcome::Resolved->value,
            'justification' => 'short',
            'per_order_actions' => [],
        ])->assertStatus(422)
          ->assertJsonValidationErrors(['justification']);
    }

    public function test_unknown_outcome_is_422(): void
    {
        $admin = User::factory()->create();
        $admin->assignRole('admin');
        $purchase = Purchase::factory()->create();
        Dispute::factory()->create(['purchase_id' => $purchase->id]);

        Sanctum::actingAs($admin);

        $this->postJson("/v1/admin/disputes/{$purchase->id}/adjudicate", [
            'outcome' => 'banana',
            'justification' => str_repeat('x', 30),
            'per_order_actions' => [],
        ])->assertStatus(422);
    }

    public function test_non_admin_403(): void
    {
        Sanctum::actingAs(User::factory()->create());
        $purchase = Purchase::factory()->create();
        Dispute::factory()->create(['purchase_id' => $purchase->id]);

        $this->postJson("/v1/admin/disputes/{$purchase->id}/adjudicate", [
            'outcome' => DisputeOutcome::Resolved->value,
            'justification' => str_repeat('x', 30),
            'per_order_actions' => [],
        ])->assertForbidden();
    }
}
```

- [ ] **Step 2: Run, confirm failure (no route)**

- [ ] **Step 3: Create the request class**

`api/app/Modules/Admin/Requests/AdjudicateRequest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Admin\Requests;

use App\Support\Enums\DisputeOutcome;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class AdjudicateRequest extends FormRequest
{
    public function authorize(): bool
    {
        // EnsureAdmin middleware already gates the route.
        return true;
    }

    /** @return array<string, mixed> */
    public function rules(): array
    {
        return [
            'outcome' => ['required', Rule::enum(DisputeOutcome::class)],
            'justification' => ['required', 'string', 'min:20'],
            'per_order_actions' => ['array'],
            'per_order_actions.*.order_id' => ['required', 'string', 'uuid'],
            'per_order_actions.*.reverse_transfer' => ['required', 'boolean'],
        ];
    }
}
```

- [ ] **Step 4: Add the controller action**

In `AdminDisputeController.php`:

```php
public function adjudicate(
    AdjudicateRequest $request,
    Purchase $purchase,
    DisputeAdjudicator $adjudicator,
): JsonResponse {
    $dispute = Dispute::query()
        ->where('purchase_id', $purchase->id)
        ->firstOrFail();

    $input = new AdjudicateInput(
        outcome: DisputeOutcome::from($request->validated('outcome')),
        justification: $request->validated('justification'),
        perOrderActions: $request->validated('per_order_actions', []),
    );

    $adjudicator->adjudicate($dispute, $input, $request->user());

    return response()->json([
        'data' => new AdminDisputeDetail($dispute->fresh('purchase.orders.store')),
    ]);
}
```

Add the imports.

- [ ] **Step 5: Wire the route**

```php
Route::post('/disputes/{purchase}/adjudicate', [AdminDisputeController::class, 'adjudicate']);
```

- [ ] **Step 6: Run all tests**

Expected: 4/4 PASS.

### Task 11: Submit-evidence endpoint

**Files:**
- Update: `api/app/Modules/Admin/Controllers/AdminDisputeController.php`
- Update: `api/app/Modules/Admin/Services/DisputeAdjudicator.php` (or split into a sibling service)
- Test: `api/tests/Feature/Admin/SubmitEvidenceEndpointTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Admin;

use App\Models\Dispute;
use App\Models\Order;
use App\Models\Purchase;
use App\Models\User;
use App\Support\Enums\OrderStatus;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class SubmitEvidenceEndpointTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_submit_evidence_freezes_orders_and_records_outcome(): void
    {
        $admin = User::factory()->create();
        $admin->assignRole('admin');

        $purchase = Purchase::factory()->create();
        Dispute::factory()->create(['purchase_id' => $purchase->id]);
        Order::factory()->count(2)->create([
            'purchase_id' => $purchase->id,
            'status' => OrderStatus::Shipped,
        ]);

        Sanctum::actingAs($admin);

        $this->postJson("/v1/admin/disputes/{$purchase->id}/submit-evidence", [
            'extra_text' => 'Tracking confirmed delivery on 2026-04-30.',
        ])->assertOk();

        foreach ($purchase->fresh()->orders as $o) {
            $this->assertSame(OrderStatus::Disputed, $o->status);
        }
    }
}
```

- [ ] **Step 2: Run, confirm failure**

- [ ] **Step 3: Implement the endpoint**

In `AdminDisputeController.php`:

```php
public function submitEvidence(
    Request $request,
    Purchase $purchase,
    DisputeAdjudicator $adjudicator,
): JsonResponse {
    $dispute = Dispute::query()
        ->where('purchase_id', $purchase->id)
        ->firstOrFail();

    $input = new AdjudicateInput(
        outcome: DisputeOutcome::SubmittedEvidence,
        justification: 'Submitted evidence to Stripe.'
            .($request->input('extra_text') ? ' '.$request->input('extra_text') : ''),
        perOrderActions: [],
    );

    $adjudicator->adjudicate($dispute, $input, $request->user());

    return response()->json([
        'data' => new AdminDisputeDetail($dispute->fresh('purchase.orders.store')),
    ]);
}
```

Route:

```php
Route::post('/disputes/{purchase}/submit-evidence', [AdminDisputeController::class, 'submitEvidence']);
```

> **Plan note:** the actual Stripe `Dispute::update($id, ['evidence' => [...]])` call is deferred to a follow-up patch — the spec lists it but it requires a structured evidence builder (tracking, address, ship dates) that can be a small standalone task. For this plan, the local state transition is the deliverable; the Stripe call is a TODO comment in the service.

- [ ] **Step 4: Run and confirm pass**

### Task 12: Retry-reversal endpoint

**Files:**
- Update: `api/app/Modules/Admin/Controllers/AdminDisputeController.php`
- Test: `api/tests/Feature/Admin/RetryReversalEndpointTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Admin;

use App\Models\Dispute;
use App\Models\Order;
use App\Models\Purchase;
use App\Models\User;
use App\Modules\Checkout\Services\StripeService;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Mockery;
use Tests\TestCase;

class RetryReversalEndpointTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_retry_reversal_reverses_when_order_has_transfer_but_no_reversal(): void
    {
        $admin = User::factory()->create();
        $admin->assignRole('admin');

        $purchase = Purchase::factory()->create();
        $dispute = Dispute::factory()->create(['purchase_id' => $purchase->id]);
        $order = Order::factory()->create([
            'purchase_id' => $purchase->id,
            'subtotal' => 1_000,
            'shipping_cost' => 200,
            'stripe_transfer_id' => 'tr_retry',
            'transferred_at' => now(),
            'transfer_reversed_at' => null,
            'stripe_transfer_reversal_id' => null,
        ]);

        $stripe = Mockery::mock(StripeService::class);
        $stripe->shouldReceive('reverseTransfer')->once()->andReturn((object) ['id' => 'trr_retry']);
        $this->app->instance(StripeService::class, $stripe);

        Sanctum::actingAs($admin);

        $this->postJson("/v1/admin/disputes/{$purchase->id}/retry-reversal/{$order->id}", [
            'justification' => 'Retrying after the original Stripe reversal failed.',
        ])->assertOk();

        $this->assertSame('trr_retry', $order->fresh()->stripe_transfer_reversal_id);
    }

    public function test_retry_returns_409_if_already_reversed(): void
    {
        $admin = User::factory()->create();
        $admin->assignRole('admin');

        $purchase = Purchase::factory()->create();
        Dispute::factory()->create(['purchase_id' => $purchase->id]);
        $order = Order::factory()->create([
            'purchase_id' => $purchase->id,
            'stripe_transfer_id' => 'tr_done',
            'transferred_at' => now(),
            'stripe_transfer_reversal_id' => 'trr_done',
            'transfer_reversed_at' => now(),
        ]);

        Sanctum::actingAs($admin);

        $this->postJson("/v1/admin/disputes/{$purchase->id}/retry-reversal/{$order->id}", [
            'justification' => 'Trying to double-reverse — should be blocked.',
        ])->assertStatus(409);
    }
}
```

- [ ] **Step 2: Run, confirm failure**

- [ ] **Step 3: Implement**

In `AdminDisputeController.php`:

```php
public function retryReversal(
    Request $request,
    Purchase $purchase,
    Order $order,
    StripeService $stripe,
): JsonResponse {
    $request->validate([
        'justification' => ['required', 'string', 'min:20'],
    ]);

    if ($order->purchase_id !== $purchase->id) {
        abort(404);
    }
    if ($order->transfer_reversed_at !== null) {
        abort(409, 'Transfer already reversed.');
    }
    if ($order->stripe_transfer_id === null) {
        abort(422, 'Order has no transfer to reverse.');
    }

    $reversal = $stripe->reverseTransfer(
        transferId: $order->stripe_transfer_id,
        amountCents: $order->subtotal + $order->shipping_cost,
        idempotencyKey: "admin-reversal-retry:{$order->id}:".now()->timestamp,
        metadata: ['order_id' => $order->id],
    );

    $order->update([
        'stripe_transfer_reversal_id' => $reversal->id,
        'transfer_reversed_at' => now(),
    ]);

    activity('admin')
        ->causedBy($request->user())
        ->performedOn($order)
        ->withProperties(['justification' => $request->input('justification')])
        ->log('order.transfer_reversal_retry');

    return response()->json(['data' => ['reversal_id' => $reversal->id]]);
}
```

Route:

```php
Route::post('/disputes/{purchase}/retry-reversal/{order}', [AdminDisputeController::class, 'retryReversal']);
```

- [ ] **Step 4: Run and confirm pass**

### Task 13: OpenAPI + types regeneration

**Files:**
- Update: `api/contracts/openapi.yaml` (and the synced copy in `alqove-web/contracts/openapi.yaml` after running `bin/sync-openapi.sh`)
- Update: `web/packages/types/src/generated.ts` (regenerated)
- Test: `web/packages/types/__tests__/admin-disputes-types.test.ts` (light import-only smoke)

- [ ] **Step 1: Append the new paths and schemas to `api/contracts/openapi.yaml`**

Under the `/v1/admin/orders` block, add:

```yaml
  /v1/admin/disputes:
    get:
      operationId: adminListDisputes
      summary: List disputes
      tags: [Admin]
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [needs_response, under_review, won, lost, warning_needs_response, warning_under_review, warning_closed, charge_refunded] } }
        - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
        - { name: per_page, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 15 } }
      responses:
        '200':
          description: Paginated dispute list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/AdminDisputeSummary' }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { description: Unauthenticated, content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }
        '403': { description: Forbidden — admin role required, content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }

  /v1/admin/disputes/{purchase}:
    get:
      operationId: adminShowDispute
      summary: Get dispute detail
      tags: [Admin]
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: purchase, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Dispute detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/AdminDisputeDetail' }
        '404': { description: No dispute on that purchase, content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }
    # No PATCH/DELETE — adjudication has its own endpoint

  /v1/admin/disputes/{purchase}/adjudicate:
    post:
      operationId: adminAdjudicateDispute
      summary: Adjudicate a dispute
      tags: [Admin]
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: purchase, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AdjudicateRequest' }
      responses:
        '200':
          description: Updated dispute detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/AdminDisputeDetail' }
        '422': { description: Validation error, content: { application/json: { schema: { $ref: '#/components/schemas/ValidationError' } } } }

  /v1/admin/disputes/{purchase}/submit-evidence:
    post:
      operationId: adminSubmitEvidence
      summary: Submit evidence to Stripe (freezes orders)
      tags: [Admin]
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: purchase, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                extra_text: { type: string }
      responses:
        '200':
          description: Updated dispute detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/AdminDisputeDetail' }

  /v1/admin/disputes/{purchase}/retry-reversal/{order}:
    post:
      operationId: adminRetryReversal
      summary: Retry a Stripe Transfer reversal that previously failed
      tags: [Admin]
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: purchase, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: order, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [justification]
              properties:
                justification: { type: string, minLength: 20 }
      responses:
        '200':
          description: Reversal id
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      reversal_id: { type: string }
        '409': { description: Already reversed, content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }
        '422': { description: Validation error or no transfer to reverse, content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }
```

Append to `components/schemas`:

```yaml
    AdminDisputeSummary:
      type: object
      required: [id, purchase_id, stripe_dispute_id, status, amount_cents, created_at]
      properties:
        id: { type: string, format: uuid }
        purchase_id: { type: string, format: uuid }
        stripe_dispute_id: { type: string }
        status: { type: string, enum: [needs_response, under_review, won, lost, warning_needs_response, warning_under_review, warning_closed, charge_refunded] }
        reason: { type: [string, 'null'] }
        amount_cents: { type: integer, minimum: 0 }
        evidence_due_by: { type: [string, 'null'], format: date-time }
        buyer_first_name: { type: [string, 'null'] }
        buyer_last_name: { type: [string, 'null'] }
        outcome: { type: [string, 'null'], enum: [submitted_evidence, accepted, resolved, null] }
        decided_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }

    AdminDisputeDetail:
      type: object
      required: [id, purchase_id, stripe_dispute_id, status, amount_cents, purchase, orders]
      properties:
        id: { type: string, format: uuid }
        purchase_id: { type: string, format: uuid }
        stripe_dispute_id: { type: string }
        status: { type: string }
        reason: { type: [string, 'null'] }
        amount_cents: { type: integer }
        evidence_due_by: { type: [string, 'null'], format: date-time }
        outcome: { type: [string, 'null'] }
        decided_at: { type: [string, 'null'], format: date-time }
        decision_justification: { type: [string, 'null'] }
        purchase:
          type: object
          required: [id, stripe_payment_intent_id]
          properties:
            id: { type: string, format: uuid }
            stripe_payment_intent_id: { type: [string, 'null'] }
            shipping_address: { type: object, additionalProperties: true }
            subtotal: { type: integer }
            shipping_total: { type: integer }
        orders:
          type: array
          items:
            type: object
            required: [id, status, subtotal, shipping_cost]
            properties:
              id: { type: string, format: uuid }
              store:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  name: { type: string }
              status: { type: string }
              subtotal: { type: integer }
              shipping_cost: { type: integer }
              stripe_transfer_id: { type: [string, 'null'] }
              transferred_at: { type: [string, 'null'], format: date-time }
              transfer_reversed_at: { type: [string, 'null'], format: date-time }
              tracking_number: { type: [string, 'null'] }
              tracking_url: { type: [string, 'null'] }
              shipped_at: { type: [string, 'null'], format: date-time }

    AdjudicateRequest:
      type: object
      required: [outcome, justification]
      properties:
        outcome: { type: string, enum: [submitted_evidence, accepted, resolved] }
        justification: { type: string, minLength: 20 }
        per_order_actions:
          type: array
          items:
            type: object
            required: [order_id, reverse_transfer]
            properties:
              order_id: { type: string, format: uuid }
              reverse_transfer: { type: boolean }
```

- [ ] **Step 2: Validate the YAML**

```
python3 -c "import yaml; yaml.safe_load(open('api/contracts/openapi.yaml'))"
```

- [ ] **Step 3: Sync to alqove-web and regenerate types**

In the alqove-web repo:

```
./bin/sync-openapi.sh
npm run build:types
```

Confirm `packages/types/src/generated.ts` contains `AdminDisputeSummary`, `AdminDisputeDetail`, `AdjudicateRequest`.

### Task 14: api-client extensions

**Files:**
- Update: `web/packages/api-client/src/endpoints/admin.ts`
- Update: `web/packages/api-client/src/index.ts`

- [ ] **Step 1: Extend `admin.ts`**

Add to the existing exports:

```ts
export interface AdminDisputeSummary {
  id: string;
  purchase_id: string;
  stripe_dispute_id: string;
  status: string;
  reason: string | null;
  amount_cents: number;
  evidence_due_by: string | null;
  buyer_first_name: string | null;
  buyer_last_name: string | null;
  outcome: string | null;
  decided_at: string | null;
  created_at: string;
}

export interface AdminDisputeDetail extends AdminDisputeSummary {
  decision_justification: string | null;
  purchase: {
    id: string;
    stripe_payment_intent_id: string | null;
    shipping_address: Record<string, unknown>;
    subtotal: number;
    shipping_total: number;
  };
  orders: {
    id: string;
    store: { id: string; name: string };
    status: string;
    subtotal: number;
    shipping_cost: number;
    stripe_transfer_id: string | null;
    transferred_at: string | null;
    transfer_reversed_at: string | null;
    tracking_number: string | null;
    tracking_url: string | null;
    shipped_at: string | null;
  }[];
}

export interface AdjudicateInput {
  outcome: 'submitted_evidence' | 'accepted' | 'resolved';
  justification: string;
  per_order_actions: { order_id: string; reverse_transfer: boolean }[];
}

export interface AdminDisputesQueryInput {
  status?:
    | 'needs_response'
    | 'under_review'
    | 'won'
    | 'lost'
    | 'warning_needs_response'
    | 'warning_under_review'
    | 'warning_closed'
    | 'charge_refunded';
  page?: number;
  per_page?: number;
}
```

Inside `createAdminEndpoints`'s return object, add:

```ts
listDisputes(params: AdminDisputesQueryInput = {}) {
  return client.get<PaginatedResponse<AdminDisputeSummary>>(
    `/v1/admin/disputes${toQuery({ ...params })}`,
  );
},
showDispute(purchaseId: string) {
  return client.get<ApiResponse<AdminDisputeDetail>>(
    `/v1/admin/disputes/${purchaseId}`,
  );
},
adjudicate(purchaseId: string, body: AdjudicateInput) {
  return client.post<ApiResponse<AdminDisputeDetail>>(
    `/v1/admin/disputes/${purchaseId}/adjudicate`,
    body,
  );
},
submitEvidence(purchaseId: string, extraText?: string) {
  return client.post<ApiResponse<AdminDisputeDetail>>(
    `/v1/admin/disputes/${purchaseId}/submit-evidence`,
    extraText ? { extra_text: extraText } : {},
  );
},
retryReversal(purchaseId: string, orderId: string, justification: string) {
  return client.post<ApiResponse<{ reversal_id: string }>>(
    `/v1/admin/disputes/${purchaseId}/retry-reversal/${orderId}`,
    { justification },
  );
},
```

- [ ] **Step 2: Re-export the new types from `index.ts`**

```ts
export type {
  AdminDashboardMetrics,
  AdminStoreSummary,
  AdminOrderSummary,
  AdminStoresQueryInput,
  AdminOrdersQueryInput,
  AdminDisputeSummary,
  AdminDisputeDetail,
  AdjudicateInput,
  AdminDisputesQueryInput,
} from './endpoints/admin';
```

- [ ] **Step 3: Typecheck the api-client**

```
npm run typecheck --workspace=@alqove/api-client
```

Expected: clean.

---

## Phase C — Frontend dispute queue + detail

### Task 15: `useAdminDisputes` and `useAdminDispute` hooks

**File:** `web/src/lib/queries/use-admin.ts`

- [ ] **Step 1: Extend the hooks file**

Append:

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

export function useAdminDisputes(params: AdminDisputesQueryInput) {
  return useQuery({
    queryKey: ['admin', 'disputes', params],
    queryFn: () => api.admin.listDisputes(params),
  });
}

export function useAdminDispute(purchaseId: string | null) {
  return useQuery({
    queryKey: ['admin', 'dispute', purchaseId],
    queryFn: () => api.admin.showDispute(purchaseId!),
    enabled: Boolean(purchaseId),
  });
}

export function useAdjudicateDispute(purchaseId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (body: AdjudicateInput) => api.admin.adjudicate(purchaseId, body),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['admin', 'dispute', purchaseId] });
      qc.invalidateQueries({ queryKey: ['admin', 'disputes'] });
      qc.invalidateQueries({ queryKey: ['admin', 'dashboard'] });
    },
  });
}

export function useSubmitEvidence(purchaseId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (extraText?: string) => api.admin.submitEvidence(purchaseId, extraText),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['admin', 'dispute', purchaseId] });
      qc.invalidateQueries({ queryKey: ['admin', 'disputes'] });
    },
  });
}
```

- [ ] **Step 2: Workspace typecheck (`npm run typecheck`)** — clean.

### Task 16: Dispute queue page

**Files:**
- Create: `web/src/app/(admin)/admin/disputes/page.tsx`
- Create: `web/src/app/(admin)/admin/disputes/disputes-client.tsx`
- Create: `web/src/app/(admin)/admin/disputes/__tests__/disputes-client.test.tsx`

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

```tsx
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { DisputesClient } from '../disputes-client';

const listMock = vi.fn();
vi.mock('@/lib/api', () => ({
  api: { admin: { listDisputes: (...a: unknown[]) => listMock(...a) } },
}));

function wrap(node: React.ReactNode) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return <QueryClientProvider client={qc}>{node}</QueryClientProvider>;
}

const row = {
  id: 'd1',
  purchase_id: 'p1',
  stripe_dispute_id: 'dp_x',
  status: 'needs_response',
  reason: 'product_not_received',
  amount_cents: 12_800,
  evidence_due_by: '2026-05-10T00:00:00Z',
  buyer_first_name: 'Jane',
  buyer_last_name: 'Doe',
  outcome: null,
  decided_at: null,
  created_at: '2026-05-04T00:00:00Z',
};

describe('DisputesClient', () => {
  beforeEach(() => listMock.mockReset());

  it('renders rows with buyer name and amount', async () => {
    listMock.mockResolvedValue({ data: [row], meta: { current_page: 1, last_page: 1, total: 1, per_page: 15 } });
    render(wrap(<DisputesClient />));
    await waitFor(() => expect(screen.getByText('Jane Doe')).toBeInTheDocument());
    expect(screen.getByText('$128.00')).toBeInTheDocument();
    expect(screen.getByText(/needs response/i)).toBeInTheDocument();
  });

  it('filter chip passes status to the API', async () => {
    listMock.mockResolvedValue({ data: [], meta: { current_page: 1, last_page: 1, total: 0, per_page: 15 } });
    render(wrap(<DisputesClient />));
    fireEvent.click(screen.getByRole('button', { name: /under review/i }));
    await waitFor(() => {
      const lastArgs = listMock.mock.calls[listMock.mock.calls.length - 1][0];
      expect(lastArgs).toMatchObject({ status: 'under_review' });
    });
  });

  it('shows error banner when API rejects', async () => {
    listMock.mockRejectedValue(new Error('forbidden'));
    render(wrap(<DisputesClient />));
    await waitFor(() => expect(screen.getByText(/Couldn't load disputes/)).toBeInTheDocument());
  });
});
```

- [ ] **Step 2: Implement `DisputesClient`**

```tsx
'use client';

import Link from 'next/link';
import { useState } from 'react';
import { useAdminDisputes } from '@/lib/queries/use-admin';
import type { AdminDisputesQueryInput } from '@alqove/api-client';

const STATUS_LABEL: Record<string, string> = {
  needs_response: 'Needs response',
  under_review: 'Under review',
  won: 'Won',
  lost: 'Lost',
  warning_needs_response: 'Warning · needs response',
  warning_under_review: 'Warning · under review',
  warning_closed: 'Warning · closed',
  charge_refunded: 'Charge refunded',
};

const FILTERS: { label: string; status: AdminDisputesQueryInput['status'] | undefined }[] = [
  { label: 'Open', status: 'needs_response' },
  { label: 'Under review', status: 'under_review' },
  { label: 'Won', status: 'won' },
  { label: 'Lost', status: 'lost' },
  { label: 'All', status: undefined },
];

function formatPrice(cents: number) {
  return `$${(cents / 100).toFixed(2)}`;
}

export function DisputesClient() {
  const [status, setStatus] = useState<AdminDisputesQueryInput['status'] | undefined>(
    'needs_response',
  );
  const { data, isLoading, isError } = useAdminDisputes({ status });
  const rows = data?.data ?? [];

  return (
    <div>
      <h1 className="text-2xl font-bold text-slate-900">Disputes</h1>
      <p className="mt-1 text-sm text-slate-500">Stripe dispute queue.</p>

      <div className="mt-4 flex gap-2">
        {FILTERS.map((f) => (
          <button
            key={f.label}
            onClick={() => setStatus(f.status)}
            className={`rounded-md px-3 py-1 text-xs font-medium ${
              status === f.status
                ? 'bg-slate-900 text-white'
                : 'border border-slate-300 text-slate-600 hover:bg-slate-50'
            }`}
          >
            {f.label}
          </button>
        ))}
      </div>

      {isError && (
        <p className="mt-4 rounded bg-red-50 p-3 text-sm text-red-700">
          Couldn&apos;t load disputes. You may need admin access.
        </p>
      )}

      <div className="mt-4 bg-white rounded border border-slate-200">
        <table className="w-full text-sm">
          <thead>
            <tr className="border-b border-slate-200">
              <th className="text-left px-4 py-3 font-medium text-slate-500">Purchase</th>
              <th className="text-left px-4 py-3 font-medium text-slate-500">Buyer</th>
              <th className="text-left px-4 py-3 font-medium text-slate-500">Amount</th>
              <th className="text-left px-4 py-3 font-medium text-slate-500">State</th>
              <th className="text-left px-4 py-3 font-medium text-slate-500">Filed</th>
            </tr>
          </thead>
          <tbody>
            {isLoading && (
              <tr><td colSpan={5} className="px-4 py-6 text-center text-sm text-slate-400">Loading…</td></tr>
            )}
            {!isLoading && rows.length === 0 && (
              <tr><td colSpan={5} className="px-4 py-6 text-center text-sm text-slate-400">No disputes.</td></tr>
            )}
            {rows.map((r) => {
              const name = [r.buyer_first_name, r.buyer_last_name].filter(Boolean).join(' ') || '—';
              return (
                <tr key={r.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50">
                  <td className="px-4 py-3 font-mono text-xs text-slate-700">
                    <Link href={`/admin/disputes/${r.purchase_id}`} className="text-emerald-700 hover:underline">
                      {r.purchase_id.slice(0, 8)}
                    </Link>
                  </td>
                  <td className="px-4 py-3 text-slate-500">{name}</td>
                  <td className="px-4 py-3 text-slate-900">{formatPrice(r.amount_cents)}</td>
                  <td className="px-4 py-3 text-slate-500">{STATUS_LABEL[r.status] ?? r.status}</td>
                  <td className="px-4 py-3 text-slate-500 text-xs">
                    {new Date(r.created_at).toLocaleString()}
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
    </div>
  );
}
```

- [ ] **Step 3: Page wrapper**

```tsx
import { DisputesClient } from './disputes-client';

export const metadata = { title: 'Disputes | Admin' };

export default function Page() {
  return <DisputesClient />;
}
```

- [ ] **Step 4: Run the test, iterate to PASS**

### Task 17: Shared `ConfirmWithJustificationDialog`

**Files:**
- Create: `web/src/components/admin/confirm-with-justification-dialog.tsx`
- Create: `web/src/components/admin/__tests__/confirm-with-justification-dialog.test.tsx`

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

```tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { vi, describe, it, expect } from 'vitest';
import { ConfirmWithJustificationDialog } from '../confirm-with-justification-dialog';

describe('ConfirmWithJustificationDialog', () => {
  it('confirm button is disabled until justification is at least 20 chars', () => {
    const onConfirm = vi.fn();
    render(
      <ConfirmWithJustificationDialog
        open
        title="t"
        description="d"
        confirmLabel="Confirm"
        onConfirm={onConfirm}
        onCancel={() => {}}
      />,
    );
    const button = screen.getByRole('button', { name: 'Confirm' });
    expect(button).toBeDisabled();

    fireEvent.change(screen.getByLabelText('Justification'), {
      target: { value: 'short' },
    });
    expect(button).toBeDisabled();

    fireEvent.change(screen.getByLabelText('Justification'), {
      target: { value: 'this is a sufficiently long justification' },
    });
    expect(button).not.toBeDisabled();
    fireEvent.click(button);
    expect(onConfirm).toHaveBeenCalledWith('this is a sufficiently long justification');
  });

  it('cancel button calls onCancel', () => {
    const onCancel = vi.fn();
    render(
      <ConfirmWithJustificationDialog
        open
        title="t"
        description="d"
        confirmLabel="Go"
        onConfirm={() => {}}
        onCancel={onCancel}
      />,
    );
    fireEvent.click(screen.getByRole('button', { name: /Cancel/ }));
    expect(onCancel).toHaveBeenCalled();
  });
});
```

- [ ] **Step 2: Implement**

```tsx
'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';

interface Props {
  open: boolean;
  title: string;
  description: string;
  confirmLabel: string;
  onConfirm: (justification: string) => void;
  onCancel: () => void;
  isPending?: boolean;
}

export function ConfirmWithJustificationDialog({
  open,
  title,
  description,
  confirmLabel,
  onConfirm,
  onCancel,
  isPending,
}: Props) {
  const [text, setText] = useState('');
  if (!open) return null;
  const disabled = text.trim().length < 20 || !!isPending;

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
      <div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
        <h2 className="text-lg font-bold text-slate-900">{title}</h2>
        <p className="mt-1 text-sm text-slate-500">{description}</p>
        <label className="mt-4 block text-sm">
          <span className="font-medium text-slate-700">Justification</span>
          <textarea
            aria-label="Justification"
            value={text}
            onChange={(e) => setText(e.target.value)}
            className="mt-1 w-full rounded-md border border-slate-300 p-2 text-sm"
            rows={4}
            placeholder="≥ 20 characters — recorded in the audit log"
          />
        </label>
        <div className="mt-4 flex gap-2">
          <Button variant="outline" className="flex-1" onClick={onCancel}>
            Cancel
          </Button>
          <Button className="flex-1" disabled={disabled} onClick={() => onConfirm(text.trim())}>
            {isPending ? 'Working…' : confirmLabel}
          </Button>
        </div>
      </div>
    </div>
  );
}
```

- [ ] **Step 3: Run test, confirm PASS**

### Task 18: Dispute detail page + adjudication form

**Files:**
- Create: `web/src/app/(admin)/admin/disputes/[id]/page.tsx`
- Create: `web/src/app/(admin)/admin/disputes/[id]/dispute-detail-client.tsx`
- Create: `web/src/app/(admin)/admin/disputes/[id]/__tests__/dispute-detail-client.test.tsx`

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

```tsx
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { DisputeDetailClient } from '../dispute-detail-client';

const showMock = vi.fn();
const adjudicateMock = vi.fn();
const submitEvidenceMock = vi.fn();
vi.mock('@/lib/api', () => ({
  api: {
    admin: {
      showDispute: (...a: unknown[]) => showMock(...a),
      adjudicate: (...a: unknown[]) => adjudicateMock(...a),
      submitEvidence: (...a: unknown[]) => submitEvidenceMock(...a),
    },
  },
}));

function wrap(node: React.ReactNode) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return <QueryClientProvider client={qc}>{node}</QueryClientProvider>;
}

const detail = {
  id: 'd1',
  purchase_id: 'p1',
  stripe_dispute_id: 'dp_x',
  status: 'needs_response',
  reason: 'product_not_received',
  amount_cents: 12_800,
  evidence_due_by: '2026-05-10T00:00:00Z',
  buyer_first_name: 'Jane',
  buyer_last_name: 'Doe',
  outcome: null,
  decided_at: null,
  created_at: '2026-05-04T00:00:00Z',
  decision_justification: null,
  purchase: {
    id: 'p1',
    stripe_payment_intent_id: 'pi_1',
    shipping_address: { first_name: 'Jane', last_name: 'Doe' },
    subtotal: 10000,
    shipping_total: 2800,
  },
  orders: [
    {
      id: 'o1',
      store: { id: 's1', name: 'Store A' },
      status: 'shipped',
      subtotal: 5000,
      shipping_cost: 1400,
      stripe_transfer_id: 'tr_1',
      transferred_at: '2026-05-01T00:00:00Z',
      transfer_reversed_at: null,
      tracking_number: '1Z',
      tracking_url: null,
      shipped_at: '2026-05-01T00:00:00Z',
    },
    {
      id: 'o2',
      store: { id: 's2', name: 'Store B' },
      status: 'shipped',
      subtotal: 5000,
      shipping_cost: 1400,
      stripe_transfer_id: 'tr_2',
      transferred_at: '2026-05-01T00:00:00Z',
      transfer_reversed_at: null,
      tracking_number: '1Z',
      tracking_url: null,
      shipped_at: '2026-05-01T00:00:00Z',
    },
  ],
};

describe('DisputeDetailClient', () => {
  beforeEach(() => {
    showMock.mockReset();
    adjudicateMock.mockReset();
    submitEvidenceMock.mockReset();
  });

  it('renders evidence panel and per-order rows', async () => {
    showMock.mockResolvedValue({ data: detail });
    render(wrap(<DisputeDetailClient purchaseId="p1" />));
    await waitFor(() => expect(screen.getByText('Store A')).toBeInTheDocument());
    expect(screen.getByText('Store B')).toBeInTheDocument();
    expect(screen.getByText('product_not_received')).toBeInTheDocument();
  });

  it('Accept outcome shows per-order toggles, opens confirm dialog, submits', async () => {
    showMock.mockResolvedValue({ data: detail });
    adjudicateMock.mockResolvedValue({ data: detail });
    render(wrap(<DisputeDetailClient purchaseId="p1" />));
    await waitFor(() => expect(screen.getByText('Store A')).toBeInTheDocument());

    fireEvent.click(screen.getByRole('radio', { name: /Accept dispute/ }));
    // Per-order reverse toggles should appear
    const reverseChecks = screen.getAllByLabelText(/Reverse transfer/);
    expect(reverseChecks).toHaveLength(2);

    fireEvent.click(screen.getByRole('button', { name: /Submit decision/ }));
    fireEvent.change(screen.getByLabelText('Justification'), {
      target: { value: 'Buyer evidence is conclusive — refund both stores.' },
    });
    fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
    await waitFor(() => expect(adjudicateMock).toHaveBeenCalled());
    const [, body] = adjudicateMock.mock.calls[0];
    expect(body.outcome).toBe('accepted');
    expect(body.per_order_actions).toHaveLength(2);
    expect(body.per_order_actions.every((a: { reverse_transfer: boolean }) => a.reverse_transfer)).toBe(true);
  });

  it('Submit-evidence outcome calls submitEvidence not adjudicate', async () => {
    showMock.mockResolvedValue({ data: detail });
    submitEvidenceMock.mockResolvedValue({ data: detail });
    render(wrap(<DisputeDetailClient purchaseId="p1" />));
    await waitFor(() => expect(screen.getByText('Store A')).toBeInTheDocument());

    fireEvent.click(screen.getByRole('radio', { name: /Submit evidence/ }));
    fireEvent.click(screen.getByRole('button', { name: /Submit decision/ }));
    fireEvent.change(screen.getByLabelText('Justification'), {
      target: { value: 'Tracking confirms delivery — contesting the dispute.' },
    });
    fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
    await waitFor(() => expect(submitEvidenceMock).toHaveBeenCalled());
    expect(adjudicateMock).not.toHaveBeenCalled();
  });
});
```

- [ ] **Step 2: Implement `DisputeDetailClient`**

```tsx
'use client';

import { useState } from 'react';
import { useAdminDispute, useAdjudicateDispute, useSubmitEvidence } from '@/lib/queries/use-admin';
import { ConfirmWithJustificationDialog } from '@/components/admin/confirm-with-justification-dialog';

type Outcome = 'accepted' | 'submitted_evidence' | 'resolved';

function formatPrice(cents: number) {
  return `$${(cents / 100).toFixed(2)}`;
}

export function DisputeDetailClient({ purchaseId }: { purchaseId: string }) {
  const { data, isLoading, isError } = useAdminDispute(purchaseId);
  const detail = data?.data;
  const adjudicate = useAdjudicateDispute(purchaseId);
  const submitEvidence = useSubmitEvidence(purchaseId);

  const [outcome, setOutcome] = useState<Outcome>('submitted_evidence');
  const [reverseMap, setReverseMap] = useState<Record<string, boolean>>({});
  const [confirmOpen, setConfirmOpen] = useState(false);

  if (isLoading) return <div className="text-sm text-slate-400">Loading…</div>;
  if (isError || !detail)
    return (
      <p className="rounded bg-red-50 p-3 text-sm text-red-700">
        Couldn&apos;t load this dispute.
      </p>
    );

  const onConfirm = (justification: string) => {
    if (outcome === 'submitted_evidence') {
      submitEvidence.mutate(undefined, { onSettled: () => setConfirmOpen(false) });
      return;
    }
    adjudicate.mutate(
      {
        outcome,
        justification,
        per_order_actions:
          outcome === 'accepted'
            ? detail.orders.map((o) => ({
                order_id: o.id,
                reverse_transfer: !!reverseMap[o.id],
              }))
            : [],
      },
      { onSettled: () => setConfirmOpen(false) },
    );
  };

  return (
    <div className="max-w-4xl">
      <h1 className="text-2xl font-bold text-slate-900">
        Dispute · {formatPrice(detail.amount_cents)}
      </h1>
      <p className="mt-1 text-sm text-slate-500">
        Stripe state: {detail.status} · Reason: {detail.reason ?? '—'}
      </p>

      <section className="mt-6 rounded-lg border border-slate-200 bg-white p-5">
        <h2 className="font-semibold">Per-order breakdown</h2>
        <ul className="mt-3 divide-y divide-slate-100">
          {detail.orders.map((o) => (
            <li key={o.id} className="py-3 flex items-center justify-between">
              <div>
                <div className="font-medium text-slate-900">{o.store.name}</div>
                <div className="text-xs text-slate-400">
                  {formatPrice(o.subtotal + o.shipping_cost)} · status: {o.status} ·{' '}
                  {o.transferred_at ? 'transferred ✅' : 'not transferred'}
                </div>
              </div>
              {outcome === 'accepted' && o.stripe_transfer_id && (
                <label className="flex items-center gap-2 text-sm">
                  <input
                    type="checkbox"
                    aria-label={`Reverse transfer for ${o.store.name}`}
                    checked={!!reverseMap[o.id]}
                    onChange={(e) =>
                      setReverseMap((m) => ({ ...m, [o.id]: e.target.checked }))
                    }
                  />
                  Reverse transfer
                </label>
              )}
            </li>
          ))}
        </ul>
      </section>

      <section className="mt-6 rounded-lg border border-slate-200 bg-white p-5">
        <h2 className="font-semibold">Adjudication</h2>
        <fieldset className="mt-3 space-y-2 text-sm">
          {(
            [
              ['submitted_evidence', 'Submit evidence (platform contests)'],
              ['accepted', 'Accept dispute (refund + per-order reversals)'],
              ['resolved', 'Mark resolved (informational only)'],
            ] as const
          ).map(([value, label]) => (
            <label key={value} className="flex items-center gap-2">
              <input
                type="radio"
                name="outcome"
                value={value}
                checked={outcome === value}
                onChange={() => setOutcome(value)}
              />
              {label}
            </label>
          ))}
        </fieldset>

        <button
          className="mt-4 rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white"
          onClick={() => setConfirmOpen(true)}
        >
          Submit decision
        </button>
      </section>

      <ConfirmWithJustificationDialog
        open={confirmOpen}
        title={
          outcome === 'accepted'
            ? 'Accept dispute and process refunds?'
            : outcome === 'submitted_evidence'
              ? 'Submit evidence to Stripe?'
              : 'Mark this dispute resolved?'
        }
        description="This action is recorded in the audit log."
        confirmLabel="Confirm"
        onConfirm={onConfirm}
        onCancel={() => setConfirmOpen(false)}
        isPending={adjudicate.isPending || submitEvidence.isPending}
      />
    </div>
  );
}
```

Page wrapper:

```tsx
import { use } from 'react';
import { DisputeDetailClient } from './dispute-detail-client';

export const metadata = { title: 'Dispute · Admin' };

export default function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = use(params);
  return <DisputeDetailClient purchaseId={id} />;
}
```

- [ ] **Step 3: Run the tests; iterate to PASS**

### Task 19: Sidebar nav update

**File:** `web/src/app/(admin)/layout.tsx`

- [ ] **Step 1: Update `navItems`**

```ts
const navItems = [
  { href: '/admin', label: 'Dashboard' },
  { href: '/admin/disputes', label: 'Disputes' },
  { href: '/admin/stores', label: 'Stores' },
  { href: '/admin/orders', label: 'Orders' },
];
```

(The `/admin/inbox` and `/admin/activity` items will land in their own plans. Drop the previous `Users` and `Settings` placeholders.)

### Task 20: Dashboard tile additions

**Files:**
- Update: `api/app/Modules/Admin/Services/AdminDashboardMetrics.php`
- Update: `api/contracts/openapi.yaml` (extend `AdminDashboardMetrics` schema)
- Update: `web/packages/api-client/src/endpoints/admin.ts` (extend the `AdminDashboardMetrics` interface)
- Update: `web/src/app/(admin)/admin/page.tsx` (extra tiles)
- Test: `api/tests/Feature/Admin/AdminDashboardEndpointTest.php` (extend)

- [ ] **Step 1: Extend the existing dashboard test**

In the `test_admin_gets_platform_metrics` test, assert two new fields:

```php
'open_disputes',
'overdue_disputes',
```

inside the existing `assertJsonStructure` map, and seed two `Dispute::factory()` rows — one open, one overdue (`evidence_due_by` < now + 24h).

- [ ] **Step 2: Run, confirm failure**

- [ ] **Step 3: Extend `AdminDashboardMetrics::build()`**

Add at the end of the returned array:

```php
'open_disputes' => Dispute::query()
    ->whereIn('status', [
        DisputeStatus::NeedsResponse->value,
        DisputeStatus::WarningNeedsResponse->value,
    ])->count(),
'overdue_disputes' => Dispute::query()
    ->whereIn('status', [
        DisputeStatus::NeedsResponse->value,
        DisputeStatus::WarningNeedsResponse->value,
    ])
    ->where('evidence_due_by', '<', now()->addHours(24))
    ->count(),
```

Add the imports.

- [ ] **Step 4: Update OpenAPI**

Add `open_disputes`, `overdue_disputes` to the `AdminDashboardMetrics` schema's `required` and `properties`.

- [ ] **Step 5: Sync + regenerate types + extend the api-client interface**

```
./bin/sync-openapi.sh
npm run build:types
```

Then add the two fields to `AdminDashboardMetrics` in `web/packages/api-client/src/endpoints/admin.ts`.

- [ ] **Step 6: Add tiles to the dashboard page**

Insert into the `tiles` array (after the existing entries):

```ts
{ label: 'Open disputes', value: metrics ? metrics.open_disputes.toLocaleString() : '—' },
{ label: 'Overdue disputes', value: metrics ? metrics.overdue_disputes.toLocaleString() : '—' },
```

- [ ] **Step 7: Re-run backend test, frontend tests, all should pass**

---

## Phase D — Wrap-up

### Task 21: Full sweep

- [ ] **Step 1: Backend** — `docker compose exec -T laravel.test php artisan test`. Expected: all green; total count > previous `333 passed` by at least 13 (3 dashboard + 1 model + 2 column scaffolding + 1 backfill + 1 webhook sync + 4 adjudicate + 1 submit-evidence + 2 retry-reversal + 1 disputes index + 1 disputes show + 1 unit StripeService = matches).
- [ ] **Step 2: Backend lint** — `./vendor/bin/pint --test app/Modules/Admin app/Models/Dispute.php app/Support/Enums/DisputeStatus.php app/Support/Enums/DisputeOutcome.php tests/Feature/Admin tests/Unit/Stripe`. Expected: PASS.
- [ ] **Step 3: Web typecheck** — `npm run typecheck`. Expected: clean.
- [ ] **Step 4: Web lint** — `npm run lint`. Expected: same baseline (5 pre-existing img warnings, no new).
- [ ] **Step 5: Web tests** — `npm run test`. Expected: 118 → ≥125 passing.
- [ ] **Step 6: Manual QA**
  - Seed: create an admin user (`User::factory()->create(); $u->assignRole('admin');`), a Purchase + Order with a fake `stripe_transfer_id`, and a `Dispute` row.
  - Hit `/admin/disputes` — row shows.
  - Click into detail — adjudication form renders.
  - Choose Accept, toggle reverse on, submit — confirm modal — type justification ≥ 20 chars — Confirm.
  - Verify the Order is marked `Refunded`, the Dispute is `Accepted`, and an entry appears in `activity_log`.

### Task 22: Commit + push

- [ ] **Step 1:** `cd ~/projects/alqove-api && git add api docs && git commit -m "feat(admin): dispute resolution backend + audit log"` (full message follows the L7 commit body style).
- [ ] **Step 2:** `cd ~/projects/alqove-web && git add . && git commit -m "feat(admin): dispute queue + adjudication UI"`.
- [ ] **Step 3:** Push both. Watch CI for both repos.

---

## Open items deferred to follow-up plans

- **Stripe `Dispute::update($id, ['evidence' => [...]])`** — Task 11's submit-evidence currently leaves the actual Stripe API call as a TODO. A dedicated follow-up (small, self-contained) should build the structured evidence payload from each Order's tracking number, address-match data, and ship timestamps and POST it.
- **Reconciliation extension** — `orders:reconcile-money` console command should also detect orphaned reversals (rows where `stripe_transfer_reversal_id` is set locally but Stripe has no matching reversal, or vice versa). Land alongside Plan 4 (`admin-inbox-activity`).
- **Buyer / seller dispute outcome notifications** — `BuyerRefundIssuedNotification`, `SellerOrderForceCancelledNotification`. Wired in Plan 2 (`admin-orders-money-movement`) where they fit naturally with the manual-refund flow.
- **`AdminDisputeAdjudicatedNotification`** fan-out to other admins. Plan 4.
- **Stripe dashboard deep-link** on the dispute detail page. Cosmetic; one-line addition; can land any time.
