# Cart-Recovery Deep Link Implementation Plan

> **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:** Make the abandoned-cart recovery email link rehydrate the shopper's cart (revalidated to current price/stock), auto-apply any attached coupon, land them on `/cart`, and record honest `link` vs `email_match` recovery attribution.

**Architecture:** A signed per-send token (stored on `cart_recovery_emails`, 30-day expiry) backs a new `GET /cart/recover/{token}` route. Its controller rebuilds the session cart from the `abandoned_carts.cart_items` snapshot via `CartService::restore()`, which revalidates each line against current catalog state using two new pure helpers (`ProductPricing::unitPrice` and `CartLineRevalidator::evaluate`). Checkout auto-applies the recovery coupon and attributes the recovery by source.

**Tech Stack:** Laravel 13, Livewire, Filament 5, stancl/tenancy (tenant tables under `database/migrations/tenant/`), PHPUnit 12.

**Spec:** `docs/superpowers/specs/2026-06-12-cart-recovery-deep-link-design.md`

**Branch:** `feature/cart-recovery-deep-link` (ecstores repo).

## Test strategy (read first)

This repo has **no tenant-context / HTTP / Livewire automated test harness** — every tenant feature is verified through the **testman QA** suite, and the existing automated tests are **pure in-memory unit tests** (models built with `new`, no DB persistence; e.g. `tests/Unit/Mail/ReturnAcknowledgedMailTest.php`, `tests/Unit/Services/ShippingCalculatorTest.php`). This plan follows that pattern:

- **TDD (runnable now):** the pure logic units — `ProductPricing::unitPrice` (Task 3), `CartLineRevalidator::evaluate` (Task 4), the mailable property changes (Task 6), and model metadata (Task 2).
- **QA-verified (no automated harness):** the migration, `CartService::restore` DB wiring, the route/controller, the Filament action, and the Livewire checkout changes (Tasks 1, 5, 7, 8, 9, 10). Each lists the exact manual/testman verification. Task 11 updates the QA cases + SOP.

Run unit tests with: `php artisan test --testsuite=Unit` (or `vendor\bin\phpunit --testsuite=Unit`).

---

### Task 1: Tenant migration — recovery-link columns

**Files:**
- Create: `database/migrations/tenant/2026_06_12_000001_add_recovery_link_columns.php`

- [ ] **Step 1: Write the migration**

```php
<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('cart_recovery_emails', function (Blueprint $table) {
            $table->string('token', 64)->nullable()->unique()->after('coupon_id');
            $table->timestamp('expires_at')->nullable()->after('token');
            $table->timestamp('clicked_at')->nullable()->after('expires_at');
        });

        Schema::table('abandoned_carts', function (Blueprint $table) {
            $table->enum('recovered_via', ['link', 'email_match'])->nullable()->after('recovered_order_id');
            $table->foreignId('recovered_recovery_email_id')->nullable()->after('recovered_via')
                ->constrained('cart_recovery_emails')->nullOnDelete();
        });
    }

    public function down(): void
    {
        Schema::table('abandoned_carts', function (Blueprint $table) {
            $table->dropForeign(['recovered_recovery_email_id']);
            $table->dropColumn(['recovered_via', 'recovered_recovery_email_id']);
        });

        Schema::table('cart_recovery_emails', function (Blueprint $table) {
            $table->dropUnique(['token']);
            $table->dropColumn(['token', 'expires_at', 'clicked_at']);
        });
    }
};
```

- [ ] **Step 2: Apply to local tenant DB(s) and verify**

Run: `php artisan tenants:migrate`
Expected: migration runs without error against each tenant DB. Confirm columns exist (tinker against a tenant, or check the tenant DB): `cart_recovery_emails` has `token, expires_at, clicked_at`; `abandoned_carts` has `recovered_via, recovered_recovery_email_id`.

- [ ] **Step 3: Commit**

```bash
git add database/migrations/tenant/2026_06_12_000001_add_recovery_link_columns.php
git commit -m "feat(abandoned-cart): migration for recovery-link token + attribution columns"
```

---

### Task 2: Model updates (fillable/casts + relation)

**Files:**
- Modify: `app/Models/CartRecoveryEmail.php`
- Modify: `app/Models/AbandonedCart.php`
- Test: `tests/Unit/Models/CartRecoveryEmailTest.php`, `tests/Unit/Models/AbandonedCartTest.php`

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

Add to `tests/Unit/Models/CartRecoveryEmailTest.php`:

