# Layer 10 Plan 3: Proactive Refunds + Admin Escalation

> **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:** Close out Layer 10. Plan 1 shipped buyer-initiated returns; Plan 2 inserted the EasyPost return-shipment leg + refund-on-receipt. Three big surfaces remain. (1) **Seller-initiated proactive refund** — a "Refund this order" button on `/seller/orders/{id}` with two modes: `keep-it` (no physical return; collapses straight to `Refunded → Closed`, mirroring Plan 1's old `approve` cascade but with no buyer-request step) and `ship-back` (issues an EasyPost return label and follows Plan 2's `AwaitingShipment → InTransit → Received → Refunded → Closed` path). Both modes accept a partial `amount_cents` override, an `items[]` selection, and a `refund_original_shipping` toggle. (2) **`ReturnEscalation` model + admin queue** — a buyer can escalate a stuck or contentious return to admin from any non-terminal post-approval state (`Approved | AwaitingShipment | InTransit | Received`); admin sees the queue at `/admin/returns`, reads the argument, and resolves with one of three actions (`force_refund`, `force_close_no_refund`, `no_action`). Resolution writes a `spatie/laravel-activitylog` row. (3) **`seller_close_without_refund`** — the spec's "seller cancellation post-approval" transition, allowed from `Approved | AwaitingShipment | InTransit` (NOT `Received`+; the package already arrived and the seller owes the refund).

**Architecture:** (1) Schema — one new `return_escalations` table (UUID PK, `return_id` UNIQUE, `escalated_by_user_id`, `reason`, `state` open/resolved, `resolved_by_admin_id`, `resolution`, `action`, `resolution_notes`, `resolved_at`, timestamps); the spec called this out at line 128–138. **No `ReturnState` enum changes** — verified the existing enum at `api/app/Support/Enums/ReturnState.php` already enumerates `Escalated` (Plan 1 added it eagerly), and `Closed` covers both proactive `keep-it` cascade and `seller_close_without_refund`. The escalation lives in its own table — the return state can be `Escalated` *or* it can stay in its current state with a parallel `ReturnEscalation` row open; we go with the latter (escalations are an annotation on the return, not a state of it) so the admin's `force_refund` resolution can dispatch into `markReceived`-equivalent logic without first un-escalating. (2) Backend services — new `ProactiveReturnService` (orchestrates the keep-it vs ship-back fork, builds the `OrderReturn` row, dispatches into `ReturnTransitioner` and `ReturnRefundIssuer` / `ReturnLabelService`); new `ReturnEscalationService` (open + admin-resolve, including the activity-log row + cross-fanout to other admins); two new methods on `ReturnTransitioner`: `sellerCloseWithoutRefund(OrderReturn, User $seller, string $reason)` and `applyAdminResolution(OrderReturn, ReturnEscalation, AdminResolutionInput, User $admin)`. (3) Endpoints — `POST /v1/seller/orders/{order}/returns/proactive`, `POST /v1/returns/{return}/escalate`, `POST /v1/returns/{return}/seller-close-without-refund`, `GET /v1/admin/returns`, `POST /v1/admin/returns/{return}/resolve`. (4) Notifications — `ProactiveRefundIssuedNotification` (to buyer), `ReturnEscalationOpenedNotification` (to admin fanout — pattern from Layer 8 `AdminDisputeAdjudicatedNotification`), `ReturnEscalationResolvedNotification` (to buyer + seller, with an admin-set message), `SellerClosedReturnNotification` (to buyer). (5) Frontend — `<ProactiveRefundModal>` on `/seller/orders/[id]`, "Escalate to admin" button on the buyer's purchase view (visible in the right states), new `/admin/returns/page.tsx` queue with filter chips, `<AdminResolveReturnDialog>`, "Close without refund" dialog on the seller panel, and a small `<EscalationBadge>` component the seller / admin / buyer all see when an escalation exists.

**Tech Stack:** Laravel 12, PHPUnit class-based feature tests in `tests/Feature/Returns/` (matches Plan 1 + Plan 2 style — verified `ReturnTransitionerTest.php` extends `Tests\TestCase`), Postgres 17, Stripe PHP SDK (existing), EasyPost via `App\Modules\Shipping\Services\EasyPostProvider` (existing), `spatie/laravel-activitylog` (existing — used by `DisputeAdjudicator`, `StoreSuspender`, `AdminOrderActions`, `AdminMessageController::bulkDelete`), OpenAPI → `openapi-typescript`, Next.js 15 App Router, TanStack Query v5, Tailwind, Vitest + React Testing Library.

**Spec:** `docs/superpowers/specs/2026-05-07-layer-10-returns-design.md`

**Prerequisites:** Plan 1 + Plan 2 fully shipped on both repos.

- API head: `2a9504f feat(returns): EasyPost return label + in-transit tracking + refund-on-receipt` followed by `690f461 fix(shipping): PDF labels + PurchaseFactory address shape matches production` (post-Plan-2 fix that stabilised the EasyPost mock surface and PurchaseFactory address shape — depend on it).
- Web head: `c759f79 feat(returns): label download + mark-received + retry-label UI`. Cosmetic follow-up `bb7b9e3 fix(messaging): style system messages as italic muted text` is also in main.
- `ReturnState` already includes `Escalated` (Plan 1's enum was forward-looking). `OrderReturn` model at `api/app/Models/OrderReturn.php` is fully wired (factory, casts, relations). `ReturnTransitioner::approve | reject | cancel | markReceived | retryLabel | handleCarrierEvent` exist. `ReturnRefundIssuer::issue` exists and computes `item_subtotal − restocking_fee` (we'll extend its responsibilities, not duplicate the logic). `ReturnLabelService::issue` exists. `MessagePoster::postSystem(Order, body)` exists. `NotificationCategory::Returns` exists. `App\Modules\Admin\routes.php` already groups admin routes under `auth:sanctum + admin` middleware with the `/admin` prefix. The `activity('admin')->causedBy($admin)->performedOn(...)->withProperties([...])->log('...')` pattern is canonical (used by `DisputeAdjudicator`, `AdminOrderActions`, `StoreSuspender`).
- **Test counts at start: API 595 passing, web 205 passing** (the post-Plan-2 baseline; verified via the recent commit messages and the existing files in `tests/Feature/Returns/`).

**Successor plan:** None — Plan 3 closes Layer 10. The spec's outstanding open items (negative-payout collection / seller-paid label cost recovery, EasyPost label expiration handling, `pre_transit` carrier event surfacing, restocking-fee disclosure on the buyer modal, weight-fallback accuracy, internationalisation) are deferred outside the layer and tracked under `## Open items` at the end of this plan.

---

## Phase A — Schema

### Task 1: `return_escalations` table

**Files:**
- Create: `api/database/migrations/2026_05_09_100001_create_return_escalations_table.php`
- Create: `api/app/Models/ReturnEscalation.php`
- Create: `api/database/factories/ReturnEscalationFactory.php`
- Update: `api/app/Models/OrderReturn.php` (add `escalation()` HasOne relation + `openEscalation()` helper)
- Create: `api/app/Support/Enums/ReturnEscalationState.php` (`open | resolved`)
- Create: `api/app/Support/Enums/ReturnAdminAction.php` (`force_refund | force_close_no_refund | no_action`)
- Test: `api/tests/Feature/Returns/ReturnEscalationSchemaTest.php`

> **Plan note (no `ReturnState` enum changes):** Verified `api/app/Support/Enums/ReturnState.php` lines 7–18: cases include `Requested | Approved | Rejected | AwaitingShipment | InTransit | Received | Refunded | Closed | Cancelled | Escalated`. Plan 1 added `Escalated` speculatively but Plan 2 never used it. **Plan 3 also does not use `Escalated`.** An escalation is a parallel record on the `return_escalations` table, not a state of the return — the return's `state` continues to advance (e.g., a return can be `Approved` AND have an open escalation; the seller can still issue a label, the buyer can still receive it; the admin's `resolve` is the cleanup action). Rationale: this matches `Dispute` (Layer 8) — disputes are an annotation on a `Purchase`, not a `PurchaseStatus`. Keep `Escalated` in the enum for future use; ignore it in this plan. The transitioner methods that gate state advance simply check whether an open escalation exists in the few cases where it should block (none in v1; flagged as an open item).

> **Plan note (one escalation per return forever):** The spec line 130 says `return_id (uuid, FK, UNIQUE)` — at most one escalation per return *ever*. We honour that with a unique index. If the admin resolves and a new dispute later arises, the buyer's recourse is a Stripe chargeback (out of scope for this layer). No "re-escalate" path. Validation in `ReturnEscalationService::open` returns 422 if a row already exists.

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Returns;

use App\Models\OrderReturn;
use App\Models\ReturnEscalation;
use App\Models\User;
use App\Support\Enums\ReturnAdminAction;
use App\Support\Enums\ReturnEscalationState;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;

class ReturnEscalationSchemaTest extends TestCase
{
    use RefreshDatabase;

    public function test_return_escalations_table_has_expected_columns(): void
    {
        $this->assertTrue(Schema::hasTable('return_escalations'));
        foreach ([
            'id', 'return_id', 'escalated_by_user_id', 'reason', 'state',
            'resolved_by_admin_id', 'resolution', 'action', 'resolved_at',
            'created_at', 'updated_at',
        ] as $col) {
            $this->assertTrue(
                Schema::hasColumn('return_escalations', $col),
                "return_escalations.$col missing",
            );
        }
    }

    public function test_return_id_is_unique(): void
    {
        $return = OrderReturn::factory()->create();
        $user = User::factory()->create();

        ReturnEscalation::factory()->create([
            'return_id' => $return->id,
            'escalated_by_user_id' => $user->id,
        ]);

        $this->expectException(QueryException::class);
        ReturnEscalation::factory()->create([
            'return_id' => $return->id,
            'escalated_by_user_id' => $user->id,
        ]);
    }

    public function test_return_escalation_state_enum_cases(): void
    {
        $this->assertEqualsCanonicalizing(
            ['open', 'resolved'],
            array_map(fn ($c) => $c->value, ReturnEscalationState::cases()),
        );
    }

    public function test_return_admin_action_enum_cases(): void
    {
        $this->assertEqualsCanonicalizing(
            ['force_refund', 'force_close_no_refund', 'no_action'],
            array_map(fn ($c) => $c->value, ReturnAdminAction::cases()),
        );
    }

