# Layer 8 Plan 3: Admin Store Detail + Verify / Suspend / Unsuspend

> **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:** Build the admin store detail surface with three lifecycle actions — verify (move a pending store into the marketplace), suspend (immediately hide the store, cancel + refund every open order, remove verification), and unsuspend (restore but require a separate verify step) — plus the buyer/seller-side cascade that makes a suspended store disappear from the marketplace and notifies the store owner.

**Architecture:** (1) Schema — `stores.is_suspended` (boolean, default false), `stores.suspended_at` (timestamp), `stores.suspension_reason` (text). (2) Backend service — `StoreSuspender` orchestrates the suspend cascade: marks the store suspended, removes `is_verified`, iterates the store's pending/processing orders and refunds them via the existing `CancellationService::sellerCancel`-shape path with `cancellation_reason=store_suspended`, dispatches `SellerStoreSuspendedNotification` to the owner. Verify and unsuspend are simpler service methods (or controller-level for unsuspend; see notes). (3) Endpoints — `GET /v1/admin/stores/{store}`, `POST .../verify`, `POST .../suspend`, `POST .../unsuspend`. (4) Buyer-side cascade — `StoreController::indexPublic`, `showPublic`, and `ItemController::showPublic` add `is_suspended=false` to their existing verified filter so suspended stores disappear from the marketplace instantly. (5) Notifications — `StoreVerifiedNotification` (to owner on verify), `SellerStoreSuspendedNotification` (to owner on suspend, includes the justification snippet so the seller knows why). (6) Frontend — `/admin/stores/[id]` detail page with header card, counters (items active/draft, orders open/total), recent-orders + recent-listings tables, and an action strip (Verify / Suspend / Unsuspend) that opens `ConfirmWithJustificationDialog`; row click on `/admin/stores` navigates to detail.

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

**Spec:** `docs/superpowers/specs/2026-05-04-layer-8-admin-console-disputes-design.md` (section: "Store detail + suspend / verify" lines 188–215)
**Prerequisites:** Plan 1 + Plan 2 merged. `EnsureAdmin` middleware, `CancellationReason::StoreSuspended`, `ConfirmWithJustificationDialog`, and `JustificationRequest` all exist.
**Successor plan:** `2026-XX-XX-layer-8-admin-inbox-activity.md`.

---

## Phase A — Schema

### Task 1: Migration adds suspension columns to `stores`

**Files:**
- Create: `api/database/migrations/2026_05_04_000004_add_suspension_columns_to_stores_table.php`
- Update: `api/app/Models/Store.php` (fillable, casts, docblock)
- Test: `api/tests/Feature/Admin/StoreSuspensionColumnsTest.php`

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

```php
<?php
declare(strict_types=1);
namespace Tests\Feature\Admin;

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

class StoreSuspensionColumnsTest extends TestCase
{
    use RefreshDatabase;

    public function test_columns_exist(): void
    {
        $this->assertTrue(Schema::hasColumn('stores', 'is_suspended'));
        $this->assertTrue(Schema::hasColumn('stores', 'suspended_at'));
        $this->assertTrue(Schema::hasColumn('stores', 'suspension_reason'));
    }

    public function test_defaults_and_casts(): void
    {
        $store = Store::factory()->create();
        $this->assertFalse($store->is_suspended);

        $store->update([
            'is_suspended' => true,
            'suspended_at' => now(),
            'suspension_reason' => 'Repeated dispute losses',
        ]);
        $this->assertTrue($store->fresh()->is_suspended);
        $this->assertNotNull($store->fresh()->suspended_at);
    }
}
```

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

- [ ] **Step 3: Migration**

```php
<?php
declare(strict_types=1);

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('stores', function (Blueprint $table) {
            $table->boolean('is_suspended')->default(false)->after('is_verified');
            $table->timestamp('suspended_at')->nullable()->after('is_suspended');
            $table->text('suspension_reason')->nullable()->after('suspended_at');
            $table->index('is_suspended');
        });
    }

    public function down(): void
    {
        Schema::table('stores', function (Blueprint $table) {
            $table->dropIndex(['is_suspended']);
            $table->dropColumn(['is_suspended', 'suspended_at', 'suspension_reason']);
        });
    }
};
```

- [ ] **Step 4: Update `Store` model — fillable + casts + property docblocks**

