251 lines
10 KiB
Markdown
251 lines
10 KiB
Markdown
|
|
# Abandoned cart recovery
|
|||
|
|
|
|||
|
|
> Status: Planned
|
|||
|
|
> Tasks: #75–77 in PROGRESS.md
|
|||
|
|
> 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
|