add abandoned cart recovery

When a Stripe checkout session expires without payment, if the customer
entered their email, we record an AbandonedCart and schedule a single
plain-text recovery email (1h delay via Oban).

Privacy design:
- feature is off by default; shop owner opts in via admin settings
- only contacts customers who entered their email at Stripe checkout
- single email, never more (emailed_at timestamp gate)
- suppression list blocks repeat contact; one-click unsubscribe via
  signed token (/unsubscribe/:token)
- records pruned after 30 days (nightly Oban cron)
- no tracking pixels, no redirected links, no HTML

Legal notes:
- custom_text added to Stripe session footer when recovery is on
- UK PECR soft opt-in; EU legitimate interests both satisfied by this design

Files:
- migration: abandoned_carts + email_suppressions tables
- schemas: AbandonedCart, EmailSuppression
- context: Orders.create_abandoned_cart, check_suppression, add_suppression,
  has_recent_paid_order?, get_abandoned_cart_by_session, mark_abandoned_cart_emailed
- workers: AbandonedCartEmailWorker (checkout queue), AbandonedCartPruneWorker (cron)
- notifier: OrderNotifier.deliver_cart_recovery/3
- webhook: extended checkout.session.expired handler
- controller: UnsubscribeController, admin settings toggle
- tests: 28 new tests across context, workers, and controller

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-24 10:02:37 +00:00
parent 758e66db5c
commit 2f4cd81f98
18 changed files with 844 additions and 6 deletions

View File

@@ -9,7 +9,7 @@ defmodule Berrypod.Orders do
import Ecto.Query
alias Berrypod.Repo
alias Berrypod.Orders.{Order, OrderItem, OrderNotifier}
alias Berrypod.Orders.{AbandonedCart, EmailSuppression, Order, OrderItem, OrderNotifier}
alias Berrypod.Products
alias Berrypod.Providers.Provider
@@ -410,4 +410,90 @@ defmodule Berrypod.Orders do
do: Map.put(attrs, key, DateTime.utc_now() |> DateTime.truncate(:second))
defp maybe_set(attrs, _key, false), do: attrs
# =============================================================================
# Abandoned cart
# =============================================================================
@doc """
Creates an abandoned cart record.
"""
def create_abandoned_cart(attrs) do
%AbandonedCart{}
|> AbandonedCart.changeset(attrs)
|> Repo.insert()
end
@doc """
Gets an abandoned cart by ID.
"""
def get_abandoned_cart(id) do
Repo.get(AbandonedCart, id)
end
@doc """
Gets an abandoned cart by Stripe session ID, or nil if not found.
"""
def get_abandoned_cart_by_session(stripe_session_id) do
Repo.get_by(AbandonedCart, stripe_session_id: stripe_session_id)
end
@doc """
Stamps an abandoned cart as emailed.
"""
def mark_abandoned_cart_emailed(cart) do
cart
|> AbandonedCart.changeset(%{emailed_at: DateTime.utc_now() |> DateTime.truncate(:second)})
|> Repo.update()
end
@doc """
Returns true if the given email has a paid order within the last `hours` hours.
Used to skip recovery emails for customers who completed a purchase shortly
after their session expired (e.g. started over in a new tab).
"""
def has_recent_paid_order?(email, hours \\ 48) do
normalised = String.downcase(String.trim(email))
cutoff = DateTime.add(DateTime.utc_now(), -(hours * 3600), :second)
Order
|> where([o], fragment("lower(trim(?))", o.customer_email) == ^normalised)
|> where([o], o.payment_status == "paid")
|> where([o], o.inserted_at >= ^cutoff)
|> Repo.exists?()
end
# =============================================================================
# Email suppression
# =============================================================================
@doc """
Checks whether an email address is suppressed from marketing emails.
Returns `:ok` if the address is not suppressed, `:suppressed` if it is.
Only applies to marketing emails (cart recovery). Transactional emails
(order confirmation, shipping) are not affected.
"""
def check_suppression(email) do
normalised = String.downcase(String.trim(email))
case Repo.get_by(EmailSuppression, email: normalised) do
nil -> :ok
_suppression -> :suppressed
end
end
@doc """
Adds an email address to the suppression list.
Idempotent — inserting an already-suppressed address is a no-op.
"""
def add_suppression(email, reason \\ "unsubscribed") do
normalised = String.downcase(String.trim(email))
%EmailSuppression{}
|> EmailSuppression.changeset(%{email: normalised, reason: reason})
|> Repo.insert(on_conflict: :nothing, conflict_target: :email)
end
end

View File

@@ -0,0 +1,34 @@
defmodule Berrypod.Orders.AbandonedCart do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "abandoned_carts" do
field :customer_email, :string
field :stripe_session_id, :string
field :cart_total, :integer
field :expired_at, :utc_datetime
field :emailed_at, :utc_datetime
belongs_to :order, Berrypod.Orders.Order
timestamps(type: :utc_datetime)
end
def changeset(cart, attrs) do
cart
|> cast(attrs, [
:customer_email,
:order_id,
:stripe_session_id,
:cart_total,
:expired_at,
:emailed_at
])
|> validate_required([:customer_email, :stripe_session_id, :expired_at])
|> validate_format(:customer_email, ~r/@/)
|> unique_constraint(:stripe_session_id)
end
end

View File