```php
// add to $fillable
'is_suspended',
'suspended_at',
'suspension_reason',

// add to casts()
'is_suspended' => 'boolean',
'suspended_at' => 'datetime',

// add to property docblock
 * @property bool $is_suspended
 * @property \Illuminate\Support\Carbon|null $suspended_at
 * @property string|null $suspension_reason
```

- [ ] **Step 5: Migrate + re-run test → PASS**

---

## Phase B — Backend service + endpoints

### Task 2: `StoreSuspender` service

**Files:**
- Create: `api/app/Modules/Admin/Services/StoreSuspender.php`
- Test: `api/tests/Feature/Admin/StoreSuspenderTest.php`

The service orchestrates the suspend cascade:

1. Mark store suspended (`is_suspended=true`, `suspended_at=now()`, `suspension_reason=$justification`)
2. Remove verification (`is_verified=false`) so the buyer-side filter immediately hides the store
3. Iterate every Order owned by stores belonging to this store with `status in (pending, processing)`:
   - Refund the PaymentIntent for the order's portion (subtotal+shipping+tax)
   - Mark the order Cancelled with `cancelled_by=platform`, `cancellation_reason=store_suspended`, `cancelled_at=now()`
4. Dispatch `SellerStoreSuspendedNotification` to the store owner with the justification
5. Write activity log entry

Verify is straight: flip `is_verified=true` only if not suspended, dispatch `StoreVerifiedNotification`, log activity. Unsuspend flips `is_suspended=false` and clears `suspended_at`/`suspension_reason`; does **not** auto-restore `is_verified` — admin has to verify in a separate step.