```php
public function test_token_columns_are_fillable(): void
{
    $fillable = (new \App\Models\CartRecoveryEmail())->getFillable();
    $this->assertContains('token', $fillable);
    $this->assertContains('expires_at', $fillable);
    $this->assertContains('clicked_at', $fillable);
}

public function test_recovery_link_dates_cast_to_datetime(): void
{
    $casts = (new \App\Models\CartRecoveryEmail())->getCasts();
    $this->assertEquals('datetime', $casts['expires_at']);
    $this->assertEquals('datetime', $casts['clicked_at']);
}
```

Add to `tests/Unit/Models/AbandonedCartTest.php`:

```php
public function test_attribution_columns_are_fillable(): void
{
    $fillable = (new AbandonedCart())->getFillable();
    $this->assertContains('recovered_via', $fillable);
    $this->assertContains('recovered_recovery_email_id', $fillable);
}

public function test_recovered_recovery_email_returns_belongs_to(): void
{
    $relation = (new AbandonedCart())->recoveredRecoveryEmail();
    $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\BelongsTo::class, $relation);
    $this->assertEquals('recovered_recovery_email_id', $relation->getForeignKeyName());
}
```

- [ ] **Step 2: Run tests, verify they fail**

Run: `php artisan test --testsuite=Unit --filter="CartRecoveryEmailTest|AbandonedCartTest"`
Expected: FAIL (token not fillable / `recoveredRecoveryEmail` undefined).

- [ ] **Step 3: Update `CartRecoveryEmail.php`**

Add to `$fillable`: `'token', 'expires_at', 'clicked_at'`. Add a `casts()` method (the model currently has none) or extend it:

```php
protected function casts(): array
{
    return [
        'sent_at'    => 'datetime',
        'expires_at' => 'datetime',
        'clicked_at' => 'datetime',
    ];
}
```

(Keep existing `sent_at` behavior; if `sent_at` was already cast elsewhere, merge rather than duplicate.)

- [ ] **Step 4: Update `AbandonedCart.php`**

Add to `$fillable`: `'recovered_via', 'recovered_recovery_email_id'`. Add the relation:

```php
public function recoveredRecoveryEmail(): BelongsTo
{
    return $this->belongsTo(CartRecoveryEmail::class, 'recovered_recovery_email_id');
}
```

(`BelongsTo` is already imported.)

- [ ] **Step 5: Run tests, verify they pass**

Run: `php artisan test --testsuite=Unit --filter="CartRecoveryEmailTest|AbandonedCartTest"`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add app/Models/CartRecoveryEmail.php app/Models/AbandonedCart.php tests/Unit/Models/CartRecoveryEmailTest.php tests/Unit/Models/AbandonedCartTest.php
git commit -m "feat(abandoned-cart): model fillable/casts + recoveredRecoveryEmail relation"
```

---

### Task 3: Shared price resolver `ProductPricing::unitPrice`

Extract the price logic currently inlined in `ProductVariantSelector::getDisplayPrice()` (lines ~59-72) into a pure, reusable, unit-tested helper, then have the selector call it. `CartService::restore` (Task 5) will reuse the same helper so the restored price always matches the product page.

**Files:**
- Create: `app/Support/ProductPricing.php`
- Modify: `app/Livewire/ProductVariantSelector.php` (replace the inline price block in `getDisplayPrice()`)
- Test: `tests/Unit/Support/ProductPricingTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Support;

use App\Models\Product;
use App\Models\ProductVariantCombination;
use App\Support\ProductPricing;
use Tests\TestCase;

class ProductPricingTest extends TestCase
{
    private function product(array $attrs = []): Product
    {
        return new Product(array_merge([
            'price'               => 50.00,
            'sale_price'          => null,
            'is_on_sale'          => false,
            'track_variant_price' => false,
        ], $attrs));
    }

    private function combo(array $attrs = []): ProductVariantCombination
    {
        return new ProductVariantCombination(array_merge([
            'price_modifier' => null,
            'price_flag'     => null,
        ], $attrs));
    }

    public function test_base_price_when_no_sale_no_variant(): void
    {
        $this->assertSame(50.00, ProductPricing::unitPrice($this->product(), null));
    }

    public function test_sale_price_overrides_base(): void
    {
        $p = $this->product(['is_on_sale' => true, 'sale_price' => 40.00]);
        $this->assertSame(40.00, ProductPricing::unitPrice($p, null));
    }

    public function test_variant_fixed_price_flag_replaces_base(): void
    {
        $p = $this->product(['track_variant_price' => true]);
        $c = $this->combo(['price_modifier' => 33.00, 'price_flag' => 'F']);
        $this->assertSame(33.00, ProductPricing::unitPrice($p, $c));
    }