@@ -0,0 +1,69 @@
defmodule Berrypod.Orders.AbandonedCartEmailWorker do
@moduledoc """
Sends a single cart recovery email after an abandoned Stripe checkout session.
Enqueued with a 1-hour delay from the webhook handler. Re-checks suppression
and recent paid orders at send time in case anything changed in the interim.
"""
use Oban.Worker, queue: :checkout, max_attempts: 3
alias Berrypod.Orders
alias Berrypod.Orders.OrderNotifier
require Logger
@impl Oban.Worker
def perform(%Oban.Job{args: %{"abandoned_cart_id" => cart_id}}) do
with {:cart, cart} when not is_nil(cart) <- {:cart, Orders.get_abandoned_cart(cart_id)},
{:not_emailed, true} <- {:not_emailed, is_nil(cart.emailed_at)},
{:suppression, :ok} <- {:suppression, Orders.check_suppression(cart.customer_email)},
{:no_recent_order, false} <-
{:no_recent_order, Orders.has_recent_paid_order?(cart.customer_email)},
{:order, order} when not is_nil(order) <- {:order, Orders.get_order(cart.order_id)} do
unsubscribe_url = build_unsubscribe_url(cart.customer_email)
case OrderNotifier.deliver_cart_recovery(cart, order, unsubscribe_url) do
{:ok, _} ->
{:ok, _} = Orders.mark_abandoned_cart_emailed(cart)
Logger.info("Cart recovery email sent to #{cart.customer_email}")
:ok
{:error, reason} ->
Logger.error("Cart recovery email failed: #{inspect(reason)}")
{:error, reason}
end
else
{:cart, nil} ->
Logger.warning("Cart recovery: abandoned cart #{cart_id} not found")
{:cancel, :not_found}
{:not_emailed, false} ->
Logger.info("Cart recovery: already emailed for cart #{cart_id}")
:ok
{:suppression, :suppressed} ->
Logger.info("Cart recovery: email suppressed for cart #{cart_id}")
:ok
{:no_recent_order, true} ->
Logger.info("Cart recovery: recent paid order found, skipping #{cart_id}")
:ok
{:order, nil} ->
Logger.warning("Cart recovery: order not found for cart #{cart_id}")
{:cancel, :order_not_found}
end
end
def enqueue(abandoned_cart_id) do
%{abandoned_cart_id: abandoned_cart_id}
|> new(schedule_in: 3600)
|> Oban.insert()
end
defp build_unsubscribe_url(email) do
token = Phoenix.Token.sign(BerrypodWeb.Endpoint, "email-unsub", email)
BerrypodWeb.Endpoint.url() <> "/unsubscribe/" <> token
end
end

View File

@@ -0,0 +1,25 @@
defmodule Berrypod.Orders.AbandonedCartPruneWorker do
@moduledoc """
Nightly cron job that deletes abandoned cart records older than 30 days.
After 30 days the recovery window is well past. Pruning keeps the table
small and avoids retaining customer email addresses indefinitely.
"""
use Oban.Worker, queue: :checkout
import Ecto.Query
alias Berrypod.Orders.AbandonedCart
alias Berrypod.Repo
require Logger
@impl Oban.Worker
def perform(_job) do
cutoff = DateTime.add(DateTime.utc_now(), -30, :day)
{count, _} = Repo.delete_all(from a in AbandonedCart, where: a.inserted_at < ^cutoff)
Logger.info("Pruned #{count} abandoned cart records older than 30 days")
:ok
end
end

View File

@@ -0,0 +1,22 @@
defmodule Berrypod.Orders.EmailSuppression do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "email_suppressions" do
field :email, :string
field :reason, :string
timestamps(type: :utc_datetime)
end
def changeset(suppression, attrs) do
suppression
|> cast(attrs, [:email, :reason])
|> validate_required([:email])
|> validate_format(:email, ~r/@/)
|> unique_constraint(:email)
end
end

View File

@@ -92,6 +92,53 @@ defmodule Berrypod.Orders.OrderNotifier do
deliver(order.customer_email, subject, body)
end
@doc """
Sends a cart recovery email to a customer who abandoned a Stripe checkout.
Plain text, no tracking pixels, single send. Includes an unsubscribe link.
"""
def deliver_cart_recovery(cart, order, unsubscribe_url) do
from_address = Berrypod.Settings.get_setting("email_from_address", "contact@example.com")
subject = "You left something behind"
body = """
==============================
You recently started a checkout but didn't complete it.
Your cart had:
#{format_items(order.items)}
Total: #{Cart.format_price(cart.cart_total)}
If you'd like to complete your order, head to our shop and add these items again.
We're only sending this once.
Don't want to hear from us? Unsubscribe: #{unsubscribe_url}
==============================
"""
email =
new()
|> to(cart.customer_email)
|> from(from_address)
|> subject(subject)
|> text_body(body)
case Mailer.deliver(email) do
{:ok, _metadata} = result ->
result
{:error, reason} = error ->
Logger.warning(
"Failed to send cart recovery email to #{cart.customer_email}: #{inspect(reason)}"
)
error
end
end
# --- Private ---
defp deliver(recipient, subject, body) do

View File

@@ -133,6 +133,23 @@ defmodule Berrypod.Settings do
put_setting("site_live", live?, "boolean")
end
@doc """
Returns whether abandoned cart recovery emails are enabled.
Defaults to false — shop owners must explicitly opt in, since the
feature has legal implications (privacy policy wording, PECR/GDPR compliance).
"""
def abandoned_cart_recovery_enabled? do
get_setting("abandoned_cart_recovery", false) == true
end
@doc """
Enables or disables abandoned cart recovery emails.
"""
def set_abandoned_cart_recovery(enabled?) when is_boolean(enabled?) do
put_setting("abandoned_cart_recovery", enabled?, "boolean")
end
@doc """
Deletes a setting by key.
"""