# Layer 8 — Admin Console & Dispute Resolution (Design)

**Status:** Draft · 2026-05-04
**Predecessors:** Layer 5 (Fulfillment & Shipping), Layer 6 (Notifications), Layer 7 (Seller Dashboard)
**Successors:** TBD (likely: financial reporting, support tooling, multi-admin / RBAC)

---

## Purpose

Build the admin console explicitly deferred by every preceding layer. The headline scope is **dispute resolution** — the workflow Layer 5 left a dangling event for, Layer 6 left a placeholder notification for, and Layer 7 sealed off as out-of-scope. Around that core, fill in the operational tooling admins need to run the platform: store moderation, manual money movement, an audit trail, and an admin inbox.

Three previously-deferred surfaces are in scope here:

- **Dispute resolution UI + actions** (Layer 5 design, lines 132–134, 294, 303–304, 385, 408)
- **Admin inbox UI** (Layer 6 design, lines 326, 460)
- **Dispute resolution UI** (Layer 7 design, line 48)

A *foundation slice* of the admin console (dashboard metrics tiles, paginated stores list, paginated orders list, role gating) shipped on 2026-05-04 in `alqove-api@c4c073b` and `alqove-web@236c45a` ahead of this spec to validate the API plumbing. This spec covers everything *beyond* that slice.

## User model

Target user: a small operations team (1–3 admins early on) who triage incoming disputes within hours of arrival but spend most of their time on other work. They are not living in the console; they get pulled in by an alert (email + admin inbox + Slack later). When they're in, they need to act decisively and leave a clean audit trail.

Implications:

- **Polling is fine.** Disputes don't arrive often enough to need WebSocket push. NotificationBell-style polling at 30 s is sufficient.
- **Desktop-only (1280px+).** No tablet, no phone. Admin actions involve money movement; we want the full screen real estate and we don't want to optimise for mobile mistakes.
- **Confirmations everywhere money moves.** Every action that triggers a Stripe Transfer reversal, refund, or store suspension goes through a typed-confirmation modal (e.g. "type the order id to confirm"). One-click destructive actions are forbidden.
- **Audit-by-default.** Every state-changing admin action writes a `spatie/laravel-activitylog` entry with the actor, target, before/after, and a free-text justification field that is required by the form, not optional.

## Scope

### In scope

- **Dispute queue** (`/admin/disputes`) — list of active and historical disputes
- **Dispute detail + adjudication** (`/admin/disputes/:id`) — the core workflow: review evidence, choose an outcome, execute money movement
- **Manual Transfer reversal** — admin-initiated Stripe Transfer reversal on Orders that already paid out before a dispute landed
- **Manual refund issuance** — admin-initiated full or partial refund without going through the seller-cancel flow
- **Order detail (admin view)** (`/admin/orders/:id`) — the existing list page gets a row-click target with the same surface sellers see, plus admin-only actions
- **Store detail + suspend/verify** (`/admin/stores/:id`) — verify pending stores, suspend a store (cancels open Orders with `cancellation_reason=store_suspended`, blocks new listings)
- **Admin inbox** (`/admin/inbox`) — same notification UI sellers got in Layer 7, scoped to admin role
- **Audit log surface** — read-only, filterable; every adjudication action writes one and shows up here
- **Dashboard upgrades** — add an "open disputes" tile and an "action needed" widget to the existing dashboard slice

### Out of scope

- **Customer support live chat / messaging.** Not a Layer 8 concern.
- **Financial reporting / CSV exports.** Defer to a later layer focused on reconciliation and accounting.
- **Multi-admin RBAC** beyond the single `admin` role. No admin-of-admins, no scoped sub-permissions, no team assignment / SLA tracking.
- **Phone / tablet layouts.**
- **Buyer-initiated dispute submission UI.** Disputes enter the system via Stripe webhooks; buyer-side UI for opening a dispute is a separate (later) feature.
- **Multi-currency.** USD-only, same as the rest of the platform.
- **Bulk actions** in the dispute or stores list. Each adjudication is high-stakes and individual.
- **Automated adjudication / ML classification.** Every dispute is human-decided.

## Information architecture