    public function test_variant_additive_price_flag_adds_to_base(): void
    {
        $p = $this->product(['track_variant_price' => true]);
        $c = $this->combo(['price_modifier' => 5.00, 'price_flag' => 'A']);
        $this->assertSame(55.00, ProductPricing::unitPrice($p, $c));
    }

    public function test_variant_ignored_when_track_variant_price_false(): void
    {
        $p = $this->product(['track_variant_price' => false]);
        $c = $this->combo(['price_modifier' => 5.00, 'price_flag' => 'A']);
        $this->assertSame(50.00, ProductPricing::unitPrice($p, $c));
    }
}
```

- [ ] **Step 2: Run tests, verify they fail**

Run: `php artisan test --testsuite=Unit --filter=ProductPricingTest`
Expected: FAIL with "Class App\Support\ProductPricing not found".

- [ ] **Step 3: Create `app/Support/ProductPricing.php`**

```php
<?php

declare(strict_types=1);

namespace App\Support;

use App\Models\Product;
use App\Models\ProductVariantCombination;

class ProductPricing
{
    /**
     * Current unit price for a product (and optional variant combination), mirroring the
     * product-page display price: sale price when on sale, then variant price_modifier
     * (F = fixed replacement, A = additive) when the product tracks variant pricing.
     */
    public static function unitPrice(Product $product, ?ProductVariantCombination $combo): float
    {
        $base = $product->is_on_sale && $product->sale_price
            ? (float) $product->sale_price
            : (float) $product->price;

        if ($product->track_variant_price && $combo && $combo->price_modifier !== null) {
            if ($combo->price_flag === 'F') {
                return (float) $combo->price_modifier;
            }
            if ($combo->price_flag === 'A') {
                $base += (float) $combo->price_modifier;
            }
        }

        return $base;
    }
}
```

- [ ] **Step 4: Run tests, verify they pass**

Run: `php artisan test --testsuite=Unit --filter=ProductPricingTest`
Expected: PASS.

- [ ] **Step 5: Refactor `ProductVariantSelector::getDisplayPrice()` to use the helper**

`getDisplayPrice()` already resolves a `$combo` (the selected `ProductVariantCombination` or null) before the price arithmetic at ~lines 59-72. Keep that resolution; replace only the price arithmetic block (the `$base = ...` lines through the final `return`) with:

```php
return \App\Support\ProductPricing::unitPrice($this->product, $combo);
```

If the local variable holding the combination is named something other than `$combo`, pass that variable instead. Do not change the method signature or its callers.

- [ ] **Step 6: Verify the selector still computes the same prices**

Run: `php artisan test --testsuite=Unit`
Expected: PASS (no regressions). Then manual smoke (QA): on a tenant storefront, a variant product with a price modifier still shows the correct price on the product page.

- [ ] **Step 7: Commit**

```bash
git add app/Support/ProductPricing.php app/Livewire/ProductVariantSelector.php tests/Unit/Support/ProductPricingTest.php
git commit -m "refactor(pricing): extract ProductPricing::unitPrice and reuse in ProductVariantSelector"
```

---

### Task 4: Pure revalidation `CartLineRevalidator::evaluate`

**Files:**
- Create: `app/Services/CartRecovery/CartLineRevalidator.php`
- Test: `tests/Unit/Services/CartRecovery/CartLineRevalidatorTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Services\CartRecovery;

use App\Services\CartRecovery\CartLineRevalidator;
use Tests\TestCase;

class CartLineRevalidatorTest extends TestCase
{
    private function snapshot(array $o = []): array
    {
        return array_merge([
            'product_id' => 1, 'combination_id' => null, 'name' => 'Widget',
            'sku' => 'W-1', 'price' => 10.00, 'quantity' => 2,
            'variant_label' => null, 'image' => null,
        ], $o);
    }

    public function test_drops_line_when_product_missing(): void
    {
        $r = CartLineRevalidator::evaluate($this->snapshot(), null, null);
        $this->assertSame('drop', $r['action']);
    }

    public function test_drops_line_when_out_of_stock(): void
    {
        $r = CartLineRevalidator::evaluate($this->snapshot(), 10.00, 0);
        $this->assertSame('drop', $r['action']);
    }

    public function test_keeps_line_with_current_price_unlimited_stock(): void
    {
        $r = CartLineRevalidator::evaluate($this->snapshot(), 10.00, null);
        $this->assertSame('keep', $r['action']);
        $this->assertSame(10.00, $r['line']['price']);
        $this->assertSame(2, $r['line']['quantity']);
        $this->assertFalse($r['price_changed']);
        $this->assertFalse($r['qty_capped']);
    }

