# ECStores Abandoned Cart Recovery — Design Spec

## Goal

Give Pro-tier merchants visibility into abandoned shopping carts and the ability to send recovery emails — optionally attaching a coupon incentive — directly from the Filament admin.

## Context

- **Platform:** Laravel 13, Livewire 4.3, Filament 5.6, stancl/tenancy 3.x
- **Plan gate:** `has_abandoned_cart` on the `Plan` model; `PlanService::hasAbandonedCart()` already wired
- **Tenancy:** New table created via tenant migration under `database/migrations/tenant/`
- **Existing pattern:** Follows same nav-badge + persistent-notification gate pattern as Coupons, Expenses, Refunds, and Customers
- **Cart today:** Session-only via `CartService` (SESSION_KEY `'cart'`). Items stored as `array<string, array<string, mixed>>` with keys `product_id`, `combination_id`, `name`, `sku`, `price`, `quantity`, `variant_label`, `image`.
- **Abandonment definition:** Fixed 1-hour window — industry standard, no merchant config required
- **Coupon integration:** Merchant can attach an existing active coupon OR create a one-off discount (auto-generates a `Coupon` record with code `CART-{XXXXXX}`, `max_uses=1`)
- **Recovery tracking:** When `CheckoutWizard::placeOrder()` completes, match by `contact_email` and mark the most recent non-recovered cart as `recovered`

---

## Data Layer

### New: `abandoned_carts` Table

Created via tenant migration.

```sql
id                  bigint PK auto-increment
contact_email       varchar(255) NOT NULL  -- identity key
contact_name        varchar(255) nullable  -- captured from CheckoutWizard step 1
cart_items          json NOT NULL          -- snapshot of CartService::items()
cart_value          decimal(10,2) NOT NULL -- sum of price * quantity at capture time
status              enum('pending','abandoned','recovered') default 'pending'
captured_at         timestamp              -- when record was created/upserted
recovered_at        timestamp nullable     -- set when matching order is placed
```

Indexes: `contact_email`, `status`, `captured_at`.

### New: `cart_recovery_emails` Table

Stores each recovery email sent (no limit per cart).

```sql
id                  bigint PK auto-increment
abandoned_cart_id   bigint FK → abandoned_carts.id
coupon_id           bigint FK → coupons.id nullable  -- null if no coupon attached
sent_at             timestamp
```

### `AbandonedCart` Model

- `$table = 'abandoned_carts'`
- `$timestamps = false` (uses `captured_at` and `recovered_at` manually)
- Casts: `cart_items` → `array`, `cart_value` → `decimal:2`, `captured_at` → `datetime`, `recovered_at` → `datetime`, `status` → `string`
- `recoveryEmails()` → `hasMany(CartRecoveryEmail::class)`

### `CartRecoveryEmail` Model

- `$table = 'cart_recovery_emails'`
- `$timestamps = false` (uses `sent_at` manually)
- Casts: `sent_at` → `datetime`
- `cart()` → `belongsTo(AbandonedCart::class)`
- `coupon()` → `belongsTo(Coupon::class)`

---

## Cart Capture

### Where Capture Happens

`CheckoutWizard` (`app/Livewire/Checkout/CheckoutWizard.php`) — when the shopper advances from step 1 (contact details) to step 2 (shipping). At this point `contact_email` and `contact_name` are known and the cart has meaningful items.

### Upsert Logic

```php
AbandonedCart::updateOrCreate(
    ['contact_email' => $this->contact_email],
    [
        'contact_name' => $this->contact_name,
        'cart_items'   => CartService::items(),
        'cart_value'   => /* sum of price * quantity */,
        'status'       => 'pending',
        'captured_at'  => now(),
        'recovered_at' => null,
    ]
);
```

Upsert by `contact_email` so multiple partial checkouts from the same shopper don't accumulate duplicate rows.

### Recovery Marking

In `CheckoutWizard::placeOrder()`, after the order is successfully created:

```php
AbandonedCart::where('contact_email', $this->contact_email)
    ->where('status', '!=', 'recovered')
    ->update(['status' => 'recovered', 'recovered_at' => now()]);
```

---

## Scheduled Job

### `MarkCartsAbandoned` (Artisan command or invokable)

Runs every 15 minutes via Laravel scheduler (`routes/console.php`).

Logic: find all `abandoned_carts` where `status = 'pending'` and `captured_at <= now() - 1 hour`, set `status = 'abandoned'`.

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

---

## Filament Resource

### `AbandonedCartResource`

**Location:** `app/Filament/Resources/AbandonedCarts/AbandonedCartResource.php`

