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

@ -94,7 +94,8 @@ config :berrypod, Oban,
crontab: [ crontab: [
{"*/30 * * * *", Berrypod.Orders.FulfilmentStatusWorker}, {"*/30 * * * *", Berrypod.Orders.FulfilmentStatusWorker},
{"0 */6 * * *", Berrypod.Sync.ScheduledSyncWorker}, {"0 */6 * * *", Berrypod.Sync.ScheduledSyncWorker},
{"0 3 * * *", Berrypod.Analytics.RetentionWorker} {"0 3 * * *", Berrypod.Analytics.RetentionWorker},
{"0 4 * * *", Berrypod.Orders.AbandonedCartPruneWorker}
]} ]}
], ],
queues: [images: 2, sync: 1, checkout: 1] queues: [images: 2, sync: 1, checkout: 1]

View File

@ -9,7 +9,7 @@ defmodule Berrypod.Orders do
import Ecto.Query import Ecto.Query
alias Berrypod.Repo alias Berrypod.Repo
alias Berrypod.Orders.{Order, OrderItem, OrderNotifier} alias Berrypod.Orders.{AbandonedCart, EmailSuppression, Order, OrderItem, OrderNotifier}
alias Berrypod.Products alias Berrypod.Products
alias Berrypod.Providers.Provider alias Berrypod.Providers.Provider
@ -410,4 +410,90 @@ defmodule Berrypod.Orders do
do: Map.put(attrs, key, DateTime.utc_now() |> DateTime.truncate(:second)) do: Map.put(attrs, key, DateTime.utc_now() |> DateTime.truncate(:second))
defp maybe_set(attrs, _key, false), do: attrs 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 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) deliver(order.customer_email, subject, body)
end 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 --- # --- Private ---
defp deliver(recipient, subject, body) do defp deliver(recipient, subject, body) do

View File

@ -133,6 +133,23 @@ defmodule Berrypod.Settings do
put_setting("site_live", live?, "boolean") put_setting("site_live", live?, "boolean")
end 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 """ @doc """
Deletes a setting by key. Deletes a setting by key.
""" """

View File

@ -68,6 +68,7 @@ defmodule BerrypodWeb.CheckoutController do
} }
} }
|> maybe_add_shipping_options(hydrated_items) |> maybe_add_shipping_options(hydrated_items)
|> maybe_add_cart_recovery_notice()
case Stripe.Checkout.Session.create(params) do case Stripe.Checkout.Session.create(params) do
{:ok, session} -> {:ok, session} ->
@ -94,6 +95,19 @@ defmodule BerrypodWeb.CheckoutController do
end end
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 defp maybe_add_shipping_options(params, hydrated_items) do
gb_result = Shipping.calculate_for_cart(hydrated_items, "GB") gb_result = Shipping.calculate_for_cart(hydrated_items, "GB")
us_result = Shipping.calculate_for_cart(hydrated_items, "US") 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 defp handle_event(%Stripe.Event{type: "checkout.session.expired", data: %{object: session}}) do
order_id = session.metadata["order_id"] order_id = session.metadata["order_id"]
order = Orders.get_order(order_id)
case Orders.get_order(order_id) do if order, do: Orders.mark_failed(order)
nil -> :ok
order -> Orders.mark_failed(order)
end
Logger.info("Stripe checkout session expired for order #{order_id}") 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 end
defp handle_event(%Stripe.Event{type: type}) do defp handle_event(%Stripe.Event{type: type}) do
Logger.debug("Unhandled Stripe event: #{type}") Logger.debug("Unhandled Stripe event: #{type}")
end 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 defp update_shipping(order, shipping_details) do
address = shipping_details.address || %{} 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 socket
|> assign(:page_title, "Settings") |> assign(:page_title, "Settings")
|> assign(:site_live, Settings.site_live?()) |> assign(:site_live, Settings.site_live?())
|> assign(:cart_recovery_enabled, Settings.abandoned_cart_recovery_enabled?())
|> assign_stripe_state() |> assign_stripe_state()
|> assign_products_state() |> assign_products_state()
|> assign_account_state(user)} |> assign_account_state(user)}
@ -90,6 +91,23 @@ defmodule BerrypodWeb.Admin.Settings do
|> put_flash(:info, message)} |> put_flash(:info, message)}
end 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 -- # -- Events: Stripe --
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
@ -367,6 +385,49 @@ defmodule BerrypodWeb.Admin.Settings do
<% end %> <% end %>
</section> </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 --%> <%!-- Account --%>
<section class="mt-10"> <section class="mt-10">
<h2 class="text-lg font-semibold">Account</h2> <h2 class="text-lg font-semibold">Account</h2>

View File

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

View File

@ -0,0 +1,31 @@
defmodule Berrypod.Repo.Migrations.CreateAbandonedCarts do
use Ecto.Migration
def change do
create table(:abandoned_carts, primary_key: false) do
add :id, :binary_id, primary_key: true
add :customer_email, :string, null: false
add :order_id, references(:orders, type: :binary_id, on_delete: :nilify_all)
add :stripe_session_id, :string, null: false
add :cart_total, :integer
add :expired_at, :utc_datetime, null: false
add :emailed_at, :utc_datetime
timestamps(type: :utc_datetime)
end
create unique_index(:abandoned_carts, [:stripe_session_id])
create index(:abandoned_carts, [:customer_email])
create index(:abandoned_carts, [:inserted_at])
create table(:email_suppressions, primary_key: false) do
add :id, :binary_id, primary_key: true
add :email, :string, null: false
add :reason, :string
timestamps(type: :utc_datetime)
end
create unique_index(:email_suppressions, [:email])
end
end

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