# Abandoned Cart Recovery 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:** Persist abandoned carts to the database, let Pro merchants see them in Filament, and send optional coupon-attached recovery emails from a modal action.

**Architecture:** A tenant migration adds two tables (`abandoned_carts`, `cart_recovery_emails`). `CheckoutWizard` upserts a cart record when the shopper advances from step 1 and marks it recovered after a successful order. A Laravel scheduler marks carts older than 1 hour as `abandoned`. A Filament resource with a custom `SendRecoveryEmailAction` gives merchants the recovery email workflow.

**Tech Stack:** Laravel 13, Filament 5.6, Livewire 4.3, stancl/tenancy 3.x, MySQL, Laravel Queues (Mail::queue)

---

## File Structure

**New files:**
- `database/migrations/tenant/2026_05_22_000004_create_abandoned_cart_tables.php` — creates `abandoned_carts` + `cart_recovery_emails` tables
- `app/Models/AbandonedCart.php` — reads/writes `abandoned_carts`, has `recoveryEmails()` HasMany
- `app/Models/CartRecoveryEmail.php` — reads/writes `cart_recovery_emails`, has `cart()` and `coupon()` BelongsTo
- `tests/Unit/Models/AbandonedCartTest.php` — model reflection tests (no DB)
- `tests/Unit/Models/CartRecoveryEmailTest.php` — model reflection tests (no DB)
- `app/Mail/AbandonedCartMail.php` — Mailable; receives `AbandonedCart`, optional `Coupon`, store URL
- `resources/views/emails/abandoned-cart.blade.php` — HTML email template
- `app/Filament/Resources/AbandonedCarts/Actions/SendRecoveryEmailAction.php` — table Action with modal form; creates/attaches coupon, queues mail, logs `CartRecoveryEmail`
- `app/Filament/Resources/AbandonedCarts/Pages/ListAbandonedCarts.php` — list page with plan-gate notification
- `app/Filament/Resources/AbandonedCarts/AbandonedCartResource.php` — resource; nav sort 5, Pro badge, table with filters + row actions

**Modified files:**
- `app/Livewire/Checkout/CheckoutWizard.php` — add `AbandonedCart::updateOrCreate()` in `nextStep()` (step 1) and recovery marking in `placeOrder()`
- `routes/console.php` — add `Schedule::call()` to mark carts abandoned every 15 minutes

---

## Context for Subagents

**Currency pattern (mandatory):** Always use `->money(fn () => strtoupper(\App\Models\SiteSettings::current()->currency ?? 'cad'))` for money columns. Never use `->prefix('$')->numeric()`.

**Navigation icon pattern:** Use `Heroicon::OutlinedShoppingCart` (BackedEnum) — not a string like `'heroicon-o-shopping-cart'`.

**Filament 5.6 Schema API:** `form(Schema $schema)` uses `$schema->components([])`. Form components come from `Filament\Forms\Components\*`. For reactive fields in action modals, use `->live()` on the triggering field and `fn ($get): bool =>` closures on `->hidden()` and `->required()`.

**Tenant migrations:** Run with `php artisan tenants:migrate --force`. Migrations in `database/migrations/tenant/` are applied to each tenant database, not the central DB.

**Mail pattern:** Follow `OrderConfirmationMail` — extend `Mailable`, use `Queueable` + `SerializesModels` traits, define `envelope()` + `content()`. Dispatch with `Mail::to()->queue()` (no `ShouldQueue` interface needed).

**Action modal pattern:** Follow `app/Filament/Resources/Orders/Actions/RefundAction.php` — static factory class, `Filament\Tables\Actions\Action::make()`, `->schema([])` for modal form fields, `->action(function ($record, array $data) {})` for submit logic.

**Spec:** `docs/superpowers/specs/2026-05-22-abandoned-cart-recovery-design.md`

---

## Task 1: Database Migration

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

- [ ] **Step 1: Create the migration file**