- [ ] **Step 1: Failing test (3 cases — verify, suspend with cascade, unsuspend doesn't auto-verify)**

```php
<?php
declare(strict_types=1);
namespace Tests\Feature\Admin;

use App\Models\Order;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Modules\Admin\Services\StoreSuspender;
use App\Modules\Checkout\Services\StripeService;
use App\Support\Enums\CancellationReason;
use App\Support\Enums\OrderStatus;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Spatie\Activitylog\Models\Activity;
use Stripe\Refund;
use Tests\TestCase;

class StoreSuspenderTest extends TestCase
{
    use RefreshDatabase;

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

    public function test_verify_flips_flag_and_logs(): void
    {
        $admin = User::factory()->create();
        $store = Store::factory()->create(['is_verified' => false]);

        app(StoreSuspender::class)->verify(
            store: $store,
            justification: 'Owner uploaded proof of business identity.',
            admin: $admin,
        );

        $this->assertTrue($store->fresh()->is_verified);
        $this->assertSame(1, Activity::query()->where('description', 'store.verify')->count());
    }

    public function test_suspend_cancels_open_orders_and_unverifies(): void
    {
        $admin = User::factory()->create();
        $sellerOwner = User::factory()->create();
        $store = Store::factory()->create([
            'is_verified' => true,
            'owner_user_id' => $sellerOwner->id,
        ]);
        $purchase = Purchase::factory()->create(['stripe_payment_intent_id' => 'pi_sus']);

        $pending = Order::factory()->create([
            'purchase_id' => $purchase->id,
            'store_id' => $store->id,
            'status' => OrderStatus::Pending,
        ]);
        $processing = Order::factory()->create([
            'purchase_id' => $purchase->id,
            'store_id' => $store->id,
            'status' => OrderStatus::Processing,
        ]);
        $shipped = Order::factory()->create([
            'purchase_id' => $purchase->id,
            'store_id' => $store->id,
            'status' => OrderStatus::Shipped,
        ]);

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

        app(StoreSuspender::class)->suspend(
            store: $store,
            justification: 'Multiple unresolved disputes within 30 days.',
            admin: $admin,
        );

        $fresh = $store->fresh();
        $this->assertTrue($fresh->is_suspended);
        $this->assertFalse($fresh->is_verified);
        $this->assertNotNull($fresh->suspended_at);

        $this->assertSame(OrderStatus::Cancelled, $pending->fresh()->status);
        $this->assertSame(OrderStatus::Cancelled, $processing->fresh()->status);
        $this->assertSame(CancellationReason::StoreSuspended, $pending->fresh()->cancellation_reason);
        // Already-shipped orders are NOT auto-cancelled
        $this->assertSame(OrderStatus::Shipped, $shipped->fresh()->status);
    }

    public function test_unsuspend_does_not_auto_verify(): void
    {
        $admin = User::factory()->create();
        $store = Store::factory()->create([
            'is_verified' => false,
            'is_suspended' => true,
            'suspended_at' => now(),
            'suspension_reason' => 'old reason',
        ]);

        app(StoreSuspender::class)->unsuspend(
            store: $store,
            justification: 'Resolved compliance documentation issues.',
            admin: $admin,
        );

        $fresh = $store->fresh();
        $this->assertFalse($fresh->is_suspended);
        $this->assertFalse($fresh->is_verified); // unchanged
    }
}
```

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

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

```php
<?php
declare(strict_types=1);
namespace App\Modules\Admin\Services;

use App\Models\Order;
use App\Models\Store;
use App\Models\User;
use App\Modules\Checkout\Services\StripeService;
use App\Modules\Notifications\Notifications\SellerStoreSuspendedNotification;
use App\Modules\Notifications\Notifications\StoreVerifiedNotification;
use App\Support\Enums\CancellationReason;
use App\Support\Enums\OrderCancelledBy;
use App\Support\Enums\OrderStatus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

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

    public function verify(Store $store, string $justification, User $admin): void
    {
        if ($store->is_suspended) {
            throw new \RuntimeException('Cannot verify a suspended store. Unsuspend first.');
        }
        DB::transaction(function () use ($store, $justification, $admin) {
            $store->update(['is_verified' => true]);
            activity('admin')
                ->causedBy($admin)
                ->performedOn($store)
                ->withProperties(['justification' => $justification])
                ->log('store.verify');
            if ($owner = $store->owner) {
                $owner->notify(new StoreVerifiedNotification($store));
            }
        });
    }

    public function suspend(Store $store, string $justification, User $admin): void
    {
        if ($store->is_suspended) {
            throw new \RuntimeException('Store already suspended.');
        }
        DB::transaction(function () use ($store, $justification, $admin) {
            $store->update([
                'is_suspended' => true,
                'is_verified' => false,
                'suspended_at' => now(),
                'suspension_reason' => $justification,
            ]);

            $orders = Order::query()
                ->where('store_id', $store->id)
                ->whereIn('status', [OrderStatus::Pending, OrderStatus::Processing])
                ->with('purchase')
                ->get();

            foreach ($orders as $order) {
                $pi = $order->purchase?->stripe_payment_intent_id;
                $amount = $order->subtotal + $order->shipping_cost + $order->tax_amount;
                if ($pi && $amount > 0) {
                    try {
                        $this->stripe->refundForOrder($pi, $amount);
                    } catch (\Throwable $e) {
                        Log::error('Suspend cascade refund failed', [
                            'order_id' => $order->id,
                            'error' => $e->getMessage(),
                        ]);
                    }
                }
                $order->update([
                    'status' => OrderStatus::Cancelled,
                    'cancelled_by' => OrderCancelledBy::Platform,
                    'cancellation_reason' => CancellationReason::StoreSuspended,
                    'cancelled_at' => now(),
                ]);
            }

            activity('admin')
                ->causedBy($admin)
                ->performedOn($store)
                ->withProperties([
                    'justification' => $justification,
                    'cancelled_order_count' => $orders->count(),
                ])
                ->log('store.suspend');

            if ($owner = $store->owner) {
                $owner->notify(new SellerStoreSuspendedNotification($store, $justification));
            }
        });
    }

    public function unsuspend(Store $store, string $justification, User $admin): void
    {
        if (! $store->is_suspended) {
            throw new \RuntimeException('Store is not suspended.');
        }
        DB::transaction(function () use ($store, $justification, $admin) {
            $store->update([
                'is_suspended' => false,
                'suspended_at' => null,
                'suspension_reason' => null,
            ]);
            // is_verified intentionally NOT auto-restored — admin re-verifies in a second step
            activity('admin')
                ->causedBy($admin)
                ->performedOn($store)
                ->withProperties(['justification' => $justification])
                ->log('store.unsuspend');
        });
    }
}
```

> **Plan note:** the two notification classes referenced will be created in Task 3.

- [ ] **Step 4: Iterate to PASS (3/3)**

### Task 3: Notifications

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/StoreVerifiedNotification.php`
- Create: `api/app/Modules/Notifications/Notifications/SellerStoreSuspendedNotification.php`
- Test: `api/tests/Feature/Notifications/AdminStoreActionNotificationsTest.php`

Both follow the same pattern as `BuyerRefundIssuedNotification` from Plan 2 (mail + database via `NotificationPreferenceGate`, `NotificationCategory::Account` since these are about the seller's account state, not orders). The suspended notification's `toDatabase` body includes the suspension reason so the seller sees why right in the inbox row.

- [ ] **Step 1: Failing test asserts each notification fires from its corresponding service method.**
- [ ] **Step 2: Implement both classes (copy the structure from existing `BuyerRefundIssuedNotification`).**
- [ ] **Step 3: PASS.**

### Task 4: Detail + action endpoints

**Files:**
- Update: `api/app/Modules/Admin/Controllers/AdminStoreController.php`
- Create: `api/app/Modules/Admin/Resources/AdminStoreDetail.php`
- Update: `api/app/Modules/Admin/routes.php`
- Test: `api/tests/Feature/Admin/AdminStoreEndpointsTest.php`

`AdminStoreDetail` resource fields:
- everything in `AdminStoreSummary`
- `is_suspended`, `suspended_at`, `suspension_reason`
- `owner` block (id, email, name)
- `counters` block: `items_active`, `items_draft`, `orders_open`, `orders_total`, `lifetime_revenue_cents`
- `recent_orders` (5, summary shape: id, status, total, created_at)
- `recent_items` (5, summary shape: id, title, price, status, created_at)

Endpoints (all gated by EnsureAdmin):
- `GET /v1/admin/stores/{store}` — returns `AdminStoreDetail`
- `POST .../verify` — body: `JustificationBody` (already exists)
- `POST .../suspend` — body: `JustificationBody`
- `POST .../unsuspend` — body: `JustificationBody`

Each action endpoint catches `RuntimeException` from the service and maps to 409.

- [ ] **Step 1: Endpoint test (8 cases — show happy path, show 401, verify happy + 409 if suspended, suspend happy with cascade-count assertion, suspend 409 if already suspended, unsuspend happy + does-not-verify, non-admin 403)**
- [ ] **Step 2: Implement resource + controller actions + routes**
- [ ] **Step 3: PASS**

### Task 5: Buyer-side cascade — hide suspended stores from marketplace

**Files:**
- Update: `api/app/Modules/Stores/Controllers/StoreController.php` (`indexPublic` + `showPublic`)
- Update: `api/app/Modules/Items/Controllers/ItemController.php` (`showPublic`)
- Test: `api/tests/Feature/Stores/SuspendedStoreInvisibilityTest.php`

Add `where('is_suspended', false)` to the existing verified filter. Suspended stores immediately fall out of `GET /v1/stores`, the public store-show endpoint 404s, and items belonging to suspended stores 404 from the public detail endpoint.

- [ ] **Step 1: Failing test — create a verified+suspended store, assert it's missing from `/v1/stores`, the show endpoint returns 404, and an item from it returns 404 from the public detail endpoint.**
- [ ] **Step 2: One-line `where()` addition in each location.**
- [ ] **Step 3: PASS — confirm the existing verified-store tests still pass too (no regression).**

---

## Phase C — OpenAPI + types + api-client

### Task 6: Document the four new paths and regenerate types

- `GET /v1/admin/stores/{store}` (operationId `adminShowStore`) → `AdminStoreDetail`
- `POST .../verify` (operationId `adminVerifyStore`) → reuses `JustificationBody`
- `POST .../suspend` (operationId `adminSuspendStore`) → reuses `JustificationBody`
- `POST .../unsuspend` (operationId `adminUnsuspendStore`) → reuses `JustificationBody`

New schema:
- `AdminStoreDetail` — extends `AdminStoreSummary` shape with `is_suspended`, `suspended_at`, `suspension_reason`, `owner`, `counters`, `recent_orders`, `recent_items`

Append yaml; validate; sync; regen.

### Task 7: api-client extensions

In `web/packages/api-client/src/endpoints/admin.ts`, add:

```ts
export interface AdminStoreDetail extends AdminStoreSummary {
  is_suspended: boolean;
  suspended_at: string | null;
  suspension_reason: string | null;
  owner: { id: string; email: string; name: string };
  counters: {
    items_active: number;
    items_draft: number;
    orders_open: number;
    orders_total: number;
    lifetime_revenue_cents: number;
  };
  recent_orders: { id: string; status: string; total: number; created_at: string }[];
  recent_items: { id: string; title: string; price: number; status: string; created_at: string }[];
}

// Inside createAdminEndpoints():
showStore(storeId: string) { return client.get<ApiResponse<AdminStoreDetail>>(`/v1/admin/stores/${storeId}`); },
verifyStore(storeId: string, justification: string) {
  return client.post<ApiResponse<AdminStoreDetail>>(`/v1/admin/stores/${storeId}/verify`, { justification });
},
suspendStore(storeId: string, justification: string) {
  return client.post<ApiResponse<AdminStoreDetail>>(`/v1/admin/stores/${storeId}/suspend`, { justification });
},
unsuspendStore(storeId: string, justification: string) {
  return client.post<ApiResponse<AdminStoreDetail>>(`/v1/admin/stores/${storeId}/unsuspend`, { justification });
},
```

Re-export `AdminStoreDetail` from `index.ts`. `npm run typecheck --workspace=@alqove/api-client` clean.

---

## Phase D — Frontend

### Task 8: Hooks

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

```ts
export function useAdminStore(storeId: string | null) {
  return useQuery({
    queryKey: ['admin', 'store', storeId],
    queryFn: () => api.admin.showStore(storeId!),
    enabled: Boolean(storeId),
  });
}

export function useAdminVerifyStore(storeId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (justification: string) => api.admin.verifyStore(storeId, justification),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['admin', 'store', storeId] });
      qc.invalidateQueries({ queryKey: ['admin', 'stores'] });
      qc.invalidateQueries({ queryKey: ['admin', 'dashboard'] });
    },
  });
}

