# Layer 10 Plan 1: Foundation + Buyer-Initiated Returns (no physical shipment)

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

**Goal:** Lay the cross-cutting foundation Layer 10 needs (a new `Returns` module, `returns` / `return_items` tables, the `Returns` notification category, the four returns enums, `ReturnAccess` + `ReturnTransitioner` + `ReturnRefundIssuer` services) and ship the buyer-request → seller-approve/reject → refund happy path. **No physical return shipping** in Plan 1 — every approval issues a Stripe refund immediately (the "no ship-back required" pattern, useful for low-value items and goodwill). Plan 2 adds the EasyPost return-label leg between approve and refund. Plan 3 adds seller-initiated proactive refunds + admin escalation.

**Architecture:** (1) Backend foundation — new `App\Modules\Returns` module, three migrations (`returns` + `return_items` + `store_settings.restocking_fee_percent_max`), `Return` + `ReturnItem` models, four enums, `Returns` `NotificationCategory` case, and the `MessageRole::System` extension that `ReturnTransitioner` uses to post state-change rows in the existing Layer 9 thread. (2) Backend services — `ReturnAccess` (auth gate + role resolution), `ReturnTransitioner` (state machine, side effects, system messages, notifications), and `ReturnRefundIssuer` (Stripe partial-refund + Layer-8 transfer-reversal coordination). (3) Backend endpoints — buyer (`POST /v1/orders/{order}/returns`, `GET /v1/me/returns`, `POST /v1/returns/{return}/cancel`), shared (`GET /v1/returns/{return}`), seller (`GET /v1/seller/returns`, `POST /v1/returns/{return}/approve`, `POST /v1/returns/{return}/reject`). (4) Notifications — `ReturnRequested`, `ReturnApproved`, `ReturnRejected`, `ReturnRefunded`. (5) Frontend — `useReturn` / `useMyReturns` / `useSellerReturns` hooks, `<ReturnRequestModal>` + `<ReturnStateBadge>` + `<ReturnTimeline>` components, `Request return` button + state strip on the buyer order card, `/seller/returns` list page + returns panel on `/seller/orders/[id]`.

**Tech Stack:** Laravel 12, Pest PHP, Postgres 17, Spatie Permission (existing), Stripe PHP SDK (existing), OpenAPI → `openapi-typescript`, Next.js 16 App Router, TanStack Query v5, Tailwind, Vitest + React Testing Library.

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

**Prerequisites:** Layer 9 fully shipped (all three plans merged). `MessageRole` enum at `app/Support/Enums/MessageRole.php` currently has `Buyer | Seller | Admin`; this plan extends it with `System`. `NotificationCategory::Support` exists; this plan adds `Returns`. `Order` has `delivered_at`, `purchase_id`, `store_id`, items via `hasMany OrderItem`. `Order::store->owner_user_id` and `Order::purchase->buyer_id` are the auth signals. `MessagePoster` exists and accepts `MessageRole::System` posts after the enum extension. `StripeService::refund(orderId, amountCents)` already does partial Stripe refunds + Layer-8 transfer-reversal coordination — reuse it directly. Last-known head: `35d36e9` (api), `e682002` (web). Test counts at start: API **480 passing**, web **165 passing (1 skipped)**.

**Successor plans:**
- `2026-XX-XX-layer-10-return-shipping.md` — EasyPost return-label issuance, in-transit tracking webhook, `mark-received` action, refund-on-receipt (defers refund out of `approve`), per-store ship-from address fallback.
- `2026-XX-XX-layer-10-proactive-and-escalation.md` — seller-initiated proactive refund (full + partial + keep-it), `ReturnEscalation` model, `/admin/returns` queue, admin resolution endpoint, audit log row.

---

## Phase A — Enums + migrations

### Task 1: Returns enums

**Files:**
- Create: `api/app/Support/Enums/ReturnState.php`
- Create: `api/app/Support/Enums/ReturnReason.php`
- Create: `api/app/Support/Enums/ReturnInitiatedBy.php`
- Create: `api/app/Support/Enums/ReturnShippingPayer.php`
- Update: `api/app/Support/Enums/MessageRole.php` (add `System`)
- Update: `api/app/Support/Enums/NotificationCategory.php` (add `Returns`)
- Test: `api/tests/Unit/ReturnEnumsTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Unit;

use App\Support\Enums\MessageRole;
use App\Support\Enums\NotificationCategory;
use App\Support\Enums\ReturnInitiatedBy;
use App\Support\Enums\ReturnReason;
use App\Support\Enums\ReturnShippingPayer;
use App\Support\Enums\ReturnState;
use PHPUnit\Framework\TestCase;

class ReturnEnumsTest extends TestCase
{
    public function test_return_state_has_all_required_cases(): void
    {
        $values = array_map(fn ($c) => $c->value, ReturnState::cases());
        $this->assertEqualsCanonicalizing(
            ['requested', 'approved', 'rejected', 'awaiting_shipment',
             'in_transit', 'received', 'refunded', 'closed', 'cancelled', 'escalated'],
            $values,
        );
    }

    public function test_return_state_terminal_helper(): void
    {
        $this->assertTrue(ReturnState::Closed->isTerminal());
        $this->assertTrue(ReturnState::Cancelled->isTerminal());
        $this->assertFalse(ReturnState::Requested->isTerminal());
        $this->assertFalse(ReturnState::Approved->isTerminal());
    }

    public function test_return_reason_default_payer_mapping(): void
    {
        $this->assertSame(ReturnShippingPayer::Seller, ReturnReason::Damaged->defaultPayer());
        $this->assertSame(ReturnShippingPayer::Seller, ReturnReason::WrongItem->defaultPayer());
        $this->assertSame(ReturnShippingPayer::Seller, ReturnReason::NotAsDescribed->defaultPayer());
        $this->assertSame(ReturnShippingPayer::Buyer, ReturnReason::DoesntFit->defaultPayer());
        $this->assertSame(ReturnShippingPayer::Buyer, ReturnReason::ChangedMind->defaultPayer());
        $this->assertSame(ReturnShippingPayer::Buyer, ReturnReason::Other->defaultPayer());
    }

    public function test_return_reason_restocking_fee_eligibility(): void
    {
        // No restocking fee allowed on seller-fault reasons
        $this->assertFalse(ReturnReason::Damaged->allowsRestockingFee());
        $this->assertFalse(ReturnReason::WrongItem->allowsRestockingFee());
        $this->assertFalse(ReturnReason::NotAsDescribed->allowsRestockingFee());
        // Allowed on buyer-side reasons
        $this->assertTrue(ReturnReason::DoesntFit->allowsRestockingFee());
        $this->assertTrue(ReturnReason::ChangedMind->allowsRestockingFee());
        $this->assertTrue(ReturnReason::Other->allowsRestockingFee());
    }

    public function test_return_initiated_by_cases(): void
    {
        $this->assertEqualsCanonicalizing(
            ['buyer', 'seller'],
            array_map(fn ($c) => $c->value, ReturnInitiatedBy::cases()),
        );
    }

    public function test_message_role_gains_system(): void
    {
        $this->assertContains(
            'system',
            array_map(fn ($c) => $c->value, MessageRole::cases()),
        );
    }

    public function test_notification_category_gains_returns(): void
    {
        $this->assertContains(
            'returns',
            array_map(fn ($c) => $c->value, NotificationCategory::cases()),
        );
    }
}
```