**Navigation:**
- Group: `'Store'`
- Sort: `5` (after Customers at sort 4)
- Icon: `Heroicon::OutlinedShoppingCart`
- Label: `'Abandoned Carts'`

**Plan gate:**
- `getNavigationBadge()` returns `'Pro'` when `!hasAbandonedCart()`, null otherwise
- `getNavigationBadgeColor()` returns `'warning'` when `!hasAbandonedCart()`, null otherwise
- `canCreate()` always returns `false`
- `ListAbandonedCarts::mount()` sends persistent warning notification when locked:
  > "Abandoned cart recovery is available on the Pro plan. Contact us to upgrade."

**List table columns:**

| Column | Label | Notes |
|---|---|---|
| `captured_at` | Captured | DateTime `M j, Y g:i a`, sortable, default sort desc |
| `contact_name` | Name | Searchable, sortable |
| `contact_email` | Email | Searchable, sortable |
| `cart_value` | Cart Value | `money()` with currency from SiteSettings, sortable |
| `status` | Status | Badge: pending=gray, abandoned=warning, recovered=success |
| `recovery_emails_count` | Emails Sent | Count via `withCount('recoveryEmails')`, plain integer |

**Filters:**
- `SelectFilter` on `status` — options: All / Pending / Abandoned / Recovered
- `Filter` with date range on `captured_at`

**Row actions:**
- `SendRecoveryEmailAction` — visible only when `status = 'abandoned'`
- `DeleteAction` — all rows

**No view page** — all relevant info is on the list table.

---

### `SendRecoveryEmailAction`

Custom `Tables\Actions\Action` with a modal form.

**Modal form fields:**

1. `coupon_type` — `Radio` or `Select` with options:
   - `none` — No coupon
   - `existing` — Select an existing coupon
   - `one_off` — Create a one-off discount

2. `coupon_id` — `Select` (searchable), visible when `coupon_type = 'existing'`
   - Pulls from `Coupon` model: `is_active = true`, not expired, options display `code — description`

3. `discount_type` — `Select` with `percentage` / `fixed`, visible when `coupon_type = 'one_off'`

4. `discount_value` — `TextInput` (numeric), visible when `coupon_type = 'one_off'`

**On submit:**
1. If `coupon_type = 'one_off'`: create a `Coupon` record with:
   - `code` = `'CART-' . strtoupper(Str::random(6))`
   - `discount_type`, `discount_value` from form
   - `max_uses = 1`, `used_count = 0`, `is_active = true`, `expires_at = null`
2. Dispatch `AbandonedCartMail` to queue (to `$cart->contact_email`)
3. Create `CartRecoveryEmail` record (`abandoned_cart_id`, `coupon_id`, `sent_at = now()`)
4. Show success notification

---

## Recovery Email

### `AbandonedCartMail` Mailable

**Location:** `app/Mail/AbandonedCartMail.php`

```php
class AbandonedCartMail extends Mailable implements ShouldQueue
{
    use Queueable, SerializesModels;

    public function __construct(
        public AbandonedCart $cart,
        public ?Coupon $coupon = null,
    ) {}
}
```

Subject: `"You left something behind"`

View: `mail.abandoned-cart`

### Blade Template

**Location:** `resources/views/mail/abandoned-cart.blade.php`

Content structure:

```
Subject: You left something behind

Hi [contact_name / "there"],

You left some items in your cart — they're still waiting for you.

[Item list from cart_items JSON]
  • {name} × {quantity} — ${price}
  • ...

Cart Total: ${cart_value}

[Conditional coupon block — shown only if coupon attached]
  Use code {coupon.code} at checkout to get
  [X% off / $X off] your order.

[Return to Shop] ← CTA button → {SiteSettings::current()->store_url or site domain}

──────────────────────────────────
You're receiving this because you started a checkout at {store name}.
```

No unsubscribe link — this is a merchant-triggered transactional email, not bulk marketing.

---

## Deployment

1. Run tenant migration (creates `abandoned_carts` and `cart_recovery_emails` tables)
2. Ensure Laravel scheduler is running (`* * * * * php artisan schedule:run`)
3. Clear caches
4. Verify: Abandoned Carts nav item appears in Store group for Pro tenants; locked with badge for lower tiers

---

## Future Considerations (out of scope)

- Automated recovery email sequences (e.g., send 1hr and 24hr after abandonment automatically)
- Cart restore link — deep link that pre-populates the cart (requires persistent cart or signed URL)
- Analytics dashboard — open rates, recovery conversion rate
- Bulk send recovery emails to multiple abandoned carts at once