    public function test_flags_price_change_and_uses_current_price(): void
    {
        $r = CartLineRevalidator::evaluate($this->snapshot(['price' => 10.00]), 12.50, null);
        $this->assertSame(12.50, $r['line']['price']);
        $this->assertTrue($r['price_changed']);
    }

    public function test_caps_quantity_to_stock_and_flags(): void
    {
        $r = CartLineRevalidator::evaluate($this->snapshot(['quantity' => 5]), 10.00, 3);
        $this->assertSame(3, $r['line']['quantity']);
        $this->assertTrue($r['qty_capped']);
    }

    public function test_preserves_variant_identity(): void
    {
        $r = CartLineRevalidator::evaluate($this->snapshot(['combination_id' => 7, 'variant_label' => 'Red / L']), 10.00, null);
        $this->assertSame(7, $r['line']['combination_id']);
        $this->assertSame('Red / L', $r['line']['variant_label']);
    }
}
```

- [ ] **Step 2: Run tests, verify they fail**

Run: `php artisan test --testsuite=Unit --filter=CartLineRevalidatorTest`
Expected: FAIL with "Class ... CartLineRevalidator not found".

- [ ] **Step 3: Create `app/Services/CartRecovery/CartLineRevalidator.php`**

```php
<?php

declare(strict_types=1);

namespace App\Services\CartRecovery;

class CartLineRevalidator
{
    /**
     * Decide how one snapshot cart line should be restored against current catalog state.
     *
     * @param  array       $item            snapshot line (product_id, combination_id, name, sku, price, quantity, variant_label, image)
     * @param  float|null  $currentPrice    current unit price, or null when the product no longer exists
     * @param  int|null    $availableStock  purchasable stock; null = unlimited, 0 = out of stock
     * @return array{action:'keep'|'drop', line:?array, price_changed:bool, qty_capped:bool}
     */
    public static function evaluate(array $item, ?float $currentPrice, ?int $availableStock): array
    {
        if ($currentPrice === null || $availableStock === 0) {
            return ['action' => 'drop', 'line' => null, 'price_changed' => false, 'qty_capped' => false];
        }

        $snapQty  = max(1, (int) ($item['quantity'] ?? 1));
        $finalQty = $availableStock === null ? $snapQty : min($snapQty, $availableStock);

        $snapPrice = (float) ($item['price'] ?? 0);

        $line = [
            'product_id'     => (int) $item['product_id'],
            'combination_id' => $item['combination_id'] !== null ? (int) $item['combination_id'] : null,
            'name'           => $item['name'] ?? '',
            'sku'            => $item['sku'] ?? null,
            'price'          => $currentPrice,
            'quantity'       => $finalQty,
            'variant_label'  => $item['variant_label'] ?? null,
            'image'          => $item['image'] ?? null,
        ];

        return [
            'action'        => 'keep',
            'line'          => $line,
            'price_changed' => abs($currentPrice - $snapPrice) > 0.001,
            'qty_capped'    => $finalQty < $snapQty,
        ];
    }
}
```

- [ ] **Step 4: Run tests, verify they pass**

Run: `php artisan test --testsuite=Unit --filter=CartLineRevalidatorTest`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/Services/CartRecovery/CartLineRevalidator.php tests/Unit/Services/CartRecovery/CartLineRevalidatorTest.php
git commit -m "feat(cart-recovery): pure CartLineRevalidator for stale-item revalidation"
```

---

### Task 5: `CartService::restore`

Wires DB lookups (`Product::find`, `ProductPricing::unitPrice`, existing `availableStock`) to `CartLineRevalidator`, rebuilds the session cart, and returns a change report. No automated test (DB-dependent, in line with repo convention) — verified via QA in Task 11.

**Files:**
- Modify: `app/Services/CartService.php`

- [ ] **Step 1: Add the `restore` method**

Add to `App\Services\CartService` (uses the existing private `availableStock()` and `SESSION_KEY`; add the import `use App\Support\ProductPricing;` and reuse the existing `use App\Models\Product; use App\Models\ProductVariantCombination;`):