- [ ] **Step 2: Run, confirm failure** (enum classes missing)

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

`ReturnState.php`:
```php
<?php

declare(strict_types=1);

namespace App\Support\Enums;

enum ReturnState: string
{
    case Requested = 'requested';
    case Approved = 'approved';
    case Rejected = 'rejected';
    case AwaitingShipment = 'awaiting_shipment';
    case InTransit = 'in_transit';
    case Received = 'received';
    case Refunded = 'refunded';
    case Closed = 'closed';
    case Cancelled = 'cancelled';
    case Escalated = 'escalated';

    public function isTerminal(): bool
    {
        return in_array($this, [self::Closed, self::Cancelled], true);
    }
}
```

`ReturnReason.php`:
```php
<?php

declare(strict_types=1);

namespace App\Support\Enums;

enum ReturnReason: string
{
    case Damaged = 'damaged';
    case WrongItem = 'wrong_item';
    case NotAsDescribed = 'not_as_described';
    case DoesntFit = 'doesnt_fit';
    case ChangedMind = 'changed_mind';
    case Other = 'other';

    public function defaultPayer(): ReturnShippingPayer
    {
        return match ($this) {
            self::Damaged, self::WrongItem, self::NotAsDescribed => ReturnShippingPayer::Seller,
            default => ReturnShippingPayer::Buyer,
        };
    }

    public function allowsRestockingFee(): bool
    {
        return ! in_array($this, [self::Damaged, self::WrongItem, self::NotAsDescribed], true);
    }
}
```

`ReturnInitiatedBy.php`:
```php
<?php

declare(strict_types=1);

namespace App\Support\Enums;

enum ReturnInitiatedBy: string
{
    case Buyer = 'buyer';
    case Seller = 'seller';
}
```

`ReturnShippingPayer.php`:
```php
<?php

declare(strict_types=1);

namespace App\Support\Enums;

enum ReturnShippingPayer: string
{
    case Seller = 'seller';
    case Buyer = 'buyer';
    case None = 'none';   // 'keep-it' / refund without physical return
}
```

Extend `MessageRole`:
```php
case System = 'system';
```

Extend `NotificationCategory`:
```php
case Returns = 'returns';
```

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

### Task 2: Migrations

**Files:**
- Create: `api/database/migrations/2026_05_07_100001_create_returns_table.php`
- Create: `api/database/migrations/2026_05_07_100002_create_return_items_table.php`
- Create: `api/database/migrations/2026_05_07_100003_add_restocking_fee_percent_max_to_store_settings.php`
- Test: `api/tests/Feature/Returns/ReturnsSchemaTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Returns;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;

class ReturnsSchemaTest extends TestCase
{
    use RefreshDatabase;

    public function test_returns_table_exists_with_expected_columns(): void
    {
        $this->assertTrue(Schema::hasTable('returns'));
        foreach ([
            'id', 'order_id', 'initiated_by', 'initiator_user_id', 'state',
            'reason', 'reason_text', 'return_shipping_payer', 'restocking_fee_cents',
            'refund_amount_cents', 'stripe_refund_id', 'easypost_shipment_id',
            'easypost_shipment_cost_cents', 'tracking_number', 'carrier',
            'approved_at', 'rejected_at', 'label_issued_at', 'in_transit_at',
            'received_at', 'refunded_at', 'closed_at', 'cancelled_at',
            'created_at', 'updated_at',
        ] as $col) {
            $this->assertTrue(
                Schema::hasColumn('returns', $col),
                "returns.$col missing",
            );
        }
    }

    public function test_return_items_table_exists(): void
    {
        $this->assertTrue(Schema::hasTable('return_items'));
        foreach (['id', 'return_id', 'order_item_id', 'quantity'] as $col) {
            $this->assertTrue(Schema::hasColumn('return_items', $col), "return_items.$col missing");
        }
    }

    public function test_partial_unique_index_blocks_two_open_returns_on_same_order(): void
    {
        // The constraint is: at most one row per order_id where state NOT IN ('closed','cancelled')
        // Insert one open return, then attempt a second; the second must fail.
        $orderId = (string) \Illuminate\Support\Str::uuid();
        $userId = (string) \Illuminate\Support\Str::uuid();
        \App\Models\User::factory()->create(['id' => $userId]);
        \App\Models\Order::factory()->create(['id' => $orderId]);

        DB::table('returns')->insert([
            'id' => (string) \Illuminate\Support\Str::uuid(),
            'order_id' => $orderId,
            'initiated_by' => 'buyer',
            'initiator_user_id' => $userId,
            'state' => 'requested',
            'reason' => 'damaged',
            'return_shipping_payer' => 'seller',
            'restocking_fee_cents' => 0,
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        $this->expectException(\Illuminate\Database\QueryException::class);
        DB::table('returns')->insert([
            'id' => (string) \Illuminate\Support\Str::uuid(),
            'order_id' => $orderId,
            'initiated_by' => 'buyer',
            'initiator_user_id' => $userId,
            'state' => 'approved',
            'reason' => 'damaged',
            'return_shipping_payer' => 'seller',
            'restocking_fee_cents' => 0,
            'created_at' => now(),
            'updated_at' => now(),
        ]);
    }

    public function test_store_settings_gains_restocking_fee_percent_max(): void
    {
        $this->assertTrue(Schema::hasColumn('store_settings', 'restocking_fee_percent_max'));
    }
}
```

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

