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

View File

@@ -68,6 +68,7 @@ defmodule BerrypodWeb.CheckoutController do
}
}
|> maybe_add_shipping_options(hydrated_items)
|> maybe_add_cart_recovery_notice()
case Stripe.Checkout.Session.create(params) do
{:ok, session} ->
@@ -94,6 +95,19 @@ defmodule BerrypodWeb.CheckoutController do
end
end
defp maybe_add_cart_recovery_notice(params) do
if Berrypod.Settings.abandoned_cart_recovery_enabled?() do
Map.put(params, :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."
}
})
else
params
end
end
defp maybe_add_shipping_options(params, hydrated_items) do
gb_result = Shipping.calculate_for_cart(hydrated_items, "GB")
us_result = Shipping.calculate_for_cart(hydrated_items, "US")

View File

@@ -86,19 +86,65 @@ defmodule BerrypodWeb.StripeWebhookController do
defp handle_event(%Stripe.Event{type: "checkout.session.expired", data: %{object: session}}) do
order_id = session.metadata["order_id"]
order = Orders.get_order(order_id)
case Orders.get_order(order_id) do
nil -> :ok
order -> Orders.mark_failed(order)
end
if order, do: Orders.mark_failed(order)
Logger.info("Stripe checkout session expired for order #{order_id}")
if Berrypod.Settings.abandoned_cart_recovery_enabled?() do
maybe_create_abandoned_cart(session, order)
end
end
defp handle_event(%Stripe.Event{type: type}) do
Logger.debug("Unhandled Stripe event: #{type}")
end
defp maybe_create_abandoned_cart(_session, nil), do: :ok
defp maybe_create_abandoned_cart(session, order) do
email = session.customer_details && session.customer_details.email
cond do
is_nil(email) or email == "" ->
Logger.debug("Cart recovery: no email captured for session #{session.id}")
Orders.check_suppression(email) == :suppressed ->
Logger.debug("Cart recovery: #{email} is suppressed")
Orders.has_recent_paid_order?(email) ->
Logger.debug("Cart recovery: #{email} has a recent paid order, skipping")
not is_nil(Orders.get_abandoned_cart_by_session(session.id)) ->
Logger.debug("Cart recovery: session #{session.id} already recorded")
true ->
create_cart_and_enqueue(session, order, email)
end
end
defp create_cart_and_enqueue(session, order, email) do
expired_at = DateTime.utc_now() |> DateTime.truncate(:second)
attrs = %{
customer_email: email,
order_id: order.id,
stripe_session_id: session.id,
cart_total: order.total,
expired_at: expired_at
}
case Orders.create_abandoned_cart(attrs) do
{:ok, cart} ->
Berrypod.Orders.AbandonedCartEmailWorker.enqueue(cart.id)
Logger.info("Abandoned cart recorded for #{email}, recovery email scheduled")
{:error, reason} ->
Logger.warning("Failed to create abandoned cart: #{inspect(reason)}")
end
end
defp update_shipping(order, shipping_details) do
address = shipping_details.address || %{}

View File

@@ -0,0 +1,52 @@
defmodule BerrypodWeb.UnsubscribeController do
use BerrypodWeb, :controller
alias Berrypod.Orders
# Unsubscribe links should be long-lived — use 2 years
@max_age 2 * 365 * 24 * 3600
def unsubscribe(conn, %{"token" => token}) do
case Phoenix.Token.verify(BerrypodWeb.Endpoint, "email-unsub", token, max_age: @max_age) do
{:ok, email} ->
Orders.add_suppression(email, "unsubscribed")
conn
|> put_status(200)
|> html("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Unsubscribed</title>
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 24px;color:#111}</style>
</head>
<body>
<h1 style="font-size:1.25rem;margin-bottom:0.75rem">You've been unsubscribed</h1>
<p style="color:#555">We've removed #{email} from our marketing list. You won't receive any more cart recovery emails from us.</p>
</body>
</html>
""")
{:error, _reason} ->
conn
|> put_status(400)
|> html("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Invalid link</title>
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 24px;color:#111}</style>
</head>
<body>
<h1 style="font-size:1.25rem;margin-bottom:0.75rem">Link invalid or expired</h1>
<p style="color:#555">This unsubscribe link has expired or is invalid. If you'd like to unsubscribe, reply to any email we've sent you.</p>
</body>
</html>
""")
end
end
end

View File

@@ -14,6 +14,7 @@ defmodule BerrypodWeb.Admin.Settings do
socket
|> assign(:page_title, "Settings")
|> assign(:site_live, Settings.site_live?())
|> assign(:cart_recovery_enabled, Settings.abandoned_cart_recovery_enabled?())
|> assign_stripe_state()
|> assign_products_state()
|> assign_account_state(user)}
@@ -90,6 +91,23 @@ defmodule BerrypodWeb.Admin.Settings do
|> put_flash(:info, message)}
end
# -- Events: cart recovery --
def handle_event("toggle_cart_recovery", _params, socket) do
new_value = !socket.assigns.cart_recovery_enabled
{:ok, _} = Settings.set_abandoned_cart_recovery(new_value)
message =
if new_value,
do: "Cart recovery emails enabled",
else: "Cart recovery emails disabled"
{:noreply,
socket
|> assign(:cart_recovery_enabled, new_value)
|> put_flash(:info, message)}
end
# -- Events: Stripe --
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
@@ -367,6 +385,49 @@ defmodule BerrypodWeb.Admin.Settings do
<% end %>
</section>
<%!-- Cart recovery --%>
<section class="mt-10">
<div class="flex items-center gap-3">
<h2 class="text-lg font-semibold">Cart recovery</h2>
<%= if @cart_recovery_enabled do %>
<.status_pill color="green">
<.icon name="hero-check-circle-mini" class="size-3" /> On
</.status_pill>
<% else %>
<.status_pill color="zinc">Off</.status_pill>
<% end %>
</div>
<p class="mt-2 text-sm text-base-content/60">
When on, customers who entered their email at Stripe checkout but didn't complete
payment receive a single plain-text recovery email one hour later.
No tracking pixels. One email, never more.
</p>
<%= if @cart_recovery_enabled do %>
<p class="mt-2 text-sm text-amber-700">
Make sure your privacy policy mentions that a single recovery email may be sent,
and that customers can unsubscribe at any time.
</p>
<% end %>
<div class="mt-4">
<button
phx-click="toggle_cart_recovery"
class={[
"inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-semibold shadow-xs",
if(@cart_recovery_enabled,
do: "bg-base-200 text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset",
else: "bg-base-content text-white hover:bg-base-content/80"
)
]}
>
<%= if @cart_recovery_enabled do %>
Turn off
<% else %>
Turn on
<% end %>
</button>
</div>
</section>
<%!-- Account --%>
<section class="mt-10">
<h2 class="text-lg font-semibold">Account</h2>

View File

@@ -164,6 +164,7 @@ defmodule BerrypodWeb.Router do
pipe_through [:browser]
get "/orders/verify/:token", OrderLookupController, :verify
get "/unsubscribe/:token", UnsubscribeController, :unsubscribe
end
# Setup page — minimal live_session, no theme/cart/search hooks