export function useAdminSuspendStore(storeId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (justification: string) => api.admin.suspendStore(storeId, justification),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['admin', 'store', storeId] });
      qc.invalidateQueries({ queryKey: ['admin', 'stores'] });
      qc.invalidateQueries({ queryKey: ['admin', 'orders'] });
    },
  });
}

export function useAdminUnsuspendStore(storeId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (justification: string) => api.admin.unsuspendStore(storeId, justification),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['admin', 'store', storeId] });
      qc.invalidateQueries({ queryKey: ['admin', 'stores'] });
    },
  });
}
```

### Task 9: Store detail page

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

Layout:

```
Header card: name · location · pills (Verified / Pending / Suspended)
ADMIN ACTIONS
[Verify] (visible when not verified AND not suspended)
[Suspend] (visible when not suspended)
[Unsuspend] (visible when suspended)

COUNTERS (4 small cards)  active items · draft items · open orders · lifetime revenue
RECENT ORDERS (5 rows)    id · status · total · created
RECENT ITEMS (5 rows)     title · price · status · created
SUSPENSION DETAILS (when suspended)  suspended_at + suspension_reason
```

Each action button opens a `ConfirmWithJustificationDialog`. Buttons disable while their mutation is pending.

- [ ] **Step 1: Failing tests (4: render with counters, verify flow, suspend flow, unsuspend hidden when not suspended)**
- [ ] **Step 2: Implement page + client component.**
- [ ] **Step 3: PASS**

### Task 10: Wire row click on `/admin/stores`

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

Wrap each store name cell in `<Link href={`/admin/stores/${store.id}`}>`. Existing test should still pass (the name is the `getByText` target; wrapping in a Link doesn't change its text).

---

## Phase E — Wrap-up

### Task 11: Full sweep

- [ ] **API:** `php artisan test`. Expected: previous 372 + ~16 (3 columns + 3 service + 8 endpoint + 2 buyer cascade) ≥ 388.
- [ ] **Pint:** auto-fix new files.
- [ ] **Web typecheck + lint + test.** Expected: 129 → ≥ 133.
- [ ] **Manual QA:**
  - Seed: an unverified store, a verified store with 2 pending orders.
  - Visit `/admin/stores`, click an unverified one → Verify with justification → store flips Verified.
  - Click a verified store with open orders → Suspend → both pending orders move to cancelled with `cancellation_reason=store_suspended`, store falls out of `/v1/stores` public list.
  - Unsuspend → store comes back but stays unverified; verify in a second step to bring back into the marketplace.

### Task 12: Commit + push

- [ ] **API:** `feat(admin): store verify / suspend / unsuspend with cascade + notifications`
- [ ] **Web:** `feat(admin): store detail page with verify / suspend / unsuspend actions`

---

## Open items deferred

- **Pending-store onboarding queue UI** — a separate "stores awaiting verification" filter on `/admin/stores`. Out of scope this plan; the existing `verified=false` query param already supports it.
- **Bulk-suspend** — explicitly excluded by the spec.
- **Soft-restore tracking** — when a store is unsuspended, the cancelled orders stay cancelled. Restoration to active never restores cancelled orders. This matches the spec.
- **Search-engine de-indexing** when a store is suspended — handled at the marketplace-data layer (suspended stores are filtered out of the items list); a sitemap update is a SEO concern beyond this layer.