- [ ] **Step 3: Author the migrations**

`create_returns_table.php`:
```php
public function up(): void
{
    Schema::create('returns', function (Blueprint $t) {
        $t->uuid('id')->primary();
        $t->foreignUuid('order_id')->constrained('orders')->cascadeOnDelete();
        $t->string('initiated_by', 16);          // enum: buyer / seller
        $t->foreignUuid('initiator_user_id')->constrained('users');
        $t->string('state', 32);                  // enum: requested...escalated
        $t->string('reason', 32);                 // enum: damaged...other
        $t->text('reason_text')->nullable();
        $t->string('return_shipping_payer', 16);  // enum: seller / buyer / none
        $t->unsignedInteger('restocking_fee_cents')->default(0);
        $t->unsignedInteger('refund_amount_cents')->nullable();
        $t->string('stripe_refund_id')->nullable();
        $t->string('easypost_shipment_id')->nullable();
        $t->unsignedInteger('easypost_shipment_cost_cents')->nullable();
        $t->string('tracking_number')->nullable();
        $t->string('carrier')->nullable();
        $t->timestamp('approved_at')->nullable();
        $t->timestamp('rejected_at')->nullable();
        $t->timestamp('label_issued_at')->nullable();
        $t->timestamp('in_transit_at')->nullable();
        $t->timestamp('received_at')->nullable();
        $t->timestamp('refunded_at')->nullable();
        $t->timestamp('closed_at')->nullable();
        $t->timestamp('cancelled_at')->nullable();
        $t->timestamps();

        $t->index(['order_id', 'created_at']);
        $t->index('state');
    });

    // Partial unique index: at most one open return per order
    DB::statement("
        CREATE UNIQUE INDEX returns_one_open_per_order
        ON returns (order_id)
        WHERE state NOT IN ('closed', 'cancelled')
    ");
}

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

`create_return_items_table.php`:
```php
Schema::create('return_items', function (Blueprint $t) {
    $t->uuid('id')->primary();
    $t->foreignUuid('return_id')->constrained('returns')->cascadeOnDelete();
    $t->foreignUuid('order_item_id')->constrained('order_items');
    $t->unsignedInteger('quantity')->default(1);
    $t->timestamps();
    $t->index('return_id');
});
```

`add_restocking_fee_percent_max_to_store_settings.php`:
```php
Schema::table('store_settings', function (Blueprint $t) {
    $t->unsignedSmallInteger('restocking_fee_percent_max')->default(20);
});
```

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

### Task 3: Models + factory + relationships

**Files:**
- Create: `api/app/Models/Return.php` — note: PHP class name `ReturnModel` (since `Return` is reserved); aliased to `Return` via `class_alias` is overkill — just name the class `OrderReturn` and reference it everywhere. (Confirms during review.)
- Create: `api/app/Models/ReturnItem.php`
- Create: `api/database/factories/OrderReturnFactory.php`
- Create: `api/database/factories/ReturnItemFactory.php`
- Update: `api/app/Models/Order.php` (add `returns()` + `openReturn()`)
- Update: `api/app/Models/StoreSettings.php` (add `restocking_fee_percent_max` to `$fillable`/cast)
- Test: `api/tests/Feature/Returns/OrderReturnModelTest.php`

> **Plan note (naming):** PHP allows `class Return` but it's awkward (reserved-word-ish in many tools, breaks IDE autocomplete). Settle on **`OrderReturn`** as the model class name and `order_return` references in code; the *table* stays `returns` (idiomatic Laravel pluralization works there), and `OrderReturn::$table = 'returns'` overrides the auto-derived table name.

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

```php
public function test_order_return_belongs_to_order_and_initiator(): void
{
    $buyer = User::factory()->create();
    $purchase = Purchase::factory()->create(['buyer_id' => $buyer->id]);
    $order = Order::factory()->create(['purchase_id' => $purchase->id]);

    $return = OrderReturn::factory()->create([
        'order_id' => $order->id,
        'initiator_user_id' => $buyer->id,
        'initiated_by' => ReturnInitiatedBy::Buyer,
        'state' => ReturnState::Requested,
        'reason' => ReturnReason::Damaged,
        'return_shipping_payer' => ReturnShippingPayer::Seller,
    ]);

    $this->assertSame($order->id, $return->order->id);
    $this->assertSame($buyer->id, $return->initiator->id);
    $this->assertInstanceOf(ReturnState::class, $return->state);
    $this->assertInstanceOf(ReturnReason::class, $return->reason);
}

