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:
78
test/berrypod/orders/abandoned_cart_email_worker_test.exs
Normal file
78
test/berrypod/orders/abandoned_cart_email_worker_test.exs
Normal 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
|
||||
51
test/berrypod/orders/abandoned_cart_prune_worker_test.exs
Normal file
51
test/berrypod/orders/abandoned_cart_prune_worker_test.exs
Normal 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
|
||||
167
test/berrypod/orders/abandoned_cart_test.exs
Normal file
167
test/berrypod/orders/abandoned_cart_test.exs
Normal 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
|
||||
Reference in New Issue
Block a user