berrypod/docs/plans/abandoned-cart.md
jamey edef628214 tidy docs: condense progress, trim readme, mark plan statuses
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:15:18 +00:00

251 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Abandoned cart recovery
> Status: Complete
> Tasks: #7577
> 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