    public function test_open_escalation_helper_returns_open_row_only(): void
    {
        $return = OrderReturn::factory()->create();
        $user = User::factory()->create();
        $esc = ReturnEscalation::factory()->create([
            'return_id' => $return->id,
            'escalated_by_user_id' => $user->id,
            'state' => ReturnEscalationState::Open,
        ]);

        $this->assertSame($esc->id, $return->fresh()->openEscalation()?->id);

        $esc->update(['state' => ReturnEscalationState::Resolved, 'resolved_at' => now()]);
        $this->assertNull($return->fresh()->openEscalation());
    }
}
```

- [ ] **Step 2: Run, confirm failure** (table missing, enums missing, model missing)

- [ ] **Step 3: Author the migration + model + enums**

`2026_05_09_100001_create_return_escalations_table.php`:

```php
public function up(): void
{
    Schema::create('return_escalations', function (Blueprint $t) {
        $t->uuid('id')->primary();
        $t->foreignUuid('return_id')->unique()->constrained('returns')->cascadeOnDelete();
        $t->foreignUuid('escalated_by_user_id')->constrained('users');
        $t->text('reason');
        $t->string('state', 16)->default('open');     // ReturnEscalationState
        $t->foreignUuid('resolved_by_admin_id')->nullable()->constrained('users');
        $t->text('resolution')->nullable();
        $t->string('action', 32)->nullable();         // ReturnAdminAction (nullable until resolve)
        $t->timestamp('resolved_at')->nullable();
        $t->timestamps();

        $t->index(['state', 'created_at']);  // for the admin queue ordering
    });
}

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

`ReturnEscalationState.php`:

```php
<?php

declare(strict_types=1);

namespace App\Support\Enums;

enum ReturnEscalationState: string
{
    case Open = 'open';
    case Resolved = 'resolved';
}
```

`ReturnAdminAction.php`:

```php
<?php

declare(strict_types=1);

namespace App\Support\Enums;

enum ReturnAdminAction: string
{
    case ForceRefund = 'force_refund';
    case ForceCloseNoRefund = 'force_close_no_refund';
    case NoAction = 'no_action';
}
```

`ReturnEscalation.php` model — `HasUuid` trait, `BelongsTo` to `OrderReturn`, `escalatedBy` → `User`, `resolvedByAdmin` → `User`, casts `state => ReturnEscalationState::class`, `action => ReturnAdminAction::class`, `resolved_at => 'datetime'`.

`OrderReturn::escalation()` `HasOne` + `openEscalation(): ?ReturnEscalation` helper that filters to `state = Open`.

- [ ] **Step 4: Run; iterate to 5/5 PASS**

---

## Phase B — Services

### Task 2: `ProactiveReturnService` — orchestrate seller-initiated refunds

**Files:**
- Create: `api/app/Modules/Returns/Services/ProactiveReturnService.php`
- Create: `api/app/Modules/Returns/Data/ProactiveRefundInput.php` (DTO; mirrors Layer 8 `AdjudicateInput` pattern)
- Update: `api/app/Modules/Returns/Services/ReturnRefundIssuer.php` (extend `issue` to accept an optional override amount + `refund_original_shipping` flag)
- Test: `api/tests/Feature/Returns/ProactiveReturnServiceTest.php`

The service handles the orchestration — building the `OrderReturn` row with `initiated_by = seller`, the right `return_shipping_payer`, the chosen items, and dispatching into the keep-it cascade or the ship-back leg. The service does NOT call `ReturnTransitioner::approve` (that method is buyer-flow specific — guards `state === Requested`); instead it constructs the return directly in the appropriate state.

```php
final class ProactiveReturnService
{
    public function __construct(
        private readonly ReturnRefundIssuer $refunds,
        private readonly ReturnLabelService $labels,
        private readonly MessagePoster $messages,
    ) {}

    /** @throws ReturnLabelException on ship-back when EasyPost rejects. */
    public function create(Order $order, User $seller, ProactiveRefundInput $input): OrderReturn
    {
        $this->guard($order, $seller, $input);

        return DB::transaction(function () use ($order, $seller, $input) {
            $return = OrderReturn::create([
                'order_id' => $order->id,
                'initiated_by' => ReturnInitiatedBy::Seller,
                'initiator_user_id' => $seller->id,
                'state' => $input->mode === 'keep-it'
                    ? ReturnState::Approved
                    : ReturnState::Approved,  // both start at Approved; ship-back advances to AwaitingShipment after label
                'reason' => $input->reason ?? ReturnReason::Other,
                'reason_text' => $input->reasonText,
                'return_shipping_payer' => $input->mode === 'keep-it'
                    ? ReturnShippingPayer::None
                    : ReturnShippingPayer::Seller,
                'restocking_fee_cents' => 0,  // proactive refunds never charge a restocking fee
                'approved_at' => now(),
            ]);

            foreach ($input->orderItemIds as $orderItemId) {
                ReturnItem::create([
                    'return_id' => $return->id,
                    'order_item_id' => $orderItemId,
                    'quantity' => 1,
                ]);
            }

            $this->messages->postSystem(
                $order,
                'Seller initiated a proactive refund'
                    .($input->mode === 'keep-it' ? ' (keep the item).' : ' with a return label.'),
            );

            if ($input->mode === 'keep-it') {
                return $this->finishKeepIt($return, $input);
            }

            return $this->finishShipBack($return, $input);
        });
    }

    private function finishKeepIt(OrderReturn $return, ProactiveRefundInput $input): OrderReturn
    {
        $this->refunds->issue(
            $return,
            overrideAmountCents: $input->amountCents,
            includeOriginalShipping: $input->refundOriginalShipping,
        );
        $return->refresh();

        $return->update(['state' => ReturnState::Refunded]);

        $amount = number_format(((int) $return->refund_amount_cents) / 100, 2);
        $this->messages->postSystem(
            $return->order,
            "Refunded \${$amount} to the original payment method.",
        );

        $buyer = $return->order->purchase?->buyer;
        if ($buyer) {
            Notification::send($buyer, new ProactiveRefundIssuedNotification($return->fresh()));
        }

        $return->update(['state' => ReturnState::Closed, 'closed_at' => now()]);

        return $return->fresh();
    }

    private function finishShipBack(OrderReturn $return, ProactiveRefundInput $input): OrderReturn
    {
        // Persist the override so markReceived (later) honours it.
        if ($input->amountCents !== null) {
            $return->update(['refund_amount_cents' => $input->amountCents]);  // pending; refunded_at stays null
        }
        if ($input->refundOriginalShipping) {
            // We need a flag to remember the seller's choice. Add a `refund_original_shipping` boolean
            // column to `returns` — see Task 3.
            $return->update(['refund_original_shipping' => true]);
        }

        try {
            $this->labels->issue($return);
        } catch (ReturnLabelException $e) {
            // Label failure leaves state at Approved with easypost_label_error set,
            // matching Plan 2's seller-flow behaviour. Seller can hit /retry-label.
            return $return->fresh();
        }

        $return->refresh();
        $return->update(['state' => ReturnState::AwaitingShipment]);

        $this->messages->postSystem(
            $return->order,
            'Return label issued. Tracking: '.$return->carrier.' '.$return->tracking_number.'.',
        );

        $buyer = $return->order->purchase?->buyer;
        if ($buyer) {
            Notification::send($buyer, new ReturnLabelIssuedNotification($return->fresh()));
        }

        return $return->fresh();
    }

    private function guard(Order $order, User $seller, ProactiveRefundInput $input): void
    {
        if ($order->store?->owner_user_id !== $seller->id) {
            abort(403, 'Only the seller of this order can issue a proactive refund.');
        }
        if ($order->returns()->exists()) {
            abort(422, 'A return already exists on this order.');
        }
        if (! in_array($input->mode, ['keep-it', 'ship-back'], true)) {
            abort(422, 'mode must be "keep-it" or "ship-back".');
        }
        if (empty($input->orderItemIds)) {
            abort(422, 'At least one item must be selected.');
        }
        $orderItemCount = $order->orderItems()->whereIn('id', $input->orderItemIds)->count();
        if ($orderItemCount !== count(array_unique($input->orderItemIds))) {
            abort(422, 'One or more items do not belong to this order.');
        }
        if ($input->amountCents !== null && $input->amountCents < 0) {
            abort(422, 'amount_cents cannot be negative.');
        }
    }
}
```

`ProactiveRefundInput` (DTO):

```php
final class ProactiveRefundInput
{
    public function __construct(
        public readonly string $mode,                  // 'keep-it' | 'ship-back'
        public readonly array $orderItemIds,           // string[]
        public readonly ?int $amountCents,             // null = compute from items
        public readonly bool $refundOriginalShipping,
        public readonly ?ReturnReason $reason,
        public readonly ?string $reasonText,
    ) {}
}
```