public function test_order_returns_relation_and_open_return_helper(): void
{
    $order = Order::factory()->create();
    $closed = OrderReturn::factory()->create([
        'order_id' => $order->id,
        'state' => ReturnState::Closed,
    ]);
    $open = OrderReturn::factory()->create([
        'order_id' => $order->id,
        'state' => ReturnState::Requested,
    ]);

    $this->assertCount(2, $order->returns);
    $this->assertSame($open->id, $order->openReturn()?->id);
}
```

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

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

`OrderReturn.php`:
```php
class OrderReturn extends Model
{
    use HasFactory;
    use HasUuid;

    protected $table = 'returns';

    protected $fillable = [
        'order_id', 'initiated_by', 'initiator_user_id', 'state', 'reason',
        'reason_text', 'return_shipping_payer', 'restocking_fee_cents',
        'refund_amount_cents', 'stripe_refund_id', 'easypost_shipment_id',
        'easypost_shipment_cost_cents', 'tracking_number', 'carrier',
        'approved_at', 'rejected_at', 'label_issued_at', 'in_transit_at',
        'received_at', 'refunded_at', 'closed_at', 'cancelled_at',
    ];

    protected function casts(): array
    {
        return [
            'initiated_by' => ReturnInitiatedBy::class,
            'state' => ReturnState::class,
            'reason' => ReturnReason::class,
            'return_shipping_payer' => ReturnShippingPayer::class,
            'approved_at' => 'datetime',
            'rejected_at' => 'datetime',
            'label_issued_at' => 'datetime',
            'in_transit_at' => 'datetime',
            'received_at' => 'datetime',
            'refunded_at' => 'datetime',
            'closed_at' => 'datetime',
            'cancelled_at' => 'datetime',
        ];
    }

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

    public function initiator(): BelongsTo
    {
        return $this->belongsTo(User::class, 'initiator_user_id');
    }

    public function items(): HasMany
    {
        return $this->hasMany(ReturnItem::class, 'return_id');
    }
}
```

`ReturnItem.php`: standard `BelongsTo OrderReturn`, `BelongsTo OrderItem`, `quantity` int cast.

`Order::openReturn()` helper:
```php
public function returns(): HasMany
{
    return $this->hasMany(OrderReturn::class, 'order_id');
}

public function openReturn(): ?OrderReturn
{
    return $this->returns()
        ->whereNotIn('state', [ReturnState::Closed->value, ReturnState::Cancelled->value])
        ->first();
}
```

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

---

## Phase B — Services

### Task 4: `MessageRole::System` plumbing in `MessagePoster`

**Files:**
- Update: `api/app/Modules/Messaging/Services/MessagePoster.php` (add `postSystem(Order $order, string $body, array $properties = []): Message`)
- Update: `api/app/Modules/Messaging/Resources/MessageResource.php` (system messages serialize `author_user_id: null`, `author_role: 'system'`)
- Update: `api/app/Modules/Messaging/Resources/MessageResource.php` deletion guard — system messages cannot be deleted (`MessageController::destroy` 422s when `author_role === System`)
- Test: extend `api/tests/Feature/Messaging/MessagePosterTest.php`

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

```php
public function test_post_system_creates_message_without_user_and_with_role_system(): void
{
    Notification::fake();
    $order = Order::factory()->create();

    $msg = app(MessagePoster::class)->postSystem(
        $order,
        'Buyer requested a return — reason: damaged.',
    );

    $this->assertSame(MessageRole::System, $msg->author_role);
    $this->assertNull($msg->author_user_id);
    $this->assertStringStartsWith('Buyer requested', $msg->body);

    Notification::assertNothingSent();   // system messages don't notify
}

public function test_admin_cannot_delete_system_message(): void
{
    $order = Order::factory()->create();
    $msg = app(MessagePoster::class)->postSystem($order, 'system row');

    $admin = User::factory()->create();
    $admin->assignRole('admin');
    Sanctum::actingAs($admin);

    $this->deleteJson("/v1/messages/{$msg->id}")->assertStatus(422);
}
```

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

- [ ] **Step 3: Implement**

`MessagePoster::postSystem`:
```php
public function postSystem(Order $order, string $body, array $properties = []): Message
{
    return DB::transaction(function () use ($order, $body, $properties) {
        $thread = MessageThread::query()->firstOrCreate(['order_id' => $order->id]);

        return Message::query()->create([
            'thread_id' => $thread->id,
            'author_user_id' => null,
            'author_role' => MessageRole::System,
            'body' => $body,
        ]);
    });
}
```

Make `messages.author_user_id` nullable (it already is per the Layer 9 schema — confirm in the migration; if not, add `..._make_messages_author_user_id_nullable.php`).

`MessageController::destroy` guard:
```php
if ($message->author_role === MessageRole::System) {
    abort(422, 'System messages cannot be deleted.');
}
```

- [ ] **Step 4: Run; iterate to 2/2 PASS** (plus existing MessagePoster tests still pass)

### Task 5: `ReturnAccess` service

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

`ReturnAccess` mirrors `MessageThreadAccess`:
```php
public function canView(User $user, OrderReturn $return): bool
{
    if ($user->hasRole('admin')) return true;
    $return->loadMissing('order.purchase.buyer', 'order.store');
    if ($return->order->purchase->buyer_id === $user->id) return true;
    if ($return->order->store->owner_user_id === $user->id) return true;
    return false;
}

/** Returns 'buyer' | 'seller' | 'admin' or null if unauthorized. */
public function roleFor(User $user, OrderReturn $return): ?string
{
    if ($user->hasRole('admin')) return 'admin';
    if ($return->order->purchase->buyer_id === $user->id) return 'buyer';
    if ($return->order->store->owner_user_id === $user->id) return 'seller';
    return null;
}
```

Tests cover all four cases (buyer / seller / admin / unrelated) for both methods. 4 assertions across 2 tests.

### Task 6: `ReturnRefundIssuer` service

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

This wraps the existing `StripeService::refund(Order $order, int $amountCents, string $idempotencyKey)` (used by Layer 8 admin actions) with returns-specific bookkeeping:

```php
final class ReturnRefundIssuer
{
    public function __construct(private readonly StripeService $stripe) {}