```php
/**
 * Rebuild the session cart from an abandoned-cart snapshot, revalidating each line
 * against current price/stock. Clears any existing session cart first (replace).
 *
 * @param  array<int,array<string,mixed>> $snapshot  cart_items from an AbandonedCart
 * @return array{restored:int, removed:string[], price_changed:int, qty_capped:int}
 */
public static function restore(array $snapshot): array
{
    self::clear();

    $report = ['restored' => 0, 'removed' => [], 'price_changed' => 0, 'qty_capped' => 0];
    $cart   = [];

    foreach ($snapshot as $item) {
        $productId     = (int) ($item['product_id'] ?? 0);
        $combinationId = isset($item['combination_id']) && $item['combination_id'] !== null
            ? (int) $item['combination_id'] : null;

        $product = $productId ? Product::find($productId) : null;
        if (! $product) {
            $report['removed'][] = (string) ($item['name'] ?? 'Item');
            continue;
        }

        $combo        = $combinationId ? ProductVariantCombination::find($combinationId) : null;
        $currentPrice = ProductPricing::unitPrice($product, $combo);
        $stock        = self::availableStock($productId, $combinationId);

        $result = CartLineRevalidator::evaluate($item, $currentPrice, $stock);

        if ($result['action'] === 'drop') {
            $report['removed'][] = (string) ($item['name'] ?? 'Item');
            continue;
        }

        $line             = $result['line'];
        $key              = $line['product_id'] . '-' . ($line['combination_id'] ?? 'base');
        $cart[$key]       = array_merge(['key' => $key], $line);

        $report['restored']++;
        if ($result['price_changed']) $report['price_changed']++;
        if ($result['qty_capped'])    $report['qty_capped']++;
    }

    session()->put(self::SESSION_KEY, $cart);

    return $report;
}
```

Add `use App\Services\CartRecovery\CartLineRevalidator;` and `use App\Support\ProductPricing;` at the top.

- [ ] **Step 2: Verify (QA / tinker)**

In tinker against a tenant: build a snapshot array mixing a valid product, a deleted product id, and a quantity above stock; call `CartService::restore($snapshot)` and assert the returned report (`restored`, `removed`, `price_changed`, `qty_capped`) and `CartService::items()` match expectations. Covered end-to-end by the new testman case in Task 11.

- [ ] **Step 3: Commit**

```bash
git add app/Services/CartService.php
git commit -m "feat(cart-recovery): CartService::restore rebuilds session cart from snapshot"
```

---

### Task 6: Mailable + email template (coupon-aware CTA, recovery URL)

**Files:**
- Modify: `app/Mail/AbandonedCartMail.php`
- Modify: `resources/views/emails/abandoned-cart.blade.php`
- Test: `tests/Unit/Mail/AbandonedCartMailTest.php` (create)

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

```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Mail;

use App\Mail\AbandonedCartMail;
use App\Models\AbandonedCart;
use App\Models\Coupon;
use Tests\TestCase;

class AbandonedCartMailTest extends TestCase
{
    private function cart(): AbandonedCart
    {
        $c = new AbandonedCart();
        $c->contact_name = 'Sam';
        $c->cart_items   = [];
        $c->cart_value   = 7.00;
        return $c;
    }

    public function test_subject_is_you_left_something_behind(): void
    {
        $mail = new AbandonedCartMail($this->cart(), null, 'https://store.test/cart/recover/abc');
        $this->assertSame('You left something behind', $mail->envelope()->subject);
    }

    public function test_recovery_url_is_stored(): void
    {
        $mail = new AbandonedCartMail($this->cart(), null, 'https://store.test/cart/recover/abc');
        $this->assertSame('https://store.test/cart/recover/abc', $mail->recoveryUrl);
    }

    public function test_coupon_is_stored_when_provided(): void
    {
        $coupon = new Coupon(['code' => 'SAVE10']);
        $mail   = new AbandonedCartMail($this->cart(), $coupon, 'https://store.test/cart/recover/abc');
        $this->assertSame('SAVE10', $mail->coupon->code);
    }

    public function test_content_uses_abandoned_cart_view(): void
    {
        $mail = new AbandonedCartMail($this->cart(), null, 'https://store.test/cart/recover/abc');
        $this->assertSame('emails.abandoned-cart', $mail->content()->view);
    }
}
```

- [ ] **Step 2: Run tests, verify they fail**

Run: `php artisan test --testsuite=Unit --filter=AbandonedCartMailTest`
Expected: FAIL (constructor signature / `recoveryUrl` property mismatch).

- [ ] **Step 3: Update `AbandonedCartMail` constructor**

Change the constructor signature so the third positional argument is the recovery URL (it was `storeUrl`):

```php
public function __construct(
    public readonly AbandonedCart $cart,
    public readonly ?Coupon $coupon = null,
    public readonly string $recoveryUrl = '',
    public readonly ?SiteSettings $settings = null,
) {}
```