> **Plan note (idempotency):** A seller hitting "Refund this order" twice in quick succession (network blip → second click) must NOT create two refunds. Two layers of defence:
> 1. **Business-level guard** — `ProactiveReturnService::guard` already blocks if `$order->returns()->exists()` (Plan 1's `request` does the same). Any return — open *or* closed — blocks a second proactive submission. This is the sufficient condition: even if the second request races past the guard, the partial unique index `returns_one_open_per_order` blocks at DB level until the first one closes; once the first is closed, the `$order->returns()->exists()` guard takes over.
> 2. **Stripe-level guard** — the Stripe refund call inside `ReturnRefundIssuer::issue` already uses `idempotency_key = "return-refund-{$return->id}"`, so even a duplicate `issue` invocation against the same return is safe at Stripe.
>
> The `idempotency-key` HTTP header is **NOT** added in this plan. The natural per-order uniqueness on `returns` is sufficient; introducing a second idempotency mechanism would duplicate state without solving an additional case.

> **Plan note (auth semantics — `initiator` vs `created_by`):** Plan 1's `OrderReturn::initiator` returns the user who filed the return. For buyer-initiated returns that's the buyer (a `User` who is also the refund recipient). For seller-initiated proactive returns, the seller files but the buyer receives the money. Two design options:
> - **(A)** Keep `initiator_user_id = seller.id` for proactive returns, and rely on `initiated_by = ReturnInitiatedBy::Seller` to disambiguate. Code that wants "who gets the refund?" walks `$return->order->purchase->buyer`. Notifications already do this (Plan 1's `ReturnApprovedNotification` is fired against `$buyer = $return->order->purchase->buyer` directly, not against `$return->initiator`; same elsewhere). **No code change needed.**
> - **(B)** Add a separate `created_by_user_id` column distinct from `initiator_user_id` (which would always be the buyer = refund recipient).
>
> Going with **(A)**. Reason: Plan 1 + 2's notification call sites all resolve the buyer via `$return->order->purchase?->buyer`, never via `$return->initiator`. Adding `created_by_user_id` would create two fields with overlapping meaning and force every existing call site to think about which one is correct. The semantic is: `initiator_user_id` = "who pressed the button that created this row", `initiated_by` = "buyer-flow vs seller-flow", and the actual buyer/seller for permission and notification routing comes from the `Order` walks. **Confirm with the user before implementation** — the alternative is cheap to add later if the reading turns out to be confusing in practice.

> **Plan note (`refund_original_shipping` and the Stripe/Connect transfer-reversal scope):** When `refund_original_shipping` is true, the refund includes `Order::shipping_cost`. **The seller's payout already includes a portion of that shipping** — Layer 5 wires `Order::shipping_cost` into the seller's `seller_payout` calculation (verify by checking `Order::shipping_cost`'s flow into the `transfer_amount` in `StripeService::createTransfer`-ish code). Therefore: refunding shipping must include a corresponding transfer reversal of the same amount, otherwise the seller is over-paid by the shipping cost. `ReturnRefundIssuer::issue` currently does **not** call `StripeService::reverseTransfer` — Plan 1 + 2 always refund only `item_subtotal − restocking_fee`, which the seller never received as part of their `seller_payout` (the platform "kept" item revenue minus seller payout in those refunds — verify against `Order::seller_payout` arithmetic). **For Plan 3, when `refund_original_shipping` is true, ALSO call `StripeService::reverseTransfer` for the shipping portion.** Mirror Layer 8's `DisputeAdjudicator::accept` — same idempotency key shape (`"return-shipping-reversal:{$return->id}"`), same try/catch wrapping (Stripe failure logged but doesn't crash; seller is left over-paid by the shipping amount and admin retries via Layer 8's reverse-transfer endpoint as needed). Implementer must verify `Order::seller_payout` includes shipping in current arithmetic before wiring this — flag if it doesn't, in which case no reversal is needed.

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

```php
public function test_create_keep_it_cascades_to_closed_with_full_subtotal_refund(): void
public function test_create_keep_it_with_amount_cents_override_uses_override(): void
public function test_create_keep_it_refunds_original_shipping_when_flag_set(): void
public function test_create_keep_it_fires_proactive_refund_issued_notification_to_buyer(): void
public function test_create_keep_it_does_not_fire_return_requested_notification(): void  // there was no buyer request
public function test_create_keep_it_posts_two_system_messages(): void  // initiated + refunded
public function test_create_ship_back_persists_label_artifacts_and_state_awaiting_shipment(): void
public function test_create_ship_back_with_label_failure_leaves_state_approved_and_records_error(): void
public function test_create_ship_back_with_amount_cents_override_persists_pending_amount(): void
public function test_create_by_non_seller_returns_403(): void
public function test_create_when_order_already_has_a_return_returns_422(): void
public function test_create_with_invalid_item_ids_returns_422(): void
public function test_create_with_negative_amount_returns_422(): void
public function test_keep_it_idempotency_second_call_returns_422(): void  // verifies the openReturn guard
```

~14 tests. Use `Notification::fake()`, mock `EasyPostProvider` per Plan 2's pattern, mock `StripeService::refundForOrder` returning a stub `Stripe\Refund` (mirror Plan 2's existing test helpers).

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

- [ ] **Step 3: Implement** the service + DTO + extended `ReturnRefundIssuer` (Task 3 below).

- [ ] **Step 4: Run; iterate to 14/14 PASS**

### Task 3: Extend `ReturnRefundIssuer` for amount override + shipping inclusion

**Files:**
- Update: `api/app/Modules/Returns/Services/ReturnRefundIssuer.php`
- Create: `api/database/migrations/2026_05_09_100002_add_refund_original_shipping_to_returns.php`
- Update: `api/app/Models/OrderReturn.php` (`refund_original_shipping` → `$fillable` + bool cast)
- Update: `api/tests/Feature/Returns/ReturnRefundIssuerTest.php`

The current signature is `issue(OrderReturn $return): void`. Two new optional kwargs:

```php
public function issue(
    OrderReturn $return,
    ?int $overrideAmountCents = null,
    bool $includeOriginalShipping = false,
): void
```

Refund amount logic:

1. If `overrideAmountCents !== null`, use it as-is.
2. Else compute `item_subtotal − restocking_fee`.
3. If `includeOriginalShipping` is true (or `$return->refund_original_shipping === true`), add `$return->order->shipping_cost`.
4. If shipping was added: ALSO call `StripeService::reverseTransfer` for the shipping amount with idempotency key `"return-shipping-reversal:{$return->id}"`. Catch Stripe failure, log, do not propagate (matches `DisputeAdjudicator::accept`).

Migration `add_refund_original_shipping_to_returns`:

```php
public function up(): void
{
    Schema::table('returns', function (Blueprint $t) {
        $t->boolean('refund_original_shipping')->default(false)->after('restocking_fee_cents');
    });
}
```

> **Plan note (`mark-received` reading the override):** When a ship-back proactive return reaches `markReceived` (via webhook or seller manual), the refund issuer must honour the same `amountCents` override and `refund_original_shipping` toggle the seller picked at creation time. The override `amount_cents` is persisted on the `returns` row (`refund_amount_cents` set early as a "pending" value); the boolean is the new column added above. `ReturnTransitioner::markReceived` already calls `$this->refunds->issue($return)` — extend that call to read the persisted values and forward them: `$this->refunds->issue($return, overrideAmountCents: $return->refund_amount_cents, includeOriginalShipping: $return->refund_original_shipping)`. **Caveat:** Plan 2's `ReturnRefundIssuer` writes `refund_amount_cents` as the *final* refund amount on `issue`. Re-issuing on `markReceived` overwrites it with the same value — fine. But because `$return->refund_amount_cents` is non-null when the issuer is called, we must distinguish "non-null because seller pre-set an override" vs "non-null because issuer already ran". Resolution: clear `refund_amount_cents` to NULL on the row, store the override in a new column `refund_amount_override_cents` (also added in this migration). Concretely:

Actually — re-thinking that carefully. Add a **separate** column rather than overloading the final field:

```php
public function up(): void
{
    Schema::table('returns', function (Blueprint $t) {
        $t->boolean('refund_original_shipping')->default(false)->after('restocking_fee_cents');
        $t->unsignedInteger('refund_amount_override_cents')->nullable()->after('refund_original_shipping');
    });
}
```

Now `refund_amount_cents` retains its Plan-1-2 meaning (= the actually-refunded amount; NULL until issuer runs), and `refund_amount_override_cents` is the seller's pre-set override (NULL = compute normally; non-NULL = use this).

`ReturnRefundIssuer::issue` resolves the amount:

```php
private function computeRefundAmount(OrderReturn $return, ?int $overrideAmountCents, bool $includeOriginalShipping): int
{
    if ($overrideAmountCents !== null) {
        return $overrideAmountCents;
    }

    $return->loadMissing('items.orderItem', 'order');
    $itemSubtotal = $return->items->sum(fn ($ri) => (int) $ri->orderItem->price_snapshot * (int) $ri->quantity);
    $amount = max(0, $itemSubtotal - (int) $return->restocking_fee_cents);

    if ($includeOriginalShipping) {
        $amount += (int) ($return->order?->shipping_cost ?? 0);
    }

    return $amount;
}
```

`ReturnTransitioner::markReceived` updated:

```php
$this->refunds->issue(
    $return,
    overrideAmountCents: $return->refund_amount_override_cents,
    includeOriginalShipping: (bool) $return->refund_original_shipping,
);
```

- [ ] **Step 1: Write the failing tests** — extend `ReturnRefundIssuerTest.php`:

```php
public function test_issue_uses_override_amount_when_provided(): void
public function test_issue_includes_original_shipping_when_flag_set(): void
public function test_issue_calls_reverse_transfer_when_shipping_refunded(): void
public function test_issue_swallows_reverse_transfer_failure_and_logs(): void
public function test_issue_skips_reverse_transfer_when_shipping_not_refunded(): void
public function test_issue_with_zero_override_persists_zero(): void  // seller wants $0 refund (edge case — UI should block but service shouldn't crash)
```

Plus extend `ReturnTransitioner::markReceived` test in `ReturnTransitionerTest.php`:

```php
public function test_mark_received_forwards_override_amount_and_shipping_flag_to_issuer(): void
```

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

- [ ] **Step 3: Implement** migration + extended `issue` signature + updated `markReceived` call.

- [ ] **Step 4: Run; iterate to 7/7 new tests PASS** (existing `ReturnRefundIssuerTest` cases must still pass — the new args are optional with backwards-compatible defaults).

### Task 4: `ReturnTransitioner::sellerCloseWithoutRefund`

**Files:**
- Update: `api/app/Modules/Returns/Services/ReturnTransitioner.php`
- Update: `api/tests/Feature/Returns/ReturnTransitionerTest.php`

Spec: "Seller cancellation post-approval... voluntarily mark `closed` without refund." Allowed states: `Approved | AwaitingShipment | InTransit`. **NOT** allowed from `Received | Refunded | Closed | Cancelled` — past `Received` the package has arrived; if the seller wants to close without refund at that point, the buyer would have a valid Stripe chargeback. Block server-side.

```php
public function sellerCloseWithoutRefund(OrderReturn $return, User $seller, string $reason): OrderReturn
{
    $return->loadMissing('order.purchase.buyer', 'order.store');

    if (! in_array($return->state, [ReturnState::Approved, ReturnState::AwaitingShipment, ReturnState::InTransit], true)) {
        abort(422, 'Return cannot be closed without refund in its current state.');
    }
    if ($return->order->store->owner_user_id !== $seller->id) {
        abort(403, 'Only the seller can close this return.');
    }
    if (trim($reason) === '') {
        abort(422, 'A reason is required when closing a return without a refund.');
    }

    return DB::transaction(function () use ($return, $reason) {
        $return->update([
            'state' => ReturnState::Closed,
            'closed_at' => now(),
            'reason_text' => $reason,
        ]);

        $this->messages->postSystem(
            $return->order,
            'Seller closed the return without refund: "'.$reason.'"',
        );

        $buyer = $return->order->purchase?->buyer;
        if ($buyer) {
            Notification::send($buyer, new SellerClosedReturnNotification($return));
        }

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

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

```php
public function test_seller_close_without_refund_from_approved_transitions_to_closed(): void
public function test_seller_close_without_refund_from_awaiting_shipment(): void
public function test_seller_close_without_refund_from_in_transit(): void
public function test_seller_close_without_refund_from_received_returns_422(): void
public function test_seller_close_without_refund_from_refunded_returns_422(): void
public function test_seller_close_without_refund_by_non_seller_returns_403(): void
public function test_seller_close_without_refund_with_empty_reason_returns_422(): void
public function test_seller_close_without_refund_posts_system_message_with_reason(): void
public function test_seller_close_without_refund_fires_seller_closed_return_notification(): void
public function test_seller_close_without_refund_does_not_call_refund_issuer(): void
```

10 tests.

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

- [ ] **Step 3: Implement.** Mock `ReturnRefundIssuer` with `shouldNotReceive('issue')` for the last test.

- [ ] **Step 4: Run; iterate to 10/10 PASS**

### Task 5: `ReturnEscalationService::open` (buyer-side)

**Files:**
- Create: `api/app/Modules/Returns/Services/ReturnEscalationService.php`
- Test: `api/tests/Feature/Returns/ReturnEscalationServiceTest.php`

Buyer can escalate from `Approved | AwaitingShipment | InTransit | Received`. Reason from the spec ASKs (line 199): originally the spec said "after a `rejected` decision, only within 7 days of rejection" — but the user's prompt explicitly broadens the allowed states to `Approved | AwaitingShipment | InTransit | Received`, with `Requested` and `Closed` excluded. **Following the user's prompt** (which is more lenient than the spec). The implementing agent should flag this divergence for the user during planning if the spec wins on a re-read; the deliberate broader scope handles the "stuck shipment", "seller never marked received", "received but not refunded" pain points the prompt called out.

```php
final class ReturnEscalationService
{
    public function __construct(
        private readonly MessagePoster $messages,
    ) {}

    public function open(OrderReturn $return, User $buyer, string $reason): ReturnEscalation
    {
        $return->loadMissing('order.purchase.buyer', 'order.store.owner', 'escalation');

        if ($return->order?->purchase?->buyer_id !== $buyer->id) {
            abort(403, 'Only the buyer can escalate this return.');
        }
        if (! in_array($return->state, [
            ReturnState::Approved,
            ReturnState::AwaitingShipment,
            ReturnState::InTransit,
            ReturnState::Received,
        ], true)) {
            abort(422, 'Return cannot be escalated in its current state.');
        }
        if ($return->escalation !== null) {
            abort(422, 'This return has already been escalated.');
        }
        if (trim($reason) === '' || mb_strlen($reason) < 10) {
            abort(422, 'Escalation reason must be at least 10 characters.');
        }

        return DB::transaction(function () use ($return, $buyer, $reason) {
            $escalation = ReturnEscalation::create([
                'return_id' => $return->id,
                'escalated_by_user_id' => $buyer->id,
                'reason' => $reason,
                'state' => ReturnEscalationState::Open,
            ]);

            $this->messages->postSystem(
                $return->order,
                'Buyer escalated this return to admin.',
            );

            // Notify the seller + every admin (matches Layer 8 dispute fanout).
            $sellerOwner = $return->order->store?->owner;
            if ($sellerOwner) {
                Notification::send($sellerOwner, new ReturnEscalationOpenedNotification($return, $escalation, recipient: 'seller'));
            }
            $admins = User::role('admin')->get();
            if ($admins->isNotEmpty()) {
                Notification::send($admins, new ReturnEscalationOpenedNotification($return, $escalation, recipient: 'admin'));
            }

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

> **Plan note (escalation authorization — buyer only):** The user's prompt asks "who can escalate? Buyer always; seller too?" The spec line 199 says "Buyer escalates after rejection" — buyer only. We go **buyer-only** in v1. A seller's avenue if they want admin help is the existing `/admin` inbox / message thread; if real demand emerges for seller-initiated escalation (e.g., "buyer is ghosting after I issued the label"), it's a small follow-up. **Confirm with the user** — if they want seller-too, add a `User $actor` arg + branch on `actor->id` matching buyer-or-seller.

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

```php
public function test_open_creates_escalation_in_open_state(): void
public function test_open_from_approved_succeeds(): void
public function test_open_from_awaiting_shipment_succeeds(): void
public function test_open_from_in_transit_succeeds(): void
public function test_open_from_received_succeeds(): void
public function test_open_from_requested_returns_422(): void
public function test_open_from_closed_returns_422(): void
public function test_open_when_already_escalated_returns_422(): void
public function test_open_by_non_buyer_returns_403(): void
public function test_open_by_seller_returns_403(): void
public function test_open_with_short_reason_returns_422(): void
public function test_open_fires_notification_to_seller_and_all_admins(): void
public function test_open_posts_system_message_in_thread(): void
```

13 tests.

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

- [ ] **Step 3: Implement.**

- [ ] **Step 4: Run; iterate to 13/13 PASS**

### Task 6: `ReturnEscalationService::resolve` + `ReturnTransitioner::applyAdminResolution`

**Files:**
- Update: `api/app/Modules/Returns/Services/ReturnEscalationService.php`
- Update: `api/app/Modules/Returns/Services/ReturnTransitioner.php`
- Create: `api/app/Modules/Returns/Data/AdminResolutionInput.php`
- Test: extend `api/tests/Feature/Returns/ReturnEscalationServiceTest.php`

```php
final class AdminResolutionInput
{
    public function __construct(
        public readonly ReturnAdminAction $action,
        public readonly string $resolutionNotes,   // shown to buyer + seller in notification
    ) {}
}
```

`ReturnEscalationService::resolve`:

```php
public function resolve(
    ReturnEscalation $escalation,
    AdminResolutionInput $input,
    User $admin,
): ReturnEscalation {
    if (! $admin->hasRole('admin')) {
        abort(403, 'Only admins can resolve escalations.');
    }
    if ($escalation->state !== ReturnEscalationState::Open) {
        abort(422, 'This escalation has already been resolved.');
    }
    if (trim($input->resolutionNotes) === '') {
        abort(422, 'Resolution notes are required.');
    }

    return DB::transaction(function () use ($escalation, $input, $admin) {
        $escalation->update([
            'state' => ReturnEscalationState::Resolved,
            'resolved_by_admin_id' => $admin->id,
            'resolution' => $input->resolutionNotes,
            'action' => $input->action,
            'resolved_at' => now(),
        ]);

        $return = $escalation->return->fresh(['order.purchase.buyer', 'order.store.owner']);

        // Side-effect: dispatch the chosen action.
        app(ReturnTransitioner::class)->applyAdminResolution($return, $escalation, $input, $admin);

        // Activity log row — pattern from DisputeAdjudicator.
        activity('admin')
            ->causedBy($admin)
            ->performedOn($return)
            ->withProperties([
                'return_id' => $return->id,
                'order_id' => $return->order_id,
                'escalation_id' => $escalation->id,
                'action' => $input->action->value,
                'resolution_notes' => $input->resolutionNotes,
            ])
            ->log('return.escalation_resolved');

        // Notify buyer + seller of the outcome.
        $buyer = $return->order->purchase?->buyer;
        $sellerOwner = $return->order->store?->owner;
        if ($buyer) {
            Notification::send($buyer, new ReturnEscalationResolvedNotification($return, $escalation, recipient: 'buyer'));
        }
        if ($sellerOwner) {
            Notification::send($sellerOwner, new ReturnEscalationResolvedNotification($return, $escalation, recipient: 'seller'));
        }

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

`ReturnTransitioner::applyAdminResolution`:

```php
public function applyAdminResolution(
    OrderReturn $return,
    ReturnEscalation $escalation,
    AdminResolutionInput $input,
    User $admin,
): OrderReturn {
    return match ($input->action) {
        ReturnAdminAction::ForceRefund => $this->forceRefundFromEscalation($return, $admin),
        ReturnAdminAction::ForceCloseNoRefund => $this->forceCloseFromEscalation($return),
        ReturnAdminAction::NoAction => $return,
    };
}

private function forceRefundFromEscalation(OrderReturn $return, User $admin): OrderReturn
{
    // Allowed regardless of current state (admin override). Skip mark-received cascade —
    // refund directly + close. Mirrors the `keep-it` cascade.
    if (in_array($return->state, [ReturnState::Refunded, ReturnState::Closed], true)) {
        return $return;  // already done; idempotent
    }

    return DB::transaction(function () use ($return) {
        $this->refunds->issue(
            $return,
            overrideAmountCents: $return->refund_amount_override_cents,
            includeOriginalShipping: (bool) $return->refund_original_shipping,
        );
        $return->refresh();

        $return->update(['state' => ReturnState::Refunded]);

        $amount = number_format(((int) $return->refund_amount_cents) / 100, 2);
        $this->messages->postSystem(
            $return->order,
            "Admin force-refunded \${$amount} to the buyer.",
        );

        $return->update(['state' => ReturnState::Closed, 'closed_at' => now()]);

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

private function forceCloseFromEscalation(OrderReturn $return): OrderReturn
{
    if ($return->state === ReturnState::Closed) {
        return $return;
    }

    $return->update(['state' => ReturnState::Closed, 'closed_at' => now()]);
    $this->messages->postSystem($return->order, 'Admin closed the return without refund.');

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

> **Plan note (admin force-refund through Stripe):** `force_refund` from any state — including `Approved` (no package shipped) and `Received` (package arrived) — issues the Stripe refund directly. We do NOT route through `markReceived` because that path requires `state ∈ {AwaitingShipment, InTransit}` and posts seller-acknowledgement notifications that would be misleading on an admin override. Instead the admin path is closer to the `keep-it` cascade: issue refund, post system message naming the admin, transition to `Closed`. The override + shipping flags persist from creation time (proactive) or remain at default (buyer-initiated returns, which never set them). Stripe idempotency is preserved by `ReturnRefundIssuer::issue`'s existing `"return-refund-{$return->id}"` key, which stays the same across attempts.

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

```php
public function test_resolve_force_refund_issues_refund_and_closes_return(): void
public function test_resolve_force_close_no_refund_closes_without_stripe_call(): void
public function test_resolve_no_action_leaves_return_state_unchanged(): void
public function test_resolve_writes_activity_log_row_with_correct_properties(): void
public function test_resolve_by_non_admin_returns_403(): void
public function test_resolve_already_resolved_escalation_returns_422(): void
public function test_resolve_with_empty_notes_returns_422(): void
public function test_resolve_force_refund_is_idempotent_when_already_refunded(): void
public function test_resolve_force_refund_respects_persisted_amount_override(): void
public function test_resolve_force_refund_respects_persisted_shipping_flag(): void
public function test_resolve_fires_notifications_to_buyer_and_seller(): void
public function test_resolve_posts_system_message_for_each_action(): void  // 3 sub-cases
```

12 tests. Use the spatie activitylog facade (the package is already wired — verify with `grep -rn "spatie/laravel-activitylog" api/composer.json`); assert via `Activity::query()->where('log_name', 'admin')->where('description', 'return.escalation_resolved')->latest()->first()`.

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

- [ ] **Step 3: Implement.**

- [ ] **Step 4: Run; iterate to 12/12 PASS**

---

## Phase C — Endpoints

### Task 7: `POST /v1/seller/orders/{order}/returns/proactive`

**Files:**
- Update: `api/app/Modules/Returns/Controllers/SellerReturnController.php` (add `proactive` action)
- Create: `api/app/Modules/Returns/Requests/ProactiveRefundRequest.php`
- Update: `api/app/Modules/Returns/routes.php`
- Test: extend `api/tests/Feature/Returns/SellerReturnEndpointsTest.php`

`ProactiveRefundRequest`:

```php
class ProactiveRefundRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user() !== null;
    }

    public function rules(): array
    {
        return [
            'mode' => ['required', Rule::in(['keep-it', 'ship-back'])],
            'item_ids' => ['required', 'array', 'min:1'],
            'item_ids.*' => ['uuid'],
            'amount_cents' => ['nullable', 'integer', 'min:0'],
            'refund_original_shipping' => ['nullable', 'boolean'],
            'reason' => ['nullable', 'string', Rule::enum(ReturnReason::class)],
            'reason_text' => ['nullable', 'string', 'max:2000'],
        ];
    }
}
```

Controller:

```php
public function proactive(
    ProactiveRefundRequest $request,
    Order $order,
    ProactiveReturnService $service,
): JsonResponse {
    $input = new ProactiveRefundInput(
        mode: $request->validated('mode'),
        orderItemIds: (array) $request->validated('item_ids'),
        amountCents: $request->validated('amount_cents'),
        refundOriginalShipping: (bool) $request->validated('refund_original_shipping', false),
        reason: $request->filled('reason') ? ReturnReason::from($request->validated('reason')) : null,
        reasonText: $request->validated('reason_text'),
    );

    $return = $service->create($order, $request->user(), $input);

    return response()->json([
        'data' => (new ReturnResource($return))->resolve($request),
    ], 201);
}
```

Route: `Route::post('/seller/orders/{order}/returns/proactive', [SellerReturnController::class, 'proactive']);`

- [ ] **Step 1: Write the failing tests** (controller-level; Task 2's service tests cover deeper semantics):

```php
public function test_post_proactive_keep_it_creates_return_in_closed_state(): void
public function test_post_proactive_ship_back_creates_return_in_awaiting_shipment(): void
public function test_post_proactive_with_amount_override_persists_override(): void
public function test_post_proactive_with_refund_original_shipping_flag_persists_flag(): void
public function test_post_proactive_by_non_seller_returns_403(): void
public function test_post_proactive_when_order_already_has_a_return_returns_422(): void
public function test_post_proactive_returns_201_with_full_return_resource(): void
```

7 tests.

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

- [ ] **Step 3: Implement** request + controller action + route.

- [ ] **Step 4: Run; iterate to 7/7 PASS**

### Task 8: `POST /v1/returns/{return}/escalate`

**Files:**
- Update: `api/app/Modules/Returns/Controllers/BuyerReturnController.php` (add `escalate` action)
- Create: `api/app/Modules/Returns/Requests/EscalateReturnRequest.php`
- Create: `api/app/Modules/Returns/Resources/ReturnEscalationResource.php`
- Update: `api/app/Modules/Returns/routes.php`
- Test: extend `api/tests/Feature/Returns/BuyerReturnEndpointsTest.php`

`EscalateReturnRequest` validates `reason: required, string, min:10, max:2000`.

`ReturnEscalationResource`:

```php
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'return_id' => $this->return_id,
        'escalated_by_user_id' => $this->escalated_by_user_id,
        'reason' => $this->reason,
        'state' => $this->state->value,
        'resolved_by_admin_id' => $this->resolved_by_admin_id,
        'resolution' => $this->resolution,
        'action' => $this->action?->value,
        'resolved_at' => $this->resolved_at?->toIso8601String(),
        'created_at' => $this->created_at->toIso8601String(),
        'updated_at' => $this->updated_at->toIso8601String(),
    ];
}
```

Controller:

```php
public function escalate(
    EscalateReturnRequest $request,
    OrderReturn $return,
    ReturnEscalationService $service,
): JsonResponse {
    $escalation = $service->open($return, $request->user(), $request->validated('reason'));

    return response()->json([
        'data' => (new ReturnEscalationResource($escalation))->resolve($request),
    ], 201);
}
```

Route: `Route::post('/returns/{return}/escalate', [BuyerReturnController::class, 'escalate']);`

Also extend `ReturnResource` to embed the escalation when present:

```php
'escalation' => $this->escalation
    ? (new ReturnEscalationResource($this->escalation))->resolve($request)
    : null,
```

(The seller's panel + buyer's view + admin's queue all benefit from seeing the escalation inline. Pre-load on `BuyerReturnController::show` and `SellerReturnController::index`.)

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

```php
public function test_post_escalate_creates_escalation_returns_201(): void
public function test_post_escalate_by_non_buyer_returns_403(): void
public function test_post_escalate_in_requested_state_returns_422(): void
public function test_post_escalate_in_closed_state_returns_422(): void
public function test_post_escalate_short_reason_returns_422(): void
public function test_post_escalate_when_already_escalated_returns_422(): void
public function test_get_return_includes_escalation_when_present(): void
```

7 tests.

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

- [ ] **Step 3: Implement** request + controller action + route + resource extension.

- [ ] **Step 4: Run; iterate to 7/7 PASS**

### Task 9: `POST /v1/returns/{return}/seller-close-without-refund`

**Files:**
- Update: `api/app/Modules/Returns/Controllers/SellerReturnController.php` (add `closeWithoutRefund` action)
- Create: `api/app/Modules/Returns/Requests/SellerCloseReturnRequest.php`
- Update: `api/app/Modules/Returns/routes.php`
- Test: extend `api/tests/Feature/Returns/SellerReturnEndpointsTest.php`

`SellerCloseReturnRequest`: `reason: required, string, min:10, max:2000`.

Controller:

```php
public function closeWithoutRefund(
    SellerCloseReturnRequest $request,
    OrderReturn $return,
): JsonResponse {
    $user = $request->user();
    $return->loadMissing('order.store');
    if ($return->order?->store?->owner_user_id !== $user->id) {
        abort(403);
    }

    $closed = $this->transitions->sellerCloseWithoutRefund(
        $return,
        $user,
        $request->validated('reason'),
    );

    return response()->json(['data' => (new ReturnResource($closed))->resolve($request)]);
}
```

Route: `Route::post('/returns/{return}/seller-close-without-refund', [SellerReturnController::class, 'closeWithoutRefund']);`

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

```php
public function test_seller_close_without_refund_happy_path(): void
public function test_seller_close_without_refund_by_non_seller_returns_403(): void
public function test_seller_close_without_refund_from_received_returns_422(): void
public function test_seller_close_without_refund_short_reason_returns_422(): void
```

4 tests.

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

- [ ] **Step 3: Implement.**

- [ ] **Step 4: Run; iterate to 4/4 PASS**

### Task 10: `GET /v1/admin/returns` — escalation queue

**Files:**
- Create: `api/app/Modules/Returns/Controllers/AdminReturnController.php`
- Update: `api/app/Modules/Admin/routes.php` (add the two admin returns routes inside the existing `auth:sanctum + admin` group)
- Test: `api/tests/Feature/Returns/AdminReturnEndpointsTest.php`

> **Plan note (where the admin routes live):** The existing `Admin\routes.php` file already groups every admin endpoint under `Route::middleware(['auth:sanctum', 'admin'])->prefix('admin')->group(...)`. Plan 3's two admin endpoints (`GET /admin/returns`, `POST /admin/returns/{return}/resolve`) belong inside that group — NOT inside `Modules/Returns/routes.php` (which is the buyer/seller surface and lacks the `admin` middleware). The controller can live under `Modules/Returns/Controllers/AdminReturnController.php` for module cohesion; just register its routes from `Modules/Admin/routes.php`. This matches `AdminMessageController`'s shape (the controller lives in `Modules/Admin/Controllers/`, but it could equally have lived in `Messaging/Controllers/`; we go module-cohesive for returns).

Controller:

```php
final class AdminReturnController
{
    public function __construct(private readonly ReturnEscalationService $escalations) {}

    public function index(Request $request): JsonResponse
    {
        // Default: only returns with at least one open escalation. ?include=resolved adds resolved escalations.
        $includeResolved = $request->boolean('include_resolved');

        $query = OrderReturn::query()
            ->whereHas('escalation', fn ($q) => $includeResolved
                ? $q
                : $q->where('state', ReturnEscalationState::Open->value))
            ->with(['order.purchase.buyer', 'order.store', 'escalation', 'items.orderItem'])
            ->orderByDesc('updated_at');

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

        return response()->json([
            'data' => ReturnSummaryResource::collection($query->get())->resolve($request),
        ]);
    }

    public function resolve(
        AdminResolveReturnRequest $request,
        OrderReturn $return,
    ): JsonResponse {
        $escalation = $return->escalation;
        if ($escalation === null || $escalation->state !== ReturnEscalationState::Open) {
            abort(422, 'No open escalation on this return.');
        }

        $input = new AdminResolutionInput(
            action: ReturnAdminAction::from($request->validated('action')),
            resolutionNotes: $request->validated('resolution_notes'),
        );

        $resolved = $this->escalations->resolve($escalation, $input, $request->user());

        return response()->json([
            'data' => [
                'escalation' => (new ReturnEscalationResource($resolved))->resolve($request),
                'return' => (new ReturnResource($return->fresh()))->resolve($request),
            ],
        ]);
    }
}
```

`AdminResolveReturnRequest`:

```php
public function rules(): array
{
    return [
        'action' => ['required', Rule::enum(ReturnAdminAction::class)],
        'resolution_notes' => ['required', 'string', 'min:10', 'max:2000'],
    ];
}
```

Routes registered in `Modules/Admin/routes.php`:

```php
Route::get('/returns', [AdminReturnController::class, 'index']);
Route::post('/returns/{return}/resolve', [AdminReturnController::class, 'resolve']);
```

`ReturnSummaryResource` extension — the admin queue benefits from seeing escalation reason inline. Add an optional `escalation_reason` and `escalated_at` to the summary serializer; gated to admins via `roleFor === 'admin'` (or simpler: always emit, since the summary is already gated by route auth).

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

```php
public function test_admin_index_returns_only_returns_with_open_escalations_by_default(): void
public function test_admin_index_with_include_resolved_returns_all(): void
public function test_admin_index_filters_by_state(): void
public function test_admin_index_by_non_admin_returns_403(): void
public function test_admin_index_orders_by_updated_at_desc(): void

public function test_admin_resolve_force_refund_happy_path(): void
public function test_admin_resolve_force_close_no_refund_happy_path(): void
public function test_admin_resolve_no_action_happy_path(): void
public function test_admin_resolve_writes_activity_log_row(): void
public function test_admin_resolve_by_non_admin_returns_403(): void
public function test_admin_resolve_when_no_open_escalation_returns_422(): void
public function test_admin_resolve_with_invalid_action_returns_422(): void
public function test_admin_resolve_response_contains_escalation_and_return(): void
```

13 tests.

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

- [ ] **Step 3: Implement.**

- [ ] **Step 4: Run; iterate to 13/13 PASS**

---

## Phase D — Notifications

### Task 11: Four new notification classes

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/ProactiveRefundIssuedNotification.php` (recipient: buyer)
- Create: `api/app/Modules/Notifications/Notifications/SellerClosedReturnNotification.php` (recipient: buyer)
- Create: `api/app/Modules/Notifications/Notifications/ReturnEscalationOpenedNotification.php` (recipient: admin OR seller, via constructor `recipient` arg — same pattern as `ReturnReceivedNotification`)
- Create: `api/app/Modules/Notifications/Notifications/ReturnEscalationResolvedNotification.php` (recipient: buyer OR seller, same pattern)
- Test: extend `api/tests/Feature/Returns/ReturnNotificationsTest.php`

All four follow Plan 1's notification shape (look at `ReturnApprovedNotification` as the reference): `ShouldQueue`, `via()` consults `NotificationPreferenceGate->channelsFor($notifiable, NotificationCategory::Returns, ['mail', 'database'])`, `toMail` returns a `MailMessage` with subject + body + CTA, `toDatabase` returns the `{title, body, cta_url, icon, context_type, context_id}` shape used by the bell.

| Class                                    | Recipient | Subject                                              | DB title                       |
| ---------------------------------------- | --------- | ---------------------------------------------------- | ------------------------------ |
| `ProactiveRefundIssuedNotification`      | buyer     | "You've been refunded ${amount} on order #{short_id}" | "Refund issued"                |
| `SellerClosedReturnNotification`         | buyer     | "Your return on order #{short_id} was closed by the seller" | "Return closed by seller"      |
| `ReturnEscalationOpenedNotification` (a) | admin     | "Buyer escalated a return — order #{short_id}"        | "Return escalated"             |
| `ReturnEscalationOpenedNotification` (s) | seller    | "Buyer escalated their return — order #{short_id}"    | "Return escalated to admin"    |
| `ReturnEscalationResolvedNotification` (b) | buyer   | "Admin resolved your escalated return — order #{short_id}" | "Escalation resolved"          |
| `ReturnEscalationResolvedNotification` (s) | seller  | "Admin resolved the escalated return — order #{short_id}"  | "Escalation resolved"          |

Icons:
- `ProactiveRefundIssuedNotification` → `'package-return'` (matches Plan 1 pattern)
- `SellerClosedReturnNotification` → `'package-return'`
- Both escalation classes → `'package-return'` (no separate "escalation" icon in the design tokens)

`cta_url`:
- Buyer-side: `/purchases/{order_id}`
- Seller-side: `/seller/orders/{order_id}`
- Admin-side: `/admin/returns` (queue) + `?focus={return_id}` query so the front-end can scroll/highlight (optional polish)

Body content for `ReturnEscalationResolvedNotification` includes the admin's `resolution_notes` and the chosen `action` (rendered as a friendly label: "Refunded", "Closed without refund", "No change" — keep the rendering helper colocated with the notification class).

- [ ] **Step 1: Write the failing tests** — for each class, assert (a) `via()` honors the gate, (b) `toDatabase` shape, (c) `toMail` subject + key body string. Plus for the two-recipient classes, assert the `recipient` arg drives the right subject/body. ~16 assertions across ~14 tests.

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

- [ ] **Step 3: Implement** the four classes.

- [ ] **Step 4: Run; iterate to all PASS**

---

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

### Task 12: OpenAPI

**Files:**
- Update: `api/contracts/openapi.yaml`

Five new paths:

```yaml
/v1/seller/orders/{order}/returns/proactive:
  post:
    operationId: createProactiveReturn
    summary: Seller initiates a proactive refund (keep-it or ship-back)
    description: |
      Creates a seller-initiated return on a delivered order without requiring a buyer request.
      `mode: 'keep-it'` issues the Stripe refund immediately and closes the return — no physical shipment.
      `mode: 'ship-back'` issues an EasyPost return label (seller pays) and follows the
      `awaiting_shipment → in_transit → received → refunded → closed` cascade.
    tags: [Returns]
    security: [{ bearerAuth: [] }]
    parameters:
      - name: order
        in: path
        required: true
        schema: { type: string, format: uuid }
    requestBody:
      required: true
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ProactiveRefundRequest' }
    responses:
      '201':
        description: Return created (state = closed for keep-it, awaiting_shipment for ship-back)
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/OrderReturn' }
      '403': { description: Forbidden, content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }
      '422': { description: Validation error or order already has a return, content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }

/v1/returns/{return}/escalate:
  post:
    operationId: escalateReturn
    summary: Buyer escalates a return to admin
    description: |
      Allowed from `approved`, `awaiting_shipment`, `in_transit`, `received`. NOT allowed from
      `requested`, `rejected`, `cancelled`, `closed`, `refunded`. At most one escalation per return.
    tags: [Returns]
    security: [{ bearerAuth: [] }]
    parameters:
      - name: return
        in: path
        required: true
        schema: { type: string, format: uuid }
    requestBody:
      required: true
      content:
        application/json:
          schema: { $ref: '#/components/schemas/EscalateReturnRequest' }
    responses:
      '201':
        description: Escalation created
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/ReturnEscalation' }
      '403': { description: Forbidden, content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }
      '422': { description: Invalid state, already escalated, or short reason, content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }

/v1/returns/{return}/seller-close-without-refund:
  post:
    operationId: sellerCloseReturnWithoutRefund
    summary: Seller closes a return without refunding the buyer
    description: |
      Allowed from `approved`, `awaiting_shipment`, `in_transit`. NOT allowed from `received` or later
      (the package has arrived; the buyer is owed the refund). Reason text is required and visible to
      the buyer.
    tags: [Returns]
    security: [{ bearerAuth: [] }]
    parameters:
      - name: return
        in: path
        required: true
        schema: { type: string, format: uuid }
    requestBody:
      required: true
      content:
        application/json:
          schema: { $ref: '#/components/schemas/SellerCloseReturnRequest' }
    responses:
      '200':
        description: Return closed without refund
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/OrderReturn' }

/v1/admin/returns:
  get:
    operationId: listAdminReturns
    summary: Admin queue of escalated returns
    tags: [Admin, Returns]
    security: [{ bearerAuth: [] }]
    parameters:
      - name: include_resolved
        in: query
        required: false
        schema: { type: boolean, default: false }
      - name: state
        in: query
        required: false
        schema: { type: string, enum: [requested, approved, rejected, awaiting_shipment, in_transit, received, refunded, closed, cancelled, escalated] }
    responses:
      '200':
        description: Returns with at least one open escalation (default), sortable by updated_at
        content:
          application/json:
            schema:
              type: object
              properties:
                data:
                  type: array
                  items: { $ref: '#/components/schemas/OrderReturnSummary' }
      '403': { description: Forbidden, content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }

/v1/admin/returns/{return}/resolve:
  post:
    operationId: resolveAdminReturn
    summary: Admin resolves a return escalation
    description: |
      Closes the escalation and optionally forces a state transition on the underlying return.
      `action: 'force_refund'` issues the Stripe refund (honouring any persisted amount override or
      shipping-refund flag) and closes the return. `action: 'force_close_no_refund'` closes the return
      without refunding. `action: 'no_action'` resolves the escalation as a no-op (e.g., admin sided
      with the seller without changing the return's trajectory). All three write a
      `return.escalation_resolved` row to the activity log.
    tags: [Admin, Returns]
    security: [{ bearerAuth: [] }]
    parameters:
      - name: return
        in: path
        required: true
        schema: { type: string, format: uuid }
    requestBody:
      required: true
      content:
        application/json:
          schema: { $ref: '#/components/schemas/AdminResolveReturnRequest' }
    responses:
      '200':
        description: Resolved escalation + updated return
        content:
          application/json:
            schema:
              type: object
              properties:
                data:
                  type: object
                  properties:
                    escalation: { $ref: '#/components/schemas/ReturnEscalation' }
                    return: { $ref: '#/components/schemas/OrderReturn' }
      '403': { description: Forbidden, content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }
      '422': { description: No open escalation, content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } }
```

New schemas:

```yaml
ProactiveRefundRequest:
  type: object
  required: [mode, item_ids]
  properties:
    mode: { type: string, enum: [keep-it, ship-back] }
    item_ids:
      type: array
      minItems: 1
      items: { type: string, format: uuid }
    amount_cents: { type: integer, minimum: 0, nullable: true }
    refund_original_shipping: { type: boolean, default: false }
    reason: { type: string, enum: [damaged, wrong_item, not_as_described, doesnt_fit, changed_mind, other], nullable: true }
    reason_text: { type: string, maxLength: 2000, nullable: true }

EscalateReturnRequest:
  type: object
  required: [reason]
  properties:
    reason: { type: string, minLength: 10, maxLength: 2000 }

SellerCloseReturnRequest:
  type: object
  required: [reason]
  properties:
    reason: { type: string, minLength: 10, maxLength: 2000 }

AdminResolveReturnRequest:
  type: object
  required: [action, resolution_notes]
  properties:
    action: { type: string, enum: [force_refund, force_close_no_refund, no_action] }
    resolution_notes: { type: string, minLength: 10, maxLength: 2000 }

ReturnEscalation:
  type: object
  properties:
    id: { type: string, format: uuid }
    return_id: { type: string, format: uuid }
    escalated_by_user_id: { type: string, format: uuid }
    reason: { type: string }
    state: { type: string, enum: [open, resolved] }
    resolved_by_admin_id: { type: string, format: uuid, nullable: true }
    resolution: { type: string, nullable: true }
    action: { type: string, enum: [force_refund, force_close_no_refund, no_action], nullable: true }
    resolved_at: { type: string, format: date-time, nullable: true }
    created_at: { type: string, format: date-time }
    updated_at: { type: string, format: date-time }
```

Extend the `OrderReturn` schema with three new properties:

```yaml
OrderReturn:
  # ...existing properties...
  refund_original_shipping: { type: boolean }
  refund_amount_override_cents: { type: integer, nullable: true }
  escalation: { $ref: '#/components/schemas/ReturnEscalation', nullable: true }
```

Extend `OrderReturnSummary` with optional escalation context:

```yaml
OrderReturnSummary:
  # ...existing properties...
  escalation_state: { type: string, enum: [open, resolved], nullable: true }
  escalated_at: { type: string, format: date-time, nullable: true }
```

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

- [ ] **Step 1: Edit the YAML.**
- [ ] **Step 2: Validate (single-pass).**

### Task 13: Sync to web + api-client

```bash
cd ~/projects/alqove-web
./bin/sync-openapi.sh
npm run build:types
```

Extend `web/packages/api-client/src/endpoints/returns.ts`:

```ts
export interface ReturnEscalation {
  id: string;
  return_id: string;
  escalated_by_user_id: string;
  reason: string;
  state: 'open' | 'resolved';
  resolved_by_admin_id: string | null;
  resolution: string | null;
  action: 'force_refund' | 'force_close_no_refund' | 'no_action' | null;
  resolved_at: string | null;
  created_at: string;
  updated_at: string;
}

export type ProactiveRefundMode = 'keep-it' | 'ship-back';
export type ReturnAdminAction = 'force_refund' | 'force_close_no_refund' | 'no_action';

export interface ProactiveRefundInput {
  mode: ProactiveRefundMode;
  item_ids: string[];
  amount_cents?: number | null;
  refund_original_shipping?: boolean;
  reason?: ReturnReason;
  reason_text?: string;
}

// ... extend OrderReturn interface with refund_original_shipping, refund_amount_override_cents, escalation

export function createReturnEndpoints(client: AlqoveClient) {
  return {
    // ...existing...
    proactive(orderId: string, input: ProactiveRefundInput) {
      return client.post<OrderReturnResponse>(`/v1/seller/orders/${orderId}/returns/proactive`, input);
    },
    escalate(returnId: string, reason: string) {
      return client.post<{ data: ReturnEscalation }>(`/v1/returns/${returnId}/escalate`, { reason });
    },
    sellerCloseWithoutRefund(returnId: string, reason: string) {
      return client.post<OrderReturnResponse>(`/v1/returns/${returnId}/seller-close-without-refund`, { reason });
    },
  };
}

export function createAdminReturnEndpoints(client: AlqoveClient) {
  return {
    list(params: { state?: ReturnState; include_resolved?: boolean } = {}) {
      const qs = new URLSearchParams();
      if (params.state) qs.set('state', params.state);
      if (params.include_resolved) qs.set('include_resolved', '1');
      const suffix = qs.toString() ? `?${qs.toString()}` : '';
      return client.get<OrderReturnSummaryListResponse>(`/v1/admin/returns${suffix}`);
    },
    resolve(returnId: string, input: { action: ReturnAdminAction; resolution_notes: string }) {
      return client.post<{ data: { escalation: ReturnEscalation; return: OrderReturn } }>(
        `/v1/admin/returns/${returnId}/resolve`,
        input,
      );
    },
  };
}
```

Wire `createAdminReturnEndpoints` into the `admin` namespace on the root `client` factory (`api.admin.returns.list(...)`, `api.admin.returns.resolve(...)`); export the new types from `index.ts`.

Run `npm run typecheck` + `npx tsc --noEmit` in `web/`. Clean.

- [ ] **Step 1: Sync.**
- [ ] **Step 2: Build types.**
- [ ] **Step 3: Wire api-client.**
- [ ] **Step 4: Typecheck.**

---

## Phase F — Frontend

### Task 14: TanStack hooks

**Files:**
- Update: `web/src/lib/queries/use-returns.ts`
- Create: `web/src/lib/queries/use-admin-returns.ts`

Extend `use-returns.ts` with three new mutations:

```ts
export function useProactiveRefund(orderId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (input: ProactiveRefundInput) => api.returns.proactive(orderId, input),
    onSuccess: (resp) => invalidateReturnsAndRelated(qc, orderId, resp.data.id),
  });
}

export function useEscalateReturn() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ returnId, reason }: { returnId: string; reason: string }) =>
      api.returns.escalate(returnId, reason),
    onSuccess: (_, { returnId }) => {
      qc.invalidateQueries({ queryKey: ['returns', returnId] });
      qc.invalidateQueries({ queryKey: ['returns'] });
      qc.invalidateQueries({ queryKey: ['admin', 'returns'] });
    },
  });
}

export function useSellerCloseReturnWithoutRefund() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ returnId, reason }: { returnId: string; reason: string }) =>
      api.returns.sellerCloseWithoutRefund(returnId, reason),
    onSuccess: (resp) => invalidateReturnsAndRelated(qc, resp.data.order_id, resp.data.id),
  });
}
```

Create `use-admin-returns.ts`:

```ts
export const ADMIN_RETURN_KEYS = {
  list: (state?: ReturnState, includeResolved?: boolean) =>
    ['admin', 'returns', state ?? 'all', includeResolved ? 'with_resolved' : 'open'] as const,
};

