# Cart-Recovery Deep Link — Design

**Date:** 2026-06-12
**Status:** Approved (brainstorming), pending implementation plan
**Area:** ECStores tenant storefront + Filament merchant admin (Abandoned Cart Recovery)
**Related:** `docs/superpowers/specs/2026-05-22-abandoned-cart-recovery-design.md`

## Problem

The abandoned-cart recovery email currently renders a **"Return to Shop"** button that links to the
store home (`url('/')`). Because the storefront cart is session-only, a shopper clicking that link from
their inbox lands on a generic homepage with an **empty cart** and must re-find and re-add every item.
Recovery is then attributed purely by **email-match at checkout** (`CheckoutWizard.php` ~line 380): any
later order with the same `contact_email` flips the cart to `recovered`, even if the shopper never opened
the email — so we cannot tell email-driven recoveries from organic re-purchases.

This is an MVP shortcut. The industry norm (Shopify, Klaviyo, et al.) is a one-click link that rebuilds
the exact cart. It reduces friction (higher conversion) and enables honest attribution.

## Goal

Replace the home-page link with a **signed, per-send deep link** that:

1. Rehydrates the shopper's session cart from the `abandoned_carts.cart_items` snapshot,
2. **Revalidates** every line against current price / stock (never honors a stale price; drops
   unavailable items) and shows a concise notice of what changed,
3. **Auto-applies** any coupon attached to that recovery email (if still valid),
4. Lands them on the **Cart page** (replacing whatever was in the current session cart),
5. Records **honest recovery attribution** (`link` vs `email_match`), keeping email-match as a fallback.

## Decisions (from brainstorming)

| # | Decision | Choice |
|---|----------|--------|
| 1 | Landing + existing session cart | Land on **/cart**, **replace** the current session cart |
| 2 | Stale items (price/stock/deleted) | **Revalidate** to current price, cap qty at stock, drop unavailable; **show a notice** |
| 3 | Link identity / attribution depth | **Per-send token** stored on `cart_recovery_emails` |
| 4 | Link expiry | **30 days** from send |
| 5 | Email-match attribution | **Keep as fallback**; tag source `link` vs `email_match` |
| 6 | CTA label | **Coupon-aware**: "Return to Your Cart" / "Return to Your Cart and claim your discount" |
| 7 | Coupon usage at completion | **No special clearing** — existing `used_count`/`max_uses` governs it; link is idempotent |

## Data model (tenant migration)

New migration under `database/migrations/tenant/` (e.g. `2026_06_12_000001_add_recovery_link_columns.php`).

**`cart_recovery_emails`** — each *send* owns a distinct link:
- `token` — string(64), unique, indexed.
- `expires_at` — timestamp.
- `clicked_at` — timestamp, nullable (set on first click).

**`abandoned_carts`** — attribution of the recovery:
- `recovered_via` — enum(`link`, `email_match`), nullable.
- `recovered_recovery_email_id` — unsignedBigInteger, nullable, FK → `cart_recovery_emails.id`
  (`nullOnDelete`). Records which send drove the recovery.

No change to `cart_items` shape (already: `key, product_id, combination_id, name, sku, price, quantity,
variant_label, image`).

## Components

### 1. Token issue at send time — `SendRecoveryEmailAction`
On send, after resolving the optional coupon and before/while creating the `CartRecoveryEmail` row:
- `token = Str::random(64)`
- `expires_at = now()->addDays(30)`
- persist `token`, `expires_at` on the `CartRecoveryEmail` row (alongside existing `abandoned_cart_id`,
  `coupon_id`, `sent_at`).
- Build the recovery URL `route('storefront.cart.recover', ['token' => $token])` and pass it into the
  mailable.

### 2. Mailable + template — `AbandonedCartMail`, `resources/views/emails/abandoned-cart.blade.php`
- `AbandonedCartMail` gains a `public readonly string $recoveryUrl` (replaces the generic `storeUrl` use
  for the CTA; store home remains only as a defensive fallback if `recoveryUrl` is empty).
- Blade CTA:
  - Label is **coupon-aware**: `@if($coupon)` "Return to Your Cart and claim your discount" `@else`
    "Return to Your Cart" `@endif`.
  - `href = $recoveryUrl`.

### 3. Restore route + controller
Route (alongside the storefront cart/checkout routes in `routes/tenant.php`):
```
Route::get('/cart/recover/{token}', [CartRecoveryController::class, 'restore'])
    ->name('storefront.cart.recover');
```
`App\Http\Controllers\Storefront\CartRecoveryController@restore($token)` (web guard, tenant context, no
auth required):

1. Find the `CartRecoveryEmail` by `token` (eager-load `abandonedCart`, `coupon`). **Not found or
   `expires_at < now()`** → redirect to `storefront.index` with a flash:
   *"This recovery link is no longer valid."*
2. Set `clicked_at = now()` if not already set (first click).
3. **Rebuild** the session cart via `CartService::restore($recoveryEmail->abandonedCart->cart_items)`
   (see §4). Capture the returned `{restored, removed, adjusted}` report.