- [ ] **Step 4: Update the blade CTA** in `resources/views/emails/abandoned-cart.blade.php`

Replace the existing CTA block:

```blade
@if ($storeUrl)
    <a href="{{ $storeUrl }}" class="cta">Return to Shop</a>
@endif
```

with:

```blade
@if ($recoveryUrl)
    <a href="{{ $recoveryUrl }}" class="cta">{{ $coupon ? 'Return to Your Cart and claim your discount' : 'Return to Your Cart' }}</a>
@endif
```

- [ ] **Step 5: Run tests, verify they pass**

Run: `php artisan test --testsuite=Unit --filter=AbandonedCartMailTest`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add app/Mail/AbandonedCartMail.php resources/views/emails/abandoned-cart.blade.php tests/Unit/Mail/AbandonedCartMailTest.php
git commit -m "feat(abandoned-cart): coupon-aware cart-restore CTA + recovery URL in email"
```

---

### Task 7: Issue token + recovery URL in `SendRecoveryEmailAction`

**Files:**
- Modify: `app/Filament/Resources/AbandonedCarts/Actions/SendRecoveryEmailAction.php`

- [ ] **Step 1: Generate token + expiry, store on the recovery email, pass the URL to the mail**

In the `->action(function (AbandonedCart $record, array $data): void { ... })` closure, replace the mail-send + `CartRecoveryEmail::create` block:

```php
$token = \Illuminate\Support\Str::random(64);

$recoveryEmail = CartRecoveryEmail::create([
    'abandoned_cart_id' => $record->id,
    'coupon_id'         => $coupon?->id,
    'sent_at'           => now(),
    'token'             => $token,
    'expires_at'        => now()->addDays(30),
]);

$recoveryUrl = route('storefront.cart.recover', ['token' => $token]);

Mail::to($record->contact_email)
    ->queue(new AbandonedCartMail($record, $coupon, $recoveryUrl, SiteSettings::current()));
```

(Remove the old `Mail::to(...)->queue(new AbandonedCartMail($record, $coupon, url('/'), ...))` line and the old `CartRecoveryEmail::create([...])` without the token. Add `use Illuminate\Support\Str;` if not already imported — note `Str` is already imported in this file.)

- [ ] **Step 2: Verify (QA)**

This is exercised by testman case **TC-SO-73-02** (Task 11): sending a recovery email produces an email whose CTA links to `/cart/recover/{token}`, and a `cart_recovery_emails` row with a populated `token` and `expires_at = +30 days`.

- [ ] **Step 3: Commit**

```bash
git add app/Filament/Resources/AbandonedCarts/Actions/SendRecoveryEmailAction.php
git commit -m "feat(abandoned-cart): issue 30-day recovery token and deep link on send"
```

---

### Task 8: Restore route + `CartRecoveryController`

**Files:**
- Create: `app/Http/Controllers/Storefront/CartRecoveryController.php`
- Modify: `routes/tenant.php` (add the route beside the storefront cart/checkout routes, ~line 47)

- [ ] **Step 1: Add the route**

In `routes/tenant.php`, directly after the `Route::view('/cart', ...)` line:

```php
Route::get('/cart/recover/{token}', [\App\Http\Controllers\Storefront\CartRecoveryController::class, 'restore'])
    ->name('storefront.cart.recover');
```

- [ ] **Step 2: Create the controller**

```php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Storefront;

use App\Http\Controllers\Controller;
use App\Models\CartRecoveryEmail;
use App\Services\CartService;
use Illuminate\Http\RedirectResponse;

