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

@@ -0,0 +1,78 @@
defmodule Berrypod.Orders.AbandonedCartEmailWorkerTest do
use Berrypod.DataCase, async: false
import Swoosh.TestAssertions
alias Berrypod.Orders
alias Berrypod.Orders.AbandonedCartEmailWorker
import Berrypod.OrdersFixtures
defp build_cart(attrs \\ %{}) do
order = order_fixture(%{customer_email: Map.get(attrs, :customer_email, "buyer@example.com")})
defaults = %{
customer_email: order.customer_email,
order_id: order.id,
stripe_session_id: "cs_#{System.unique_integer([:positive])}",
cart_total: order.total,
expired_at: DateTime.utc_now() |> DateTime.truncate(:second)
}
{:ok, cart} = Orders.create_abandoned_cart(Map.merge(defaults, attrs))
{cart, order}
end
describe "perform/1 — sends email" do
test "sends cart recovery email and marks as emailed" do
{cart, _order} = build_cart()
assert :ok =
AbandonedCartEmailWorker.new(%{abandoned_cart_id: cart.id})
|> Oban.insert!()
|> then(fn job -> AbandonedCartEmailWorker.perform(job) end)
assert_email_sent(subject: "You left something behind", to: cart.customer_email)
updated = Orders.get_abandoned_cart(cart.id)
assert not is_nil(updated.emailed_at)
end
test "does not send twice if already emailed" do
{cart, _order} = build_cart()
{:ok, cart} = Orders.mark_abandoned_cart_emailed(cart)
AbandonedCartEmailWorker.perform(%Oban.Job{args: %{"abandoned_cart_id" => cart.id}})
assert_no_email_sent()
end
test "skips send if email is suppressed" do
{cart, _} = build_cart(%{customer_email: "suppressed@example.com"})
Orders.add_suppression("suppressed@example.com", "unsubscribed")
AbandonedCartEmailWorker.perform(%Oban.Job{args: %{"abandoned_cart_id" => cart.id}})
assert_no_email_sent()
end
test "skips send if customer has a recent paid order" do
{cart, _} = build_cart(%{customer_email: "recent@example.com"})
order_fixture(%{customer_email: "recent@example.com", payment_status: "paid"})
AbandonedCartEmailWorker.perform(%Oban.Job{args: %{"abandoned_cart_id" => cart.id}})
assert_no_email_sent()
end
test "cancels if cart not found" do
result =
AbandonedCartEmailWorker.perform(%Oban.Job{
args: %{"abandoned_cart_id" => Ecto.UUID.generate()}
})
assert {:cancel, :not_found} = result
assert_no_email_sent()
end
end
end

View File

@@ -0,0 +1,51 @@
defmodule Berrypod.Orders.AbandonedCartPruneWorkerTest do
use Berrypod.DataCase, async: true
alias Berrypod.Orders
alias Berrypod.Orders.AbandonedCartPruneWorker
defp create_cart(attrs \\ %{}) do
defaults = %{
customer_email: "test@example.com",
stripe_session_id: "cs_#{System.unique_integer([:positive])}",
expired_at: DateTime.utc_now() |> DateTime.truncate(:second)
}
{:ok, cart} = Orders.create_abandoned_cart(Map.merge(defaults, attrs))
cart
end
defp backdate(cart, days) do
Berrypod.Repo.update_all(
Ecto.Query.from(a in Berrypod.Orders.AbandonedCart, where: a.id == ^cart.id),
set: [inserted_at: DateTime.add(DateTime.utc_now(), -days, :day)]
)
end
describe "perform/1" do
test "deletes carts older than 30 days" do
old_cart = create_cart()
backdate(old_cart, 31)
recent_cart = create_cart()
assert :ok = AbandonedCartPruneWorker.perform(%Oban.Job{args: %{}})
assert is_nil(Orders.get_abandoned_cart(old_cart.id))
assert not is_nil(Orders.get_abandoned_cart(recent_cart.id))
end
test "keeps carts exactly at the 30-day boundary" do
boundary_cart = create_cart()
backdate(boundary_cart, 29)
assert :ok = AbandonedCartPruneWorker.perform(%Oban.Job{args: %{}})
assert not is_nil(Orders.get_abandoned_cart(boundary_cart.id))
end
test "is a no-op when there's nothing to prune" do
assert :ok = AbandonedCartPruneWorker.perform(%Oban.Job{args: %{}})
end
end
end

View File

