berrypod/docs/plans/abandoned-cart.md
jamey 0f1135256d add canonical URLs, robots.txt, and sitemap.xml
Canonical: all shop pages now assign og_url (reusing the existing og:url
assign), which the layout renders as <link rel="canonical">. Collection
pages strip the sort param so ?sort=price_asc doesn't create a duplicate
canonical.

robots.txt: dynamic controller disallows /admin/, /api/, /users/,
/webhooks/, /checkout/. Removed robots.txt from static_paths so it
goes through the router instead of Plug.Static.

sitemap.xml: auto-generated from all visible products + categories +
static pages, served as application/xml. 8 tests.

Also updates PROGRESS.md: marks tasks 55, 58, 59, 61, 62 as done.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 21:47:35 +00:00

10 KiB
Raw Blame History

Abandoned cart recovery

Status: Planned Tasks: #7577 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.

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

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
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.

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 allows custom text on the Checkout page footer via the custom_text parameter on the Checkout session. Add a brief note:

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.excreate_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.exsend_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