class CartRecoveryController extends Controller
{
    public function restore(string $token): RedirectResponse
    {
        $recoveryEmail = CartRecoveryEmail::with(['abandonedCart', 'coupon'])
            ->where('token', $token)
            ->first();

        if (! $recoveryEmail
            || ! $recoveryEmail->abandonedCart
            || ($recoveryEmail->expires_at && $recoveryEmail->expires_at->isPast())) {
            return redirect()
                ->route('storefront.index')
                ->with('cart_recovery_error', 'This recovery link is no longer valid.');
        }

        if ($recoveryEmail->clicked_at === null) {
            $recoveryEmail->forceFill(['clicked_at' => now()])->save();
        }

        $report = CartService::restore($recoveryEmail->abandonedCart->cart_items ?? []);

        // Coupon: auto-apply only if still valid; otherwise note it was already used.
        $couponNote = null;
        $coupon     = $recoveryEmail->coupon;
        if ($coupon) {
            if ($coupon->isValid(CartService::subtotal())) {
                session(['recovery_coupon_code' => $coupon->code]);
            } else {
                $couponNote = 'Your earlier discount has already been used.';
            }
        }

        session(['recovery_email_id' => $recoveryEmail->id]);

        $messages = [];
        if (! empty($report['removed']))        $messages[] = 'Some items in your cart are no longer available and were removed.';
        if (($report['price_changed'] ?? 0) > 0) $messages[] = 'Prices have been updated to reflect current pricing.';
        if (($report['qty_capped'] ?? 0) > 0)    $messages[] = 'Some quantities were reduced to match current stock.';
        if ($report['restored'] === 0)           $messages[] = 'The items from your saved cart are no longer available.';
        if ($couponNote)                          $messages[] = $couponNote;

        $redirect = redirect()->route('storefront.cart');
        if ($messages) {
            $redirect->with('cart_recovery_notice', implode(' ', $messages));
        }

        return $redirect;
    }
}
```

- [ ] **Step 3: Surface the notice on the cart + storefront views**

In `resources/views/storefront/cart.blade.php`, near the top of the cart content, render the flash if present:

```blade
@if (session('cart_recovery_notice'))
    <div class="rounded-md bg-amber-50 border border-amber-200 text-amber-800 px-4 py-3 mb-4 text-sm">
        {{ session('cart_recovery_notice') }}
    </div>
@endif
```

And in the storefront index view (`resources/views/storefront/...` index template used by `storefront.index`), render the error flash:

```blade
@if (session('cart_recovery_error'))
    <div class="rounded-md bg-red-50 border border-red-200 text-red-800 px-4 py-3 mb-4 text-sm">
        {{ session('cart_recovery_error') }}
    </div>