export function useAdminReturns(params: { state?: ReturnState; includeResolved?: boolean } = {}) {
  return useQuery({
    queryKey: ADMIN_RETURN_KEYS.list(params.state, params.includeResolved),
    queryFn: () => api.admin.returns.list({ state: params.state, include_resolved: params.includeResolved }),
    staleTime: 15_000,
  });
}

export function useResolveAdminReturn() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ returnId, action, notes }: { returnId: string; action: ReturnAdminAction; notes: string }) =>
      api.admin.returns.resolve(returnId, { action, resolution_notes: notes }),
    onSuccess: (resp) => {
      qc.invalidateQueries({ queryKey: ['admin', 'returns'] });
      qc.invalidateQueries({ queryKey: ['returns', resp.data.return.id] });
      qc.invalidateQueries({ queryKey: ['orders', resp.data.return.order_id] });
    },
  });
}
```

No new hook tests — covered indirectly by the panel/dialog tests below.

### Task 15: `<ProactiveRefundModal>` (seller surface)

**Files:**
- Create: `web/src/components/seller/proactive-refund-modal.tsx`
- Create: `web/src/components/seller/__tests__/proactive-refund-modal.test.tsx`
- Update: `web/src/components/seller/seller-returns-panel.tsx` (mount the modal trigger)

The modal is the seller's "Issue refund" CTA on `/seller/orders/[id]`, shown ONLY when no return exists yet. On open:

- **Mode toggle** (radio group): "Keep the item, refund only" (`keep-it`) vs "Issue a return label, refund on receipt" (`ship-back`). Default: `ship-back`.
- **Per-item checklist** (default all checked, qty = original).
- **Refund amount input** — number field, defaulted to the computed item subtotal of the checked items. Editable; switches the request body's `amount_cents` from null (compute) to the entered value.
- **Refund original shipping** — checkbox. Disabled (greyed) when `mode === 'keep-it'` if you choose to keep semantics tight, or always enabled otherwise — the user's prompt explicitly lists it as a parameter for both modes, so we keep it always enabled and let the seller decide.
- **Optional reason text** — textarea, capped 2000 chars. Required iff seller picks a `reason` enum of `other` (`reason` is itself optional in the request). UI surfaces a small reason picker with the existing 6 enum options + a "no reason / proactive goodwill" first option that just leaves `reason` null.
- **Submit** — `useProactiveRefund(orderId).mutate(input)`. On success: close modal, toast/snackbar (`keep-it`: "Refund issued."; `ship-back`: "Return label issued — buyer can print it.").
- **Confirmation dialog before submit** — small "Issue refund of $X.XX to {buyer name}?" gate. Two-click prevents accidental click-through; this is a real money button.

> **Plan note (no idempotency-key header in the UI):** The combination of business-level guard (`order.returns().exists()`) + Stripe idempotency key + the per-store unique index makes a duplicate POST a 422 from the second attempt onwards. The modal disables the submit button between click and response (TanStack `mutation.isPending`). No additional client-side debounce is added.

Tests:

```ts
test('proactive modal renders all 6 reasons + per-line items')
test('default mode is ship-back')
test('switching to keep-it does not disable refund-original-shipping checkbox')
test('amount input defaults to item subtotal of checked items')
test('amount input recalculates when items toggled')
test('confirmation dialog gate appears on submit')
test('mutates with correct payload on confirm')
test('submit button disabled while mutation pending')
test('keep-it mode toast says "Refund issued"')
test('ship-back mode toast says "Return label issued — buyer can print it"')
```

10 tests.

- [ ] **Step 1: Write failing tests.**
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement modal + integration in seller-returns-panel.**
- [ ] **Step 4: Run; iterate to 10/10 PASS.**

### Task 16: Seller `seller-returns-panel.tsx` — Close-without-refund + escalation visibility

**Files:**
- Update: `web/src/components/seller/seller-returns-panel.tsx`
- Update: `web/src/components/seller/__tests__/seller-returns-panel.test.tsx`
- Create: `web/src/components/returns/escalation-badge.tsx`
- Create: `web/src/components/seller/seller-close-without-refund-dialog.tsx`

Per state additions:

- `approved | awaiting_shipment | in_transit`: NEW "Close without refund" button (red/destructive variant). Opens a small dialog with a textarea for the `reason` (min 10 chars). On submit: `useSellerCloseReturnWithoutRefund`. Shows a clear "This will not refund the buyer; if the buyer escalates, an admin can override." disclaimer.
- Any state with an open escalation: render `<EscalationBadge>` at the top of the panel — shows the escalation reason + "Awaiting admin review" label. The seller cannot interact with the escalation (admin-only); they can still operate the underlying return state machine (e.g., issue a label, mark received). The badge is a read-only marker.

`<EscalationBadge>`:

```tsx
export function EscalationBadge({ escalation }: { escalation: ReturnEscalation }) {
  if (escalation.state === 'resolved') {
    return <Badge variant="muted">Escalation resolved · {labelForAction(escalation.action)}</Badge>;
  }
  return (
    <Alert variant="warning">
      <AlertTitle>Escalated to admin</AlertTitle>
      <AlertDescription>"{escalation.reason}"</AlertDescription>
    </Alert>
  );
}
```

Tests:

```ts
test('renders Close-without-refund button in approved/awaiting_shipment/in_transit states')
test('button hidden in received/refunded/closed')
test('dialog requires min-10-char reason')
test('submit calls useSellerCloseReturnWithoutRefund with returnId + reason')
test('renders escalation badge when an open escalation exists')
test('renders muted resolved badge with action label when resolved')
```

6 tests.

- [ ] **Step 1: Write failing tests.**
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run; iterate to 6/6 PASS.**

### Task 17: Buyer `purchase-detail-client.tsx` — Escalate button

**Files:**
- Update: `web/src/app/(buyer)/purchases/[id]/purchase-detail-client.tsx`
- Update: `web/src/app/(buyer)/purchases/[id]/__tests__/purchase-detail-client.test.tsx`
- Create: `web/src/components/returns/escalate-return-dialog.tsx`

When the order has an open return AND `return.state ∈ {Approved, AwaitingShipment, InTransit, Received}` AND no existing escalation:

- "Escalate to admin" button (subtle / secondary variant; not the primary CTA — the buyer's first instinct should still be "wait for the seller", and the escalate path is an explicit "I've waited long enough" action).
- Click opens `<EscalateReturnDialog>` — textarea with a min-10-char `reason` input + a clear preamble: "Escalating sends this return to Alqove support for review. They'll read your message + the seller's response and make a binding decision."
- Submit: `useEscalateReturn().mutate({ returnId, reason })`.

When an escalation already exists (open OR resolved): render `<EscalationBadge>` instead of the button.

Tests:

```ts
test('escalate button hidden when no open return')
test('escalate button hidden when state is requested')
test('escalate button hidden when state is closed/refunded')
test('escalate button visible in approved/awaiting_shipment/in_transit/received')
test('escalate button hidden when escalation already exists; badge shown instead')
test('dialog requires min-10-char reason')
test('submit calls useEscalateReturn with correct payload')
test('successful submit hides the button and renders the badge')
```

8 tests.

- [ ] **Step 1: Write failing tests.**
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run; iterate to 8/8 PASS.**

### Task 18: Admin `/admin/returns` queue page

**Files:**
- Create: `web/src/app/(admin)/admin/returns/page.tsx`
- Create: `web/src/app/(admin)/admin/returns/admin-returns-client.tsx`
- Create: `web/src/components/admin/admin-resolve-return-dialog.tsx`
- Create: `web/src/app/(admin)/admin/returns/__tests__/admin-returns-client.test.tsx`

Layout mirrors `/admin/disputes`:

- Filter: chips for `state` (across all return states), plus a "Show resolved" toggle that flips `include_resolved`.
- Table: state badge, escalation reason snippet, buyer name, store name, refund amount, escalation `created_at`, "Resolve" button.
- Click "Resolve" → `<AdminResolveReturnDialog>`:
  - Action radio group: `force_refund` ("Refund the buyer"), `force_close_no_refund` ("Close in seller's favour"), `no_action` ("Resolve without action").
  - Resolution notes textarea (min 10, max 2000).
  - Submit → `useResolveAdminReturn().mutate({ returnId, action, notes })`. On success: close dialog, refetch the queue.

Tests:

```ts
test('default queue fetches with include_resolved=false')
test('toggle "Show resolved" passes include_resolved=1 to api')
test('state filter chips drive ?state= param')
test('clicking Resolve opens the dialog')
test('dialog requires action selection + min-10 notes')
test('submit calls useResolveAdminReturn with correct payload')
test('rows show escalation reason snippet')
test('non-admin viewer redirected (or 403; depending on layout guard) — match how /admin/disputes handles it')
```

8 tests.

- [ ] **Step 1: Write failing tests.**
- [ ] **Step 2: Run, confirm failure.**
- [ ] **Step 3: Implement page + client + dialog.**
- [ ] **Step 4: Run; iterate to 8/8 PASS.**

### Task 19: Notification bell — wire new icons

**Files:**
- Update: `web/src/components/notifications/notification-bell.tsx` (or wherever icon→route mapping lives)

The four new notifications all carry `icon: 'package-return'` and `context_type: 'order'` — already handled by Plan 1's mapping. **No code change expected.** Verify by smoke-testing locally; flag if Plan 1's mapping was per-notification rather than per-icon.

Admin recipients: `ReturnEscalationOpenedNotification` (admin variant) routes to `/admin/returns?focus={return_id}`; the existing icon-route mapping doesn't know about admin-targeted return notifications. Add a small guard: if the recipient role is admin AND the icon is `package-return`, route to `/admin/returns`. Otherwise unchanged.

- [ ] **Step 1: Verify icon-mapping handles the four new classes.**
- [ ] **Step 2: Add admin route override.**
- [ ] **Step 3: Smoke-test in dev.**

---

## Phase G — Wrap-up

### Task 20: Full sweep

- [ ] **Step 1: Backend** — `docker compose exec -T laravel.test php artisan test`. Expected delta: schema ~5, ProactiveReturnService ~14, ReturnRefundIssuer extension ~7, ReturnTransitioner extensions (sellerCloseWithoutRefund + applyAdminResolution) ~10, ReturnEscalationService open ~13 + resolve ~12, controller tests (proactive ~7, escalate ~7, seller-close ~4, admin index/resolve ~13) = 41, notifications ~14. **Total ~136 new tests.** API count: **595 → ≥ 730**.
- [ ] **Step 2: Backend lint** — `./vendor/bin/pint app/Modules/Returns app/Modules/Notifications/Notifications/{ProactiveRefundIssued,SellerClosedReturn,ReturnEscalation*}.php tests/Feature/Returns app/Support/Enums/ReturnEscalationState.php app/Support/Enums/ReturnAdminAction.php`. Expected PASS or auto-fix.
- [ ] **Step 3: Web typecheck** — `npm run typecheck` at root + `npx tsc --noEmit` in `web/`. Expected clean.
- [ ] **Step 4: Web lint** — `npm run lint`. Expected: same baseline (8 pre-existing img warnings).
- [ ] **Step 5: Web tests** — `npm run test`. Delta: proactive modal 10, seller panel additions 6, buyer escalate dialog 8, admin returns page 8 = 32. **Web count: 205 → ≥ 237**.
- [ ] **Step 6: Local build** — `npm run build:web`. Watch for the new `/admin/returns` page breaking SSR.
- [ ] **Step 7: Manual QA** —
  - **Proactive `keep-it`:** As a seller on `/seller/orders/[id]` for a delivered order with no return, click "Issue refund". Pick `keep-it`, full subtotal, no shipping, no reason. Confirm Stripe test refund created, return state goes straight to `Closed`, buyer's `/purchases/[id]` shows the refund + system messages.
  - **Proactive `ship-back`:** Same flow but `ship-back`. Confirm an EasyPost return label is created (test mode), buyer sees the label download link, mark-received cascade still works the same.
  - **Proactive with `refund_original_shipping`:** Confirm the refund amount equals `subtotal + shipping_cost` AND a `StripeService::reverseTransfer` call is logged (or visible in Stripe test dashboard) for the shipping portion.
  - **Proactive with `amount_cents` override:** Confirm the override amount lands in Stripe; the items array is unchanged; `keep-it` mode runs `issue` immediately, `ship-back` mode persists the override and uses it on `mark-received`.
  - **Escalation:** As a buyer with an `Approved` return, click "Escalate to admin". Confirm: notification fires to seller + every admin (use Mailpit); `/admin/returns` queue shows the row.
  - **Admin resolve `force_refund`:** From the queue, click "Resolve", pick `force_refund`, write notes. Confirm Stripe refund issued, return moves to `Closed`, activity log row written (`return.escalation_resolved`), buyer + seller receive `ReturnEscalationResolvedNotification`.
  - **Admin resolve `force_close_no_refund`:** Same flow; confirm NO Stripe refund call (check the test logs), return moves to `Closed`, activity log row written.
  - **Admin resolve `no_action`:** Confirm escalation marked resolved, return state untouched, activity log row written.
  - **Seller close without refund:** As a seller in `Approved`, click "Close without refund", supply a reason. Confirm return → `Closed`, no Stripe call, system message visible, buyer receives `SellerClosedReturnNotification`.
  - **Seller close without refund blocked:** Try the same on a `Received` return — confirm 422.
  - **Cross-flow:** Approve a buyer return, escalate it, admin force-refunds — confirm the seller's panel reflects the resolved escalation badge AND the underlying return is `Closed`.
  - **Open `/admin/orders/[id]`** for an order whose return was force-refunded — confirm the Layer 9 thread shows all the system messages (proactive initiated, refund issued, escalation opened, escalation resolved with admin action) in italic gray.

### Task 21: Commit + push

- [ ] **Step 1:** In `~/projects/alqove-api`, stage `app contracts database tests docs` and commit with `feat(returns): proactive refunds + admin escalation`.
- [ ] **Step 2:** In `~/projects/alqove-web`, stage `packages web contracts` and commit with `feat(returns): proactive refund modal + admin queue + escalation flow`.
- [ ] **Step 3:** Push both. Watch GH Actions on each — both should be green inside 3 minutes.

---

## Open items (deferred outside Layer 10)

- **Negative-payout collection / seller-paid label cost recovery** — `easypost_shipment_cost_cents` is recorded but never deducted from the seller's payout. Future infra work; an explicit spec open item from Plan 1.
- **Buyer-paid label cost deduction from refund** — when `return_shipping_payer === 'buyer'`, the marketplace currently eats the cost. Plan 3 doesn't change this. Future work.
- **EasyPost label expiration (~30 days)** — labels on long-stuck `awaiting_shipment` returns become 404s. No auto-reissue; revisit when buyers report it.
- **Restocking-fee disclosure on the buyer request modal** — surface `restocking_fee_percent_max` so the buyer isn't surprised. UI polish; doesn't gate this layer.
- **Per-store ship-from override for return destination** — defer until a seller asks.
- **Auto-close stale `requested` returns** — spec open item; defer.
- **Multi-leg / exchange flow** — explicitly out of scope.
- **`pre_transit` carrier event surfacing** — currently no-op; consider exposing as a system message.
- **`ParcelDto::fromOrderItems` weight fallback** — 1-lb default is rough; populate item weights at listing creation in a future layer.
- **Seller-initiated escalation** — Plan 3 v1 is buyer-only; if a seller wants admin help on a stuck return they message admin via the existing inbox. Promote if real demand emerges.
- **Re-escalation after admin resolution** — explicit "one escalation per return, ever" via the unique index. The buyer's recourse if they disagree with the admin is a Stripe chargeback (separate path, already exists).
- **Idempotency-key HTTP header on `/proactive`** — not added; the per-order uniqueness on `returns` + the Stripe idempotency key cover the duplicate-submit case. Add a request-level header if support tickets show repeat-create attempts in production.
- **`Order::seller_payout` / `Order::shipping_cost` arithmetic verification for transfer reversal** — the implementer must confirm whether `seller_payout` includes shipping before wiring the transfer reversal in `ReturnRefundIssuer`. If shipping is platform-retained (not paid out to the seller), no reversal is needed. Flagged in the Plan note above.