```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::create('abandoned_carts', function (Blueprint $table) {
            $table->id();
            $table->string('contact_email');
            $table->string('contact_name')->nullable();
            $table->json('cart_items');
            $table->decimal('cart_value', 10, 2);
            $table->enum('status', ['pending', 'abandoned', 'recovered'])->default('pending');
            $table->timestamp('captured_at')->useCurrent();
            $table->timestamp('recovered_at')->nullable();

            $table->index('contact_email');
            $table->index('status');
            $table->index('captured_at');
        });

        Schema::create('cart_recovery_emails', function (Blueprint $table) {
            $table->id();
            $table->foreignId('abandoned_cart_id')->constrained('abandoned_carts')->cascadeOnDelete();
            $table->foreignId('coupon_id')->nullable()->constrained('coupons')->nullOnDelete();
            $table->timestamp('sent_at')->useCurrent();
        });
    }

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

- [ ] **Step 2: Run the migration against all tenants**

```bash
php artisan tenants:migrate --force
```

Expected: no errors; each tenant DB gets both tables.

- [ ] **Step 3: Commit**

```bash
git add database/migrations/tenant/2026_05_22_000004_create_abandoned_cart_tables.php
git commit -m "feat: add abandoned_carts and cart_recovery_emails tenant migration"
```

---

## Task 2: Models + Unit Tests

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

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

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

```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Models;

use App\Models\AbandonedCart;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Tests\TestCase;

class AbandonedCartTest extends TestCase
{
    public function test_table_is_abandoned_carts(): void
    {
        $this->assertEquals('abandoned_carts', (new AbandonedCart())->getTable());
    }

    public function test_timestamps_are_disabled(): void
    {
        $this->assertFalse((new AbandonedCart())->usesTimestamps());
    }

    public function test_cart_items_cast_to_array(): void
    {
        $this->assertEquals('array', (new AbandonedCart())->getCasts()['cart_items']);
    }

    public function test_captured_at_cast_to_datetime(): void
    {
        $this->assertEquals('datetime', (new AbandonedCart())->getCasts()['captured_at']);
    }

    public function test_recovered_at_cast_to_datetime(): void
    {
        $this->assertEquals('datetime', (new AbandonedCart())->getCasts()['recovered_at']);
    }

    public function test_recovery_emails_returns_has_many(): void
    {
        $relation = (new AbandonedCart())->recoveryEmails();
        $this->assertInstanceOf(HasMany::class, $relation);
        $this->assertEquals('abandoned_cart_id', $relation->getForeignKeyName());
    }
}
```

- [ ] **Step 2: Run tests — expect FAIL**

```bash
php artisan test tests/Unit/Models/AbandonedCartTest.php
```

Expected: FAIL with `Class "App\Models\AbandonedCart" not found`.

- [ ] **Step 3: Create `app/Models/AbandonedCart.php`**

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class AbandonedCart extends Model
{
    protected $table = 'abandoned_carts';

    public $timestamps = false;

    protected $fillable = [
        'contact_email',
        'contact_name',
        'cart_items',
        'cart_value',
        'status',
        'captured_at',
        'recovered_at',
    ];

    protected function casts(): array
    {
        return [
            'cart_items'   => 'array',
            'cart_value'   => 'decimal:2',
            'captured_at'  => 'datetime',
            'recovered_at' => 'datetime',
        ];
    }

    public function recoveryEmails(): HasMany
    {
        return $this->hasMany(CartRecoveryEmail::class);
    }
}
```

- [ ] **Step 4: Run tests — expect PASS**

```bash
php artisan test tests/Unit/Models/AbandonedCartTest.php
```

Expected: 6 tests, 6 assertions, OK.

- [ ] **Step 5: Write the failing CartRecoveryEmail tests**

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

```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Models;

use App\Models\CartRecoveryEmail;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Tests\TestCase;

class CartRecoveryEmailTest extends TestCase
{
    public function test_table_is_cart_recovery_emails(): void
    {
        $this->assertEquals('cart_recovery_emails', (new CartRecoveryEmail())->getTable());
    }

    public function test_timestamps_are_disabled(): void
    {
        $this->assertFalse((new CartRecoveryEmail())->usesTimestamps());
    }

    public function test_sent_at_cast_to_datetime(): void
    {
        $this->assertEquals('datetime', (new CartRecoveryEmail())->getCasts()['sent_at']);
    }

    public function test_cart_returns_belongs_to(): void
    {
        $this->assertInstanceOf(BelongsTo::class, (new CartRecoveryEmail())->cart());
    }

    public function test_coupon_returns_belongs_to(): void
    {
        $this->assertInstanceOf(BelongsTo::class, (new CartRecoveryEmail())->coupon());
    }
}
```