@endif
```

(Match the surrounding markup/classes of each view; the storefront uses Tailwind utility classes.)

- [ ] **Step 4: Verify (QA)**

Covered by the new testman cases in Task 11 (happy-path restore + banner, expired token, invalid token). Manual smoke: hit `/cart/recover/<token>` from a real recovery email and confirm the cart fills and `/cart` shows the notice when items changed.

- [ ] **Step 5: Commit**

```bash
git add app/Http/Controllers/Storefront/CartRecoveryController.php routes/tenant.php resources/views/storefront/cart.blade.php
git commit -m "feat(cart-recovery): /cart/recover/{token} restore route, controller and notices"
```

---

### Task 9: Auto-apply recovery coupon in `CheckoutWizard::mount`

**Files:**
- Modify: `app/Livewire/Checkout/CheckoutWizard.php` (`mount()`, ~line 66)

- [ ] **Step 1: Add auto-apply at the end of `mount()`**

After the existing auth-prefill block (just before `mount()` closes), add:

```php
if (session()->has('recovery_coupon_code') && CartService::count() > 0) {
    $this->couponCode = (string) session('recovery_coupon_code');
    $this->applyCoupon();
    session()->forget('recovery_coupon_code');
}
```

(`applyCoupon()` already validates and clears `couponError` gracefully if the coupon is no longer valid, so no extra error handling is needed.)

- [ ] **Step 2: Verify (QA)**

Covered by the new testman "coupon auto-apply" case in Task 11: click a recovery link whose send had a valid coupon, proceed to checkout, and confirm the discount is pre-applied on the review/payment step.

- [ ] **Step 3: Commit**

```bash
git add app/Livewire/Checkout/CheckoutWizard.php
git commit -m "feat(cart-recovery): auto-apply recovery coupon on checkout mount"
```

---

### Task 10: Recovery attribution in `CheckoutWizard` order completion

**Files:**
- Modify: `app/Livewire/Checkout/CheckoutWizard.php` (the recovery block at ~lines 379-393)

- [ ] **Step 1: Replace the blanket email-match update with source-aware attribution**

Replace:

```php
try {
    AbandonedCart::where('contact_email', $order->contact_email)
        ->whereIn('status', ['pending', 'abandoned'])
        ->update([
            'status'               => 'recovered',
            'recovered_at'         => now(),
            'recovered_order_id'   => $order->id,
        ]);
} catch (\Throwable $e) {
    Log::warning('Failed to mark abandoned cart as recovered', [
        'contact_email' => $order->contact_email,
        'order_id'      => $order->id,
        'error'         => $e->getMessage(),
    ]);
}
```

with:

```php
try {
    $recoveryEmailId = session('recovery_email_id');

    if ($recoveryEmailId) {
        AbandonedCart::whereNull('recovered_recovery_email_id')
            ->whereHas('recoveryEmails', fn ($q) => $q->where('id', $recoveryEmailId))
            ->whereIn('status', ['pending', 'abandoned'])
            ->update([
                'status'                       => 'recovered',
                'recovered_at'                 => now(),
                'recovered_order_id'           => $order->id,
                'recovered_via'                => 'link',
                'recovered_recovery_email_id'  => $recoveryEmailId,
            ]);
        session()->forget('recovery_email_id');
    } else {
        AbandonedCart::where('contact_email', $order->contact_email)
            ->whereIn('status', ['pending', 'abandoned'])
            ->update([
                'status'              => 'recovered',
                'recovered_at'        => now(),
                'recovered_order_id'  => $order->id,
                'recovered_via'       => 'email_match',
            ]);
    }
} catch (\Throwable $e) {
    Log::warning('Failed to mark abandoned cart as recovered', [
        'contact_email' => $order->contact_email,
        'order_id'      => $order->id,
        'error'         => $e->getMessage(),
    ]);
}
```

(Note: `whereHas('recoveryEmails', ...)` ties the recovery to the cart that owns the clicked send. If the link path matches no cart, no recovery is recorded for this order — acceptable, since a clicked link always corresponds to a real cart.)

- [ ] **Step 2: Verify (QA)**

Covered by the new testman "attribution source" case in Task 11: an order completed after a link click sets `recovered_via='link'` + `recovered_recovery_email_id`; an order completed via the same email with no click sets `recovered_via='email_match'`.

- [ ] **Step 3: Commit**

```bash
git add app/Livewire/Checkout/CheckoutWizard.php
git commit -m "feat(cart-recovery): attribute recovery as link vs email_match"
```

---

### Task 11: QA cases + doc sync

Per CLAUDE.md, code changes must be reflected in the testman QA cases and the in-app SOPs.

**Files:**
- Modify: `testman/data/es-test-cases.csv` (and `es-user-stories.csv` if a new story is warranted)
- Modify: ECStores Help Center SOP source if it documents recovery emails (search `resources/views` / help-center content for "recovery")

- [ ] **Step 1: Restore TC-SO-73-02 expected text**

Change TC-SO-73-02's `Expected_Result` back to expect a filled cart:
"Email arrives with the item list, total, and optional coupon. Clicking the CTA opens the storefront cart with the original items restored (revalidated to current price/stock) and ready to check out."
(Also restore the case name/description to reference the cart link.)

- [ ] **Step 2: Add new TC-SO-73 cases** (append rows to `es-test-cases.csv`, story SO-73):
  - **Revalidation notice:** capture a cart, change a product's price and reduce another's stock, send + click the recovery link → cart shows current prices, capped quantity, and a banner noting items removed / prices updated / quantities reduced.
  - **Link expiry:** a recovery link older than 30 days → clicking shows "This recovery link is no longer valid." and lands on the storefront home.
  - **Coupon auto-apply:** send a recovery email with a coupon, click the link, go to checkout → the discount is pre-applied; re-clicking after the coupon is used shows "Your earlier discount has already been used."
  - **Attribution source:** order completed after a link click is attributed `link`; a same-email re-purchase with no click is attributed `email_match`.

- [ ] **Step 3: Update the Help Center SOP** if the recovery-email flow is documented there (note the link now restores the cart, the 30-day expiry, and the attribution).

- [ ] **Step 4: Commit**

```bash
git add testman/data/es-test-cases.csv testman/data/es-user-stories.csv
git commit -m "docs(qa): update TC-SO-73 cases for cart-restore recovery link"
```

(The testman repo is separate from ecstores; commit there. Re-import the CSVs on the testman server after run #25 finishes — the data dir is gitignored.)

---

## Verification (whole feature)

1. **Unit tests green:** `php artisan test --testsuite=Unit` — ProductPricing, CartLineRevalidator, AbandonedCartMail, and the model metadata tests all pass.
2. **Migration applied:** `php artisan tenants:migrate` ran cleanly; new columns present.
3. **testman QA (run #25 SO-73 family + the new cases):** send → email CTA links to `/cart/recover/{token}`; click → cart restored on `/cart` with the right notice; expired/invalid token → friendly redirect; coupon auto-applies; order completion records `recovered_via` correctly.
4. **No regression:** existing storefront product price display and checkout still work (manual smoke on a tenant).

## Notes / follow-ups
- `recovered_via` defaults null for historical rows — fine (only new recoveries set it).
- One-off coupons whose recovery is never completed are left as-is (out of scope per spec).
- After merge, restore TC-SO-73-02's text in the live testman tracker (re-import CSVs).
