# Layer 10 Plan 2: Return Shipping (label issuance, in-transit tracking, refund-on-receipt)

> **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:** Insert the physical-shipment leg between seller approval and refund. After Plan 1, `approve` atomically refunds and closes — fine for goodwill / no-ship-back returns but wrong for the default flow. Plan 2 changes `approve` to issue an EasyPost return label and transition to `awaiting_shipment`; an EasyPost tracker webhook (or seller manual override) advances the return to `in_transit` → `received`; the refund (already implemented as `ReturnRefundIssuer::issue` in Plan 1) is *moved* from `approve` to the `mark-received` transition. Adds `POST /returns/{id}/mark-received` (seller manual override) and `POST /returns/{id}/retry-label` (recovery when EasyPost is unhappy). Plan 3 still owns seller-initiated proactive refunds (the "keep-it" path will reuse Plan 1's collapsed approve→refund→close flow but seller-side) and admin escalation.

**Architecture:** (1) Schema — one migration extends `returns` with `tracker_id`, `tracking_url`, `shipping_label_url`, `easypost_label_error`. No new enums (Plan 1 already shipped `ReturnState::{AwaitingShipment, InTransit, Received}` against the unused branch of the state machine). (2) Backend services — new `ReturnLabelService::issue(OrderReturn)` wraps `EasyPostProvider::buyCheapestLabel` with return semantics (from = `Purchase::shipping_address`, to = `Store` ship-from), persists the label artifacts on the return. `ReturnTransitioner::approve` is rewritten — it no longer touches refund/close; instead it stamps `approved_at`, calls `ReturnLabelService::issue`, transitions to `AwaitingShipment` (or leaves at `Approved` with `easypost_label_error` set if EasyPost fails). Three new methods on `ReturnTransitioner`: `markReceived` (manual seller action OR called by webhook handler — fires the deferred refund + closes), `handleCarrierEvent` (called by `TrackingService` when a return-tracker event arrives — `in_transit` and `delivered` are the actionable ones), and `retryLabel` (re-issues the label when the previous attempt errored). (3) Webhook routing — `TrackingService::applyUpdate(string $trackerId, string $status)` already looks up `Order` by `tracker_id`; extend it to fall through to `OrderReturn` lookup when no order matches and dispatch to `ReturnTransitioner::handleCarrierEvent`. (4) Endpoints — `POST /v1/returns/{return}/mark-received`, `POST /v1/returns/{return}/retry-label`. The existing `approve` endpoint shape is unchanged — only its underlying side effects move. (5) Notifications — three new ones: `ReturnLabelIssuedNotification` (to buyer, with carrier + tracking + label URL), `ReturnInTransitNotification` (to seller, "buyer dropped off the package"), `ReturnReceivedNotification` (to buyer — refund coming — and seller — acknowledgement). (6) Frontend — new state-badge variants, timeline rows, label-download CTA on the buyer's purchase view, "Mark received" + "Retry label" buttons on the seller panel.

**Tech Stack:** Laravel 12, Pest PHP, Postgres 17, EasyPost PHP SDK (existing — used by Layer 5), 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:** Plan 1 fully shipped on both repos (api `0c6a064 feat(returns): foundation + buyer-initiated returns (no shipping)`, web `44d0534 feat(returns): buyer return request flow + seller approve/reject` plus `addb2cd` Playwright fix). Layer 5 EasyPost wiring intact: `App\Modules\Shipping\Services\EasyPostProvider` with `buyCheapestLabel(ShipmentRequest): PurchasedLabel` and `verifyWebhookSignature(string, string): bool`; `App\Modules\Shipping\Services\TrackingService::applyUpdate(string $trackerId, string $status): void`; webhook route `POST /webhooks/easypost`. `Purchase::shipping_address` (cast `array`) holds the buyer address; `Store` has embedded ship-from fields (`street1`, `street2`, `city`, `state`, `zip`, `country`) plus `Store::hasCompleteShipFromAddress(): bool`. `StripeService::refundForOrder(string $paymentIntentId, int $amountCents, ?string $idempotencyKey = null): Refund` and `StripeService::reverseTransfer(...)` reused by `ReturnRefundIssuer::issue` from Plan 1. `NotificationCategory::Returns` exists. **Test counts at start: API 531 passing (Plan 1 added ~51 in `tests/Feature/Returns/`), web 180 passing.** Last-known head: `0c6a064` (api), `addb2cd` (web).

**Successor plan:**
- `2026-XX-XX-layer-10-proactive-and-escalation.md` (Plan 3) — seller-initiated proactive refund (full + partial + keep-it; the keep-it path reuses Plan 1's collapsed approve→refund→close minus the buyer request step), `ReturnEscalation` model, `/admin/returns` queue, admin resolution endpoint, audit log row, optional refund-original-shipping toggle on the proactive modal.

---

## Phase A — Schema

### Task 1: Add label-artifact + error columns to `returns`

**Files:**
- Create: `api/database/migrations/2026_05_08_100001_add_label_columns_to_returns.php`
- Update: `api/app/Models/OrderReturn.php` (add new columns to `$fillable`)
- Test: extend `api/tests/Feature/Returns/ReturnsSchemaTest.php`

The Plan 1 schema already has `easypost_shipment_id`, `tracking_number`, `carrier`, `easypost_shipment_cost_cents`, plus the transition timestamps (`label_issued_at`, `in_transit_at`, `received_at`). Plan 2 adds the four pieces it left unfilled:

- `tracker_id` — the EasyPost **tracker** id (distinct from shipment id; this is what arrives in webhook payloads, used for routing webhooks back to the return). Indexed for webhook lookup.
- `tracking_url` — the EasyPost public tracking page URL (passed through to the buyer).
- `shipping_label_url` — the carrier label PDF URL, for the buyer to download/print.
- `easypost_label_error` — text, nullable. When EasyPost fails on `approve` or `retry-label`, the upstream message lands here; cleared on the next successful issuance.

- [ ] **Step 1: Write the failing test** — extend `ReturnsSchemaTest.php`:

```php
public function test_returns_table_has_label_columns(): void
{
    foreach (['tracker_id', 'tracking_url', 'shipping_label_url', 'easypost_label_error'] as $col) {
        $this->assertTrue(
            Schema::hasColumn('returns', $col),
            "returns.$col missing",
        );
    }
}

public function test_returns_table_has_tracker_id_index(): void
{
    $indexes = collect(DB::select(
        "SELECT indexname FROM pg_indexes WHERE tablename = 'returns'"
    ))->pluck('indexname');
    $this->assertTrue(
        $indexes->contains(fn ($n) => str_contains($n, 'tracker_id')),
        'expected an index on returns.tracker_id for webhook lookup',
    );
}
```

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

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

```php
public function up(): void
{
    Schema::table('returns', function (Blueprint $t) {
        $t->string('tracker_id')->nullable()->after('easypost_shipment_id');
        $t->string('tracking_url')->nullable()->after('tracking_number');
        $t->string('shipping_label_url')->nullable()->after('tracking_url');
        $t->text('easypost_label_error')->nullable()->after('shipping_label_url');
        $t->index('tracker_id');
    });
}

public function down(): void
{
    Schema::table('returns', function (Blueprint $t) {
        $t->dropIndex(['tracker_id']);
        $t->dropColumn(['tracker_id', 'tracking_url', 'shipping_label_url', 'easypost_label_error']);
    });
}
```

Add the four new keys to `OrderReturn::$fillable`. No new casts (all strings / nullable text).

- [ ] **Step 4: Run; iterate to PASS** (full Returns schema test should still be green; Plan 1 column assertions unaffected.)

> **Plan note (no enum work):** `ReturnState` already enumerates `AwaitingShipment | InTransit | Received` from Plan 1. Plan 1's transitioner just never reached them. No enum changes in Plan 2.

---

## Phase B — Services

### Task 2: `ReturnLabelService` — issue the EasyPost return label

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

The service wraps `EasyPostProvider::buyCheapestLabel`. Two responsibilities: build the `ShipmentRequest` with from/to flipped (buyer → seller for returns) and persist the resulting label artifacts on the return inside a transaction. It does **not** transition state — caller (`ReturnTransitioner`) owns that.

```php
final class ReturnLabelService
{
    public function __construct(private readonly EasyPostProvider $easyPost) {}

    /** Issues a return label via EasyPost and persists the result on the return.
     *  Throws ReturnLabelException on EasyPost failure (does not swallow — caller handles).
     *  Caller is responsible for state transition + system message + notification. */
    public function issue(OrderReturn $return): void
    {
        $return->loadMissing('order.purchase', 'order.store', 'items.orderItem');

        $request = $this->buildShipmentRequest($return);

        try {
            $label = $this->easyPost->buyCheapestLabel($request);
        } catch (\Throwable $e) {
            $return->update(['easypost_label_error' => $e->getMessage()]);
            throw new ReturnLabelException($e->getMessage(), previous: $e);
        }

        $return->update([
            'easypost_shipment_id' => $label->shipmentId,
            'tracker_id' => $label->trackerId,
            'tracking_number' => $label->trackingNumber,
            'tracking_url' => $label->trackingUrl,
            'shipping_label_url' => $label->labelUrl,
            'carrier' => $label->carrier,
            'easypost_shipment_cost_cents' => $label->rateCents,
            'easypost_label_error' => null,
            'label_issued_at' => now(),
        ]);
    }

    private function buildShipmentRequest(OrderReturn $return): ShipmentRequest
    {
        $purchase = $return->order->purchase;
        $store = $return->order->store;

        if (! $store->hasCompleteShipFromAddress()) {
            throw new ReturnLabelException(
                'Seller store is missing a complete ship-from address; cannot issue return label.',
            );
        }

        return new ShipmentRequest(
            from: AddressDto::fromPurchaseShipping($purchase->shipping_address),
            to: AddressDto::fromStore($store),
            parcel: ParcelDto::fromOrderItems($return->items),  // weight = sum of item snapshot weights
            isReturn: true,
        );
    }
}
```

`ReturnLabelException` is a simple `RuntimeException` subclass under `app/Modules/Returns/Exceptions/`.

`AddressDto::fromPurchaseShipping(array)` and `AddressDto::fromStore(Store)` are small static factories on the existing Layer-5 address DTO (extend it; do not duplicate). If the existing DTO doesn't have these factories, add them — they're one-liners.

`ParcelDto::fromOrderItems(Collection<ReturnItem>)` sums `order_item.weight_oz × quantity` (or whatever weight column exists; see Layer 5's outbound parcel construction for the convention — mirror it). If items lack weight data, fall back to a 1lb default and add a TODO comment + open item.

> **Plan note (`is_return` semantics):** The Layer 10 spec mentions `is_return: true` as a boolean on the EasyPost shipment. EasyPost's API doesn't actually require this flag — it's just metadata and only affects how some carriers route the label (e.g., USPS treats return labels differently). If `EasyPostProvider::buyCheapestLabel` doesn't already accept an `isReturn` flag on `ShipmentRequest`, add it as an optional constructor field that gets passed through to `$this->client->shipment->create(['is_return' => true, ...])`. Confirm with Layer 5's existing shipment builder; if the field is awkward to thread through, drop it for Plan 2 (it's metadata) and flag as an open item.

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Returns;

use App\Modules\Returns\Exceptions\ReturnLabelException;
use App\Modules\Returns\Services\ReturnLabelService;
use App\Modules\Shipping\Services\EasyPostProvider;
use App\Modules\Shipping\Support\PurchasedLabel;
use App\Modules\Shipping\Support\ShipmentRequest;
use App\Models\OrderReturn;
use App\Support\Enums\ReturnState;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;

class ReturnLabelServiceTest extends TestCase
{
    use RefreshDatabase;

    public function test_issue_persists_label_artifacts_on_the_return(): void
    {
        $return = $this->makeApprovedReturnWithCompleteAddresses();

        $easyPost = Mockery::mock(EasyPostProvider::class);
        $easyPost->shouldReceive('buyCheapestLabel')
            ->once()
            ->with(Mockery::on(fn (ShipmentRequest $r) => $r->isReturn === true))
            ->andReturn(new PurchasedLabel(
                shipmentId: 'shp_test_123',
                trackerId: 'trk_test_456',
                trackingNumber: '9400111899223344556677',
                trackingUrl: 'https://track.easypost.com/trk_test_456',
                labelUrl: 'https://easypost-files.s3.amazonaws.com/label.pdf',
                carrier: 'USPS',
                service: 'Priority',
                rateCents: 1234,
            ));
        $this->app->instance(EasyPostProvider::class, $easyPost);

        app(ReturnLabelService::class)->issue($return);

        $return->refresh();
        $this->assertSame('shp_test_123', $return->easypost_shipment_id);
        $this->assertSame('trk_test_456', $return->tracker_id);
        $this->assertSame('9400111899223344556677', $return->tracking_number);
        $this->assertSame('USPS', $return->carrier);
        $this->assertSame(1234, $return->easypost_shipment_cost_cents);
        $this->assertNotNull($return->label_issued_at);
        $this->assertNull($return->easypost_label_error);
    }

    public function test_issue_records_easypost_error_message_and_rethrows(): void
    {
        $return = $this->makeApprovedReturnWithCompleteAddresses();

        $easyPost = Mockery::mock(EasyPostProvider::class);
        $easyPost->shouldReceive('buyCheapestLabel')
            ->andThrow(new \RuntimeException('EasyPost: address invalid'));
        $this->app->instance(EasyPostProvider::class, $easyPost);

        $this->expectException(ReturnLabelException::class);
        $this->expectExceptionMessageMatches('/address invalid/');

        try {
            app(ReturnLabelService::class)->issue($return);
        } finally {
            $return->refresh();
            $this->assertSame('EasyPost: address invalid', $return->easypost_label_error);
            $this->assertNull($return->label_issued_at);
            $this->assertNull($return->tracker_id);
        }
    }

    public function test_issue_aborts_when_store_has_incomplete_ship_from(): void
    {
        $return = $this->makeApprovedReturnWithMissingStoreAddress();

        $easyPost = Mockery::mock(EasyPostProvider::class);
        $easyPost->shouldNotReceive('buyCheapestLabel');
        $this->app->instance(EasyPostProvider::class, $easyPost);

        $this->expectException(ReturnLabelException::class);
        $this->expectExceptionMessageMatches('/ship-from address/');

        app(ReturnLabelService::class)->issue($return);
    }

    public function test_successful_issue_clears_prior_error(): void
    {
        $return = $this->makeApprovedReturnWithCompleteAddresses([
            'easypost_label_error' => 'previous failure',
        ]);

        $easyPost = Mockery::mock(EasyPostProvider::class);
        $easyPost->shouldReceive('buyCheapestLabel')->andReturn($this->fakeLabel());
        $this->app->instance(EasyPostProvider::class, $easyPost);

        app(ReturnLabelService::class)->issue($return);

        $this->assertNull($return->fresh()->easypost_label_error);
    }

    // helpers: makeApprovedReturnWithCompleteAddresses, makeApprovedReturnWithMissingStoreAddress, fakeLabel
}
```

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

- [ ] **Step 3: Implement** the service + exception class. If `ShipmentRequest` lacks an `isReturn` field, add it (default `false`).

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

### Task 3: Refactor `ReturnTransitioner::approve` — issue label, do NOT refund/close

**Files:**
- Update: `api/app/Modules/Returns/Services/ReturnTransitioner.php`
- Update: `api/tests/Feature/Returns/ReturnTransitionerTest.php` (REWRITE the existing approve tests; add new ones)
- Update: `api/tests/Feature/Returns/SellerReturnEndpointsTest.php` (REWRITE the existing approve happy path)

> **CRITICAL behavior change.** Plan 1's `approve` does: stamp `approved_at` → call `ReturnRefundIssuer::issue` → state `Refunded` → state `Closed`. All atomic. After Plan 2: stamp `approved_at` → call `ReturnLabelService::issue` → state `AwaitingShipment` (label success) OR state stays `Approved` with `easypost_label_error` set (label failure — seller can retry). Refund moves entirely to `markReceived`.

The existing test names `test_approve_transitions_to_refunded_then_closed_and_invokes_refund_issuer` etc. are now wrong. Rename + rewrite, don't bolt on new tests next to them — the old expectations are misleading post-Plan-2.

New `approve` shape:

```php
public function approve(OrderReturn $return, User $seller, int $restockingFeeCents): OrderReturn
{
    $this->guardApprove($return, $seller, $restockingFeeCents);  // unchanged from Plan 1

    return DB::transaction(function () use ($return, $seller, $restockingFeeCents) {
        $return->update([
            'state' => ReturnState::Approved,
            'restocking_fee_cents' => $restockingFeeCents,
            'approved_at' => now(),
        ]);

        $this->messagePoster->postSystem(
            $return->order,
            __('Seller approved the return. A return label will be issued.'),
        );

        Notification::send($return->initiator, new ReturnApprovedNotification($return));

        // Attempt label issuance. Failure leaves state at Approved with easypost_label_error set.
        try {
            $this->labels->issue($return);
        } catch (ReturnLabelException $e) {
            // Stays at Approved; seller can hit /retry-label.
            return $return->fresh();
        }

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

        $this->messagePoster->postSystem(
            $return->order,
            __('Return label issued. Tracking: :carrier :number.', [
                'carrier' => $return->carrier,
                'number' => $return->tracking_number,
            ]),
        );

        Notification::send($return->initiator, new ReturnLabelIssuedNotification($return));

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

Constructor gains `private readonly ReturnLabelService $labels` (DI).

The `approve` controller response shape is unchanged (still returns the `OrderReturn` resource); the seller's UI now sees `state: 'awaiting_shipment'` (or `'approved'` with `easypost_label_error` set on retry-needed cases).

- [ ] **Step 1: Update the failing tests** — rewrite Plan 1's approve assertions:

```php
public function test_approve_transitions_to_awaiting_shipment_when_label_issues_successfully(): void
{
    Notification::fake();
    $this->mockEasyPostSuccess();

    $return = $this->makeRequestedReturn();

    app(ReturnTransitioner::class)->approve($return, $return->order->store->owner, 0);

    $return->refresh();
    $this->assertSame(ReturnState::AwaitingShipment, $return->state);
    $this->assertNotNull($return->approved_at);
    $this->assertNotNull($return->label_issued_at);
    $this->assertNotNull($return->tracker_id);
    $this->assertNull($return->stripe_refund_id);  // refund deferred to mark-received
    $this->assertNull($return->refunded_at);
    $this->assertNull($return->closed_at);

    Notification::assertSentTo($return->initiator, ReturnApprovedNotification::class);
    Notification::assertSentTo($return->initiator, ReturnLabelIssuedNotification::class);
    Notification::assertNotSentTo($return->initiator, ReturnRefundedNotification::class);
}

public function test_approve_with_easypost_failure_stays_at_approved_and_records_error(): void
{
    Notification::fake();
    $this->mockEasyPostFailure('rate not available');

    $return = $this->makeRequestedReturn();

    app(ReturnTransitioner::class)->approve($return, $return->order->store->owner, 0);

    $return->refresh();
    $this->assertSame(ReturnState::Approved, $return->state);
    $this->assertNotNull($return->approved_at);
    $this->assertSame('rate not available', $return->easypost_label_error);
    $this->assertNull($return->tracker_id);

    Notification::assertSentTo($return->initiator, ReturnApprovedNotification::class);
    Notification::assertNotSentTo($return->initiator, ReturnLabelIssuedNotification::class);
}

public function test_approve_posts_two_system_messages_on_success_one_on_label_failure(): void
{
    // success path: "Seller approved..." + "Return label issued..."
    // failure path: only "Seller approved..." — no label-issued row
}

public function test_approve_does_not_invoke_return_refund_issuer(): void
{
    $issuer = Mockery::mock(ReturnRefundIssuer::class);
    $issuer->shouldNotReceive('issue');
    $this->app->instance(ReturnRefundIssuer::class, $issuer);

    $this->mockEasyPostSuccess();
    $return = $this->makeRequestedReturn();
    app(ReturnTransitioner::class)->approve($return, $return->order->store->owner, 0);
}
```

Plan 1's existing approve guard tests (restocking-fee enforcement, non-seller 422, etc.) stay as-is. Only the *behavior* tests after the guard need rewriting.

- [ ] **Step 2: Run, confirm failure** (existing approve tests fail; new tests fail)

- [ ] **Step 3: Implement** the new `approve` body. Inject `ReturnLabelService` into the transitioner constructor. Keep `ReturnRefundIssuer` injection — Plan 2 still needs it from `markReceived`.

- [ ] **Step 4: Run; iterate to all approve tests PASS** (rewrites + new ones)

### Task 4: `ReturnTransitioner::markReceived` — manual seller mark + auto-trigger refund

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

Two callers will hit this method: a seller via `POST /returns/{id}/mark-received` (manual override, e.g., the carrier event was missed) and the webhook handler when EasyPost reports `delivered` on the return tracker. The method accepts a nullable `User` — seller for manual, `null` for webhook-triggered — and uses that to decide audit/access checks.

```php
public function markReceived(OrderReturn $return, ?User $seller = null): OrderReturn
{
    if (! in_array($return->state, [ReturnState::AwaitingShipment, ReturnState::InTransit], true)) {
        abort(422, 'Return is not in a state where it can be marked received.');
    }

    if ($seller !== null && $return->order->store->owner_user_id !== $seller->id) {
        abort(403);
    }

    return DB::transaction(function () use ($return, $seller) {
        $return->update([
            'state' => ReturnState::Received,
            'received_at' => now(),
        ]);

        $this->messagePoster->postSystem(
            $return->order,
            $seller !== null
                ? __('Seller marked the return as received.')
                : __('Return delivery confirmed by carrier.'),
        );

        Notification::send($return->initiator, new ReturnReceivedNotification($return, recipient: 'buyer'));
        Notification::send($return->order->store->owner, new ReturnReceivedNotification($return, recipient: 'seller'));

        // Issue the deferred refund (Plan 1's ReturnRefundIssuer is unchanged).
        $this->refunds->issue($return);

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

        $this->messagePoster->postSystem(
            $return->order,
            __('Refunded $:amount to the original payment method.', [
                'amount' => number_format($return->refresh()->refund_amount_cents / 100, 2),
            ]),
        );

        Notification::send($return->initiator, new ReturnRefundedNotification($return->fresh()));

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

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

> **Plan note (recipient param on `ReturnReceivedNotification`):** Plan 1's notifications had a 1-recipient design. `ReturnReceivedNotification` is the first 2-recipient case (buyer = "refund coming", seller = "acknowledgement"). Two options: (a) one notification class with a `recipient` constructor arg that switches subject/body, or (b) two classes (`ReturnReceivedBuyerNotification`, `ReturnReceivedSellerNotification`). Going with **(a)** — keeps the namespace flatter and the message variants live in one file. If during implementation it gets ugly, split into two classes; both are fine.

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

```php
public function test_mark_received_by_seller_transitions_through_received_refunded_closed(): void
public function test_mark_received_by_webhook_caller_transitions_same_path(): void  // pass null user
public function test_mark_received_invokes_return_refund_issuer(): void
public function test_mark_received_persists_received_at_then_refunded_at_then_closed_at(): void
public function test_mark_received_fires_three_notifications(): void  // received-buyer, received-seller, refunded-buyer
public function test_mark_received_posts_three_system_messages(): void  // received row + refunded row (closed has no row, matches Plan 1)
public function test_mark_received_from_approved_state_returns_422(): void  // must be awaiting_shipment or in_transit
public function test_mark_received_by_non_seller_returns_403(): void
public function test_mark_received_is_atomic_on_refund_failure(): void  // mock issuer to throw, expect rollback to in_transit
```

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

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

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

### Task 5: `ReturnTransitioner::handleCarrierEvent` — webhook-driven transitions

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

EasyPost tracker events fire HTTP webhooks at `/webhooks/easypost`. Layer 5's `TrackingService::applyUpdate` already exists for outbound order trackers; Plan 2 extends the dispatch to `OrderReturn` (Task 7) and routes the event into this method.

Actionable statuses for returns:
- `pre_transit` — seller might want a "buyer printed the label / booked the carrier" signal eventually but not for v1; treat as no-op.
- `in_transit`, `out_for_delivery` — transition `AwaitingShipment` → `InTransit` (idempotent if already `InTransit`). Stamp `in_transit_at` only on first transition. Send `ReturnInTransitNotification` to seller.
- `delivered` — call `markReceived(return, seller: null)` for the full Received → Refunded → Closed cascade.
- `failure`, `error`, `return_to_sender` — log activity, send a `ReturnLabelFailedNotification`? **Out of scope for v1** — log activity (use `Log::warning`), no state change. Open item: bring in proper handling once we see real carrier failures in test.

```php
public function handleCarrierEvent(OrderReturn $return, string $status): void
{
    $status = strtolower($status);

    if (in_array($status, ['in_transit', 'out_for_delivery'], true)) {
        if ($return->state === ReturnState::AwaitingShipment) {
            $return->update([
                'state' => ReturnState::InTransit,
                'in_transit_at' => now(),
            ]);

            $this->messagePoster->postSystem(
                $return->order,
                __('Return shipment is in transit.'),
            );

            Notification::send($return->order->store->owner, new ReturnInTransitNotification($return));
        }
        return;
    }

    if ($status === 'delivered') {
        $this->markReceived($return);  // null user = webhook caller
        return;
    }

    Log::warning('Unhandled return tracker status', [
        'return_id' => $return->id,
        'tracker_id' => $return->tracker_id,
        'status' => $status,
    ]);
}
```

> **Plan note (idempotency):** EasyPost can re-deliver webhooks. Layer 5 already deduplicates at the controller level via Redis (`SET tracker:{id}:{status} EX 86400 NX`); the same deduplication wrapping covers return events. Even so, the `if ($return->state === ReturnState::AwaitingShipment)` guard makes `handleCarrierEvent('in_transit')` a no-op when already in `InTransit`, and `markReceived` aborts 422 from any state other than `AwaitingShipment | InTransit`, so a duplicate `delivered` event becomes a 422 inside the transition (which is acceptable since the webhook controller already swallowed it via Redis NX). Don't add a second layer of state-transition idempotency — trust the controller's dedupe.

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

```php
public function test_handle_in_transit_event_transitions_from_awaiting_shipment(): void
public function test_handle_in_transit_is_idempotent_when_already_in_transit(): void  // no second system message, no second notification
public function test_handle_out_for_delivery_treated_as_in_transit(): void
public function test_handle_delivered_event_calls_mark_received(): void  // assert state Closed at end
public function test_handle_failure_event_logs_warning_and_does_not_change_state(): void
public function test_handle_unknown_status_logs_warning(): void
```

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

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

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

### Task 6: `ReturnTransitioner::retryLabel` — recover from prior EasyPost failure

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

```php
public function retryLabel(OrderReturn $return, User $seller): OrderReturn
{
    if ($return->state !== ReturnState::Approved) {
        abort(422, 'Return label can only be retried from the Approved state.');
    }
    if ($return->order->store->owner_user_id !== $seller->id) {
        abort(403);
    }
    if ($return->easypost_label_error === null) {
        abort(422, 'No prior label error to retry.');
    }

    $this->labels->issue($return);  // throws on failure, sets easypost_label_error

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

    $this->messagePoster->postSystem(
        $return->order,
        __('Return label issued. Tracking: :carrier :number.', [
            'carrier' => $return->carrier,
            'number' => $return->tracking_number,
        ]),
    );

    Notification::send($return->initiator, new ReturnLabelIssuedNotification($return->fresh()));

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

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

```php
public function test_retry_label_succeeds_and_transitions_to_awaiting_shipment(): void
public function test_retry_label_from_awaiting_shipment_returns_422(): void
public function test_retry_label_without_prior_error_returns_422(): void
public function test_retry_label_by_non_seller_returns_403(): void
public function test_retry_label_failure_keeps_state_approved_and_updates_error(): void
```

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

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

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

---

## Phase C — Webhook integration

### Task 7: Extend `TrackingService::applyUpdate` to dispatch return events

**Files:**
- Update: `api/app/Modules/Shipping/Services/TrackingService.php`
- Update: `api/tests/Feature/Shipping/TrackingServiceTest.php` (or add a new test file under `tests/Feature/Returns/`)

Today: `applyUpdate(string $trackerId, string $status)` looks up `Order::where('tracker_id', $trackerId)`; on hit, dispatches order transitions; on miss, logs a warning. After Plan 2: on miss, also try `OrderReturn::where('tracker_id', $trackerId)`; on hit, dispatch to `ReturnTransitioner::handleCarrierEvent($return, $status)`. On miss-of-both, keep the existing warning.

```php
public function applyUpdate(string $trackerId, string $status): void
{
    if ($order = Order::where('tracker_id', $trackerId)->first()) {
        $this->applyOrderUpdate($order, $status);
        return;
    }

    if ($return = OrderReturn::where('tracker_id', $trackerId)->first()) {
        app(ReturnTransitioner::class)->handleCarrierEvent($return, $status);
        return;
    }

    Log::warning('Tracker event for unknown order or return', [
        'tracker_id' => $trackerId,
        'status' => $status,
    ]);
}

// extract existing body into private applyOrderUpdate(Order, string)
```

> **Plan note (no webhook controller change):** The existing `ShippingWebhookController::easypost` verifies signatures, deduplicates via Redis, and delegates to `TrackingService::applyUpdate`. By extending `applyUpdate` instead of the controller, return-tracker events get the same signature verification and dedupe for free.

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

```php
public function test_apply_update_dispatches_to_return_transitioner_when_tracker_belongs_to_return(): void
public function test_apply_update_prefers_order_match_when_both_match(): void  // defensive — shouldn't happen but document
public function test_apply_update_logs_warning_when_neither_matches(): void
public function test_full_webhook_flow_via_controller_routes_return_tracker_correctly(): void  // POST /webhooks/easypost with signed body, assert state transition
```

The fourth test is an integration test — sign the body using the configured secret (test `EASYPOST_WEBHOOK_SECRET=test_secret`), POST, and assert `$return->fresh()->state === InTransit`.

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

- [ ] **Step 3: Implement** the dispatch fall-through.

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

---

## Phase D — Notifications

### Task 8: Three new notification classes

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/ReturnLabelIssuedNotification.php` (recipient: buyer)
- Create: `api/app/Modules/Notifications/Notifications/ReturnInTransitNotification.php` (recipient: seller)
- Create: `api/app/Modules/Notifications/Notifications/ReturnReceivedNotification.php` (recipient: buyer OR seller, via constructor arg)
- Test: extend `api/tests/Feature/Returns/ReturnNotificationsTest.php`

All three follow Plan 1's existing return-notification shape (look at `ReturnApprovedNotification.php` 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.

Subject lines + database titles:

| Class                            | Subject                                              | DB title              |
| -------------------------------- | ---------------------------------------------------- | --------------------- |
| `ReturnLabelIssuedNotification`  | "Your return label is ready — order #{short_id}"     | "Return label ready"  |
| `ReturnInTransitNotification`    | "Buyer's return is in transit — order #{short_id}"   | "Return in transit"   |
| `ReturnReceivedNotification` (b) | "Your return has arrived — refund coming"            | "Return received"     |
| `ReturnReceivedNotification` (s) | "Return received on order #{short_id}"               | "Return received"     |

`ReturnLabelIssuedNotification` body includes carrier + tracking number + a "Download label" CTA pointing at `$return->shipping_label_url` (this is an EasyPost-hosted URL — fine to email out; expires after a few days but that's an EasyPost concern, not ours). The `cta_url` for the in-app bell points at `/purchases/{order_id}` (buyer side).

Icon for all three: `'package-return'` (already used by Plan 1).

- [ ] **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 `ReturnReceivedNotification`, assert the `recipient: 'buyer' | 'seller'` arg drives the right subject/body. ~12 assertions.

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

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

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

---

## Phase E — Endpoints

### Task 9: `POST /v1/returns/{return}/mark-received`

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

```php
public function markReceived(Request $request, OrderReturn $return): JsonResponse
{
    $return->loadMissing('order.store');

    if ($return->order->store->owner_user_id !== $request->user()->id) {
        abort(403);
    }

    $updated = app(ReturnTransitioner::class)->markReceived($return, $request->user());

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

Route:

```php
Route::post('/returns/{return}/mark-received', [SellerReturnController::class, 'markReceived']);
```

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

```php
public function test_seller_mark_received_happy_path_returns_closed_state(): void
public function test_mark_received_by_non_seller_returns_403(): void
public function test_mark_received_when_state_is_approved_returns_422(): void  // not yet shipped
public function test_mark_received_response_includes_stripe_refund_id(): void  // refund happened
public function test_mark_received_fires_correct_notifications(): void  // received-buyer + received-seller + refunded-buyer
```

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

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

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

### Task 10: `POST /v1/returns/{return}/retry-label`

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

```php
public function retryLabel(Request $request, OrderReturn $return): JsonResponse
{
    $return->loadMissing('order.store');

    if ($return->order->store->owner_user_id !== $request->user()->id) {
        abort(403);
    }

    $updated = app(ReturnTransitioner::class)->retryLabel($return, $request->user());

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

Route: `Route::post('/returns/{return}/retry-label', [SellerReturnController::class, 'retryLabel']);`

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

```php
public function test_seller_retry_label_happy_path(): void
public function test_retry_label_by_non_seller_returns_403(): void
public function test_retry_label_without_prior_error_returns_422(): void
public function test_retry_label_when_state_not_approved_returns_422(): void
public function test_retry_label_persists_new_easypost_artifacts(): void  // tracker_id changes
```

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

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

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

---

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

### Task 11: OpenAPI

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

Two new paths:

```yaml
/v1/returns/{return}/mark-received:
  post:
    tags: [Returns]
    summary: Seller manually marks the return as received (e.g., carrier event missed)
    parameters: [{ $ref: '#/components/parameters/ReturnIdPathParam' }]
    responses:
      '200':
        description: Return moved through received → refunded → closed
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/OrderReturn' }
      '403': { $ref: '#/components/responses/Forbidden' }
      '422': { $ref: '#/components/responses/UnprocessableEntity' }

/v1/returns/{return}/retry-label:
  post:
    tags: [Returns]
    summary: Seller retries return label issuance after a prior EasyPost failure
    parameters: [{ $ref: '#/components/parameters/ReturnIdPathParam' }]
    responses:
      '200':
        description: Label issued; return moved to awaiting_shipment
        content:
          application/json:
            schema:
              type: object
              properties:
                data: { $ref: '#/components/schemas/OrderReturn' }
      '403': { $ref: '#/components/responses/Forbidden' }
      '422': { $ref: '#/components/responses/UnprocessableEntity' }
```

Extend the `OrderReturn` schema with the four new properties:

```yaml
OrderReturn:
  # ...existing properties...
  tracker_id: { type: string, nullable: true }
  tracking_url: { type: string, format: uri, nullable: true }
  shipping_label_url: { type: string, format: uri, nullable: true }
  easypost_label_error: { type: string, nullable: true }
```

Note in the path summary for `POST /v1/returns/{return}/approve` that the response state is now `awaiting_shipment` on the happy path (or `approved` with `easypost_label_error` populated on label failure) — the doc string previously implied immediate refund. Update the description.

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

- [ ] **Step 1: Edit the YAML.**
- [ ] **Step 2: Validate.** (Should be a single-pass edit; flag schema drift if `OrderReturn` already had the four properties — Plan 1 may have stubbed them.)

### Task 12: 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 function createReturnEndpoints(client: AlqoveClient) {
  return {
    // ...existing...
    markReceived: (returnId: string) =>
      client.post<ApiResponse<OrderReturn>>(`/v1/returns/${returnId}/mark-received`, {}),
    retryLabel: (returnId: string) =>
      client.post<ApiResponse<OrderReturn>>(`/v1/returns/${returnId}/retry-label`, {}),
  };
}
```

Update the `OrderReturn` TS type (regenerated by `build:types` — if it's a hand-curated interface, sync the four new fields).

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 G — Frontend

### Task 13: TanStack hooks

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

Add two mutations mirroring Plan 1's `useApproveReturn` shape:

```ts
export function useMarkReturnReceived() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (returnId: string) => api.returns.markReceived(returnId),
    onSuccess: (_, returnId) => {
      queryClient.invalidateQueries({ queryKey: ['returns', returnId] });
      queryClient.invalidateQueries({ queryKey: ['seller', 'returns'] });
      queryClient.invalidateQueries({ queryKey: ['orders'] });  // refund visible on order detail
    },
  });
}

export function useRetryReturnLabel() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (returnId: string) => api.returns.retryLabel(returnId),
    onSuccess: (_, returnId) => {
      queryClient.invalidateQueries({ queryKey: ['returns', returnId] });
      queryClient.invalidateQueries({ queryKey: ['seller', 'returns'] });
    },
  });
}
```

No new tests for the hooks themselves — the mutation surface is trivial and gets exercised by the panel tests in Task 15.

### Task 14: State badge + timeline

**Files:**
- Update: `web/src/components/returns/return-state-badge.tsx`
- Update: `web/src/components/returns/return-timeline.tsx`
- Test: extend `web/src/components/returns/__tests__/return-state-badge.test.tsx` (if it exists; create otherwise)
- Test: extend `web/src/components/returns/__tests__/return-timeline.test.tsx`

Plan 1's badge handled `requested | approved | rejected | refunded | closed | cancelled`. Plan 2 adds the three middle states:

| state              | color  | label              |
| ------------------ | ------ | ------------------ |
| awaiting_shipment  | sky    | "Awaiting shipment"|
| in_transit        | sky    | "In transit"       |
| received          | indigo | "Received"         |

(Plan 1's `approved` color stays — it's still a valid state when label issuance failed.)

Timeline gains rows for `label_issued_at`, `in_transit_at`, `received_at`. The buyer-viewer variant of the timeline at the `awaiting_shipment` row shows a "Download label" link wired to `return.shipping_label_url` and the tracking number + carrier; if `tracking_url` is set, the carrier name is a link to it.

Tests:
- Badge renders the three new states with correct colors + labels
- Timeline renders new rows when timestamps populated
- Timeline shows "Download label" link in `awaiting_shipment` (buyer view only)
- Timeline does NOT show "Download label" for seller view at the same state — seller doesn't need it

### Task 15: Seller panel — Mark received + Retry label actions

**Files:**
- Update: `web/src/components/seller/seller-returns-panel.tsx`
- Update: `web/src/components/seller/__tests__/seller-returns-panel.test.tsx`

Per state, the action bar:

- `requested`: unchanged (Approve / Decline buttons from Plan 1)
- `approved` with `easypost_label_error`: red banner showing the error + "Retry label" button → `useRetryReturnLabel`
- `approved` without error (transient — shouldn't linger but possible during the brief window between approve transaction commit and label success path): show a small spinner + "Issuing label…" copy
- `awaiting_shipment` / `in_transit`: "Mark received" button + "Print return label" link (in case seller needs to email it manually) → `useMarkReturnReceived`
- `received` / `refunded` / `closed`: read-only (Plan 1's existing rendering carries — refund amount + last 8 of stripe id).

Approve flow no longer surfaces the refund amount immediately — update the existing toast/copy: "Return approved. A label is being issued — the buyer can print it from their order page."

Tests:
- Action bar in `approved` state with `easypost_label_error` shows banner + retry button
- Retry button calls `useRetryReturnLabel` mutation with the return id
- Action bar in `awaiting_shipment` shows "Mark received"
- Mark received calls `useMarkReturnReceived` with the return id
- Read-only when state ∈ {received, refunded, closed}

### Task 16: Buyer surface — label download CTA

**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`

When the order has an open return in `awaiting_shipment` or `in_transit`:
- Return state strip shows the new badge + tracking number link (uses `return.tracking_url` if present, else carrier + bare tracking number).
- Add a "Download return label" link that opens `return.shipping_label_url` in a new tab. Hidden when state ≥ received (label is moot once the package arrived).

Tests:
- Strip shows tracking number link in `in_transit` state
- "Download return label" visible in `awaiting_shipment`, hidden in `received`

### Task 17: Notification bell — new icon mappings

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

The three 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.

---

## Phase H — Wrap-up

### Task 18: Full sweep

- [ ] **Step 1: Backend** — `docker compose exec -T laravel.test php artisan test`. Expected: 531 → ≥ **565** (~34 new tests across the schema extension, ReturnLabelService, ReturnTransitioner additions/refactors, TrackingService dispatch, two new endpoints, and the three notifications; minus a few replaced approve-tests so net is +30-ish).
- [ ] **Step 2: Backend lint** — `./vendor/bin/pint app/Modules/Returns app/Modules/Shipping/Services/TrackingService.php tests/Feature/Returns tests/Feature/Shipping`. 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: 180 → ≥ **190** (~10 new tests).
- [ ] **Step 6: Local build** — `npm run build:web`. Watch for the new buyer label-download link breaking SSR (it's an `<a target="_blank">` to an EasyPost URL — should be fine but check).
- [ ] **Step 7: Manual QA** —
  - As a buyer with a recently-delivered order: file a return for `damaged`. Confirm seller now sees `requested`.
  - As the seller: approve the return. The page should *not* show "refunded" — it should now show `awaiting_shipment` with a "Print return label" link. The buyer's purchase page should show "Download return label" + carrier + tracking number; clicking the label link downloads the EasyPost PDF.
  - Inspect EasyPost test dashboard (or Laravel logs if EasyPost is mocked locally) — confirm a return shipment was created with from = buyer's purchase shipping address, to = seller store ship-from.
  - Manually fire an EasyPost webhook with status `in_transit` for the return's tracker (via `tinker` or a curl with the right HMAC signature against `/webhooks/easypost`). Confirm: state → `in_transit`, system message posted, seller bell pinged.
  - Fire `delivered` webhook. Confirm: state cascades through `received` → `refunded` → `closed`, Stripe test refund created, both buyer and seller see the closed return.
  - Manual override path: file a fresh return, approve, do NOT fire delivered webhook. As the seller, click "Mark received" — same cascade should fire.
  - Force a label failure: temporarily break the `EASYPOST_API_KEY` env or use a known-bad address; approve a return; confirm state stays `approved`, `easypost_label_error` is set, and the seller panel shows a red retry banner. Restore the key and click "Retry label" — confirm state advances to `awaiting_shipment`.
  - Open `/admin/orders/[id]` for an order with a closed return — confirm Layer 9 thread shows all the system messages (approved → label issued → in transit → received → refunded) in italic gray.

### Task 19: Commit + push

- [ ] **Step 1:** In `~/projects/alqove-api`, stage `app contracts database tests docs` and commit with `feat(returns): EasyPost return label + in-transit tracking + refund-on-receipt`.
- [ ] **Step 2:** In `~/projects/alqove-web`, stage `packages web contracts` and commit with `feat(returns): label download + mark-received + retry-label UI`.
- [ ] **Step 3:** Push both. Watch GH Actions on each — both should be green inside 3 minutes (the api side adds an EasyPost-mocked test surface; if CI doesn't have `EASYPOST_WEBHOOK_SECRET` set, set it in the workflow env block as part of this plan).

---

## Open items deferred to Plan 3 (or later)

- **Seller-initiated proactive refund** — `POST /v1/seller/orders/{order}/returns/proactive`, "keep-it" path, partial refund amount UI. Reuses Plan 1's collapsed approve→refund→close path (with no buyer-request step) plus an optional shipping leg that mirrors Plan 2's mark-received cascade. Plan 3.
- **`ReturnEscalation` + `/admin/returns` queue + admin resolution endpoint + activity log row.** Plan 3.
- **Refund-original-shipping toggle** on the proactive modal (per-return checkbox). Plan 3 with the proactive UI.
- **EasyPost shipment-cost reconciliation against payouts** — Plan 2 records `easypost_shipment_cost_cents` but doesn't deduct from payouts. Future infra work; explicit spec open item.
- **Buyer-paid label cost deduction from refund** — when `return_shipping_payer === 'buyer'`, the marketplace currently eats the cost. The spec defers to negative-payout collection. Until then, document it as a known-loss for `doesnt_fit / changed_mind / other` reasons (these are also the lower-volume reasons in practice; sellers can mitigate via restocking fee).
- **EasyPost label expiration** — labels are typically valid ~30 days. If the buyer doesn't ship within that window, the label is dead and `tracking_url` becomes a 404. Plan 2 doesn't address this; future work could add a scheduled job that detects stale `awaiting_shipment` returns and offers a re-issue path. v1: don't address.
- **Seller cancellation post-approval** — spec says: "the buyer can't cancel client-side... they'd have to message the seller and the seller can voluntarily mark `closed` without refund." Adds a `seller_close_without_refund` transition. Plan 3 alongside the proactive surface.
- **`pre_transit` carrier event** — currently treated as no-op; consider posting a system message ("Buyer dropped off the package") if sellers ask for the visibility. Out of v1 scope.
- **Return-shipping tracker email cadence** — Plan 2 fires `ReturnLabelIssuedNotification` on label issue + `ReturnInTransitNotification` on first transit event + `ReturnReceivedNotification` on delivery. No email at every tracker scan in between. If buyers report "I don't know if my package is moving", revisit by adding an opt-in tracker-update email. Out of v1.
- **`ParcelDto::fromOrderItems` weight fallback** — if order items lack weight data, Plan 2 falls back to a 1-lb parcel. This will overcharge sellers on small items and underweight large ones, but EasyPost rates fall back gracefully. Future work: populate item weights at listing creation time.
- **`is_return: true` flag on EasyPost shipment** — passed through if `EasyPostProvider` supports it; metadata only. If the existing provider doesn't accept it cleanly, drop and revisit (no functional impact).