- [ ] **Step 6: Run tests — expect FAIL**

```bash
php artisan test tests/Unit/Models/CartRecoveryEmailTest.php
```

Expected: FAIL with `Class "App\Models\CartRecoveryEmail" not found`.

- [ ] **Step 7: Create `app/Models/CartRecoveryEmail.php`**

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class CartRecoveryEmail extends Model
{
    protected $table = 'cart_recovery_emails';

    public $timestamps = false;

    protected $fillable = [
        'abandoned_cart_id',
        'coupon_id',
        'sent_at',
    ];

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

    public function cart(): BelongsTo
    {
        return $this->belongsTo(AbandonedCart::class, 'abandoned_cart_id');
    }

    public function coupon(): BelongsTo
    {
        return $this->belongsTo(Coupon::class);
    }
}
```

- [ ] **Step 8: Run all model tests — expect PASS**

```bash
php artisan test tests/Unit/Models/AbandonedCartTest.php tests/Unit/Models/CartRecoveryEmailTest.php
```

Expected: 11 tests, 11 assertions, OK.

- [ ] **Step 9: Commit**

```bash
git add app/Models/AbandonedCart.php app/Models/CartRecoveryEmail.php \
        tests/Unit/Models/AbandonedCartTest.php tests/Unit/Models/CartRecoveryEmailTest.php
git commit -m "feat: add AbandonedCart and CartRecoveryEmail models with tests"
```

---

## Task 3: CheckoutWizard — Cart Capture

**Files:**
- Modify: `app/Livewire/Checkout/CheckoutWizard.php`

When the shopper clicks "Next" on step 1 (contact info validated), upsert an `AbandonedCart` record. Upsert by `contact_email` so repeat visits refresh the snapshot rather than creating duplicates.

- [ ] **Step 1: Add the AbandonedCart import**

In `app/Livewire/Checkout/CheckoutWizard.php`, add after the existing `use` block:

```php
use App\Models\AbandonedCart;
```

The full import block should then look like:

```php
use App\Mail\OrderConfirmationMail;
use App\Models\AbandonedCart;
use App\Models\Order;
use App\Models\Product;
use App\Models\ProductVariantCombination;
use App\Models\ShippingMethod;
use App\Models\SiteSettings;
use App\Services\CartService;
use App\Services\ShippingCalculator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Livewire\Component;
```

- [ ] **Step 2: Modify `nextStep()` to upsert on step 1**

Current `nextStep()`:

```php
public function nextStep(): void
{
    $this->validate($this->rulesForStep($this->step));

    if ($this->step === 3) {
        $this->preparePayment();
        if ($this->paymentError) {
            return; // Stay on step 3 so error is visible
        }
    }

    $this->step++;
}
```

Replace with:

```php
public function nextStep(): void
{
    $this->validate($this->rulesForStep($this->step));

    if ($this->step === 1) {
        $items = CartService::items();
        AbandonedCart::updateOrCreate(
            ['contact_email' => $this->contact_email],
            [
                'contact_name' => $this->contact_name ?: null,
                'cart_items'   => array_values($items),
                'cart_value'   => CartService::subtotal(),
                'status'       => 'pending',
                'captured_at'  => now(),
                'recovered_at' => null,
            ]
        );
    }

    if ($this->step === 3) {
        $this->preparePayment();
        if ($this->paymentError) {
            return; // Stay on step 3 so error is visible
        }
    }

    $this->step++;
}
```

- [ ] **Step 3: Run the test suite**

```bash
php artisan test
```

Expected: all existing tests pass (no regressions).

- [ ] **Step 4: Commit**

```bash
git add app/Livewire/Checkout/CheckoutWizard.php
git commit -m "feat: capture abandoned cart when shopper advances past checkout step 1"
```

---

## Task 4: CheckoutWizard — Recovery Marking

**Files:**
- Modify: `app/Livewire/Checkout/CheckoutWizard.php`

After a successful order, mark the shopper's pending/abandoned cart as recovered.

- [ ] **Step 1: Locate the post-transaction block in `placeOrder()`**

In `placeOrder()`, after the DB transaction closes and `CartService::clear()` is called, the code looks like:

```php
CartService::clear();
session()->put('last_order_id', $order->id);
```

- [ ] **Step 2: Add the recovery marking between those two lines**

Replace that section with:

```php
CartService::clear();

