# 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