# Abandoned cart recovery

> Status: Complete
> Tasks: #75–77
> Tier: 4 (Growth & content)

## Goal

Recover genuinely lost sales from customers who started checkout but didn't complete it — without invading their privacy, spamming them, or relying on dark patterns.

## The privacy line

Most abandoned cart implementations are invasive:
- Track anonymous visitors via cookies before any consent
- Email anyone who added to cart, regardless of engagement
- Send multiple escalating follow-ups ("Last chance!", "We miss you!")
- Use tracking pixels to know if the email was opened
- Hold the data indefinitely

**Berrypod's approach is narrower and more honest.** The key distinction is *consent signal*: we only contact customers who explicitly reached the Stripe Checkout page and entered their email. That's a strong signal of genuine purchase intent — they were mid-payment, not just browsing.

Anonymous cart sessions with no email are never touched. We don't know who they are and we don't try to find out.

## Legal basis

### UK — PECR soft opt-in (Regulation 22)

The strongest legal ground. PECR allows sending unsolicited marketing email without prior explicit consent if:

1. You obtained the email address **in the course of a sale or negotiation of a sale** — the ICO has confirmed this covers incomplete checkouts where the customer started but didn't finish
2. You're marketing your **own similar products/services** — same shop, same type of products they were trying to buy
3. The customer was given a **clear opportunity to opt out** at the time their details were collected, and in every subsequent message
4. They have **not opted out**

A single abandoned cart reminder fits squarely. The ICO has specifically addressed abandoned cart emails in its guidance on PECR.

### EU — Legitimate interests (GDPR Article 6(1)(f))

For EU customers (or UK shops with EU-based customers post-Brexit), the soft opt-in rule doesn't apply directly — it's a UK PECR provision. The equivalent ePrivacy Directive is implemented differently across member states.

The lawful basis would be **legitimate interests**, provided:

- The email is clearly transactional-adjacent (not general marketing — it's specifically about the items they tried to buy)
- A **Legitimate Interests Assessment (LIA)** is documented: the shop owner's interest in recovering a genuine purchase is weighed against the customer's right to privacy. A single non-pushy reminder, easy to opt out of, with prompt data deletion, tips the balance in favour of legitimate interests.
- Proper opt-out, no tracking, data deletion

The shop owner should document this LIA in their privacy policy.

### What makes it non-compliant (and what we avoid)

- Multiple emails / drip sequences
- Tracking pixels to know if the email was opened or the link clicked
- Storing the abandoned cart data indefinitely
- No real unsubscribe mechanism
- Emailing anonymous cart sessions (no email captured)
- No mention in privacy policy

## How it works

### The trigger: `checkout.session.expired`

Stripe fires this webhook when a Checkout session expires without payment. Sessions expire after 24 hours by default. The expired session object contains:

- `customer_details.email` — if the customer entered their email on the Stripe page (may be absent if they abandoned before that step)
- `line_items` — what was in their cart
- `amount_total` — cart total at time of checkout
- `expires_at` — when it expired

We already handle `checkout.session.completed` in `stripe_webhook_controller.ex`. This is a sibling handler.

### Flow

```
checkout.session.expired webhook fires
    ↓
Extract customer_details.email from session
    ↓
If no email → discard (customer never identified themselves)
    ↓
Check suppression list → if unsubscribed → discard
    ↓
Check for existing paid order with same stripe_session_id → if found → discard
(handles race: session expired but payment webhook fired first)
    ↓
Store AbandonedCart record:
  - customer_email
  - line_items (JSON snapshot)
  - cart_total
  - stripe_session_id
  - expired_at
    ↓
Enqueue AbandonedCartEmailWorker (Oban, delay ~1 hour)
    ↓
Worker sends single plain-text email
    ↓
Mark AbandonedCart as emailed (emailed_at timestamp)
```

### Schema

```elixir
create table(:abandoned_carts, primary_key: false) do
  add :id, :binary_id, primary_key: true
  add :customer_email, :string, null: false
  add :stripe_session_id, :string, null: false
  add :line_items, :map, null: false       # JSON snapshot of cart contents
  add :cart_total, :integer                # in minor units
  add :expired_at, :utc_datetime, null: false
  add :emailed_at, :utc_datetime           # nil until email sent
  timestamps()
end

create unique_index(:abandoned_carts, [:stripe_session_id])
create index(:abandoned_carts, [:customer_email])
create index(:abandoned_carts, [:inserted_at])  # for pruning
```

```elixir
create table(:email_suppressions, primary_key: false) do
  add :id, :binary_id, primary_key: true
  add :email, :string, null: false
  add :reason, :string           # "unsubscribed", "bounced", etc.
  add :suppressed_at, :utc_datetime, null: false
  timestamps()
end

create unique_index(:email_suppressions, [:email])
```

### The email

Plain text. No tracking pixels. No HTML. Consistent with the existing order confirmation and shipping notification emails.

```
Subject: You left something behind

Hi,

You recently started a checkout at [shop name] but didn't complete it.

Your cart had:
- Classic Tee (M, Black) × 1 — £25.00
- Tote Bag × 2 — £18.00

Total: £43.00

If you'd like to complete your order, head to [shop URL] and add these items again.

We're only sending this once.

—
[shop name]

Don't want to hear from us? Unsubscribe: [unsubscribe link]
```

Key points:
- "We're only sending this once" — sets expectations, builds trust
- No urgency language ("Act now!", "Limited stock!")
- No tracking link for the cart — just link to the shop homepage. Cart is session-based and can't be restored via a link anyway. Just show what they had.
- Plain text, no images, no tracking pixels
- Unsubscribe link at the bottom — signed token, one click

### Unsubscribe

Unsubscribe link is a signed token: `Phoenix.Token.sign(endpoint, "email-unsub", email)`.

`/unsubscribe/:token` route:
- Verify the token
- Insert into `email_suppressions`
- Show a simple "You've been unsubscribed" page
- Suppression is checked before every email send (recovery, newsletter, order updates — all of them)

### 30-day data pruning

New Oban cron job: `AbandonedCartPruneWorker` runs nightly.

```elixir
def perform(_job) do
  cutoff = DateTime.add(DateTime.utc_now(), -30, :day)
  Repo.delete_all(from a in AbandonedCart, where: a.inserted_at < ^cutoff)
  :ok
end
```

After 30 days, the abandoned cart record is gone. If someone comes back and buys after 30 days, that's fine — it's a fresh order, not connected to the old abandoned cart.

### Stripe Checkout footer notice

Stripe allows custom text on the Checkout page footer via the `custom_text` parameter on the Checkout session. Add a brief note:

```elixir
custom_text: %{
  after_submit: %{
    message: "If your payment doesn't complete, we may send you one follow-up email. You can unsubscribe at any time."
  }
}
```

This is the collection-time notice required for PECR soft opt-in, and satisfies the GDPR transparency requirement for legitimate interests.

### Privacy policy

The shop's privacy policy template (currently a static page) should include a paragraph covering:
- What data is collected during incomplete checkout (email, cart contents)
- That a single recovery email may be sent
- How to unsubscribe
- That data is deleted after 30 days

This is already in the template pages that come with Berrypod — the privacy page just needs this section added.

## What it deliberately can't do

- **Track anonymous sessions** — our cart is session-based. If no email was captured, we have no identity to contact. This is a feature, not a limitation.
- **Send multiple emails** — the schema has a single `emailed_at` field. The worker checks it before sending and won't send again.
- **Track email opens or link clicks** — plain text emails, no tracking pixels, no redirected links
- **Target general browsers** — only customers who got as far as entering their email on Stripe. Someone who viewed products and added to cart but never clicked Checkout is never contacted.

## Admin dashboard

A small card on the admin dashboard (or `/admin/orders`):

- Abandoned carts in last 30 days: X
- Emails sent: Y
- Estimated recovery rate (orders placed within 48h of an abandoned cart from the same email): Z%

This gives the shop owner visibility without being creepy about it. The metric is useful for understanding checkout friction — a high abandonment rate at Stripe suggests a pricing, trust, or UX issue.

## Files to create/modify

- Migration — `abandoned_carts` and `email_suppressions` tables
- `lib/berrypod/orders/abandoned_cart.ex` — schema
- `lib/berrypod/orders/email_suppression.ex` — schema
- `lib/berrypod/orders.ex` — `create_abandoned_cart/1`, `check_suppression/1`
- `lib/berrypod/workers/abandoned_cart_email_worker.ex` — Oban job
- `lib/berrypod/workers/abandoned_cart_prune_worker.ex` — Oban cron
- `lib/berrypod/notifier/order_notifier.ex` — `send_cart_recovery/1`
- `lib/berrypod_web/controllers/stripe_webhook_controller.ex` — handle `checkout.session.expired`
- `lib/berrypod_web/controllers/checkout_controller.ex` — add `custom_text` to session
- `lib/berrypod_web/controllers/unsubscribe_controller.ex` — new, handles `/unsubscribe/:token`
- Router — `/unsubscribe/:token` route
- Config — add `AbandonedCartPruneWorker` to Oban crontab

## Tests

- Webhook handler: email present → creates record, no email → discards, suppressed → discards, paid order exists → discards
- Email worker: sends email, marks as emailed, doesn't send twice
- Prune worker: deletes records older than 30 days, keeps recent ones
- Unsubscribe: valid token → suppressed, invalid token → error, already suppressed → idempotent
- Suppression check: suppressed email → blocked from all sends