The existing left sidebar gains two items and rearranges to put the new high-traffic surface (Disputes) near the top:

```
Dashboard      /admin
Disputes       /admin/disputes        ← NEW
Inbox          /admin/inbox           ← NEW
Stores         /admin/stores
Orders         /admin/orders
Activity log   /admin/activity        ← NEW
Settings       /admin/settings        (still stub — out of scope)
```

The Users tab from the original sidebar is removed for now (it was never specced and would balloon the layer).

Sidebar collapse below 1024px is preserved from the foundation slice.

## Section-by-section design

### Dispute queue (`/admin/disputes`)

```
┌────────────────────────────────────────────────────────────────┐
│ Filter chips: Open · Awaiting evidence · Won · Lost · All      │
│ [🔍 search by purchase id / buyer name]                        │
├────────────────────────────────────────────────────────────────┤
│ Purchase  · Buyer · Amount · Stripe state · Filed · [age]      │
│ #ab12cd34  Jane D.  $128    needs_response  3h ago   ⚠ Overdue │
│ #ef56gh78  Mike R.  $42     under_review    2d ago             │
│ …                                                              │
└────────────────────────────────────────────────────────────────┘
```

- Backed by `Purchase.disputed = true`. Each dispute row is one Purchase, not one Order — Stripe disputes apply to the whole charge (Layer 5 line 294).
- "Stripe state" is the Stripe dispute object's `status` field, surfaced via a small set of human-readable mappings: `needs_response`, `under_review`, `won`, `lost`, `warning_*`.
- "Overdue" pill when the Stripe `evidence_details.due_by` timestamp is < 24 h away and we haven't submitted yet.
- Sort: filed-descending default; ship-by/age sort makes no sense here.
- No bulk actions.

**Backend**

- `GET /v1/admin/disputes` — paginated, filtered. Returns disputes joined with their Purchase + Stripe dispute snapshot (cached in our DB on webhook receipt).
- New table: `disputes` — one row per Stripe dispute, FK to `purchases`. Stores `stripe_dispute_id` (PK in Stripe space, unique here), `status`, `reason`, `amount_cents`, `evidence_due_by`, `created_at`, `decided_at`, `outcome`, denormalised buyer first/last name from the Purchase shipping address for searchability.
- The existing webhook handler (`charge.dispute.created`) gains a sibling for `charge.dispute.updated` and `charge.dispute.closed` so our row stays in sync. Layer 5 only handled `created`.
- Index on `(status, evidence_due_by)` for the queue sort.

### Dispute detail + adjudication (`/admin/disputes/:purchaseId`)

This is the page `AdminPurchaseDisputedNotification` already links to (its CTA URL is `/admin/disputes/{purchase.id}`, set in Layer 6). We honour that URL as-is.

```
┌────────────────────────────────────────────────────────────────┐
│ Dispute · Purchase #ab12cd34 · $128.00       [overdue: 6h]     │
├────────────────────────────────────────────────────────────────┤
│ EVIDENCE PANEL                                                 │
│ Stripe reason · "product_not_received"                         │
│ Buyer claim (free text from Stripe payload)                    │
│ Stripe evidence due by: Tue 9:00am                             │
├────────────────────────────────────────────────────────────────┤
│ PURCHASE FACTS                                                 │
│ Buyer: Jane Doe · jane@…                                       │
│ Stripe charge: ch_123… · payment_intent: pi_456…               │
│ Address-on-file vs ship-to comparison                          │
│ Per-Order rows:                                                │
│   ord_1 · Store A · $76 · Shipped · transferred ✅ ·          │
│           tracking #1Z…                                        │
│   ord_2 · Store B · $52 · Shipped · transferred ✅ ·          │
│           tracking #1Z…                                        │
├────────────────────────────────────────────────────────────────┤
│ ADJUDICATION                                                   │
│ Outcome: ◯ Submit evidence (platform contests)                 │
│           ◯ Accept dispute (refund buyer, reverse transfers)   │
│           ◯ Mark resolved (informational only — Stripe-driven) │
│ Per-Order action selector (only when Accept):                  │
│   ord_1 [Reverse transfer] [Leave transfer in place]           │
│   ord_2 [Reverse transfer] [Leave transfer in place]           │
│ Justification (required, free text, ≥ 20 chars):               │
│ [textarea]                                                     │
│ [Submit decision]                                              │
└────────────────────────────────────────────────────────────────┘
```