4. Compose a **conditional banner** (flash) from the report (compose the sentences that apply):
   - `removed` > 0: "Some items in your cart are no longer available and were removed."
   - `price_changed` > 0: "Prices have been updated to reflect current pricing."
   - `qty_capped` > 0: "Some quantities were reduced to match current stock."
   - none of the above: no banner (the filled cart is the message).
5. **Coupon:** if `coupon_id` present and the coupon is still valid (`isValid(subtotal)`), stash
   `session(['recovery_coupon_code' => $coupon->code])`. If a coupon was attached but is no longer valid
   (e.g. already used), append a soft banner line: *"Your earlier discount has already been used."*
6. Stash `session(['recovery_email_id' => $recoveryEmail->id])` for attribution at checkout.
7. Redirect to `storefront.cart`.

**Idempotency:** the link works for its full 30 days regardless of cart status. A re-click after the order
is placed simply rebuilds a current-priced cart; the coupon won't re-apply (spent), and attribution does
not fire twice.

### 4. `CartService::restore(array $snapshot): array`
New static method. Clears the current session cart, then for each snapshot line rebuilds against **current**
product data:
- product missing OR `availableStock() === 0` → **skip** (count as `removed`).
- otherwise add with the **current** product price (not the snapshot price); cap quantity at
  `availableStock()` (reuse existing private logic); if the current price differs from the snapshot price
  count it as `price_changed`; if the quantity was capped below the snapshot quantity count it as
  `qty_capped` (a line can be both).
- variant lines resolve via `combination_id` exactly as today.

Returns `['restored' => int, 'removed' => string[] (names), 'price_changed' => int, 'qty_capped' => int]`
for the banner. Reuses the existing `availableStock()` rules so the cart can never exceed what is
purchasable.

### 5. Coupon auto-apply — `CheckoutWizard::mount()`
On mount, if `session('recovery_coupon_code')` is present and the cart is non-empty: set
`$this->couponCode` and call `applyCoupon()`, then `session()->forget('recovery_coupon_code')`. If the
coupon is no longer valid at this point, `applyCoupon()`'s existing error path handles it gracefully (no
hard failure).

### 6. Attribution — `CheckoutWizard` order-completion block (~line 380)
Replace the blanket same-email update with:
- If `session('recovery_email_id')` is set: mark the matching abandoned cart `recovered` with
  `recovered_via = 'link'`, `recovered_recovery_email_id = <id>`, `recovered_at`, `recovered_order_id`;
  then `session()->forget('recovery_email_id')`.
- Else: keep the existing same-`contact_email` update, but set `recovered_via = 'email_match'`.

Both paths still set `status='recovered'`, `recovered_at`, `recovered_order_id`. Attribution sets the
source columns only on the first recovery (cart already `recovered` is left untouched).

## Error / edge handling
- **Expired / unknown token** → friendly flash + redirect to storefront home (no stack trace, no 404 page).
- **All items unavailable after revalidation** → redirect to `/cart` (empty) with: "The items from your
  saved cart are no longer available." (still set `clicked_at`).
- **Coupon used/invalid on click** → silently skip auto-apply + soft banner line.
- **Re-click after recovery** → idempotent rebuild; no re-attribution, no coupon re-apply.

## Security
- Token is unguessable (`Str::random(64)`), scoped to a single cart, and expires in 30 days.
- The link requires no auth (abandoners are typically unauthenticated). It exposes only low-sensitivity
  cart contents and a coupon already intended for that recipient.
- Revalidation prevents stale-price abuse; a shared/forwarded link still only restores a current-priced
  cart, and one-off coupons are `max_uses = 1`.

## Testing

**Unit**
- `CartService::restore`: price changed → current price + `price_changed`; qty > stock → capped +
  `qty_capped`; product deleted / 0 stock → `removed`; variant line via `combination_id`; clean snapshot →
  all `restored`.
- Token issue: `token` set + unique, `expires_at = +30d`.

**Feature**
- Happy path: `GET /cart/recover/{token}` → session cart rebuilt, redirect to `/cart`, `clicked_at` set,
  banner matches the report.
- Expired token → redirect to home + flash.
- Unknown token → redirect to home + flash.
- Coupon auto-apply: valid coupon → applied on the checkout wizard; used/invalid coupon → not applied,
  soft banner.
- Attribution: order completed after a link click → `recovered_via='link'` +
  `recovered_recovery_email_id`; order completed via same email with no click →
  `recovered_via='email_match'`.

**QA / docs (per CLAUDE.md doc-sync rule)**
- **Restore** TC-SO-73-02 expected_result to "clicking the link opens the storefront cart with the
  original items present (revalidated to current price/stock) ready to check out."
- Add ECStores test cases: revalidation notice, link expiry, coupon auto-apply, attribution source
  (`link` vs `email_match`) — in `testman/data/es-*.csv` (es-user-stories + es-test-cases).
- Update the ECStores Help Center SOP if it documents the recovery email flow.

## Out of scope (YAGNI)
- Expiring/cleaning up unused one-off coupons whose recovery was never completed.
- A dedicated "recovery landing" page (we reuse `/cart` + a flash banner).
- Per-link revocation UI in the admin.
- Multi-use coupon policy changes (the merchant's own `max_uses` governs reuse).