@@ -0,0 +1,167 @@
defmodule Berrypod.Orders.AbandonedCartTest do
use Berrypod.DataCase, async: true
alias Berrypod.Orders
import Berrypod.OrdersFixtures
# ---------------------------------------------------------------------------
# Abandoned carts
# ---------------------------------------------------------------------------
describe "create_abandoned_cart/1" do
test "creates a record with required fields" do
{:ok, cart} =
Orders.create_abandoned_cart(%{
customer_email: "test@example.com",
stripe_session_id: "cs_test_abc",
expired_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
assert cart.customer_email == "test@example.com"
assert cart.stripe_session_id == "cs_test_abc"
assert is_nil(cart.emailed_at)
end
test "links to an existing order" do
order = order_fixture()
{:ok, cart} =
Orders.create_abandoned_cart(%{
customer_email: "buyer@example.com",
stripe_session_id: "cs_test_xyz",
order_id: order.id,
cart_total: order.total,
expired_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
assert cart.order_id == order.id
end
test "enforces unique stripe_session_id" do
attrs = %{
customer_email: "test@example.com",
stripe_session_id: "cs_test_dup",
expired_at: DateTime.utc_now() |> DateTime.truncate(:second)
}
{:ok, _} = Orders.create_abandoned_cart(attrs)
{:error, changeset} = Orders.create_abandoned_cart(attrs)
assert "has already been taken" in errors_on(changeset).stripe_session_id
end
test "requires customer_email" do
{:error, changeset} =
Orders.create_abandoned_cart(%{
stripe_session_id: "cs_test_noemail",
expired_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
assert "can't be blank" in errors_on(changeset).customer_email
end
end
describe "get_abandoned_cart_by_session/1" do
test "returns the cart for a known session" do
{:ok, cart} =
Orders.create_abandoned_cart(%{
customer_email: "a@example.com",
stripe_session_id: "cs_find_me",
expired_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
found = Orders.get_abandoned_cart_by_session("cs_find_me")
assert found.id == cart.id
end
test "returns nil for unknown session" do
assert is_nil(Orders.get_abandoned_cart_by_session("cs_nonexistent"))
end
end
describe "mark_abandoned_cart_emailed/1" do
test "sets emailed_at timestamp" do
{:ok, cart} =
Orders.create_abandoned_cart(%{
customer_email: "b@example.com",
stripe_session_id: "cs_mark_test",
expired_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
assert is_nil(cart.emailed_at)
{:ok, updated} = Orders.mark_abandoned_cart_emailed(cart)
assert not is_nil(updated.emailed_at)
end
end
describe "has_recent_paid_order?/2" do
test "returns true when email has a paid order within the window" do
order_fixture(%{customer_email: "buyer@example.com", payment_status: "paid"})
assert Orders.has_recent_paid_order?("buyer@example.com", 48)
end
test "returns false when no paid order exists" do
refute Orders.has_recent_paid_order?("nobody@example.com", 48)
end
test "returns false when paid order is outside the window" do
order = order_fixture(%{customer_email: "old@example.com", payment_status: "paid"})
# Backdating: force inserted_at to be 3 days ago
Berrypod.Repo.update_all(
Ecto.Query.from(o in Berrypod.Orders.Order, where: o.id == ^order.id),
set: [inserted_at: DateTime.add(DateTime.utc_now(), -3, :day)]
)
refute Orders.has_recent_paid_order?("old@example.com", 48)
end
test "is case-insensitive" do
order_fixture(%{customer_email: "Buyer@Example.COM", payment_status: "paid"})
assert Orders.has_recent_paid_order?("buyer@example.com")
end
end
# ---------------------------------------------------------------------------
# Email suppression
# ---------------------------------------------------------------------------
describe "check_suppression/1" do
test "returns :ok for a non-suppressed email" do
assert :ok == Orders.check_suppression("fresh@example.com")
end
test "returns :suppressed after adding suppression" do
Orders.add_suppression("blocked@example.com", "unsubscribed")
assert :suppressed == Orders.check_suppression("blocked@example.com")
end
test "is case-insensitive" do
Orders.add_suppression("upper@example.com", "unsubscribed")
assert :suppressed == Orders.check_suppression("UPPER@EXAMPLE.COM")
end
end
describe "add_suppression/2" do
test "creates a suppression record" do
{:ok, sup} = Orders.add_suppression("new@example.com", "unsubscribed")
assert sup.email == "new@example.com"
assert sup.reason == "unsubscribed"
end
test "is idempotent — second insert is a no-op" do
{:ok, _} = Orders.add_suppression("once@example.com", "unsubscribed")
{:ok, _} = Orders.add_suppression("once@example.com", "unsubscribed")
assert :suppressed == Orders.check_suppression("once@example.com")
end
test "normalises email to lowercase" do
Orders.add_suppression("Mixed@Example.COM", "test")
assert :suppressed == Orders.check_suppression("mixed@example.com")
end
end
end

View File

@@ -0,0 +1,36 @@
defmodule BerrypodWeb.UnsubscribeControllerTest do
use BerrypodWeb.ConnCase, async: true
alias Berrypod.Orders
defp sign_token(email) do
Phoenix.Token.sign(BerrypodWeb.Endpoint, "email-unsub", email)
end
describe "GET /unsubscribe/:token" do
test "valid token: suppresses email and returns 200", %{conn: conn} do
token = sign_token("unsub@example.com")
conn = get(conn, ~p"/unsubscribe/#{token}")
assert conn.status == 200
assert conn.resp_body =~ "Unsubscribed"
assert Orders.check_suppression("unsub@example.com") == :suppressed
end
test "invalid token returns 400", %{conn: conn} do
conn = get(conn, ~p"/unsubscribe/bad_token_here")
assert conn.status == 400
assert conn.resp_body =~ "invalid or expired"
end
test "already suppressed — idempotent, still returns 200", %{conn: conn} do
Orders.add_suppression("already@example.com", "unsubscribed")
token = sign_token("already@example.com")
conn = get(conn, ~p"/unsubscribe/#{token}")
assert conn.status == 200
assert Orders.check_suppression("already@example.com") == :suppressed
end
end
end