**Three adjudication outcomes**

1. **Submit evidence** — package up tracking numbers, address-match data, store fulfilment timestamps, and POST to Stripe Disputes API as evidence. Sets `Dispute.outcome = submitted_evidence`, transitions our local `OrderStatus` for affected Orders to `Disputed` (frozen). No money moves yet — Stripe decides.
2. **Accept dispute** — admin agrees the buyer is owed a refund. Per Order: optionally reverse the Transfer (`Stripe\Transfer::createReversal`) so funds come back from the seller's connected account, then issue a refund on the original PaymentIntent for that Order's portion. Sets `Order.status = Refunded`, `Order.cancelled_by = admin`, `Order.cancellation_reason = dispute_accepted` (new enum case). Sets `Dispute.outcome = accepted`.
3. **Mark resolved** — used when Stripe closed the dispute on its own (e.g. buyer withdrew). Stamps `decided_at` and `outcome = resolved` without touching money.

**Per-Order Transfer reversal**

This is the manual reversal Layer 5 explicitly punted on (lines 134, 303–304). Only Orders with a non-null `stripe_transfer_id` are reversal candidates. The form makes the per-Order choice explicit because the platform may want to keep one seller paid while clawing back another (multi-seller carts).

Stripe call: `Transfer::createReversal($order->stripe_transfer_id, ['amount' => $reverseAmount, 'metadata' => [...]])`. On success, write `order.transfer_reversed_at` and `order.stripe_transfer_reversal_id` (new columns). On failure (e.g. seller's connected account has insufficient balance), the adjudication transaction rolls back the local DB writes but the dispute-accept call to Stripe stays committed — surface an "incomplete" banner on the dispute detail page with a "Retry reversal" action. We do **not** auto-retry.

**Justification + audit**

Required textarea ≥ 20 characters. Submission writes a `spatie/laravel-activitylog` entry with `causer = admin user`, `subject = Dispute model`, `properties = { outcome, per_order_actions, justification }`. The log is immutable and feeds the `/admin/activity` surface.

**Backend**

- `POST /v1/admin/disputes/{purchase}/adjudicate` — body: `{ outcome, justification, per_order_actions: [{ order_id, reverse_transfer: bool }] }`. Server-side guards: outcome+per_order_actions consistency, justification length, idempotency-key required (existing middleware applies). Enums validated against `OutcomeKind` (new).
- `POST /v1/admin/disputes/{purchase}/submit-evidence` — body: `{ extra_text? }`. Builds the evidence payload from existing data (tracking, address, ship timestamps) and POSTs to Stripe.
- `POST /v1/admin/disputes/{purchase}/retry-reversal/{order}` — narrow recovery action used only when the initial reversal in `adjudicate` failed.

### Order detail (admin view) (`/admin/orders/:id`)

Reuse the existing seller order detail component as the base; render the same panels, gated on admin role. Add an admin-only action strip below the header:

- **Force-cancel** — when the order is stuck (e.g. seller unresponsive past auto-cancel window). Sets `cancellation_reason = other`, requires justification.
- **Issue manual refund** — partial or full, independent of dispute flow. Used for support-driven refunds. Required justification.
- **Reverse Transfer** — same as the dispute path but available on its own; only enabled when `stripe_transfer_id` is present and `transfer_reversed_at` is null.

Each goes through the same typed-confirmation modal pattern as adjudication.

**Backend**

- `POST /v1/admin/orders/{order}/refund` — body: `{ amount_cents, justification }`. Idempotency-keyed.
- `POST /v1/admin/orders/{order}/force-cancel` — body: `{ justification }`. Reuses the existing `CancellationService` with a new `CancellationReason::AdminForced` enum case.
- `POST /v1/admin/orders/{order}/reverse-transfer` — body: `{ amount_cents?, justification }`. Defaults amount to the full transferred amount.

### Store detail + suspend / verify (`/admin/stores/:id`)

The foundation slice has a list but no detail. Add:

- Header card: name, location, owner, verified state, suspended state
- Counters: items active/draft, orders open/total, lifetime revenue
- Recent orders table (10 most recent)
- Recent listings table (10 most recent)
- Action strip:
  - **Verify store** (visible if `is_verified=false` and not suspended) — required justification, sets `is_verified=true`, dispatches `StoreVerifiedNotification` to the owner
  - **Suspend store** — dispatches `SuspendStore` job which: sets `is_suspended=true` (new column on `stores`), cancels all `Order` rows in `pending`/`processing` with `CancellationReason::StoreSuspended` (already in the enum), refunds their PaymentIntents, removes `is_verified`. Required justification ≥ 20 chars.
  - **Unsuspend store** — reverse of suspend, but does *not* automatically re-verify; that's an explicit second step

**Backend**

- New column: `stores.is_suspended` (boolean, default false), `stores.suspended_at`, `stores.suspension_reason` (text)
- `GET /v1/admin/stores/{store}` — detail
- `POST /v1/admin/stores/{store}/verify` — body: `{ justification }`
- `POST /v1/admin/stores/{store}/suspend` — body: `{ justification }`. Queues `SuspendStore` job.
- `POST /v1/admin/stores/{store}/unsuspend` — body: `{ justification }`

The buyer-side store list and item search continue to filter on `is_verified=true` (existing behaviour). Add `is_suspended=false` to the same filter so suspended stores disappear from the marketplace immediately.

### Admin inbox (`/admin/inbox`)

Reuse the seller inbox component pattern wholesale. Tabs are different:

- **All**
- **Disputes** — `AdminPurchaseDisputedNotification` and any future dispute-lifecycle ones
- **Stores** — store applications, suspension events
- **Money movement** — failed transfers, manual reconciliation flags (Layer 5 already dispatches some of these via `ReconcileFailedMoneyMovements` console command — surface them here)
- **System**

The notification `category` column added in Layer 7 is the source of truth. The seller inbox queries `category in (orders, shipping, payouts, system)` implicitly because all its categories happen to be those; admin inbox queries `category in (disputes, stores, money_movement, system_admin)`. Layer 8 adds those four category values and tags new admin-only notifications with them.

The bell in the admin top bar is a copy of the seller pattern — same component, polled at 30 s.

### Activity log (`/admin/activity`)

```
┌────────────────────────────────────────────────────────────────┐
│ Filters: Actor · Subject type · Date range                     │
│ [Export CSV — out of scope this layer]                         │
├────────────────────────────────────────────────────────────────┤
│ When · Actor · Action · Subject · Justification (truncated)    │
│ 1m ago · alice@… · adjudicated · Dispute #ab12 · "Buyer …"     │
│ 5m ago · bob@…   · suspended  · Store "X"     · "Multiple …"   │
│ …                                                              │
└────────────────────────────────────────────────────────────────┘
```

- Read-only. The full justification opens in a side panel on row click.
- Backed by `activity_log` table from `spatie/laravel-activitylog`. The package is already in `composer.json` (per `api/CLAUDE.md`); confirm during planning that it's wired into a service provider — if not, that's part of Layer 8 setup.

**Backend**

- `GET /v1/admin/activity` — paginated, filterable by `causer_id`, `subject_type`, date range
- Every adjudication / refund / suspend / verify action calls `activity()->causedBy($admin)->performedOn($subject)->withProperties(...)->log('action_name')`.

### Dashboard upgrades

Add to the existing tile grid:

- **Open disputes** — count of `Dispute.status in ('needs_response', 'warning_needs_response')`
- **Overdue disputes** — count where `evidence_due_by < now() + 24h` and we haven't submitted

Replace the static 4-tile layout with a 6-tile grid; the existing `Total stores · Verified stores · Active items · Total sales · Active orders` tiles stay.

Add an **"Action needed"** widget below the tiles, listing the 5 oldest open disputes with click-through to detail. Same shape as the seller `OrdersActionList`.

## Cross-cutting

### Money movement

This layer is the first to call **`Stripe\Transfer::createReversal`** and the first to call `Stripe\Refund::create` outside the seller-cancel flow. Both go through a new method on `StripeService`:

- `reverseTransfer(string $transferId, int $amountCents, array $metadata): \Stripe\Reversal` — wraps the Stripe SDK call; throws on failure (don't swallow; the controller catches and returns a 422 with a structured error)
- `refundPaymentIntent(string $paymentIntentId, int $amountCents, array $metadata): \Stripe\Refund` — already exists for cancellation; reuse

Both calls require an `Idempotency-Key` (Stripe-side, separate from our middleware's idempotency key). Use a deterministic key derived from `("admin-reversal", $orderId, $disputeId)` so a retry of the same adjudication does not double-reverse.

The audit log entry for any money-movement action stores the Stripe object id (`reversal.id`, `refund.id`) so we can join activity → Stripe operations during reconciliation later.

### Activity log

`spatie/laravel-activitylog` only. Every state-changing admin endpoint:

1. Validates the request including the required `justification`
2. Opens a DB transaction
3. Performs the local writes (status changes, refund records, etc.)
4. Calls the Stripe operation
5. Writes the activity log entry inside the transaction
6. Commits

Stripe call inside a DB transaction is intentional: if the Stripe call fails we want to roll back our local writes too. The exception is when Stripe *succeeded* but our DB write failed — that should be exceedingly rare given Stripe is the slow leg, but we monitor for it via `ReconcileFailedMoneyMovements` (existing console command from Layer 5). This layer extends the reconciler to also detect missing reversal records.

### Notifications added

- `AdminDisputeAdjudicatedNotification` — fans out to other admins so the team sees a teammate's decision (causer is excluded)
- `BuyerRefundIssuedNotification` — to the buyer, when admin issues a manual refund
- `SellerOrderForceCancelledNotification` — to the seller, when admin force-cancels their order
- `SellerStoreSuspendedNotification` — to the store owner

Categories: `disputes`, `stores`, `money_movement`. Add these to the `category` enum / migration backfill.

### Auth & guards

- New middleware **not** required: `EnsureAdmin` already exists from the foundation slice and gates every `/v1/admin/*` route.
- The web admin layout already wraps pages in `AdminQueryProvider`. Add a client-side role check that pushes the user back to `/login` if they hit an admin route without the role. The API still enforces; the client check is purely for UX (avoid rendering a broken-looking page).

### Testing

Backend (Pest feature tests in `tests/Feature/Admin/`):

- `AdjudicateDisputeTest` — three outcome paths, per-Order reversal toggling, idempotency, justification validation, Stripe error handling
- `SubmitEvidenceTest` — happy path + Stripe error
- `SuspendStoreTest` — cancels open orders, refunds PIs, removes verification, dispatches notifications
- `AdminOrderRefundTest` — partial + full
- `AdminOrderForceCancelTest`
- `AdminActivityIndexTest` — filter + paginate
- `DisputeWebhookSyncTest` — `charge.dispute.updated` and `.closed` keep our row in sync

Web (Vitest):

- `dispute-queue.test.tsx` — list + filters
- `dispute-detail.test.tsx` — outcome selector, per-Order toggles, justification gating, confirmation modal, mutation
- `admin-order-detail.test.tsx` — refund / force-cancel / reverse-transfer modals
- `admin-store-detail.test.tsx` — verify / suspend / unsuspend
- `admin-inbox.test.tsx`
- `admin-activity.test.tsx`

E2E (Playwright):

- One spec covering the dispute-accept happy path: arrive at the queue → open a dispute → choose Accept → toggle one of two Orders to reverse → fill justification → submit → verify the dispute moves to "Lost" tab and the per-Order reversal records are written. The mock server gains dispute fixtures and a `/v1/admin/disputes/.../adjudicate` handler.

### File / module layout

Backend (`api/`):

```
app/Modules/Admin/
  Controllers/
    AdminDashboardController.php       (extend — add disputes counts)
    AdminStoreController.php           (extend — add show, verify, suspend, unsuspend)
    AdminOrderController.php           (extend — add show, refund, force-cancel, reverse-transfer)
    AdminDisputeController.php         (new — index, show, adjudicate, submit-evidence, retry-reversal)
    AdminActivityController.php        (new — index)
  Services/
    AdminDashboardMetrics.php          (extend — disputes counts)
    DisputeAdjudicator.php             (new — per-outcome service that owns the Stripe calls)
    StoreSuspender.php                 (new — orchestrates the suspend cascade)
  Resources/
    AdminDisputeSummary.php            (new)
    AdminDisputeDetail.php             (new)
    AdminActivityEntry.php             (new)
    AdminStoreDetail.php               (new — extends AdminStoreSummary)
  Jobs/
    SuspendStore.php                   (new)
  Tests/                               (mirror of tests/Feature/Admin)

app/Modules/Checkout/Controllers/CheckoutController.php
  (extend stripe webhook handler — handle .updated and .closed dispute events)

app/Models/Dispute.php                 (new)
app/Modules/Orders/Controllers/        (no changes; admin uses its own controllers)

database/migrations/
  ...add_category_admin_values.php     (already in Layer 7? confirm during planning)
  ...create_disputes_table.php
  ...add_admin_columns_to_stores.php   (is_suspended, suspended_at, suspension_reason)
  ...add_reversal_columns_to_orders.php (transfer_reversed_at, stripe_transfer_reversal_id)
```

Web (`web/`):

```
src/app/(admin)/admin/
  page.tsx                             (extend — add disputes tiles + ActionNeeded widget)
  disputes/page.tsx                    (new — queue)
  disputes/disputes-client.tsx         (new)
  disputes/[id]/page.tsx               (new — detail)
  disputes/[id]/dispute-detail-client.tsx (new)
  inbox/page.tsx                       (new — admin inbox)
  inbox/inbox-client.tsx               (new)
  stores/[id]/page.tsx                 (new — store detail)
  stores/[id]/store-detail-client.tsx  (new)
  orders/[id]/page.tsx                 (new — admin order detail; reuses seller component)
  orders/[id]/order-detail-client.tsx  (new — admin variant)
  activity/page.tsx                    (new)
  activity/activity-client.tsx         (new)

src/components/admin/
  admin-top-bar.tsx                    (new — bell + avatar)
  dispute-status-pill.tsx              (new)
  confirm-with-justification-dialog.tsx (new — shared by every adjudication action)
  open-disputes-widget.tsx             (new)
  activity-row.tsx                     (new)

src/lib/queries/
  use-admin.ts                         (extend — disputes, activity, store-detail)
```

## Open items

These are flagged for the implementation plan, not to block this spec:

- **`spatie/laravel-activitylog` provider wiring** — the package is in `composer.json`, but no code uses it yet (verified 2026-05-04). Confirm `ActivitylogServiceProvider` is registered and the `activity_log` table migration has run; if not, that's an early plan step.
- **Stripe dispute webhook coverage** — Layer 5 only handled `charge.dispute.created`. We assume `.updated` and `.closed` are not yet handled; verify during planning. If `.funds_withdrawn`/`.funds_reinstated` are also needed for accurate state, add them to the same handler.
- **Existing `OrderStatus::Disputed` semantics** — the enum value exists but isn't currently set anywhere in code. Adjudication's "Submit evidence" outcome is the first writer. Confirm there are no existing constraints that prevent transitioning into / out of `Disputed`.
- **Reconciliation extension** — the existing `orders:reconcile-money` console command (Layer 5) currently looks for missing Transfers. Extend it to also look for missing Reversals (Order with `stripe_transfer_reversal_id` set but `transfer_reversed_at` null, or vice versa).
- **Admin "Users" surface** — deliberately removed from the IA. If it comes back as urgent, scope it as a Layer 8.5 patch rather than expanding this spec.
- **CSV exports** — the activity log surface alludes to it but explicitly defers; design assumes it's added by a later financial-reporting layer.
- **Tax / multi-currency on refunds** — refund amounts are pure cents in USD. Tax and FX are out of scope project-wide for now.
- **Stripe "dashboard link" deep linking** — every dispute / order / store detail page should ideally link to its Stripe counterpart (`https://dashboard.stripe.com/...`). Cheap to add; verify the URL formats during planning.