AbandonedCart::where('contact_email', $this->contact_email)
    ->whereIn('status', ['pending', 'abandoned'])
    ->update(['status' => 'recovered', 'recovered_at' => now()]);

session()->put('last_order_id', $order->id);
```

- [ ] **Step 3: Run the test suite**

```bash
php artisan test
```

Expected: all existing tests pass.

- [ ] **Step 4: Commit**

```bash
git add app/Livewire/Checkout/CheckoutWizard.php
git commit -m "feat: mark abandoned cart as recovered when order is placed"
```

---

## Task 5: Scheduler

**Files:**
- Modify: `routes/console.php`

Every 15 minutes, find `pending` carts older than 1 hour and mark them `abandoned`.

- [ ] **Step 1: Replace `routes/console.php`**

Current file contents:

```php
<?php

use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;

Artisan::command('inspire', function () {
    $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
```

Replace with:

```php
<?php

use App\Models\AbandonedCart;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;

Artisan::command('inspire', function () {
    $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');

Schedule::call(function () {
    AbandonedCart::where('status', 'pending')
        ->where('captured_at', '<=', now()->subHour())
        ->update(['status' => 'abandoned']);
})->everyFifteenMinutes();
```

- [ ] **Step 2: Verify the schedule is registered**

```bash
php artisan schedule:list
```

Expected output includes a line showing the closure scheduled `Every 15 minutes`.

- [ ] **Step 3: Commit**

```bash
git add routes/console.php
git commit -m "feat: schedule job to mark abandoned carts after 1 hour"
```

---

## Task 6: AbandonedCartMail + Email Template

**Files:**
- Create: `app/Mail/AbandonedCartMail.php`
- Create: `resources/views/emails/abandoned-cart.blade.php`

- [ ] **Step 1: Create `app/Mail/AbandonedCartMail.php`**

Follow the `OrderConfirmationMail` pattern exactly (Queueable + SerializesModels, envelope + content methods):

```php
<?php

declare(strict_types=1);

namespace App\Mail;

use App\Models\AbandonedCart;
use App\Models\Coupon;
use App\Models\SiteSettings;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class AbandonedCartMail extends Mailable
{
    use Queueable, SerializesModels;

    public function __construct(
        public readonly AbandonedCart $cart,
        public readonly ?Coupon $coupon = null,
        public readonly string $storeUrl = '',
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(subject: 'You left something behind');
    }

    public function content(): Content
    {
        return new Content(
            view: 'emails.abandoned-cart',
            with: ['settings' => SiteSettings::current()],
        );
    }
}
```

`$cart`, `$coupon`, and `$storeUrl` are public so they're automatically available in the blade view. `$settings` is passed via `with`.

- [ ] **Step 2: Create `resources/views/emails/abandoned-cart.blade.php`**

```html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>You left something behind</title>
    <style>
        body { font-family: system-ui, sans-serif; background: #f9fafb; margin: 0; padding: 32px 16px; color: #111827; }
        .card { background: #fff; border-radius: 12px; max-width: 560px; margin: 0 auto; padding: 40px; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
        h1 { font-size: 22px; margin: 0 0 8px; }
        h2 { font-size: 16px; font-weight: 600; margin: 0 0 16px; color: #374151; }
        p { margin: 0 0 16px; line-height: 1.6; color: #374151; }
        table { width: 100%; border-collapse: collapse; margin: 0 0 16px; font-size: 14px; }
        table th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: #6b7280; border-bottom: 1px solid #e5e7eb; padding: 0 8px 8px 0; }
        table td { padding: 8px 8px 0 0; vertical-align: top; color: #374151; }
        .coupon-box { background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 16px 20px; margin: 24px 0; }
        .coupon-code { font-size: 20px; font-weight: 700; letter-spacing: .1em; color: #92400e; margin: 4px 0; }
        .cta { display: block; text-align: center; background: #1d4ed8; color: #fff !important; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 15px; margin: 24px 0; }
        .footer { text-align: center; font-size: 12px; color: #9ca3af; margin-top: 32px; }
    </style>
</head>
<body>
    <div class="card">
        <h1>{{ $settings->company_name ?? 'Your Store' }}</h1>
        <h2>You left something behind</h2>

        <p>Hi {{ $cart->contact_name ?: 'there' }},</p>
        <p>You left some items in your cart — they're still waiting for you.</p>

        <table>
            <thead>
                <tr>
                    <th>Item</th>
                    <th>Qty</th>
                    <th style="text-align:right">Price</th>
                </tr>
            </thead>
            <tbody>
                @foreach ($cart->cart_items as $item)
                    <tr>
                        <td>
                            {{ $item['name'] }}
                            @if (!empty($item['variant_label']))
                                <br><span style="font-size:12px;color:#6b7280;">{{ $item['variant_label'] }}</span>
                            @endif
                        </td>
                        <td>{{ $item['quantity'] }}</td>
                        <td style="text-align:right">{{ $settings->currency_symbol ?? '$' }}{{ number_format((float) $item['price'], 2) }}</td>
                    </tr>
                @endforeach
            </tbody>
        </table>

        <p style="text-align:right;font-weight:600;">
            Cart Total: {{ $settings->currency_symbol ?? '$' }}{{ number_format((float) $cart->cart_value, 2) }}
        </p>

        @if ($coupon)
            <div class="coupon-box">
                <p style="margin:0 0 4px;font-weight:600;color:#92400e;">Special offer just for you:</p>
                <p class="coupon-code">{{ $coupon->code }}</p>
                <p style="margin:8px 0 0;color:#92400e;font-size:14px;">
                    @if ($coupon->discount_type === 'percentage')
                        Use this code at checkout to get {{ number_format((float) $coupon->discount_value, 0) }}% off your order.
                    @else
                        Use this code at checkout to get {{ $settings->currency_symbol ?? '$' }}{{ number_format((float) $coupon->discount_value, 2) }} off your order.
                    @endif
                </p>
            </div>
        @endif

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

        @if ($settings->contact_email)
            <p style="margin-top:24px;font-size:14px;">
                Questions? Contact us at <a href="mailto:{{ $settings->contact_email }}">{{ $settings->contact_email }}</a>
            </p>
        @endif
    </div>
    <div class="footer">
        You're receiving this because you started a checkout at {{ $settings->company_name ?? 'our store' }}. Powered by ECStores.
    </div>
</body>
</html>
```

- [ ] **Step 3: Commit**

```bash
git add app/Mail/AbandonedCartMail.php resources/views/emails/abandoned-cart.blade.php
git commit -m "feat: add AbandonedCartMail mailable and email template"
```

---

## Task 7: Filament Resource

**Files:**
- Create: `app/Filament/Resources/AbandonedCarts/Actions/SendRecoveryEmailAction.php`
- Create: `app/Filament/Resources/AbandonedCarts/Pages/ListAbandonedCarts.php`
- Create: `app/Filament/Resources/AbandonedCarts/AbandonedCartResource.php`

Follow the same directory structure as `app/Filament/Resources/Customers/` and the same pattern as `app/Filament/Resources/Orders/Actions/RefundAction.php` for the action.

- [ ] **Step 1: Create `app/Filament/Resources/AbandonedCarts/Actions/SendRecoveryEmailAction.php`**

```php
<?php

declare(strict_types=1);

namespace App\Filament\Resources\AbandonedCarts\Actions;

use App\Mail\AbandonedCartMail;
use App\Models\AbandonedCart;
use App\Models\CartRecoveryEmail;
use App\Models\Coupon;
use Filament\Forms\Components\Radio;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Tables\Actions\Action;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;

class SendRecoveryEmailAction
{
    public static function make(): Action
    {
        return Action::make('sendRecoveryEmail')
            ->label('Send Recovery Email')
            ->icon('heroicon-o-envelope')
            ->color('warning')
            ->visible(fn (AbandonedCart $record): bool => $record->status === 'abandoned')
            ->schema([
                Radio::make('coupon_type')
                    ->label('Coupon')
                    ->options([
                        'none'     => 'No coupon',
                        'existing' => 'Attach existing coupon',
                        'one_off'  => 'Create one-off discount',
                    ])
                    ->default('none')
                    ->live()
                    ->required(),

                Select::make('coupon_id')
                    ->label('Select Coupon')
                    ->options(
                        Coupon::where('is_active', true)
                            ->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
                            ->pluck('code', 'id')
                    )
                    ->searchable()
                    ->hidden(fn ($get): bool => $get('coupon_type') !== 'existing')
                    ->required(fn ($get): bool => $get('coupon_type') === 'existing'),

                Select::make('discount_type')
                    ->label('Discount Type')
                    ->options([
                        'percentage' => 'Percentage (%)',
                        'fixed'      => 'Fixed Amount ($)',
                    ])
                    ->hidden(fn ($get): bool => $get('coupon_type') !== 'one_off')
                    ->required(fn ($get): bool => $get('coupon_type') === 'one_off'),

                TextInput::make('discount_value')
                    ->label('Discount Value')
                    ->numeric()
                    ->minValue(0.01)
                    ->hidden(fn ($get): bool => $get('coupon_type') !== 'one_off')
                    ->required(fn ($get): bool => $get('coupon_type') === 'one_off'),
            ])
            ->action(function (AbandonedCart $record, array $data): void {
                $coupon = null;

                if ($data['coupon_type'] === 'existing') {
                    $coupon = Coupon::find($data['coupon_id']);
                } elseif ($data['coupon_type'] === 'one_off') {
                    $coupon = Coupon::create([
                        'code'           => 'CART-' . strtoupper(Str::random(6)),
                        'discount_type'  => $data['discount_type'],
                        'discount_value' => (float) $data['discount_value'],
                        'max_uses'       => 1,
                        'used_count'     => 0,
                        'is_active'      => true,
                        'expires_at'     => null,
                    ]);
                }

                Mail::to($record->contact_email)
                    ->queue(new AbandonedCartMail($record, $coupon, url('/')));

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

                Notification::make()
                    ->title('Recovery email sent to ' . $record->contact_email)
                    ->success()
                    ->send();
            });
    }
}
```

- [ ] **Step 2: Create `app/Filament/Resources/AbandonedCarts/Pages/ListAbandonedCarts.php`**

```php
<?php

declare(strict_types=1);

namespace App\Filament\Resources\AbandonedCarts\Pages;

use App\Filament\Resources\AbandonedCarts\AbandonedCartResource;
use App\Services\PlanService;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;

class ListAbandonedCarts extends ListRecords
{
    protected static string $resource = AbandonedCartResource::class;

    public function mount(): void
    {
        parent::mount();

        if (! app(PlanService::class)->hasAbandonedCart()) {
            Notification::make()
                ->title('Abandoned cart recovery is available on the Pro plan. Contact us to upgrade.')
                ->warning()
                ->persistent()
                ->send();
        }
    }

    protected function getHeaderActions(): array
    {
        return [];
    }
}
```

- [ ] **Step 3: Create `app/Filament/Resources/AbandonedCarts/AbandonedCartResource.php`**

```php
<?php

declare(strict_types=1);

namespace App\Filament\Resources\AbandonedCarts;

use App\Filament\Resources\AbandonedCarts\Actions\SendRecoveryEmailAction;
use App\Filament\Resources\AbandonedCarts\Pages\ListAbandonedCarts;
use App\Models\AbandonedCart;
use App\Models\SiteSettings;
use App\Services\PlanService;
use BackedEnum;
use Filament\Forms\Components\DatePicker;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;

class AbandonedCartResource extends Resource
{
    protected static ?string $model = AbandonedCart::class;

    protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShoppingCart;

    public static function getNavigationGroup(): ?string
    {
        return 'Store';
    }

    public static function getNavigationSort(): ?int
    {
        return 5;
    }

    public static function getNavigationLabel(): string
    {
        return 'Abandoned Carts';
    }

    public static function getNavigationBadge(): ?string
    {
        return app(PlanService::class)->hasAbandonedCart() ? null : 'Pro';
    }

    public static function getNavigationBadgeColor(): string|array|null
    {
        return app(PlanService::class)->hasAbandonedCart() ? null : 'warning';
    }

    public static function canCreate(): bool
    {
        return false;
    }

    public static function form(Schema $schema): Schema
    {
        return $schema->components([]);
    }

    public static function getEloquentQuery(): Builder
    {
        return parent::getEloquentQuery()->withCount('recoveryEmails');
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                TextColumn::make('captured_at')
                    ->label('Captured')
                    ->dateTime('M j, Y g:i a')
                    ->sortable(),

                TextColumn::make('contact_name')
                    ->label('Name')
                    ->searchable()
                    ->sortable(),

                TextColumn::make('contact_email')
                    ->label('Email')
                    ->searchable()
                    ->sortable(),

                TextColumn::make('cart_value')
                    ->label('Cart Value')
                    ->money(fn () => strtoupper(SiteSettings::current()->currency ?? 'cad'))
                    ->sortable(),

                TextColumn::make('status')
                    ->label('Status')
                    ->badge()
                    ->color(fn (string $state): string => match ($state) {
                        'pending'   => 'gray',
                        'abandoned' => 'warning',
                        'recovered' => 'success',
                        default     => 'gray',
                    }),

                TextColumn::make('recovery_emails_count')
                    ->label('Emails Sent')
                    ->sortable(),
            ])
            ->defaultSort('captured_at', 'desc')
            ->filters([
                SelectFilter::make('status')
                    ->options([
                        'pending'   => 'Pending',
                        'abandoned' => 'Abandoned',
                        'recovered' => 'Recovered',
                    ]),

                Filter::make('captured_at')
                    ->label('Date Range')
                    ->form([
                        DatePicker::make('from')->label('From'),
                        DatePicker::make('until')->label('Until'),
                    ])
                    ->query(function (Builder $query, array $data): Builder {
                        return $query
                            ->when($data['from'] ?? null, fn ($q, $v) => $q->whereDate('captured_at', '>=', $v))
                            ->when($data['until'] ?? null, fn ($q, $v) => $q->whereDate('captured_at', '<=', $v));
                    }),
            ])
            ->recordActions([
                SendRecoveryEmailAction::make(),
                DeleteAction::make(),
            ]);
    }

    public static function getRelations(): array
    {
        return [];
    }

    public static function getPages(): array
    {
        return [
            'index' => ListAbandonedCarts::route('/'),
        ];
    }
}
```

- [ ] **Step 4: Run the test suite to confirm no regressions**

```bash
php artisan test
```

Expected: all tests pass.

- [ ] **Step 5: Open the Filament admin and verify**

Manual checks:
- "Abandoned Carts" appears in the Store nav group (sort 5, after Customers)
- Pro badge shows on the nav item when `has_abandoned_cart = false`
- Persistent warning notification fires on mount when feature is locked
- List table renders with all 6 columns, sort/filter toolbar works
- Status filter (Pending / Abandoned / Recovered) works
- Date range filter works
- Row with `status = abandoned`: "Send Recovery Email" action is visible
- Row with `status = pending` or `recovered`: action is NOT visible
- Delete action is visible on all rows
- Recovery email modal: selecting "No coupon" hides coupon fields
- Modal: selecting "Attach existing coupon" shows coupon select, hides discount fields
- Modal: selecting "Create one-off discount" shows discount_type + discount_value fields
- Submit with "No coupon": success notification, `cart_recovery_emails` row inserted with `coupon_id = null`
- Submit with "Attach existing coupon": `cart_recovery_emails` row with the selected `coupon_id`
- Submit with "Create one-off discount": new `Coupon` row with code `CART-XXXXXX`, `max_uses = 1`, then `cart_recovery_emails` row pointing to it
- Delete a row: row removed, confirm prompt shown by Filament

- [ ] **Step 6: Commit**

```bash
git add app/Filament/Resources/AbandonedCarts/
git commit -m "feat: add AbandonedCartResource with SendRecoveryEmailAction and plan gate"
```