    /** Issues the Stripe refund (with transfer reversal coordination via StripeService).
     *  Persists stripe_refund_id + refund_amount_cents + refunded_at on the return.
     *  Caller is responsible for the state transition. */
    public function issue(OrderReturn $return): void
    {
        $amount = $this->computeRefundAmount($return);

        $refundId = $this->stripe->refund(
            $return->order,
            $amount,
            idempotencyKey: "return-refund-{$return->id}",
        );

        $return->update([
            'stripe_refund_id' => $refundId,
            'refund_amount_cents' => $amount,
            'refunded_at' => now(),
        ]);
    }

    private function computeRefundAmount(OrderReturn $return): int
    {
        $return->loadMissing('items.orderItem');
        $itemSubtotal = $return->items->sum(
            fn ($ri) => $ri->orderItem->price_snapshot * $ri->quantity,
        );
        return max(0, $itemSubtotal - $return->restocking_fee_cents);
    }
}
```

Tests:
- `test_refund_amount_is_item_subtotal_minus_restocking_fee`
- `test_issue_persists_stripe_refund_id_and_refunded_at`
- `test_idempotency_key_includes_return_id` (mock StripeService, capture call)

3 tests; uses `Mockery::mock(StripeService::class)` + binds into the container.

### Task 7: `ReturnTransitioner` service (state machine for Plan 1 transitions)

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

In Plan 1, `ReturnTransitioner` supports four transition methods:

```php
public function request(Order $order, User $buyer, ReturnReason $reason, ?string $reasonText, array $orderItemIds): OrderReturn
public function approve(OrderReturn $return, User $seller, int $restockingFeeCents): OrderReturn   // immediately refunds; no shipping in Plan 1
public function reject(OrderReturn $return, User $seller, string $reasonText): OrderReturn
public function cancel(OrderReturn $return, User $buyer): OrderReturn
```

Each method:
1. Validates the current state allows the transition (else `abort(422)`).
2. Validates the actor matches the role (buyer or seller).
3. Inside `DB::transaction`:
   a. Updates the return row (state + transition timestamp).
   b. For `approve`: also calls `ReturnRefundIssuer::issue(...)` and immediately transitions to `Refunded` then `Closed` (Plan 1 has no in-transit leg).
   c. Posts the appropriate system message via `MessagePoster::postSystem`.
   d. Fires the appropriate notification.
4. Returns the fresh model.

`request` validations:
- `$order->delivered_at` is not null AND `now() <= $order->delivered_at + 14d` (else 422 "Return window has expired").
- `$order->openReturn()` is null (else 422 — partial unique index also enforces, but message is friendlier).
- All `$orderItemIds` belong to `$order` (else 422).
- `$reason` is `Other` → `$reasonText` required (else 422).

`approve` validations:
- `$return->state === Requested` (else 422).
- If `$return->reason->allowsRestockingFee()` is false, `$restockingFeeCents` MUST be 0 (else 422).
- Else, `$restockingFeeCents` MUST be `<= floor(itemSubtotal * store.restocking_fee_percent_max / 100)` (else 422).

`reject` validations:
- `$return->state === Requested`.
- `$reasonText` non-empty (controller-level via FormRequest).

`cancel` validations:
- `$return->state === Requested`.
- `$buyer->id === $return->initiator_user_id`.

System messages (illustrative):
- `request`: `"Buyer requested a return for {N} item(s) — reason: {reason}."`
- `approve`: `"Seller approved the return. Refund of \${refund/100}{ + restocking fee \${fee/100}} will be issued."` (Plan 1 always immediately refunds, so a follow-up system row gets posted after `ReturnRefundIssuer::issue`: `"Refunded \${amount/100} to the original payment method."`)
- `reject`: `"Seller rejected the return: \"{reasonText}\""`
- `cancel`: `"Buyer cancelled the return request."`

Tests (one per transition + a full happy path + each invalid-state case):

```php
test_request_creates_return_in_requested_state_and_posts_system_message
test_request_fails_outside_14d_window
test_request_fails_when_open_return_exists
test_request_with_other_reason_requires_reason_text
test_approve_transitions_to_refunded_then_closed_and_invokes_refund_issuer
test_approve_with_restocking_on_doesnt_fit_succeeds
test_approve_with_restocking_on_damaged_returns_422
test_approve_above_store_cap_returns_422
test_reject_transitions_to_rejected_with_reason_text
test_cancel_by_buyer_transitions_to_cancelled
test_cancel_by_other_user_returns_422
test_each_transition_posts_system_message_in_thread
test_each_transition_fires_correct_notification
```

~13 tests. Use `Notification::fake()` and `Mockery` for `ReturnRefundIssuer`.

---

## Phase C — Notifications

### Task 8: Returns notification classes

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/ReturnRequestedNotification.php` (recipient: seller)
- Create: `api/app/Modules/Notifications/Notifications/ReturnApprovedNotification.php` (recipient: buyer)
- Create: `api/app/Modules/Notifications/Notifications/ReturnRejectedNotification.php` (recipient: buyer)
- Create: `api/app/Modules/Notifications/Notifications/ReturnRefundedNotification.php` (recipient: buyer)
- Test: `api/tests/Feature/Returns/ReturnNotificationsTest.php`

All four follow the `MessagePostedToBuyer/SellerNotification` pattern: `via()` consults `NotificationPreferenceGate->channelsFor($notifiable, NotificationCategory::Returns, ['mail', 'database'])`. `toMail` includes the order id, return id, snippet, and a CTA URL (`/purchases/[id]` for buyer, `/seller/orders/[id]` for seller). `toDatabase` carries `title`, `body`, `cta_url`, `icon: 'package-return'`, `context_type: 'order'`, `context_id`.

Subject lines (`toMail`):
- `Requested`: "New return request on order #{short_id}"
- `Approved`: "Your return on order #{short_id} was approved"
- `Rejected`: "Your return on order #{short_id} was declined"
- `Refunded`: "You've been refunded \${amount/100}"

Database titles:
- `Requested`: "New return request"
- `Approved`: "Return approved"
- `Rejected`: "Return declined"
- `Refunded`: "Refund issued"

Tests assert: `via()` honors preferences (mock the gate), `toDatabase()` returns the expected shape, `toMail()` subject + key body strings present. 4 classes × 3 assertions = 12 tests.

---

## Phase D — Endpoints

### Task 9: Buyer endpoints

**Files:**
- Create: `api/app/Modules/Returns/Controllers/BuyerReturnController.php`
- Create: `api/app/Modules/Returns/Requests/CreateReturnRequest.php`
- Create: `api/app/Modules/Returns/Resources/ReturnResource.php`
- Create: `api/app/Modules/Returns/Resources/ReturnSummaryResource.php`
- Create: `api/app/Modules/Returns/routes.php` (auth:sanctum group)
- Update: `api/routes/api.php` (require the module's routes file)
- Test: `api/tests/Feature/Returns/BuyerReturnEndpointsTest.php`

`BuyerReturnController`:
```php
public function store(CreateReturnRequest $request, Order $order): JsonResponse
public function index(Request $request): JsonResponse                  // GET /me/returns
public function cancel(Request $request, OrderReturn $return): JsonResponse
public function show(Request $request, OrderReturn $return): JsonResponse  // shared with seller; route lives at /v1/returns/{return}
```

`CreateReturnRequest` validates:
- `reason` ∈ ReturnReason::values()
- `reason_text` required if `reason === 'other'`, max 2000
- `item_ids` array, min 1, each is uuid
- `attachment_ids` array, max 4, each is uuid (reuses Layer 9 attachment infra)

The controller delegates to `ReturnTransitioner::request(...)`.

`ReturnResource` serializes:
```
id, order_id, state, reason, reason_text, return_shipping_payer,
restocking_fee_cents, refund_amount_cents, stripe_refund_id,
items: [{ id, order_item_id, quantity, title_snapshot, price_snapshot }],
timestamps (transition + created/updated)
```

`ReturnSummaryResource` (for list endpoints):
```
id, order_id, state, reason, refund_amount_cents,
counterparty_name (store name for buyer view), created_at, updated_at
```

Routes registered in `api/app/Modules/Returns/routes.php`:
```php
Route::middleware('auth:sanctum')->group(function () {
    Route::post('/orders/{order}/returns', [BuyerReturnController::class, 'store']);
    Route::get('/me/returns', [BuyerReturnController::class, 'index']);
    Route::get('/returns/{return}', [BuyerReturnController::class, 'show']);
    Route::post('/returns/{return}/cancel', [BuyerReturnController::class, 'cancel']);
});
```

Tests (~10):
- POST happy path: creates return, returns 201 with full resource, posts system message, fires `ReturnRequestedNotification` to seller
- POST with non-buyer user → 403
- POST when window expired → 422
- POST when open return exists → 422
- POST with item_ids not on the order → 422
- GET /me/returns lists buyer's returns, paginated, ordered by updated_at desc
- GET /returns/{id} returns 200 for buyer
- GET /returns/{id} returns 403 for unrelated user
- POST /returns/{id}/cancel happy path
- POST /returns/{id}/cancel after approval → 422

### Task 10: Seller endpoints

**Files:**
- Create: `api/app/Modules/Returns/Controllers/SellerReturnController.php`
- Create: `api/app/Modules/Returns/Requests/ApproveReturnRequest.php`
- Create: `api/app/Modules/Returns/Requests/RejectReturnRequest.php`
- Update: `api/app/Modules/Returns/routes.php`
- Test: `api/tests/Feature/Returns/SellerReturnEndpointsTest.php`

`SellerReturnController`:
```php
public function index(Request $request): JsonResponse                  // GET /seller/returns
public function approve(ApproveReturnRequest $r, OrderReturn $return): JsonResponse
public function reject(RejectReturnRequest $r, OrderReturn $return): JsonResponse
```

`ApproveReturnRequest`: `restocking_fee_cents: integer, min:0` (additional validation in the transitioner).
`RejectReturnRequest`: `reason_text: required, string, min:10, max:2000`.

Routes:
```php
Route::get('/seller/returns', [SellerReturnController::class, 'index']);
Route::post('/returns/{return}/approve', [SellerReturnController::class, 'approve']);
Route::post('/returns/{return}/reject', [SellerReturnController::class, 'reject']);
```

Tests (~10):
- Seller approve happy path: 200, return now `closed` (refunded → closed in Plan 1), `stripe_refund_id` populated, system messages posted, `ReturnApprovedNotification` + `ReturnRefundedNotification` fired to buyer
- Approve with restocking_fee on damaged → 422
- Approve with restocking_fee above store cap → 422
- Approve by non-seller → 403
- Approve when state ≠ requested → 422
- Reject happy path: 200, return now `rejected`, system message posted, `ReturnRejectedNotification` to buyer
- Reject by non-seller → 403
- Reject with too-short reason_text → 422
- Seller index lists returns across seller's stores, filterable by `?state=...`
- Seller index excludes other sellers' returns

---

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

### Task 11: OpenAPI

**Files:**
- Update: `api/contracts/openapi.yaml` (add `Returns` tag, ~6 paths, 4 schemas)

Schemas to add:
- `OrderReturn` (full resource)
- `OrderReturnSummary` (list resource)
- `ReturnItem`
- Extend `Message` enum for `author_role: system`

Paths:
- `POST /v1/orders/{order}/returns`
- `GET /v1/me/returns`
- `GET /v1/returns/{return}`
- `POST /v1/returns/{return}/cancel`
- `GET /v1/seller/returns`
- `POST /v1/returns/{return}/approve`
- `POST /v1/returns/{return}/reject`

Validate YAML with the existing `python3 -c "yaml.safe_load(...)"` snippet.

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

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

Add `web/packages/api-client/src/endpoints/returns.ts`:
```ts
export interface OrderReturn { ... }
export interface OrderReturnSummary { ... }
export interface CreateReturnInput {
  reason: ReturnReason;
  reason_text?: string;
  item_ids: string[];
  attachment_ids?: string[];
}

export function createReturnEndpoints(client: AlqoveClient) {
  return {
    requestReturn: (orderId: string, input: CreateReturnInput) =>
      client.post<ApiResponse<OrderReturn>>(`/v1/orders/${orderId}/returns`, input),
    get: (returnId: string) =>
      client.get<ApiResponse<OrderReturn>>(`/v1/returns/${returnId}`),
    cancel: (returnId: string) =>
      client.post<ApiResponse<OrderReturn>>(`/v1/returns/${returnId}/cancel`, {}),
    approve: (returnId: string, restockingFeeCents = 0) =>
      client.post<ApiResponse<OrderReturn>>(`/v1/returns/${returnId}/approve`, { restocking_fee_cents: restockingFeeCents }),
    reject: (returnId: string, reasonText: string) =>
      client.post<ApiResponse<OrderReturn>>(`/v1/returns/${returnId}/reject`, { reason_text: reasonText }),
  };
}

export function createMyReturnsEndpoint(client: AlqoveClient) {
  return (params: { state?: ReturnState } = {}) =>
    client.get<ApiResponse<OrderReturnSummary[]>>(`/v1/me/returns${toQuery({ ...params })}`);
}

export function createSellerReturnsEndpoint(client: AlqoveClient) {
  return (params: { state?: ReturnState } = {}) =>
    client.get<ApiResponse<OrderReturnSummary[]>>(`/v1/seller/returns${toQuery({ ...params })}`);
}
```

Wire into the existing `client` factory (`api.returns.*`, `api.me.returns(...)`, `api.seller.returns(...)`).

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

---

## Phase F — Frontend (buyer surface)

### Task 13: TanStack hooks

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

Exports: `useReturn(returnId)`, `useMyReturns({ state })`, `useSellerReturns({ state })`, `useRequestReturn(orderId)`, `useCancelReturn()`, `useApproveReturn()`, `useRejectReturn()`.

Each mutation invalidates the relevant query keys + the order's `['orders', orderId]` + the messages thread key (system rows added by the backend will appear on next refetch).

### Task 14: Shared components

**Files:**
- Create: `web/src/components/returns/return-state-badge.tsx`
- Create: `web/src/components/returns/return-timeline.tsx`
- Create: `web/src/components/returns/return-request-modal.tsx`
- Test: `web/src/components/returns/__tests__/return-request-modal.test.tsx`
- Test: `web/src/components/returns/__tests__/return-timeline.test.tsx`

`<ReturnStateBadge state={...} />` — pill with state-specific background:
- requested: amber, "Awaiting seller"
- approved: emerald, "Approved"
- rejected: red, "Declined"
- refunded: emerald, "Refunded"
- closed: slate, "Closed"
- cancelled: slate, "Cancelled"
- (other states render in Plan 2/3)

`<ReturnTimeline return={...} viewerRole="buyer" | "seller" | "admin" />` — vertical timeline of transition timestamps. In Plan 1 the timeline shows only `created → approved/rejected → refunded → closed` (or `cancelled`).

`<ReturnRequestModal order={...} open={...} onClose={...} />` — form with:
- Per-line-item checkbox list (default all checked, qty = original)
- Reason picker (radio buttons, all 6 enum values)
- Free-text "Tell us more" textarea (required when reason = other; otherwise optional, 2000 char cap)
- Attachment uploader (reuse Layer 9 `<AttachmentUploader>` — `orderId` matches; uploads share the messaging-attachments endpoint)
- Submit → `useRequestReturn(orderId).mutate({ reason, reason_text, item_ids, attachment_ids })`
- On success: close modal, invalidate `['orders', orderId]`, toast/snackbar "Return requested"

Tests:
- Modal renders all reasons + per-line items
- Submit disabled until reason picked + at least one item checked
- "Other" reason requires reason_text
- Successful submit calls mutation with correct payload

### Task 15: Buyer order-card integration

**Files:**
- Update: `web/src/app/(buyer)/purchases/[id]/purchase-detail-client.tsx`
- Test: `web/src/app/(buyer)/purchases/[id]/__tests__/purchase-detail-client.test.tsx` (extend)

Per-order card additions:
1. **"Request return" button** — visible iff `order.delivered_at && now <= delivered_at + 14d && !order.open_return_id`. Opens `<ReturnRequestModal>`.
2. **Return state strip** — visible iff `order.open_return_id`. Renders `<ReturnStateBadge>` + last-event timestamp + "View details" link that expands to show the inline `<ReturnTimeline>`.

The order resource gains two fields (already enumerated in spec): `returns_open_until`, `open_return_id`. Make sure the OrderResource (api side) emits them.

Tests extend the existing `purchase-detail-client.test.tsx`:
- "Request return" button visible when in window + no open return
- Hidden when delivered_at null
- Hidden when window expired
- Hidden when open return exists
- Clicking it opens the modal

---

## Phase G — Frontend (seller surface)

### Task 16: Seller `/seller/returns` list page

**Files:**
- Create: `web/src/app/(seller)/seller/returns/page.tsx`
- Create: `web/src/app/(seller)/seller/returns/seller-returns-client.tsx`
- Test: `web/src/app/(seller)/seller/returns/__tests__/seller-returns-client.test.tsx`

Layout: filter chips (`requested` / `approved` / `rejected` / `refunded` / `closed` / `cancelled` / `all`), table of returns (state badge, order short id, buyer name, reason, refund amount when present, last-activity timestamp). Default filter `requested`. Click row → `/seller/orders/[id]`.

Tests: filter chips toggle the query, default selection is `requested`, empty state when none.

### Task 17: Seller order-detail returns panel

**Files:**
- Update: `web/src/app/(seller)/seller/orders/[id]/order-detail-client.tsx`
- Create: `web/src/components/seller/seller-returns-panel.tsx`
- Test: `web/src/components/seller/__tests__/seller-returns-panel.test.tsx`

Panel renders below the existing Messages panel (added in Layer 9 Plan 2):

- When `order.open_return_id` is null: nothing (Plan 3 will add the proactive-refund button here).
- When set: full `<ReturnTimeline>` + per-state action bar:
  - state=requested: "Approve" button (opens a small dialog with restocking-fee input — disabled when reason is seller-fault) + "Decline" button (opens textarea dialog for reason_text).
  - state=rejected: read-only (Plan 3 will surface the buyer's escalation if they pushed it).
  - state=closed/cancelled/refunded: read-only, shows refund amount + Stripe refund id (last 8 chars).

Approve dialog enforces the restocking-fee max client-side (using the store's `restocking_fee_percent_max` from the buyer's order resource — needs to be exposed on the seller order detail too if it isn't).

Tests:
- Renders nothing when no open return
- Renders timeline + action bar when present
- Approve flow calls the mutation with correct payload
- Reject flow requires ≥10 chars of reason_text
- Approve restocking-fee input hidden when reason is damaged/wrong/not-as-described

### Task 18: Notification bell wiring

**Files:**
- Update: `web/src/components/notifications/notification-bell.tsx` (or wherever the icon→route map lives)
- Test: extend the notification bell test if one exists

Add icon `'package-return'` → route by `context_type=order` to `/seller/orders/{context_id}` (seller) or `/purchases/{context_id}` (buyer). Visual icon: a small returning-arrow on a box. Use a Lucide icon (`PackageOpen` or `ArrowDownToLine`) to avoid asset work in this plan.

---

## Phase H — Wrap-up

### Task 19: Full sweep

- [ ] **Step 1: Backend** — `docker compose exec -T laravel.test php artisan test`. Expected: 480 → ≥ **530** (~50 new tests across enums, schema, models, services, notifications, and endpoints).
- [ ] **Step 2: Backend lint** — `./vendor/bin/pint app/Modules/Returns app/Models/OrderReturn.php app/Models/ReturnItem.php tests/Feature/Returns`. 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`. Expected: 165 → ≥ **180** (~15 new tests across hooks, modal, timeline, panel, list page).
- [ ] **Step 6: Local build** — `npm run build:web` (Layer 9 already taught us not to skip this — new `/seller/returns` page may have prerender quirks).
- [ ] **Step 7: Manual QA** —
  - As a buyer with a recently-delivered order: open `/purchases/[id]`, see "Request return" button, file a return for damaged item.
  - As the seller: see the new return in `/seller/returns`, navigate to the order, approve it. Confirm Stripe refund created (test mode), `stripe_refund_id` visible on the panel, buyer receives the `ReturnRefundedNotification` via bell + Mailpit.
  - As the buyer: see the return state strip flip to "Refunded" + closed within seconds of seller approval.
  - As the seller: file a duplicate return on the same order — confirm 422 from partial unique index (or controller pre-check).
  - As the buyer: try to file a return on an order delivered >14 days ago — confirm "Request return" button hidden.
  - Open `/admin/orders/[id]` — confirm the order's Layer 9 thread shows the system messages (italic gray) for `requested`, `approved`, `refunded`.

### Task 20: Commit + push

- [ ] **Step 1:** In `~/projects/alqove-api`, stage `app contracts database tests docs` and commit with `feat(returns): foundation + buyer-initiated returns (no shipping)`.
- [ ] **Step 2:** In `~/projects/alqove-web`, stage `packages web contracts` and commit with `feat(returns): buyer return request flow + seller approve/reject`.
- [ ] **Step 3:** Push both. Watch GH Actions on each — both should be green inside 3 minutes.

---

## Open items deferred to Plans 2 / 3

- **EasyPost return label issuance** — entire shipping leg between approve and refund. Plan 2.
- **`mark-received` action + tracking webhook** — Plan 2.
- **Seller-initiated proactive refund** — `/v1/seller/orders/{order}/returns/proactive`, "keep-it" path, partial refund amount UI. Plan 3.
- **`ReturnEscalation` + `/admin/returns` queue + admin resolution endpoint + activity log row** — Plan 3.
- **`POST /returns/{id}/retry-label`** — only relevant once Plan 2 introduces label issuance. Plan 2.
- **Restocking-fee disclosure on the buyer request modal** — show the store's `restocking_fee_percent_max` so the buyer isn't surprised when they file a `doesnt_fit` return. Polish; doesn't gate Plan 1 from shipping.
- **Per-store ship-from override for return destination** — flagged in spec; defer until a seller asks for it.
- **Auto-close stale `requested` returns** — spec open item; defer.
- **Refund original outbound shipping toggle** — spec mentions a per-return seller checkbox to refund the original shipping line too. Plan 1 always excludes; add in Plan 3 with the proactive-refund UI work.
- **EasyPost shipment-cost reconciliation against payouts** — recorded in `easypost_shipment_cost_cents` once Plan 2 issues labels, but no automated payout deduction in this layer. Future infra work.
